Skip to content

feat(packaging): a Synology DSM 7 package, the ARMv7 target it forced, and a desktop application - #1

Merged
wamxx merged 5 commits into
developfrom
feature/dsm-package
Aug 26, 2026
Merged

feat(packaging): a Synology DSM 7 package, the ARMv7 target it forced, and a desktop application#1
wamxx merged 5 commits into
developfrom
feature/dsm-package

Conversation

@wamxx

@wamxx wamxx commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Adds a .spk for DSM 7, the tooling that builds and checks it, and the rig that
proved it — and changes the ARMv7 target, which is the part worth reading first.

The ARMv7 target is no longer musl

Synology's ARMv7 kernels are 3.10 and answer the time64 syscalls with EINVAL
rather than ENOSYS. musl 1.2 falls back to the 32-bit syscalls only on ENOSYS,
so every clock_gettime, clock_nanosleep and timed futex fails. The symptom is a
binary that answers --version and then panics at time.rs:131 with
Os { code: 22, kind: InvalidInput } the moment it wants a timestamp — which looks
like an ABI problem and is not.

Measured on a DS416j (DSM 7.1, kernel 3.10.108) with a ten-line C probe:

libc clock_gettime(CLOCK_REALTIME)  -> -1  errno=22
syscall 263 (time32)                -> 0   ok
syscall 403 (time64)                -> -1  errno=22

The target is now armv7-unknown-linux-gnueabihf with a 2.17 floor, which DSM has
and which newer ARMv7 Linux keeps working with, glibc being backward compatible.
x86_64 and aarch64 are 64-bit, have no time32/time64 split, and stay static musl.
What CI asserts for armv7 is therefore no longer "static" but "needs no glibc newer
than the floor" — the old claim was repeated in six files and was only ever half true.

v0.1.0 shipped the musl ARMv7 build, so that published artifact cannot run on a
Synology. Worth a v0.1.1 and a note on the existing release; not done here.

The package

A .spk is a release format, not a build: the same binary wrapped for one platform's
package manager. Nothing in src/ learns Synology exists. The two places DSM pressed
back are answered in packaging — log rotation by a copytruncate stanza, and a CLI
that cannot find its configuration by a three-line wrapper.

make-spk.sh assembles it deterministically (uncompressed outer tar, ustar, fixed
mtimes and ownership, gzip -n); check-spk.sh asserts its shape, down to the
packaged binary's own --version.

How it is tested

Where What
check-spk.sh CI, every push the archive is what DSM expects
lifecycle-test.sh CI, every push the real lifecycle scripts against a fake tree — 34 checks
vm/on-dsm.sh a DSM 7.2.2 VM in Docker DSM's own machinery — 29 checks, including a real composed answer
the same script a DS416j installed, started, answered, upgraded, uninstalled

Running the machine found three defects a fake tree could not: the package root cannot
be derived from SYNOPKG_PKGDEST (it resolves through a symlink into
/volume1/@appstore); SYNOPKG_TEMP_UPGRADE_FOLDER outlives its upgrade, so a fresh
install was restoring a removed installation's configuration, tokens and all; and a Mac
editing the answers share over SMB drops AppleDouble files that hijack a machine's
answer — fixed in the store, with a regression test.

Still open

  • CI has never run these workflows; this PR is the first time.
  • aarch64 and the remaining DSM 7-capable ARMv7 platforms (M6).
  • A TOML configuration file, noted for later.

🤖 Generated with Claude Code

wamxx and others added 5 commits August 26, 2026 10:57
macOS writes an AppleDouble `._<name>` beside a file whose extended
attributes the filesystem will not take. On an answers directory edited
over SMB from a Mac, `._98-fa-9b-50-d8-10.toml` therefore appears next to
the real file — with an extension that *is* on the allowlist, and a name
that normalizes to the same identity, because normalization strips the
leading `._` along with every other non-alphanumeric character.

It then claims the very machine the real file was written for, with a
body that is binary. The machine being configured receives a parse error
instead of its answer, and `check` fails the group that claims it too.
`.DS_Store` escapes only by luck: its extension is not on the list.

A hidden file is never somebody's answer, so the file store now skips
every entry whose name starts with a dot. Found on a real NAS whose
share was mounted on a Mac, not by reading anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `.spk` is a release format, not a build: the same binary, wrapped for
one platform's package manager. Nothing in `src/` learns that Synology
exists. The two places DSM pressed back are answered in packaging — log
rotation by a `copytruncate` stanza, and a CLI that cannot find its
configuration by a three-line wrapper.

`packaging/dsm/` carries the archive; `make-spk.sh` assembles it
deterministically and `check-spk.sh` asserts its shape. Two harnesses
test it without a NAS: `lifecycle-test.sh` drives the real lifecycle
scripts against a fake tree, and `vm/` runs the rest on a DSM 7 virtual
machine in Docker. Both run in CI.

**armv7 is no longer musl, and that is the largest thing here.**
Synology's ARMv7 kernels are 3.10 and answer the time64 syscalls with
EINVAL rather than ENOSYS; musl 1.2 falls back to the 32-bit syscalls
only on ENOSYS, so every clock_gettime, clock_nanosleep and timed futex
fails. The symptom is a binary that answers `--version` and then panics
at time.rs:131 the moment it wants a timestamp — which looks like an ABI
problem and is not. Measured on a DS416j with a ten-line C probe. The
target is now armv7-unknown-linux-gnueabihf with a 2.17 floor, which DSM
has and newer ARMv7 Linux keeps working with. x86_64 and aarch64 are
64-bit, have no time32/time64 split, and stay static musl. What CI
asserts for armv7 is therefore no longer "static" but "needs no glibc
newer than the floor" — the old claim was repeated in six files and was
only ever half true.

Two defects the local harness could not see, both found by running the
machine: the package root cannot be derived from SYNOPKG_PKGDEST, which
resolves through a symlink into /volume1/@appstore; and
SYNOPKG_TEMP_UPGRADE_FOLDER outlives the upgrade that created it, so a
fresh install was restoring a removed installation's configuration,
tokens and all.

Installed, started, answered, upgraded and uninstalled on a DS416j.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rig and the lifecycle harness had only ever run on macOS and inside
DSM. Run in a Debian container — which is what CI is — two of them broke,
and one broke silently.

`stat -f '%Lp'` is the format flag on BSD. On GNU it means "filesystem
status", and with a separate argument it *succeeds*: the variable meant
to hold a file mode filled with overlayfs trivia and the `||` fallback
never fired, so two mode assertions compared a mode against a paragraph.
Asking GNU first fails cleanly on macOS. (`build.sh`'s `stat -f%z` is
safe by accident: bundled, GNU rejects it as an invalid option.)

`shasum` is a Perl script. A minimal Debian does not have it; Ubuntu
runners do, which is how a script like this ships broken to everyone
else. `sha256sum` is coreutils.

Also silences SC2087 in deploy.sh, where the client-side expansion in
that heredoc is the point rather than an oversight.

Verified by reproducing the CI job in a container: both .spk assembled
with GNU tar, checked structurally with a real readelf and a real
`--version`, the armv7 glibc floor asserted, shellcheck clean, and the
lifecycle harness green at 34.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mments

The configuration is environment variables, and on a deployment that reads
them from a file there was no way to see or change one without an editor.
`rescriptum config` prints every variable and — the part that matters —
which of the file and the environment put it there: the file supplies
defaults and the real environment wins, so a panel that offered to edit a
value the environment overrides would be offering to change nothing.

`config set` edits the file the way a person would want it edited. Comments
survive, because on a packaged install they are the only documentation the
configuration has; a commented-out setting is uncommented in place rather
than appended below, leaving its explanation attached to the right line.
The write preserves the file's owner as well as its mode — a root-owned
rewrite of a 0600 file the service owns is a server that stops starting one
restart later, for no reason anybody changed.

Two refusals are deliberate: a misspelled variable, which written would be
read back as a stranger and warned about only at the next start; and any
change that would leave a server unable to start, refused whole before
anything is written. The exit code says whether the configuration is one the
server would start on, which makes it usable from a script the way `check`
is.

Unlike every other subcommand it is dispatched before `Config::from_env` and
`validate`: a file that will not parse and a token one character short are
the states people run it to get out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AyfAtgDRDguioTRDEqEDEm
DSM has no settings panel for a package, so changing a port or a log level
meant SSH and vi on a 0600 file — not something a DS416j owner should have
to do. The package now installs an application on the DSM desktop: the
configuration as a form, the status, and the log.

It is a real DSM application, built on the desktop's own framework, not a
page of ours in a frame. Its backend is a CGI that authenticates against
DSM's session, requires `administrators`, and refuses a write that does not
carry a header a page on another origin cannot make a browser send. It edits
the file rather than asking the running server, so it still works when the
server will not start — which is when a settings panel earns its place.
Underneath, it runs `rescriptum config`: the env-file semantics stay in Rust
where they are tested rather than written a second time in shell.

Nothing in src/ knows any of this exists.

Four things were measured on a DSM 7.2.2 VM and a DS416j on 7.1.1, and none
of them is in the developer guide. A CGI under webman runs as the owner of
the script — the package user, not http and not root — which is what lets it
read the file it owns. That path is not authenticated by DSM at all, so the
checks inside the script are the only door. The guide's own ExtJS example
does not run: it is ExtJS 3.4.1, `callParent` does not chain, and a method
named `show` silently overrides the one DSM calls to display the window —
which built, laid out, rendered its taskbar thumbnail and never appeared,
without throwing on either version. And reproducible builds fight browser
caches: fixed mtimes become `Last-Modified: 2019`, so the application's file
carries the version in its name.

ExtJS rather than DSM 7.2's Vue framework because the DS416j is capped at
7.1.1, where Vue is undefined; ExtJS is on both, so one application covers
every DSM this package supports. os_min_ver is 7.1, and 7.0 is not claimed
because nothing has run there.

Also fixes build.sh, which still named the old ARMv7 musl target and so
built the binary and then quietly produced no package at all — for the one
machine this project exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AyfAtgDRDguioTRDEqEDEm
@wamxx wamxx changed the title feat(packaging): a Synology DSM 7 package, and the ARMv7 target it forced feat(packaging): a Synology DSM 7 package, the ARMv7 target it forced, and a desktop application Aug 26, 2026
@wamxx wamxx closed this Aug 26, 2026
@wamxx wamxx reopened this Aug 26, 2026
@wamxx
wamxx merged commit a95c9c6 into develop Aug 26, 2026
4 checks passed
@wamxx
wamxx deleted the feature/dsm-package branch August 26, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant