Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4ca46cc
Updates to include gateware build
vishnutskumar May 26, 2026
ad8c90c
Gets Host MAC for Lattice Radiant License
vishnutskumar May 26, 2026
057a336
feat(peripherals): make build/deploy half-selectors subcommands
vishnutskumar Jun 2, 2026
4a71a1e
feat(peripherals): tell axon-peripheral-sdk it was launched via synap…
vishnutskumar Jun 3, 2026
16f6819
fix(peripherals): drop --pdc/--impl from the gateware build command
vishnutskumar Jun 3, 2026
55e8b38
fix(peripherals): resolve gateware project from src/gateware in pass-…
vishnutskumar Jun 9, 2026
bf38e30
feat(peripherals): improve gateware pass-through UX (leading opts, co…
vishnutskumar Jun 9, 2026
afc0d9e
feat(peripherals): read probe usb_pid from the bitstream build summary
calvinleng-science Jun 10, 2026
8d54070
fix(peripherals): reject non-object bitstream summaries with ValueError
calvinleng-science Jun 10, 2026
e6e35ca
feat(peripherals): let find_deb_package select a deb by package name
calvinleng-science Jun 10, 2026
2de9078
feat(peripherals): package custom gateware as a -gateware .deb with m…
calvinleng-science Jun 10, 2026
6775bb4
test(peripherals): parse fpm args from the fpm token in fake_fpm_run
calvinleng-science Jun 10, 2026
96ac7ba
feat(peripherals): split build/deploy into driver and -gateware debs
calvinleng-science Jun 10, 2026
c127247
fix(peripherals): stop deploy after a failed package stream
calvinleng-science Jun 10, 2026
21d7bf4
test(peripherals): drop dead gateware_target fixtures
calvinleng-science Jun 10, 2026
283b2b7
fix(peripherals): anchor deb selection on name and version to skip st…
calvinleng-science Jun 10, 2026
2905ec5
fix(peripherals): accept SDK 1.0.2 top-level hex-string usb_pid in bu…
calvinleng-science Jun 11, 2026
315db45
fix(peripherals): require top-level hex-string usb_pid in bitstream s…
calvinleng-science Jun 11, 2026
287aeec
feat(peripherals): carry the gateware project name as display_name in…
calvinleng-science Jun 11, 2026
eef1a5c
feat(peripherals): name custom bitstreams after the gateware project,…
calvinleng-science Jun 11, 2026
dc985f6
feat(peripherals): key custom gateware on target-profile_project iden…
calvinleng-science Jun 11, 2026
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
2 changes: 1 addition & 1 deletion synapse/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def main():
peripherals.add_commands(subparsers)
settings.add_commands(subparsers)
deploy_model.add_commands(subparsers)
args = parser.parse_args()
args = peripherals.parse_args_with_passthrough(parser)

# If we need to setup the device URI, do that now
args = setup_device_uri(args)
Expand Down
188 changes: 146 additions & 42 deletions synapse/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import glob
import json
import os
import re
import shutil
import subprocess
import tempfile
from typing import Any
from typing import Any, Literal

from rich import box
from rich.console import Console
Expand All @@ -15,7 +16,7 @@
console = Console()


def validate_manifest(manifest_path: str) -> dict[str, Any] | bool:
def validate_manifest(manifest_path: str) -> dict[str, Any] | Literal[False]:
"""Return the parsed ``manifest.json`` dictionary or ``False`` on error."""

try:
Expand Down Expand Up @@ -71,42 +72,137 @@ def ensure_docker() -> bool:
return False


def build_docker_image(app_dir: str, app_name: str | None = None) -> str:
"""(Re)build the cross-compile SDK Docker image and return its tag."""
_ARG_HOST_UID_RE = re.compile(r"^ARG HOST_UID\b", re.MULTILINE)


def _resolve_host_uid() -> int:
"""Return the invoking user's UID, falling back to 1000 on non-POSIX hosts."""
try:
return os.getuid()
except AttributeError:
console.print(
"[yellow]Warning:[/yellow] os.getuid() unavailable on this host; "
"falling back to HOST_UID=1000."
)
return 1000


def _dockerfile_needs_host_uid(dockerfile_path: str) -> bool:
"""Return True iff *dockerfile_path* declares ``ARG HOST_UID`` at line start."""
with open(dockerfile_path, "r", encoding="utf-8") as fp:
return bool(_ARG_HOST_UID_RE.search(fp.read()))


def build_docker_image(
app_dir: str,
app_name: str | None = None,
roles: list[str] | None = None,
) -> dict[str, str]:
"""(Re)build the cross-compile SDK Docker image(s) and return role -> tag.

Discovers every ``*.Dockerfile`` directly under ``<app_dir>/Dockerfiles/``
and builds each as ``<app_name>-<role>:latest-<arch>`` where ``role`` is
the filename stem (e.g. ``gateware.Dockerfile`` -> ``gateware``). Returns
a dict mapping role -> image tag.

Back-compat: if ``<app_dir>/Dockerfiles/`` does not exist and
``<app_dir>/Dockerfile`` does, builds the single legacy image tagged
``<app_name>:latest-<arch>`` (no role suffix) and returns
``{"driver": "<that tag>"}``.

If ``Dockerfiles/`` exists but is empty, or if neither path exists,
raises :class:`FileNotFoundError`.

For each Dockerfile whose contents contain a line matching
``^ARG HOST_UID\\b`` the build additionally receives
``--build-arg HOST_UID=<os.getuid()>``. On non-POSIX hosts (where
``os.getuid()`` is unavailable) the value falls back to ``1000``.

Args:
app_dir, app_name: as before.
roles: when provided, restricts the build to Dockerfiles whose role
(filename stem) is in this list. If a requested role is not found
on disk, raises FileNotFoundError. When None (default), builds
every discovered Dockerfile. The back-compat legacy single-
``./Dockerfile`` path is treated as role "driver"; pass
``roles=["driver"]`` (or None) to consume it; passing
``roles=["gateware"]`` against a legacy repo with no
``Dockerfiles/`` raises FileNotFoundError.
"""

if app_name is None:
app_name = os.path.basename(app_dir)

arch_suffix = detect_arch() # "arm64" or "amd64"

# Look for a Dockerfile at the top level of the app directory
dockerfile_path = os.path.join(app_dir, "Dockerfile")

if not os.path.exists(dockerfile_path):
dockerfiles_dir = os.path.join(app_dir, "Dockerfiles")
legacy_dockerfile = os.path.join(app_dir, "Dockerfile")

# Discovery: Dockerfiles/ wins over the legacy root ./Dockerfile.
discovered: list[tuple[str, str]] = []
is_legacy = False
if os.path.isdir(dockerfiles_dir):
for entry in sorted(os.listdir(dockerfiles_dir)):
if entry.endswith(".Dockerfile"):
role = entry[: -len(".Dockerfile")]
discovered.append((role, os.path.join(dockerfiles_dir, entry)))
elif os.path.exists(legacy_dockerfile):
discovered.append(("driver", legacy_dockerfile))
is_legacy = True

if not discovered:
raise FileNotFoundError(
f"Expected Dockerfile not found at {dockerfile_path}. "
"Ensure your application provides the required Dockerfile."
f"Expected Dockerfile not found at {legacy_dockerfile} or any "
f"*.Dockerfile under {dockerfiles_dir}. Ensure your application "
"provides the required Dockerfile."
)

image_tag = f"{app_name}:latest-{arch_suffix}"
if roles is not None:
requested = set(roles)
available = {role for role, _ in discovered}
missing = requested - available
if missing:
raise FileNotFoundError(
f"Requested roles {sorted(missing)} not found in {dockerfiles_dir}. "
f"Available roles: {sorted(available)}."
)
discovered = [(role, path) for role, path in discovered if role in requested]

tags: dict[str, str] = {}
for role, dockerfile_path in discovered:
image_tag = (
f"{app_name}:latest-{arch_suffix}"
if is_legacy
else f"{app_name}-{role}:latest-{arch_suffix}"
)

console.print(f"[yellow]Building Docker image [bold]{image_tag}[/bold]...[/yellow]")
subprocess.run(
[
"docker",
"build",
"-t",
image_tag,
"-f",
dockerfile_path,
".",
],
check=True,
cwd=app_dir,
)
build_args: list[str] = []
if _dockerfile_needs_host_uid(dockerfile_path):
host_uid = _resolve_host_uid()
build_args.extend(["--build-arg", f"HOST_UID={host_uid}"])

console.print(f"[green]Successfully built Docker image {image_tag}[/green]")
return image_tag
console.print(
f"[yellow]Building Docker image [bold]{image_tag}[/bold]...[/yellow]"
)
subprocess.run(
[
"docker",
"build",
"-t",
image_tag,
"-f",
dockerfile_path,
*build_args,
".",
],
check=True,
cwd=app_dir,
)

console.print(f"[green]Successfully built Docker image {image_tag}[/green]")
tags[role] = image_tag

return tags


def build_app(
Expand All @@ -127,12 +223,9 @@ def build_app(

console.print("[yellow]Binary not found, attempting to build...[/yellow]")

arch_suffix = detect_arch()
image_tag = f"{os.path.basename(app_dir)}:latest-{arch_suffix}"

# Build (or rebuild) the Docker image – this function is idempotent.
try:
image_tag = build_docker_image(app_dir, app_name)
image_tag = build_docker_image(app_dir, app_name)["driver"]
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
console.print(
f"[bold red]Error:[/bold red] Failed to build Docker image: {exc}"
Expand Down Expand Up @@ -317,24 +410,28 @@ def build_deb_package(app_dir: str, app_name: str, version: str = "0.1.0") -> bo

lifecycle_scripts_tmp: list[str] = []

# 0o644 suffices for all three: fpm embeds each file's *contents* as a
# .deb maintainer script (--after-install / --before-remove /
# --after-remove below), and dpkg makes maintainer scripts executable
# itself at install time. The staging files' exec bits never ship.
postinstall_path = os.path.join(staging_dir, "postinstall.sh")
with open(postinstall_path, "w", encoding="utf-8") as fp:
fp.write("#!/bin/bash\nset -e\nsystemctl daemon-reload\n")
os.chmod(postinstall_path, 0o755)
os.chmod(postinstall_path, 0o644)
lifecycle_scripts_tmp.append(postinstall_path)

preremove_path = os.path.join(staging_dir, "preremove.sh")
with open(preremove_path, "w", encoding="utf-8") as fp:
fp.write(
f"#!/bin/bash\nset -e\nsystemctl stop {app_name} || true\nsystemctl disable {app_name} || true\n"
)
os.chmod(preremove_path, 0o755)
os.chmod(preremove_path, 0o644)
lifecycle_scripts_tmp.append(preremove_path)

postremove_path = os.path.join(staging_dir, "postremove.sh")
with open(postremove_path, "w", encoding="utf-8") as fp:
fp.write("#!/bin/bash\nset -e\nsystemctl daemon-reload\n")
os.chmod(postremove_path, 0o755)
os.chmod(postremove_path, 0o644)
lifecycle_scripts_tmp.append(postremove_path)

lib_dst_dir = os.path.join(staging_dir, "opt", "scifi", "lib")
Expand Down Expand Up @@ -538,15 +635,22 @@ def package_app(app_dir: str, app_name: str) -> bool:
return build_deb_package(app_dir, app_name)


def find_deb_package(dist_dir: str) -> str | None:
"""Return the path to the .deb generated in *app_dir* or *None*."""
for file in os.listdir(dist_dir):
if file.endswith(".deb"):
return os.path.join(dist_dir, file)
def find_deb_package(dist_dir: str, package_name: str | None = None) -> str | None:
"""Return the path to a .deb generated in *dist_dir* or ``None``.

console.print(
f"[bold red]Error:[/bold red] Could not find .deb package in {dist_dir}"
)
With *package_name*, only ``<package_name>_*.deb`` matches — a peripheral
dist/ holds both the driver and the ``-gateware`` deb, and the driver name
is a strict prefix of the gateware name.
"""
for file in sorted(os.listdir(dist_dir)):
if not file.endswith(".deb"):
continue
if package_name is not None and not file.startswith(f"{package_name}_"):
continue
return os.path.join(dist_dir, file)

wanted = f"{package_name} .deb package" if package_name else ".deb package"
console.print(f"[bold red]Error:[/bold red] Could not find {wanted} in {dist_dir}")
return None


Expand Down
12 changes: 11 additions & 1 deletion synapse/cli/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ def create_metadata(file_path, console):


def deploy_package(ip_address, deb_package_path):
"""Deploy the package to the device"""
"""Deploy a .deb package to the device via gRPC DeployApp streaming.

Returns True when the package was streamed and all device responses were
received without error; False on any failure (connection, gRPC, or I/O).
Callers can use the return value to decide whether to continue with
subsequent packages.
"""
package_filename = os.path.basename(deb_package_path)
console.clear_live()

Expand Down Expand Up @@ -178,6 +184,7 @@ def chunk_generator():
response_panel.renderable = Group(*display_items)
response_panel.border_style = "red"
live.refresh()
return False

except Exception as e:
# For the outer exception, also preserve any progress made
Expand All @@ -195,6 +202,9 @@ def chunk_generator():
response_panel.renderable = Group(*display_items)
response_panel.border_style = "red"
live.refresh()
return False

return True


def deploy_cmd(args):
Expand Down
Loading
Loading