Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions satellite-provisioning/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
inventory
*.retry
.ansible/
69 changes: 69 additions & 0 deletions satellite-provisioning/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Companion Satellite Provisioning

`satellite.yml` is an Ansible playbook that deploys [Companion Satellite](https://bitfocus.io/companion-satellite) to a Debian-based host (or 100 of them, if you want). It is an alternative to the curl-pipe-bash `install.sh` and subsequent `update.sh` path found in the [pi-image/ directory](../pi-image). This modern solution is fully declarative, idempotent, and suitable for managing fleets of satellites from a single control node.

Supports arm64 (e.g. Raspberry Pi) and x86_64.

## Quick start

1. Edit the ansible inventory with your satellite host(s)
```
cp inventory.example inventory
vim inventory
```

2. Provision
```
cd satellite-provisioning
ansible-playbook satellite.yml
```
Comment on lines +9 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Quick-start snippets need consistent directory context (and fence language).

Line 11 assumes you are already in satellite-provisioning, while Line 17 changes into it. Also, Line 10 and Line 16 fences are missing a language tag (MD040).

Suggested patch
-1. Edit the ansible inventory with your satellite host(s)
-```
-cp inventory.example inventory
-vim inventory
-```
+1. Move into the provisioning directory and edit inventory
+```bash
+cd satellite-provisioning
+cp inventory.example inventory
+vim inventory
+```
 
 2. Provision
-```
-cd satellite-provisioning
-ansible-playbook satellite.yml
-```
+```bash
+ansible-playbook satellite.yml
+```
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 10-10: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 16-16: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


## Variables

Change these values before running the playbook if you'd like.

| Variable | Default | Description |
|----------|---------|-------------|
| `companion_ip` | `127.0.0.1` | IP address of the Companion instance to connect to |
| `branch` | `stable` | Release branch (or `beta`) (used to query the Bitfocus API) |
| `node_version` | `24.13.0` | Node.js version to install |
| `satellite_version` | *(latest from API)* | Pin a specific satellite version (also requires `satellite_url`) |
| `satellite_url` | *(latest from API)* | Download URL for a pinned satellite version |

Alternatively, you can override these vars at run time with `-e`:

```bash
ansible-playbook satellite.yml -e companion_ip=10.0.0.10
ansible-playbook satellite.yml -e branch=beta
```

## What it deploys

- **System dependencies**: libusb, libudev, cmake, libfontconfig1, curl, wget, unzip
- **Node.js**: Pinned version installed to `/opt/node` from official tarball (arch-detected)
- **Companion Satellite**: Build from the [Bitfocus API](https://api.bitfocus.io), extracted to `/opt/companion-satellite`
- **udev rules**: Stream Deck / surface USB device permissions (`50-satellite.rules`)
- **systemd service**: `satellite.service` running as the `satellite` user (in the `dialout` group)
- **Boot config**: `/satellite-config` with `COMPANION_IP` (read once at service start as defaults)

## Updating

Simply re-run the playbook to pick up the latest build for the configured branch. The playbook queries the Bitfocus API and skips the download if the installed version already matches.

Otherwise, to pin a specific version:

```bash
ansible-playbook satellite.yml \
-e satellite_version=v2.6.0 \
-e satellite_url=https://s4.bitfocus.io/builds/companion-satellite/companion-satellite-arm64-559-eb78b78.tar.gz
```

## Differences from install.sh / update.sh

This playbook is a standalone, declarative provisioning path. It installs Node.js directly from the official tarball (no fnm) and does not clone the companion-satellite git repo. This means:

- **No helper scripts**: `satellite-update`, `satellite-help`, `satellite-license`, and `satellite-edit-config` are not available. Updates are done by re-running the playbook.
- **No motd**: The SSH login banner is not modified.
- **No fnm**: Node.js is managed directly; version changes are made by editing the `node_version` variable.

These trade-offs keep the installation lightweight and fully managed by Ansible.
2 changes: 2 additions & 0 deletions satellite-provisioning/ansible.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[defaults]
inventory = inventory
21 changes: 21 additions & 0 deletions satellite-provisioning/inventory.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Companion Satellite inventory file
#
# Copy this file and edit it with your satellites:
# cp inventory.example inventory
#
# Common host vars:
# ansible_host IP or hostname to connect to
# ansible_user SSH user (default: current user)
# ansible_ssh_private_key_file Path to SSH private key
# ansible_port SSH port (default: 22)

[satellites]
# satellite1 ansible_host=10.0.0.101
# satellite2 ansible_host=10.0.0.102
# satellite3 ansible_host=10.0.0.103
# satellite4 ansible_host=10.0.0.104
# satellite5 ansible_host=10.0.0.105

# [satellites:vars]
# ansible_user=pi
# ansible_ssh_private_key_file=~/.ssh/id_satellite
296 changes: 296 additions & 0 deletions satellite-provisioning/satellite.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
---
# companion-satellite.yml - Provision a Debian host as a Bitfocus Companion Satellite
#
# Installs Node.js from the official tarball and fetches the latest stable
# satellite build from the Bitfocus API — no fnm, no git clone, no interactive
# prompts. Supports arm64 and x86_64.
#
# Prerequisites:
# - Debian-based Linux (arm64 or x86_64) with SSH access
# - Host is reachable at its inventory IP
#
# Usage:
# ansible-playbook satellite.yml
# ansible-playbook satellite.yml --limit satellite1

- name: Provision Companion Satellite
hosts: all
become: true
gather_facts: true

vars:
node_version: "24.13.0"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not keen on the node_version being an input to the script here, we don't really document what version is needed and it does change sometimes. So I expect this will be a point of frustration in figuring out what the correct value should be.

It would be better if the .node_version from the repository was packaged into the builds, and used instead, with this as a sensible fallback value for older builds
(I'm a little surprised that I haven't already done this)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the source of truth for node_version? It is possible to pull dynamically from an external source, but it is already a var, so any build automation would do:

ansible-playbook satellite.yml \
  -e node_version=xx.xx.x

Alternatively, perhaps there is a Bitfocus API endpoint for node_version as well? This playbook is effectively standalone from the companion-satellite repository (possibly a benefit for some users) except for when it calls the API to ultimately get satellite_url.

if the .node_version from the repository was packaged into the builds, and used instead

My philosophy keeps them separate as it bloats the build, but that's up to you :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exact version to use really depends on the build.

  • 2.7.0+ wants 24.13.0
  • 2.3 - 2.6 wants 22.x
  • 1.9 - 2.2 wants 20.x

They probably run fine with the 'wrong' version, but no guarantee (which is why I am surprised I havent handled this already, but I suppose I dont expect people to update often. I'm still using some 2.1/2.2 because there hasnt been a reason to update)

So yes, I do think that a .node-version file should be added to the builds. Its only a few bytes anyway, so not exactly bloat (much less so than the way we download an electron build to then extract the contents of it)

branch: "stable"
companion_ip: "127.0.0.1"
node_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'x64' }}"
satellite_api_target: "{{ 'linux-arm64-tgz' if ansible_architecture == 'aarch64' else 'linux-tgz' }}"
satellite_api_url: "https://api.bitfocus.io/v1/product/companion-satellite/packages?branch={{ branch }}&limit=1&target={{ satellite_api_target }}"
node_url: "https://nodejs.org/dist/v{{ node_version }}/node-v{{ node_version }}-linux-{{ node_arch }}.tar.xz"
node_install_dir: "/opt/node"
satellite_install_dir: "/opt/companion-satellite"
build_tmp: "/var/tmp/companion-satellite-build"

tasks:
- name: Install system dependencies
ansible.builtin.apt:
name:
- adduser
- curl
- libusb-1.0-0-dev
- libudev-dev
- cmake
- libfontconfig1
- wget
- unzip
state: present
update_cache: true
cache_valid_time: 3600

- name: Create satellite user
ansible.builtin.user:
name: satellite
shell: /usr/sbin/nologin
create_home: true
groups: dialout
append: true

# --- Node.js installation (idempotent) ---

- name: Create build temp directory
ansible.builtin.file:
path: "{{ build_tmp }}"
state: directory
mode: "0755"

- name: Check installed Node.js version
ansible.builtin.command: "{{ node_install_dir }}/bin/node --version"
register: installed_node_version
changed_when: false
failed_when: false

- name: Install Node.js
when: >
installed_node_version.rc != 0 or
('v' + node_version) not in installed_node_version.stdout
block:
- name: Download Node.js tarball
ansible.builtin.get_url:
url: "{{ node_url }}"
dest: "{{ build_tmp }}/node.tar.xz"
mode: "0644"
Comment on lines +76 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the satellite.yml file and check its content around the flagged lines
find . -name "satellite.yml" -type f | head -1 | xargs -I {} sh -c 'echo "=== File: {} ===" && cat -n {} | sed -n "70,145p"'

Repository: bitfocus/companion-satellite

Length of output: 3096


🏁 Script executed:

#!/bin/bash
# Search for checksum-related variables and patterns
rg -n "node_sha256|satellite_sha256|checksum:" . --type yaml -B2 -A2

Repository: bitfocus/companion-satellite

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for where variables like node_url and satellite_url are defined
rg -n "node_url|satellite_url|node_version|satellite_version" . --type yaml | head -30

Repository: bitfocus/companion-satellite

Length of output: 1467


Add checksum validation to tarball downloads.

The Node.js and satellite build tarballs are downloaded without integrity verification—a good catch for supply-chain safety! Lines 76–80 and 135–138 both use get_url without checksums.

For Node.js (line 76), you could add a checksum variable pointing to the official Node.js release hash. For satellite (line 135), since the URL comes from your API response (line 116), you'd want to either include a checksum in the API response or fetch it separately.

A simple start might look like adding checksum: "sha256:..." to each get_url task once you have the checksum sources sorted. The Ansible module supports it natively, so the integration is straightforward.


- name: Create Node.js install directory
ansible.builtin.file:
path: "{{ node_install_dir }}"
state: directory
mode: "0755"

- name: Extract Node.js tarball
ansible.builtin.unarchive:
src: "{{ build_tmp }}/node.tar.xz"
dest: "{{ node_install_dir }}"
remote_src: true
extra_opts:
- --strip-components=1

- name: Clean up Node.js tarball
ansible.builtin.file:
path: "{{ build_tmp }}/node.tar.xz"
state: absent

# --- Satellite build installation (idempotent) ---

- name: Fetch satellite build info from API
ansible.builtin.uri:
url: "{{ satellite_api_url }}"
return_content: true
register: satellite_api
delegate_to: localhost
become: false
run_once: true
when: satellite_version is not defined

- name: Set satellite build facts from API
ansible.builtin.set_fact:
satellite_version: "{{ satellite_api.json.packages[0].version }}"
satellite_url: "{{ satellite_api.json.packages[0].uri }}"
when: satellite_version is not defined

- name: Show satellite version
ansible.builtin.debug:
msg: "Satellite {{ satellite_version }} — {{ satellite_url }}"

Comment on lines +103 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add explicit pin-variable validation for better failure mode.

If satellite_version is provided without satellite_url, Line 121 can fail with an undefined variable error. A fast assert gives a clear message.

Suggested patch
+    - name: Validate pinned satellite variables
+      ansible.builtin.assert:
+        that:
+          - (satellite_version is defined and satellite_url is defined) or
+            (satellite_version is not defined and satellite_url is not defined)
+        fail_msg: "Set both satellite_version and satellite_url together, or set neither."
+
     - name: Fetch satellite build info from API
       ansible.builtin.uri:
         url: "{{ satellite_api_url }}"
🧰 Tools
🪛 Checkov (3.2.334)

[medium] 103-114: Ensure that HTTPS url is used with uri

(CKV2_ANSIBLE_1)

- name: Check installed satellite build
ansible.builtin.slurp:
src: "{{ satellite_install_dir }}/BUILD"
register: installed_build
failed_when: false

- name: Install satellite build
when: >
installed_build is failed or
(installed_build.content | default('') | b64decode | trim) != satellite_version
block:
- name: Download satellite build tarball
ansible.builtin.get_url:
url: "{{ satellite_url }}"
dest: "{{ build_tmp }}/companion-satellite.tar.gz"
mode: "0644"

- name: Create temp extraction directory
ansible.builtin.file:
path: "{{ build_tmp }}/extract"
state: directory
mode: "0755"

- name: Extract satellite build tarball
ansible.builtin.unarchive:
src: "{{ build_tmp }}/companion-satellite.tar.gz"
dest: "{{ build_tmp }}/extract"
remote_src: true
extra_opts:
- --strip-components=1

- name: Extract Electron asar
ansible.builtin.command:
cmd: >
{{ node_install_dir }}/bin/npx --yes @electron/asar@3 e
{{ build_tmp }}/extract/resources/app.asar
{{ build_tmp }}/extract/app
environment:
HOME: "{{ build_tmp }}"
PATH: "{{ node_install_dir }}/bin:{{ ansible_env.PATH }}"
changed_when: true

- name: Create satellite install directory
ansible.builtin.file:
path: "{{ satellite_install_dir }}/{{ item }}"
state: directory
owner: satellite
group: satellite
mode: "0755"
loop:
- ""
- satellite
- webui/dist

- name: Install satellite app
ansible.builtin.command:
cmd: >
cp -a {{ build_tmp }}/extract/app/.
{{ satellite_install_dir }}/satellite/
changed_when: true

- name: Install satellite webui
ansible.builtin.command:
cmd: >
cp -a {{ build_tmp }}/extract/resources/webui/.
{{ satellite_install_dir }}/webui/dist/
changed_when: true

- name: Set satellite directory ownership
ansible.builtin.file:
path: "{{ satellite_install_dir }}"
owner: satellite
group: satellite
recurse: true

- name: Install udev rules
ansible.builtin.copy:
src: "{{ build_tmp }}/extract/50-satellite.rules"
dest: /etc/udev/rules.d/50-satellite.rules
owner: root
group: root
mode: "0644"
remote_src: true
notify:
- reload udev
- trigger udev

- name: Write build version file
ansible.builtin.copy:
content: "{{ satellite_version }}\n"
dest: "{{ satellite_install_dir }}/BUILD"
owner: satellite
group: satellite
mode: "0644"
notify: restart satellite

- name: Clean up build temp directory
ansible.builtin.file:
path: "{{ build_tmp }}"
state: absent

# --- Service configuration ---

- name: Deploy systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/satellite.service
mode: "0644"
content: |
[Unit]
Description=Bitfocus Companion Satellite
After=network-online.target
Wants=network-online.target

[Service]
User=satellite
Group=satellite
Type=simple
WorkingDirectory={{ satellite_install_dir }}/satellite
ExecStartPre=+{{ node_install_dir }}/bin/node {{ satellite_install_dir }}/satellite/dist/fixup-pi-config.js /home/satellite/satellite-config.json
ExecStart={{ node_install_dir }}/bin/node {{ satellite_install_dir }}/satellite/dist/main.js /home/satellite/satellite-config.json
Restart=on-failure
KillSignal=SIGINT
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target
notify: restart satellite

- name: Deploy satellite defaults config
ansible.builtin.copy:
dest: /satellite-config
owner: root
group: root
mode: "0666"
content: |
COMPANION_IP={{ companion_ip }}
# COMPANION_PORT=16622
REST_PORT=9999
# REST_PASSWORD=
notify: restart satellite

- name: Enable and start satellite service
ansible.builtin.systemd:
name: satellite
enabled: true
state: started
daemon_reload: true

- name: Flush handlers before verification
ansible.builtin.meta: flush_handlers

- name: Verify satellite is responding
ansible.builtin.uri:
url: "http://{{ ansible_host }}:9999/api/status"
return_content: false
status_code: 200
retries: 5
delay: 5

Comment on lines +274 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n satellite-provisioning/satellite.yml | sed -n '285,305p'

Repository: bitfocus/companion-satellite

Length of output: 786


Add until condition and fallback for host variable to ensure retries work correctly.

The retries and delay directives alone won't reliably retry without an until condition—the task will fail immediately instead of retrying. Additionally, using a fallback for ansible_host is a good defensive practice. The suggested patch registers the response and uses until to check the status code:

Suggested patch
     - name: Verify satellite is responding
       ansible.builtin.uri:
-        url: "http://{{ ansible_host }}:9999/api/status"
+        url: "http://{{ ansible_host | default(inventory_hostname) }}:9999/api/status"
         return_content: false
         status_code: 200
+      register: satellite_status
       retries: 5
       delay: 5
+      until: satellite_status.status == 200
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Verify satellite is responding
ansible.builtin.uri:
url: "http://{{ ansible_host }}:9999/api/status"
return_content: false
status_code: 200
retries: 5
delay: 5
- name: Verify satellite is responding
ansible.builtin.uri:
url: "http://{{ ansible_host | default(inventory_hostname) }}:9999/api/status"
return_content: false
status_code: 200
register: satellite_status
retries: 5
delay: 5
until: satellite_status.status == 200
🧰 Tools
🪛 Checkov (3.2.334)

[medium] 291-300: Ensure that HTTPS url is used with uri

(CKV2_ANSIBLE_1)

handlers:
- name: Reload udev rules
ansible.builtin.command: udevadm control --reload-rules
listen: reload udev

- name: Trigger udev events
ansible.builtin.command: udevadm trigger
listen: trigger udev

- name: Restart satellite service
ansible.builtin.systemd:
name: satellite
state: restarted
daemon_reload: true
listen: restart satellite