Skip to content

Commit 766d1c2

Browse files
committed
Add flask fetch_firmware command for the Memfault CoreDevice cron
Polls Memfault's releases/latest endpoint for each CoreDevice hardware, skips versions already recorded in the firmwares table, and for each new one streams the .pbz down (computing sha256 on the fly), uploads it to S3 at {S3_PATH}{hardware}/Pebble-{version}-{hardware}.pbz, and upserts a `normal` row pointing at {FIRMWARE_ROOT}/{hardware}/... with the computed hash. Idempotent, safe to run from cron. Also lifts the upsert logic into a Firmware.upsert classmethod so memfault.py and cli.py can share without a circular import, and adds MEMFAULT_TOKEN / AWS_ACCESS_KEY / AWS_SECRET_KEY / S3_BUCKET / S3_PATH / S3_ENDPOINT to settings + docker-compose env passthrough.
1 parent 2e89de3 commit 766d1c2

8 files changed

Lines changed: 283 additions & 24 deletions

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ Environment variables:
1010
| Variable | Required | Default | Purpose |
1111
| --- | --- | --- | --- |
1212
| `DATABASE_URL` | yes || SQLAlchemy DB URL, e.g. `postgresql+psycopg://user:pw@host:5432/cohorts` |
13-
| `FIRMWARE_ROOT` | no | `https://binaries.rebble.io/fw` | Base URL used by `import_json` to build `.pbz` URLs |
13+
| `FIRMWARE_ROOT` | no | `https://binaries.rebble.io/fw` | Base URL used by `import_json` and `fetch_firmware` to build `.pbz` URLs |
1414
| `HONEYCOMB_KEY` | no || Honeycomb write key; beeline disabled if unset |
1515
| `REBBLE_AUTH` | no || Rebble auth service URL; if unset, `Authorization` headers on `/cohort` are ignored |
16+
| `MEMFAULT_TOKEN` | for `fetch_firmware` || Memfault project key |
17+
| `AWS_ACCESS_KEY` / `AWS_SECRET_KEY` | for `fetch_firmware` || S3 creds for re-uploading firmware blobs |
18+
| `S3_BUCKET` | for `fetch_firmware` || Target bucket (e.g. `rebble-binaries`) |
19+
| `S3_PATH` | no | `fw/` | Key prefix inside `S3_BUCKET` (must align with the tail of `FIRMWARE_ROOT`) |
20+
| `S3_ENDPOINT` | no || Custom S3 endpoint URL; leave unset for AWS |
1621

1722
## Local development
1823

@@ -49,6 +54,14 @@ docker compose exec app uv run flask submit_firmware \
4954

5055
`kind` must be `normal` or `recovery`. `--timestamp` defaults to now. Re-running with the same `(hardware, kind, version)` upserts; submitting with a fresh timestamp is how you roll forward or back.
5156

57+
### Fetching CoreDevice firmware from Memfault
58+
59+
```
60+
docker compose exec app uv run flask fetch_firmware
61+
```
62+
63+
Checks Memfault's `releases/latest` for each CoreDevice hardware (asterix, obelix_*, getafix_*, obelix_bb*), skips versions already recorded, and for each new one: streams the `.pbz` down while hashing it, uploads it to S3 at `{S3_PATH}{hardware}/Pebble-{version}-{hardware}.pbz`, and upserts a `normal` row with the resulting `{FIRMWARE_ROOT}/…` URL and the computed sha256. Idempotent and safe to run from cron. Requires `MEMFAULT_TOKEN`, `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `S3_BUCKET` in the environment (docker-compose forwards these from the host). Supports `--token`.
64+
5265
### Migrations
5366

5467
`migrations/` is a standard Flask-Migrate / Alembic layout. `docker compose up` auto-applies pending migrations. To generate a new revision after editing models:

cohorts/cli.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,13 @@
44
import click
55
from flask import cli as flask_cli
66

7+
from .memfault import fetch_firmware_command
78
from .models import Firmware, db
89
from .settings import config
910

1011
VALID_FW_KINDS = ("normal", "recovery")
1112

1213

13-
def _upsert_firmware(hardware, kind, version, url, sha256, timestamp, notes):
14-
existing = Firmware.query.filter_by(hardware=hardware, kind=kind, version=version).one_or_none()
15-
if existing is None:
16-
db.session.add(
17-
Firmware(
18-
hardware=hardware,
19-
kind=kind,
20-
version=version,
21-
url=url,
22-
sha256=sha256,
23-
timestamp=timestamp,
24-
notes=notes,
25-
)
26-
)
27-
else:
28-
existing.url = url
29-
existing.sha256 = sha256
30-
existing.timestamp = timestamp
31-
existing.notes = notes
32-
33-
3414
@click.command(name="import_json")
3515
@flask_cli.with_appcontext
3616
def import_json_command():
@@ -48,7 +28,7 @@ def import_json_command():
4828
url = f"{config['FIRMWARE_ROOT']}/{hardware}/Pebble-{raw_version}-{hardware}.pbz"
4929
timestamp = timestamps_map[raw_version]
5030
notes = notes_map.get(raw_version)
51-
_upsert_firmware(hardware, kind, version, url, sha256, timestamp, notes)
31+
Firmware.upsert(hardware, kind, version, url, sha256, timestamp, notes)
5232
count += 1
5333
db.session.commit()
5434
click.echo(f"Imported {count} firmware rows.")
@@ -68,11 +48,12 @@ def submit_firmware_command(hardware, kind, version, url, sha256, timestamp, not
6848
raise click.BadParameter(f"kind must be one of {VALID_FW_KINDS}, got {kind!r}")
6949
if timestamp is None:
7050
timestamp = int(time.time())
71-
_upsert_firmware(hardware, kind, version, url, sha256, timestamp, notes)
51+
Firmware.upsert(hardware, kind, version, url, sha256, timestamp, notes)
7252
db.session.commit()
7353
click.echo(f"Submitted firmware {hardware}/{kind}/{version}.")
7454

7555

7656
def init_app(app):
7757
app.cli.add_command(import_json_command)
7858
app.cli.add_command(submit_firmware_command)
59+
app.cli.add_command(fetch_firmware_command)

cohorts/memfault.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import hashlib
2+
import io
3+
import time
4+
5+
import boto3
6+
import click
7+
import httpx
8+
from flask import cli as flask_cli
9+
10+
from .models import Firmware, db
11+
from .settings import config
12+
13+
MEMFAULT_API = "https://api.memfault.com/api/v0/releases/latest"
14+
15+
# Core Devices hardware revisions. These short names are both the `hardware`
16+
# value we store in the firmwares table and the `hardware_version` string
17+
# Memfault expects. Based on the mobileapp WatchHardwarePlatform.
18+
CORE_DEVICES_DEVICES = (
19+
"asterix",
20+
"obelix_evt",
21+
"obelix_dvt",
22+
"obelix_pvt",
23+
"getafix_evt",
24+
"getafix_dvt",
25+
"obelix_bb",
26+
"obelix_bb2",
27+
)
28+
29+
30+
def _fetch_latest(client, token, hw_revision):
31+
resp = client.get(
32+
MEMFAULT_API,
33+
params={
34+
"hardware_version": hw_revision,
35+
"software_type": "pebbleos",
36+
"device_serial": "REBBLE_COHORTS_CRON",
37+
},
38+
headers={"Memfault-Project-Key": token},
39+
)
40+
if resp.status_code == 204:
41+
return None
42+
resp.raise_for_status()
43+
return resp.json()
44+
45+
46+
def _download_and_hash(client, url):
47+
sha256 = hashlib.sha256()
48+
buf = io.BytesIO()
49+
with client.stream("GET", url) as resp:
50+
resp.raise_for_status()
51+
for chunk in resp.iter_bytes(chunk_size=8192):
52+
buf.write(chunk)
53+
sha256.update(chunk)
54+
buf.seek(0)
55+
return buf, sha256.hexdigest()
56+
57+
58+
def _s3_client():
59+
return boto3.client(
60+
"s3",
61+
aws_access_key_id=config["AWS_ACCESS_KEY"],
62+
aws_secret_access_key=config["AWS_SECRET_KEY"],
63+
endpoint_url=config["S3_ENDPOINT"],
64+
)
65+
66+
67+
def _upload(data, s3_key):
68+
data.seek(0)
69+
_s3_client().upload_fileobj(
70+
data,
71+
config["S3_BUCKET"],
72+
s3_key,
73+
ExtraArgs={"ContentType": "application/octet-stream"},
74+
)
75+
76+
77+
@click.command(name="fetch_firmware")
78+
@click.option(
79+
"--token",
80+
default=None,
81+
help="Memfault project key (falls back to MEMFAULT_TOKEN env).",
82+
)
83+
@flask_cli.with_appcontext
84+
def fetch_firmware_command(token):
85+
"""Check Memfault for the latest firmware for each CoreDevice hardware,
86+
download + re-upload to our CDN, and upsert into the firmwares table."""
87+
token = token or config["MEMFAULT_TOKEN"]
88+
if not token:
89+
raise click.UsageError("MEMFAULT_TOKEN not set (pass --token or set the env var).")
90+
for var in ("AWS_ACCESS_KEY", "AWS_SECRET_KEY", "S3_BUCKET"):
91+
if not config[var]:
92+
raise click.UsageError(f"{var} env var not set.")
93+
94+
added = 0
95+
skipped = 0
96+
failed = 0
97+
with httpx.Client(follow_redirects=True, timeout=60.0) as client:
98+
for hardware in CORE_DEVICES_DEVICES:
99+
click.echo(f"[{hardware}]: ", nl=False)
100+
try:
101+
info = _fetch_latest(client, token, hardware)
102+
except httpx.HTTPStatusError as e:
103+
click.echo(f"lookup FAILED ({e.response.status_code})")
104+
failed += 1
105+
continue
106+
107+
if info is None:
108+
click.echo("no update available")
109+
continue
110+
111+
version = info["version"]
112+
notes = info.get("notes") or None
113+
artifact_url = info["artifacts"][0]["url"]
114+
115+
existing = Firmware.query.filter_by(
116+
hardware=hardware, kind="normal", version=version
117+
).one_or_none()
118+
if existing is not None:
119+
click.echo(f"{version} already in DB, skipping")
120+
skipped += 1
121+
continue
122+
123+
filename = f"Pebble-{version}-{hardware}.pbz"
124+
s3_key = f"{config['S3_PATH']}{hardware}/{filename}"
125+
public_url = f"{config['FIRMWARE_ROOT']}/{hardware}/{filename}"
126+
127+
click.echo(f"{version} downloading... ", nl=False)
128+
try:
129+
data, sha256 = _download_and_hash(client, artifact_url)
130+
except httpx.HTTPStatusError as e:
131+
click.echo(f"download FAILED ({e.response.status_code})")
132+
failed += 1
133+
continue
134+
135+
click.echo("uploading... ", nl=False)
136+
try:
137+
_upload(data, s3_key)
138+
except Exception as e:
139+
click.echo(f"upload FAILED ({e})")
140+
failed += 1
141+
continue
142+
143+
Firmware.upsert(
144+
hardware=hardware,
145+
kind="normal",
146+
version=version,
147+
url=public_url,
148+
sha256=sha256,
149+
timestamp=int(time.time()),
150+
notes=notes,
151+
)
152+
db.session.commit()
153+
click.echo("OK")
154+
added += 1
155+
156+
click.echo(f"done. {added} added, {skipped} already present, {failed} failed.")

cohorts/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ def to_json(self, archival: bool = False):
2929
result["hardware"] = self.hardware
3030
return result
3131

32+
@classmethod
33+
def upsert(cls, hardware, kind, version, url, sha256, timestamp, notes):
34+
existing = cls.query.filter_by(hardware=hardware, kind=kind, version=version).one_or_none()
35+
if existing is None:
36+
db.session.add(
37+
cls(
38+
hardware=hardware,
39+
kind=kind,
40+
version=version,
41+
url=url,
42+
sha256=sha256,
43+
timestamp=timestamp,
44+
notes=notes,
45+
)
46+
)
47+
else:
48+
existing.url = url
49+
existing.sha256 = sha256
50+
existing.timestamp = timestamp
51+
existing.notes = notes
52+
3253

3354
db.Index(
3455
"ix_firmwares_hardware_kind_timestamp",

cohorts/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@
55
"HONEYCOMB_KEY": os.environ.get("HONEYCOMB_KEY"),
66
"REBBLE_AUTH": os.environ.get("REBBLE_AUTH"),
77
"FIRMWARE_ROOT": os.environ.get("FIRMWARE_ROOT", "https://binaries.rebble.io/fw"),
8+
"MEMFAULT_TOKEN": os.environ.get("MEMFAULT_TOKEN"),
9+
"AWS_ACCESS_KEY": os.environ.get("AWS_ACCESS_KEY"),
10+
"AWS_SECRET_KEY": os.environ.get("AWS_SECRET_KEY"),
11+
"S3_BUCKET": os.environ.get("S3_BUCKET"),
12+
"S3_PATH": os.environ.get("S3_PATH", "fw/"),
13+
"S3_ENDPOINT": os.environ.get("S3_ENDPOINT"),
814
}

docker-compose.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ services:
1818
environment:
1919
DATABASE_URL: postgresql+psycopg://cohorts:cohorts@db:5432/cohorts
2020
FLASK_APP: cohorts
21+
# Passed through from the host so operators can invoke `flask fetch_firmware`
22+
# via `docker compose exec`. Unset on the host -> unset in the container.
23+
MEMFAULT_TOKEN: ${MEMFAULT_TOKEN:-}
24+
AWS_ACCESS_KEY: ${AWS_ACCESS_KEY:-}
25+
AWS_SECRET_KEY: ${AWS_SECRET_KEY:-}
26+
S3_BUCKET: ${S3_BUCKET:-}
27+
S3_PATH: ${S3_PATH:-fw/}
28+
S3_ENDPOINT: ${S3_ENDPOINT:-}
29+
FIRMWARE_ROOT: ${FIRMWARE_ROOT:-https://binaries.rebble.io/fw}
2130
ports:
2231
- "5000:5000"
2332
volumes:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description = "The Rebble cohorts API"
55
readme = "README.md"
66
requires-python = "~=3.14.0"
77
dependencies = [
8+
"boto3>=1.35.0",
89
"flask>=3.1.3",
910
"flask-migrate>=4.1.0",
1011
"flask-sqlalchemy>=3.1.1",

0 commit comments

Comments
 (0)