Skip to content
Draft
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
1 change: 1 addition & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ test:
bats tests/test_oem_brew.bats
bats tests/test_hardware_hooks.bats
bats tests/test_nvidia_flatpak_sync.bats
bats tests/test_dakota_countme.bats

# Preview Bazaar config from this checkout on the local machine
bazaar-preview:
Expand Down
10 changes: 6 additions & 4 deletions docs/skills/image-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,13 @@ The Containerfile pulls wallpaper artwork from `ghcr.io/ublue-os/bluefin-wallpap

## CountMe telemetry reporting

Our images participate in Fedora's weekly CountMe telemetry to track installation statistics anonymously:
Our images participate in weekly CountMe telemetry using coarse installation-age buckets:
- **Bluefin & Bluefin LTS:** Handled by standard repository configuration, and since CentOS-based bootc images are broken with legacy rpm-ostree countme, they use a dnf5-based helper service.
- **Dakota:** Since it is based on GNOME OS and has no standard rpm-ostree/dnf packages, it utilizes a custom weekly systemd service/timer (`bluefin-countme.timer` triggering `/usr/libexec/dakota-countme`).
- It generates and maintains an installation epoch cookie in `/var/lib/dakota-countme-epoch` to mimic Fedora's week-based age buckets.
- It performs a weekly query to Fedora's metalink using a `libdnf5`-format User Agent with `os_name="Dakota"` (e.g. `libdnf5/5.2.9 (Dakota;${VERSION_ID};${ARCH}) hawkey`).
- **Dakota:** Since it is based on GNOME OS and has no standard rpm-ostree/dnf packages, it uses a production-grade client-server implementation:
- **Systemd units:** `dakota-countme.timer` and `dakota-countme.service`, centralized in `common/system_files/shared/`, trigger `/usr/libexec/dakota-countme`.
- **State directory:** Uses a secure systemd `StateDirectory=/var/lib/dakota-countme/` with `DynamicUser=yes`; the `epoch` and `lastrun` files are stored there to track installation age buckets.
- **Server & request format:** The client sends a GET request to our custom Cloudflare Worker at `https://countme.projectbluefin.io/metalink` with query parameters `repo`, `tag`, `flavor`, `arch`, and `countme`. Its `dakota-countme` User-Agent includes the image name, OS version, image flavor, and architecture. The client does not add a machine ID, hostname, username, or persistent token to those fields. Server-side request logging and source-IP retention are not specified in this repository.
- **Opt out:** Create `/etc/dakota-countme/disabled` (for example, `sudo install -d -m 0755 /etc/dakota-countme && sudo touch /etc/dakota-countme/disabled`). Both the timer and service then skip execution; the script also exits before sending a request if invoked manually.

### Dashboard processing dependency

Expand Down
2 changes: 1 addition & 1 deletion docs/skills/index.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-07-30",
"generated_at": "2026-07-31",
"schema_version": "1.0",
"skills": [
{
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This file is a human-readable mirror of `index.json`. Both are generated by
`scripts/generate_skill_index.py` — do not hand-edit either file.

Generated: 2026-07-30 · schema 1.0 · 39 skills
Generated: 2026-07-31 · schema 1.0 · 39 skills

| id | category | status | one-line purpose |
|---|---|---|---|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
enable dakota-countme.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Unit]
Description=Weekly Dakota Count Me telemetry reporting
Wants=network-online.target
After=network-online.target
ConditionPathExists=/run/ostree-booted
ConditionPathExists=/usr/share/ublue-os/image-info.json
ConditionPathExists=!/etc/dakota-countme/disabled

[Service]
Type=oneshot
DynamicUser=yes
StateDirectory=dakota-countme
ExecStart=/usr/libexec/dakota-countme
15 changes: 15 additions & 0 deletions system_files/shared/usr/lib/systemd/system/dakota-countme.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[Unit]
Description=Weekly Dakota Count Me telemetry timer
ConditionPathExists=/run/ostree-booted
ConditionPathExists=/usr/share/ublue-os/image-info.json
ConditionPathExists=!/etc/dakota-countme/disabled

[Timer]
OnBootSec=10m
OnCalendar=weekly
Persistent=true
AccuracySec=1h
RandomizedDelaySec=12h

[Install]
WantedBy=timers.target
84 changes: 84 additions & 0 deletions system_files/shared/usr/libexec/dakota-countme
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# /usr/libexec/dakota-countme
# Weekly user-counting client for Dakota (bootc) systems
set -euo pipefail

DISABLED_FILE="${DISABLED_FILE:-/etc/dakota-countme/disabled}"
IMAGE_INFO="${IMAGE_INFO:-/usr/share/ublue-os/image-info.json}"
STATE_DIR="${STATE_DIRECTORY:-/var/lib/dakota-countme}"
WEEK=604800

# Allow users to opt out without disabling the timer.
[ -e "$DISABLED_FILE" ] && exit 0

if ! command -v jq >/dev/null; then
echo "dakota-countme: jq is unavailable; skipping telemetry" >&2
exit 0
fi

if ! command -v curl >/dev/null; then
echo "dakota-countme: curl is unavailable; skipping telemetry" >&2
exit 0
fi

NOW=$(date +%s)

# 1. Enforce that we are running on Dakota
if [ ! -f "$IMAGE_INFO" ]; then
exit 0
fi

IMAGE_NAME=$(jq -r '."image-name" // empty' "$IMAGE_INFO")
if [ "$IMAGE_NAME" != "dakota" ]; then
exit 0
fi

# Ensure state directory exists
if [ ! -d "$STATE_DIR" ]; then
mkdir -p "$STATE_DIR"
fi

EPOCH_FILE="${STATE_DIR}/epoch"
LASTRUN_FILE="${STATE_DIR}/lastrun"

# 2. Initialize epoch file on first run
if [ ! -f "$EPOCH_FILE" ]; then
echo "$NOW" > "$EPOCH_FILE"
fi
EPOCH=$(cat "$EPOCH_FILE")

# 3. Throttling: only run once per 7-day window
if [ -f "$LASTRUN_FILE" ]; then
LASTRUN=$(cat "$LASTRUN_FILE")
if (( NOW - LASTRUN < WEEK )); then
exit 0
fi
fi

# 4. Compute Fedora-style week bucket
# Source: https://github.com/fedora-infra/mirrors-countme#client-behavior--configuration
WEEKS=$(( (NOW - EPOCH) / WEEK + 1 ))
if (( WEEKS == 1 )); then
BUCKET=1 # First week
elif (( WEEKS >= 2 && WEEKS <= 4 )); then
BUCKET=2 # First month (weeks 2-4)
elif (( WEEKS >= 5 && WEEKS <= 24 )); then
BUCKET=3 # First 6 months (weeks 5-24)
else
BUCKET=4 # >6 months
fi

# 5. Extract image metadata
IMAGE_TAG=$(jq -r '."image-tag" // "unknown"' "$IMAGE_INFO")
IMAGE_FLAVOR=$(jq -r '."image-flavor" // "unknown"' "$IMAGE_INFO")
ARCH=$(uname -m)
OS_VERSION=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"' || echo "unknown")

# 6. Formulate request
USER_AGENT="dakota-countme (${IMAGE_NAME} ${OS_VERSION}; ${IMAGE_FLAVOR}; Linux.${ARCH})"
URL="https://countme.projectbluefin.io/metalink?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}"

# 7. Ping the server (empty GET)
if curl -sf --retry 3 --max-time 10 -H "User-Agent: ${USER_AGENT}" "$URL" >/dev/null; then
echo "$NOW" > "$LASTRUN_FILE"
fi
49 changes: 49 additions & 0 deletions tests/test_dakota_countme.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bats

SCRIPT_UNDER_TEST="$BATS_TEST_DIRNAME/../system_files/shared/usr/libexec/dakota-countme"
SERVICE_UNIT="$BATS_TEST_DIRNAME/../system_files/shared/usr/lib/systemd/system/dakota-countme.service"
TIMER_UNIT="$BATS_TEST_DIRNAME/../system_files/shared/usr/lib/systemd/system/dakota-countme.timer"

setup() {
WORKDIR="$(mktemp -d)"
mkdir -p "${WORKDIR}/bin"
}

teardown() {
rm -rf "${WORKDIR}"
}

@test "dakota-countme: exits before checking dependencies when disabled" {
touch "${WORKDIR}/disabled"

run env PATH="${WORKDIR}/bin" DISABLED_FILE="${WORKDIR}/disabled" \
"${BASH}" "${SCRIPT_UNDER_TEST}"

[ "${status}" -eq 0 ]
[ -z "${output}" ]
}

@test "dakota-countme: safely skips when jq is unavailable" {
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "${WORKDIR}/bin/curl"
chmod +x "${WORKDIR}/bin/curl"

run env PATH="${WORKDIR}/bin" "${BASH}" "${SCRIPT_UNDER_TEST}"

[ "${status}" -eq 0 ]
[[ "${output}" == *"jq is unavailable; skipping telemetry"* ]]
}

@test "dakota-countme: safely skips when curl is unavailable" {
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "${WORKDIR}/bin/jq"
chmod +x "${WORKDIR}/bin/jq"

run env PATH="${WORKDIR}/bin" "${BASH}" "${SCRIPT_UNDER_TEST}"

[ "${status}" -eq 0 ]
[[ "${output}" == *"curl is unavailable; skipping telemetry"* ]]
}

@test "dakota-countme: units honor the documented opt-out file" {
grep -Fx 'ConditionPathExists=!/etc/dakota-countme/disabled' "${SERVICE_UNIT}"
grep -Fx 'ConditionPathExists=!/etc/dakota-countme/disabled' "${TIMER_UNIT}"
}
Loading