diff --git a/Justfile b/Justfile index 1df444f37..9593db363 100644 --- a/Justfile +++ b/Justfile @@ -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: diff --git a/docs/skills/image-registry.md b/docs/skills/image-registry.md index 18e713fba..ad8fb68ed 100644 --- a/docs/skills/image-registry.md +++ b/docs/skills/image-registry.md @@ -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 diff --git a/docs/skills/index.json b/docs/skills/index.json index e41096127..1a57cfc0b 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-07-30", + "generated_at": "2026-07-31", "schema_version": "1.0", "skills": [ { diff --git a/docs/skills/index.md b/docs/skills/index.md index beeb1b8f3..45d122154 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -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 | |---|---|---|---| diff --git a/system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset b/system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset new file mode 100644 index 000000000..0554d0f4d --- /dev/null +++ b/system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset @@ -0,0 +1 @@ +enable dakota-countme.timer diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.service b/system_files/shared/usr/lib/systemd/system/dakota-countme.service new file mode 100644 index 000000000..2ce17ebe7 --- /dev/null +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.service @@ -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 diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.timer b/system_files/shared/usr/lib/systemd/system/dakota-countme.timer new file mode 100644 index 000000000..ae9dd1cea --- /dev/null +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.timer @@ -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 diff --git a/system_files/shared/usr/libexec/dakota-countme b/system_files/shared/usr/libexec/dakota-countme new file mode 100755 index 000000000..5c7296ddf --- /dev/null +++ b/system_files/shared/usr/libexec/dakota-countme @@ -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 diff --git a/tests/test_dakota_countme.bats b/tests/test_dakota_countme.bats new file mode 100644 index 000000000..121f2968c --- /dev/null +++ b/tests/test_dakota_countme.bats @@ -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}" +}