@synopackageland/templates 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +27 -4
  2. package/package.json +2 -2
  3. package/templates/_shared/AGENTS.md +95 -18
  4. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  5. package/templates/native-go/backend/cmd/app/main.go +15 -6
  6. package/templates/native-go/backend/cmd/background-worker/main.go +5 -1
  7. package/templates/native-go/backend/go.mod +1 -1
  8. package/templates/native-go/backend/go.sum +2 -2
  9. package/templates/native-go/backend/internal/app/background.go +19 -8
  10. package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
  11. package/templates/native-go/backend/internal/app/background_test.go +24 -0
  12. package/templates/native-go/backend/internal/app/background_work.go +94 -0
  13. package/templates/native-go/backend/internal/app/background_work_test.go +95 -0
  14. package/templates/native-go/backend/internal/app/portal.go +49 -4
  15. package/templates/native-go/backend/internal/app/portal_test.go +128 -0
  16. package/templates/native-go/backend/internal/app/routes.go +6 -4
  17. package/templates/native-go/backend/internal/app/uninstall.go +19 -0
  18. package/templates/native-go/backend/internal/app/uninstall_test.go +33 -0
  19. package/templates/native-go/dsm-package.yaml +18 -0
  20. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +1 -1
  21. package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +2 -0
  22. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +4 -0
  23. package/templates/native-go-jobs/frontend/test/background-page.test.ts +21 -0
  24. package/templates/native-go-vue/frontend/package-lock.json +8 -8
  25. package/templates/native-go-vue/frontend/package.json +2 -2
  26. package/templates/native-rust/README.md +381 -0
  27. package/templates/native-rust/backend/Cargo.lock +125 -0
  28. package/templates/native-rust/backend/Cargo.toml +24 -0
  29. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +207 -0
  30. package/templates/native-rust/backend/permissions.json +5 -0
  31. package/templates/native-rust/backend/rust-toolchain.toml +4 -0
  32. package/templates/native-rust/backend/src/app/background/mod.rs +424 -0
  33. package/templates/native-rust/backend/src/app/background/owner.rs +77 -0
  34. package/templates/native-rust/backend/src/app/background/owner_tests.rs +174 -0
  35. package/templates/native-rust/backend/src/app/background/schedule.rs +126 -0
  36. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +108 -0
  37. package/templates/native-rust/backend/src/app/background/tests.rs +505 -0
  38. package/templates/native-rust/backend/src/app/background/work.rs +78 -0
  39. package/templates/native-rust/backend/src/app/background/work_tests.rs +123 -0
  40. package/templates/native-rust/backend/src/app/background_routes.rs +22 -0
  41. package/templates/native-rust/backend/src/app/delegate_config.rs +188 -0
  42. package/templates/native-rust/backend/src/app/delegate_routes.rs +22 -0
  43. package/templates/native-rust/backend/src/app/identity.rs +56 -0
  44. package/templates/native-rust/backend/src/app/migrations.rs +79 -0
  45. package/templates/native-rust/backend/src/app/mod.rs +30 -0
  46. package/templates/native-rust/backend/src/app/portal.rs +317 -0
  47. package/templates/native-rust/backend/src/app/root_routes.rs +23 -0
  48. package/templates/native-rust/backend/src/app/routes.rs +315 -0
  49. package/templates/native-rust/backend/src/app/session_files.rs +19 -0
  50. package/templates/native-rust/backend/src/app/shared_folder.rs +383 -0
  51. package/templates/native-rust/backend/src/app/test_support.rs +200 -0
  52. package/templates/native-rust/backend/src/app/uninstall.rs +38 -0
  53. package/templates/native-rust/backend/src/bin/api.rs +72 -0
  54. package/templates/native-rust/backend/src/bin/background-worker.rs +37 -0
  55. package/templates/native-rust/backend/src/build_env.rs +155 -0
  56. package/templates/native-rust/backend/src/lib.rs +6 -0
  57. package/templates/native-rust/backend/src/permissions.rs +54 -0
  58. package/templates/native-rust/backend/src/process.rs +91 -0
  59. package/templates/native-rust/dsm-package.yaml +89 -0
  60. package/templates/native-rust/gitignore +2 -0
  61. package/templates/native-rust/i18n/glossary.json +663 -0
  62. package/templates/native-rust/i18n/messages/de.json +29 -0
  63. package/templates/native-rust/i18n/messages/en.json +29 -0
  64. package/templates/native-rust/i18n/messages/es.json +29 -0
  65. package/templates/native-rust/i18n/messages/fr.json +29 -0
  66. package/templates/native-rust/i18n/messages/it.json +29 -0
  67. package/templates/native-rust/i18n/messages/ja.json +29 -0
  68. package/templates/native-rust/i18n/messages/ko.json +29 -0
  69. package/templates/native-rust/i18n/messages/zh-CN.json +29 -0
  70. package/templates/native-rust/i18n/messages/zh-TW.json +29 -0
  71. package/templates/native-rust/ui/app.css +45 -0
  72. package/templates/native-rust/ui/app.js +111 -0
  73. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +92 -0
  74. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +52 -0
  75. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +109 -0
  76. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +475 -0
  77. package/templates/native-rust-vue/README.md +55 -0
  78. package/templates/native-rust-vue/gitignore +3 -0
package/README.md CHANGED
@@ -11,6 +11,11 @@ the files an App owns; the SDK (`github.com/synopackageland/syno-pkg-land-sdk`,
11
11
  | `native-go-jobs` | Background job routes in `internal/app` and their page test |
12
12
  | `native-go-delegate` | Delegate routes, the delegate background worker, the delegate `CapabilityPanels.vue` |
13
13
  | `native-go-root-ops` | Executor routes in `internal/app` and an example `root/whoami.sh` |
14
+ | `native-rust` | Base: `dsm-package.yaml` with `backend.kind: rust`, Cargo package `app` (`backend/src/bin/{api,background-worker}.rs`, `backend/src/app`, `backend/permissions.json`, committed `Cargo.lock`, `rust-toolchain.toml`), plain `ui/`, i18n |
15
+ | `native-rust-vue` | Only the Vue README and gitignore; the shell itself is `native-go-vue`'s `frontend/` |
16
+ | `native-rust-jobs` | `backend/src/app/background_routes.rs`: the background job routes and their tests |
17
+ | `native-rust-delegate` | `delegate_routes.rs` and the delegate `background-worker.rs` |
18
+ | `native-rust-root-ops` | `root_routes.rs`: the executor routes and their tests |
14
19
  | `binary` | A prebuilt static CGI (`example/main.go`) with a plain UI |
15
20
  | `compose` | Container Manager project (`compose.yaml`, `synology-app.yaml`) |
16
21
  | `_shared` | Agent guides, template license, Go license texts, third-party notice writer |
@@ -21,9 +26,22 @@ An overlay may replace `internal/app/*_none.go` (the CLI deletes the matching
21
26
  The placeholders `__PACKAGE__` (also the Go module path), `__DISPLAY_NAME__` and
22
27
  `__BACKGROUND_ENABLED__` are substituted by `init`.
23
28
 
24
- `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.9.5`; that line is
29
+ Rust has no build tags, so the `native-rust-*` overlays overwrite same-named
30
+ base files (`background_routes.rs`, `delegate_routes.rs`, `root_routes.rs`,
31
+ `bin/background-worker.rs`) and nothing is removed. They carry only the
32
+ backend. A Rust project borrows the native-go overlays' non-backend files: the
33
+ Vue shell from `native-go-vue` (then `native-rust-vue`), `frontend/` of
34
+ `native-go-jobs` and `native-go-delegate`, and `root/whoami.sh` of
35
+ `native-go-root-ops`. A delegate project without root-ops also takes
36
+ `native-rust-root-ops`'s `root_routes.rs`, the executor shell Go borrows as
37
+ `executor.go`. `scripts/assemble.sh --language rust` does exactly this.
38
+
39
+ `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.9.7`; that line is
25
40
  the single source of the SDK version and `frontend/package.json` pins
26
- `@synopackageland/sdk` to the same version.
41
+ `@synopackageland/sdk` to the same version. A Rust project takes it from the
42
+ `tag` of the `syno-pkg-land-sdk` git dependency in `backend/Cargo.toml`, which
43
+ `Cargo.lock` repeats; build-time values reach it through the contract's
44
+ `SYNOPKGLAND_*` environment variables.
27
45
 
28
46
  ## Notice writer
29
47
 
@@ -45,7 +63,8 @@ and `UI-THIRD_PARTY.md` in the output directory; other files there stay.
45
63
  export GOPRIVATE='github.com/synopackageland/*' # the SDK repository is private
46
64
  export SYNOPKGLAND_SDK_DIR=~/Public/synopackageland/sdk # optional sdk checkout: Go module at the root, ts/
47
65
  scripts/assemble.sh /tmp/app vue delegate # one combination, as init lays it out
48
- scripts/gate.sh # layout, every combination, binary, npm pack
66
+ scripts/assemble.sh --language rust /tmp/app vue delegate # the same, for native-rust
67
+ scripts/gate.sh # layout, every combination (Go and Rust), binary, npm pack
49
68
  ```
50
69
 
51
70
  Without `SYNOPKGLAND_SDK_DIR` the combinations use the released SDK that
@@ -53,7 +72,11 @@ Without `SYNOPKGLAND_SDK_DIR` the combinations use the released SDK that
53
72
  `replace github.com/synopackageland/syno-pkg-land-sdk => $SYNOPKGLAND_SDK_DIR`
54
73
  and installs `$SYNOPKGLAND_SDK_DIR/ts` as an `npm pack` tarball (not a link, so Vue
55
74
  resolves once). With `SYNOPKGLAND_UI_TARBALL` set it installs that tarball as
56
- `@synopackageland/ui`, for a UI version not yet on npm. CI is not available; `scripts/gate.sh` is the gate.
75
+ `@synopackageland/ui`, for a UI version not yet on npm. The Rust gate runs `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --locked`
76
+ and a target clippy for the three DSM targets per combination; `rustup` installs the
77
+ toolchain `backend/rust-toolchain.toml` pins. With `SYNOPKGLAND_SDK_DIR`, `scripts/rust-env.sh`
78
+ (sourced by the gate) makes cargo fetch the SDK's pinned tag from that checkout instead of
79
+ GitHub, leaving the Cargo files as shipped. CI is not available; `scripts/gate.sh` is the gate.
57
80
 
58
81
  SDK usage, protocol and API documentation: <https://github.com/synopackageland/syno-pkg-land-sdk>.
59
82
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@synopackageland/templates",
3
- "version": "0.9.4",
4
- "description": "Project templates for synopkgland init: native-go (plain or Vue, with jobs, delegate and root-ops overlays), binary and compose.",
3
+ "version": "0.9.6",
4
+ "description": "Project templates for synopkgland init: native-go and native-rust (plain or Vue, with jobs, delegate and root-ops overlays), binary and compose.",
5
5
  "license": "MIT",
6
6
  "repository": {"type": "git", "url": "git+https://github.com/synopackageland/syno-pkg-land-templates.git"},
7
7
  "exports": {
@@ -17,6 +17,9 @@ Only a confirmed refusal (signed out, expired session, not an administrator)
17
17
  shows the sign-in page or a `@synopackageland/ui` Dialog; loading text inside a
18
18
  section is fine.
19
19
 
20
+ Everything below holds for every native backend language; the backend
21
+ sections at the end name each language's files, types and test commands.
22
+
20
23
  From the project root, run `synopkgland check --json`,
21
24
  `synopkgland build --json`, then `synopkgland inspect <spk> --json` on the emitted SPK.
22
25
  For Vue, first run `npm ci`, `npm test` and `npm run build` in frontend.
@@ -32,7 +35,7 @@ access. One CGI check covers every route; failures refuse; changes apply within
32
35
  30 seconds. Never re-implement AppPriv rules in the App.
33
36
  Mark routes ordinary users call `authenticated` in permissions.json, keep whole
34
37
  admin routes at the default, guard global settings inside shared routes with
35
- `api.RequireAdmin(caller)`, and hide admin controls with `ctx.user?.isAdmin`
38
+ the SDK's require-admin check, and hide admin controls with `ctx.user?.isAdmin`
36
39
  (UI only, never the authority). Example: users read the greeting; only admins
37
40
  call `greeting.set`.
38
41
 
@@ -51,9 +54,46 @@ admin's session) or Control Panel > Login Portal > Applications. Ordinary users
51
54
  get the link from the backend's read-only `portal.status`, else "ask an
52
55
  administrator". Never write `AppPortal.json` or `@appconf/<package>`, never add
53
56
  a backend route that sets the alias, and never relay QuickConnect responses or
54
- build QuickConnect links. A portal needs os_min_ver 7.3.2-86009 or later (DSM
55
- 7.1.1 refuses it with error 320; 7.2 is untested) and `add portal` raises it;
56
- beyond that, support is detected (SYNO.API.Info), not version-gated.
57
+ build QuickConnect links. A portal needs os_min_ver 7.1-42661 or later, and
58
+ `add portal` raises a lower one. Below DSM 7.2 the executor registers the
59
+ entry: there is no alias wizard step, and an administrator enables the entry
60
+ from the portal panel (`ctx.portal.register()`), which the executor's approved
61
+ `portal.register` operation carries out. From 7.2 the SPK registers it as a
62
+ DSM resource. Beyond that, support is detected (SYNO.API.Info), not
63
+ version-gated.
64
+
65
+ ## Background jobs (`jobs`)
66
+
67
+ The worker runs the App's Work for one claimed job at a time, as the package
68
+ account. The Work receives the job's Task: it reads the private input copy,
69
+ reports progress (`done`/`total`, `bytes`/`totalBytes`, `item`) and saves a
70
+ checkpoint, at most 16 KiB of text, only once the work it describes is done.
71
+ Owners pause and resume their jobs (`bg.jobs.pause`, `bg.jobs.resume`; the
72
+ SDK BackgroundPanel shows the buttons): a queued job pauses at once, a running
73
+ one at its next checkpoint poll. A package stop pauses the running job too, and
74
+ the next start resumes it. A resumed job runs again from its last checkpoint,
75
+ or from the start when it saved none, so a Work must be safe to repeat from any
76
+ checkpoint it saved. Delegated jobs cannot pause, and the pause routes answer
77
+ `conflict` for them. The template's Work hashes the input, reports bytes read
78
+ and checkpoints the hash state every 64 KiB; replace it with the App's own work.
79
+
80
+ ## Uninstall hook (`uninstall`)
81
+
82
+ Declare `uninstall.purge` in dsm-package.yaml (the template ships it
83
+ commented) to run a fixed package executable on a real uninstall (not an
84
+ upgrade), as the package account, before the executor is removed. It gets
85
+ `SYNOPKGLAND_DELETE_STATE=0|1` and `SYNOPKGLAND_REMOVE_DATA=0|1` from the
86
+ uninstall wizard and nothing else from the environment. Release what the App
87
+ holds outside the package on every uninstall; delete the state listed in
88
+ `uninstall.state.items` (each key needs `uninstall.state.<key>` in
89
+ i18n/messages) only with `SYNOPKGLAND_DELETE_STATE=1`, and keep what a
90
+ reinstall can reuse otherwise. A failure or timeout (2–120 s, default 60) is
91
+ logged to syslog tag `synopkgland-uninstall` with output in
92
+ `var/uninstall-hook.log` and never blocks the uninstall. `purge.root: <op>`
93
+ (rootOps only) adds a root step `root/<op>.sh [delete-state]` that runs only
94
+ while the administrator's approval of that exact policy stands; sessions
95
+ cannot call it through `root.run`. The template backend's `uninstall` mode
96
+ deletes the background job spool when asked.
57
97
 
58
98
  ## Permanent destination integration
59
99
 
@@ -80,8 +120,8 @@ a receipt. Cancel by the same reference; revoke by grant ID. Never resubmit
80
120
  uncertain/interrupted work. Admin disable is protected and revokes grants;
81
121
  enable/mailbox start does not resurrect them. Keep IDs, never session credentials.
82
122
 
83
- App-owned daemon completion and scheduled CSV export call configured Go
84
- `Service.MoveFromStaging` with `delegation_id`, stable `job_id`, private relative
123
+ App-owned daemon completion and scheduled CSV export call the configured
124
+ delegate service's move-from-staging with `delegation_id`, stable `job_id`, private relative
85
125
  `staging`, `name`, `size`. No owner/UID selector. Bounds: 16 GiB/file, queued16,
86
126
  active1/package, 24-hour attempt, 5-second revalidation, 10 MiB operation records,
87
127
  50 MiB audit. Scope is a usage restriction, not hard subtree confinement.
@@ -93,7 +133,7 @@ Admission recovery (per job): on `EXECUTOR_UNAVAILABLE`, `EXECUTOR_TIMEOUT`,
93
133
  `EXECUTOR_PROTOCOL`, `canceled`, `EXECUTOR_PAUSED`, `queue-full`,
94
134
  `STORAGE_ERROR`, `ADMISSION_PENDING`, `jobs_busy` or `jobs_unavailable` (the
95
135
  package journal was busy or failed transiently; a caller deadline or cancel
96
- spent waiting for it is `canceled`), call `MoveFromStaging` again later with
136
+ spent waiting for it is `canceled`), call move-from-staging again later with
97
137
  the same exact params. It queries the original reference first and re-sends
98
138
  admission only when the executor does not know it and the pinned source is
99
139
  unchanged; executor tuple deduplication keeps one operation, so a lost reply or a
@@ -105,11 +145,11 @@ retry: `APP_DENIED`, `DELEGATION_DENIED`, `operation-conflict`, `disabled`,
105
145
  spool first), and `operation-not-found` for an accepted operation.
106
146
  Never change job/grant IDs, delete mirrors or call the socket directly to retry.
107
147
 
108
- Cancellation (per job): `CancelOperation` / `delegate.operations.cancel` with the
148
+ Cancellation (per job): the service's cancel-operation / `delegate.operations.cancel` with the
109
149
  original ref is idempotent; repeat it with the same ref after any retryable
110
150
  error or a lost reply. A reply `queued`→`canceled` is final at once. A copy in
111
151
  flight answers `copying` with code `CANCEL_REQUESTED`: the cancel is accepted and
112
- durable, not finished; poll `OperationStatus` on the same ref until terminal
152
+ durable, not finished; poll the operation status on the same ref until terminal
113
153
  (repeating the cancel is harmless). `canceled` (`OPERATION_CANCELED`) guarantees the writer
114
154
  stopped, its part file is removed, nothing was published and staging is kept; it
115
155
  stays canceled across executor crash, package stop/start and logout. `committed`
@@ -118,7 +158,7 @@ the app record may be dropped. `uncertain` means a publish attempt failed before
118
158
  the cancel; keep querying. A cancel of a ref the executor has not admitted yet
119
159
  (reserved/sent mirror) returns `canceled` and leaves a tombstone, so a late or
120
160
  recovered admission of that ref returns `canceled` and never copies; a producer
121
- cancel is also written to the mirror first, so `MoveFromStaging` and the worker
161
+ cancel is also written to the mirror first, so move-from-staging and the worker
122
162
  sweep forward the cancel instead of admitting it.
123
163
 
124
164
 
@@ -142,14 +182,51 @@ sweep forward the cancel instead of admitting it.
142
182
  sdk.destination('save')` returns typed `{key, selection, destination, status}`.
143
183
  `selection` contains `{scope, root, generation}`; `destination` is this caller's
144
184
  grant or null. Handle active/missing/reselection-required/needs-scope-confirmation/disabled.
145
- - Go: `Service.Destination(ctx, caller, request, key)` verifies the fresh DSM
146
- session and username+UID. Capture its active grant ID into the App job. The
147
- offline producer uses existing `Service.MoveFromStaging`; no user/UID selector,
148
- cookie, token, or browser path cache. Executor revalidates at use/publication.
185
+ - Backend: the service's destination lookup verifies the fresh DSM session and
186
+ username+UID. Capture its active grant ID into the App job. The offline
187
+ producer uses the existing move-from-staging; no user/UID selector, cookie,
188
+ token, or browser path cache. Executor revalidates at use/publication.
149
189
  - The generated Background saves page includes a working **Save example file**
150
- (`sdk.exportExample({key, job_id})`, `delegate.example.export`). Go
151
- `ExportExample` creates a private JSON file, calls `MoveFromStaging` once, and
152
- repeats the original request on repeated IDs. Review `example.go` to
153
- adapt a real producer; show Saved only from a protected committed receipt.
190
+ (`sdk.exportExample({key, job_id})`, `delegate.example.export`). The SDK's
191
+ export-example creates a private JSON file, calls move-from-staging once, and
192
+ repeats the original request on repeated IDs. Review it in the SDK's delegate
193
+ package to adapt a real producer; show Saved only from a protected committed
194
+ receipt.
154
195
  - Wizard support is measured on vt-nas DSM 7.4 via a session-only SSH local
155
196
  forward. This is not proof of other DSM releases or full writer/reboot behavior.
197
+
198
+ ## Go backend
199
+
200
+ - Routes: `backend/internal/app` (`routes.go` registers them); App
201
+ permissions: `backend/internal/api/permissions.json`. Test with
202
+ `(cd backend && go test ./...)`, then `go vet ./...` and `gofmt -l .`.
203
+ - Admin guard inside a shared route: `api.RequireAdmin(caller)`.
204
+ - Jobs: `background.Work` is `func(ctx, *background.Task) (background.Result,
205
+ error)`; call `t.Report(background.Progress{...})` and `t.Checkpoint(state)`,
206
+ and resume from `t.Job.Checkpoint`. The template's Work is
207
+ `app.HashWork` (`background_work.go`), set as `Runner.Work` in
208
+ `cmd/background-worker` and as `delegate.Service.Work` with delegate.
209
+ - Uninstall hook: `app.Uninstall(dataDir, deleteState)` (`uninstall.go`),
210
+ reached by `api.cgi uninstall`.
211
+ - Delegate: `delegate.Service.Destination(ctx, caller, request, key)`,
212
+ `Service.MoveFromStaging`, `Service.CancelOperation`,
213
+ `Service.OperationStatus`, `Service.ExportExample` (the SDK's
214
+ `delegate/example.go`).
215
+
216
+ ## Rust backend
217
+
218
+ - Routes: `backend/src/app/routes.rs`; App permissions:
219
+ `backend/permissions.json`. Test with `(cd backend && cargo test --locked)`,
220
+ then `cargo clippy --locked --all-targets -- -D warnings` and
221
+ `cargo fmt --check`.
222
+ - Admin guard inside a shared route: `api::require_admin(caller)`.
223
+ - Jobs: `background::Work` is `Arc<dyn Fn(&Context, &mut Task<'_>) ->
224
+ Result<JobResult, BoxError>>`; call `t.report(Progress {..})` and
225
+ `t.checkpoint(state)`, and resume from `t.job.checkpoint`. The template's
226
+ Work is `app::hash_work` (`app/background/work.rs`, hash state through
227
+ `sha256::Sha256::save`/`restore`), set as `Runner.work` in
228
+ `bin/background-worker.rs` and as `delegate::Service.work` with delegate.
229
+ - Uninstall hook: `app::uninstall(data_dir, delete_state)`
230
+ (`app/uninstall.rs`), reached by `api.cgi uninstall`.
231
+ - Delegate: `Service::destination`, `move_from_staging`, `cancel_operation`,
232
+ `operation_status`, `export_example` (the SDK's `delegate/example.rs`).
@@ -1,6 +1,6 @@
1
1
  Native Go package build dependencies
2
2
 
3
- github.com/synopackageland/syno-pkg-land-sdk v0.9.5 (MIT)
3
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.7 (MIT)
4
4
 
5
5
  Copyright (c) 2026 synopkgland
6
6
 
@@ -1,5 +1,6 @@
1
1
  // Command app is the package backend. DSM runs it as a CGI (ui/cgi/api.cgi in
2
- // the package) under the package account, once per request.
2
+ // the package) under the package account, once per request, and as the
3
+ // uninstall hook (`api.cgi uninstall`) when dsm-package.yaml declares one.
3
4
  //
4
5
  //line __PACKAGE__/cmd/app/main.go:1
5
6
  package main
@@ -36,13 +37,20 @@ var (
36
37
  identity = ""
37
38
  // dsm-package.yaml entry: "admin-only" or "app-privilege"; empty keeps the route policy alone.
38
39
  entry = ""
39
- // "on" when dsm-package.yaml declares portal: serves portal.status.
40
+ // "on" when dsm-package.yaml declares portal: serves portal.status;
41
+ // "executor" below DSM 7.2, where the executor registers the entry.
40
42
  portal = ""
41
43
  )
42
44
 
43
45
  func main() {
44
46
  log.SetFlags(0)
45
47
  log.SetPrefix(packageID + ": ")
48
+ if os.Getenv("GATEWAY_INTERFACE") == "" && len(os.Args) == 2 && os.Args[1] == "uninstall" {
49
+ if err := app.Uninstall("/var/packages/"+packageID+"/var", os.Getenv("SYNOPKGLAND_DELETE_STATE") == "1"); err != nil {
50
+ log.Fatal(err)
51
+ }
52
+ return
53
+ }
46
54
  if os.Getenv("GATEWAY_INTERFACE") == "" {
47
55
  fmt.Fprintf(os.Stderr, "%s %s: this program runs as the package's DSM CGI; open the app from the DSM main menu.\n", packageID, version)
48
56
  os.Exit(2)
@@ -53,10 +61,11 @@ func main() {
53
61
  DelegateDestinationsJSON: delegateDestinationsJSON,
54
62
  DelegateLifetime: delegateLifetime,
55
63
  ExecutorDigest: executorDigest, PolicyDigest: policyDigest,
56
- PackageID: packageID,
57
- Version: version,
58
- DataDir: "/var/packages/" + packageID + "/var",
59
- Portal: portal == "on",
64
+ PackageID: packageID,
65
+ Version: version,
66
+ DataDir: "/var/packages/" + packageID + "/var",
67
+ Portal: portal != "",
68
+ PortalExecutor: portal == "executor",
60
69
  }
61
70
  if _, err := app.ParseDelegateConfig(cfg); err != nil {
62
71
  log.Fatal(err)
@@ -13,6 +13,8 @@ import (
13
13
  "syscall"
14
14
 
15
15
  "github.com/synopackageland/syno-pkg-land-sdk/background"
16
+
17
+ "__PACKAGE__/internal/app"
16
18
  )
17
19
 
18
20
  // Set by `synopkgland build-spk` through -ldflags -X.
@@ -32,7 +34,9 @@ func main() {
32
34
  ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
33
35
  defer stop()
34
36
  log.Printf("started as uid %s", strconv.Itoa(info.UID))
35
- if err := background.NewRunner("/var/packages/"+packageID+"/var/background", info).Run(ctx); err != nil {
37
+ runner := background.NewRunner("/var/packages/"+packageID+"/var/background", info)
38
+ runner.Work = app.HashWork
39
+ if err := runner.Run(ctx); err != nil {
36
40
  log.Fatal(err)
37
41
  }
38
42
  }
@@ -4,6 +4,6 @@ go 1.25.0
4
4
 
5
5
  toolchain go1.26.9
6
6
 
7
- require github.com/synopackageland/syno-pkg-land-sdk v0.9.5
7
+ require github.com/synopackageland/syno-pkg-land-sdk v0.9.7
8
8
 
9
9
  require golang.org/x/sys v0.44.0 // indirect
@@ -1,4 +1,4 @@
1
- github.com/synopackageland/syno-pkg-land-sdk v0.9.5 h1:Kab3TVny+Zqqy7tzp5YqT1ZdcUhzVtH0uL3uu58wPXo=
2
- github.com/synopackageland/syno-pkg-land-sdk v0.9.5/go.mod h1:JNoqISvR4biA9IuLi0CFe4c272tsKzCN9Nyj71nrXsQ=
1
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.7 h1:i8Zeowz7pLu3dI7v5/RijCw5OwMoZdMgzUoV/sD68n8=
2
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.7/go.mod h1:hlsvaVuH2wsAoR8jzI1Rg/NeejQSFpAaN6zpIWsVbjI=
3
3
  golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
4
4
  golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -65,6 +65,8 @@ func newBackgroundRoutes(store *background.Store, files backgroundFiles) []api.R
65
65
  api.HandleRequest("bg.jobs.list", b.owner(b.list)),
66
66
  api.HandleRequest("bg.jobs.get", b.owner(b.get)),
67
67
  api.HandleRequest("bg.jobs.cancel", b.owner(b.cancel)),
68
+ api.HandleRequest("bg.jobs.pause", b.owner(b.pause)),
69
+ api.HandleRequest("bg.jobs.resume", b.owner(b.resume)),
68
70
  api.HandleRequest("bg.jobs.result", b.owner(b.result)),
69
71
  }
70
72
  }
@@ -175,20 +177,28 @@ func (b backgroundAPI) list(ctx context.Context, _ api.Caller, _ *http.Request,
175
177
  }
176
178
 
177
179
  func (b backgroundAPI) get(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
178
- var in idParams
179
- if err := decodeParams(params, &in); err != nil {
180
- return nil, err
181
- }
182
- job, err := b.store.GetFor(ctx, in.ID, accessFrom(ctx))
183
- return jobOrError(job, err)
180
+ return b.byID(ctx, params, b.store.GetFor)
184
181
  }
185
182
 
186
183
  func (b backgroundAPI) cancel(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
184
+ return b.byID(ctx, params, b.store.CancelFor)
185
+ }
186
+
187
+ // pause holds a queued job at once; a running job pauses at its next checkpoint.
188
+ func (b backgroundAPI) pause(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
189
+ return b.byID(ctx, params, b.store.PauseFor)
190
+ }
191
+
192
+ func (b backgroundAPI) resume(ctx context.Context, _ api.Caller, _ *http.Request, params json.RawMessage) (any, error) {
193
+ return b.byID(ctx, params, b.store.ResumeFor)
194
+ }
195
+
196
+ func (b backgroundAPI) byID(ctx context.Context, params json.RawMessage, act func(context.Context, string, background.Access) (background.Job, error)) (any, error) {
187
197
  var in idParams
188
198
  if err := decodeParams(params, &in); err != nil {
189
199
  return nil, err
190
200
  }
191
- job, err := b.store.CancelFor(ctx, in.ID, accessFrom(ctx))
201
+ job, err := act(ctx, in.ID, accessFrom(ctx))
192
202
  return jobOrError(job, err)
193
203
  }
194
204
 
@@ -239,7 +249,8 @@ func backgroundError(err error) error {
239
249
  return &api.Error{Code: "queue-full", Message: "the background queue is full"}
240
250
  case errors.Is(err, background.ErrQuota):
241
251
  return &api.Error{Code: "quota-exceeded", Message: "the background spool is full"}
242
- case errors.Is(err, background.ErrKeyConflict), errors.Is(err, background.ErrNotCancelable):
252
+ case errors.Is(err, background.ErrKeyConflict), errors.Is(err, background.ErrNotCancelable),
253
+ errors.Is(err, background.ErrNotPausable):
243
254
  return &api.Error{Code: "conflict", Message: "the request conflicts with the job's current state"}
244
255
  case errors.Is(err, background.ErrNotFound):
245
256
  return &api.Error{Code: "not_found", Message: "job not found"}
@@ -52,7 +52,7 @@ func TestBackgroundOwnersAreIsolatedIncludingKeysAndCounts(t *testing.T) {
52
52
  t.Fatalf("status leaks: %v", out)
53
53
  }
54
54
  }
55
- for _, method := range []string{"bg.jobs.get", "bg.jobs.cancel", "bg.jobs.result"} {
55
+ for _, method := range []string{"bg.jobs.get", "bg.jobs.cancel", "bg.jobs.pause", "bg.jobs.resume", "bg.jobs.result"} {
56
56
  for _, id := range []string{a, c, strings.Repeat("0", 32)} {
57
57
  if code, out := e.call(bob, method, `{"id":"`+id+`"}`); code != "not_found" {
58
58
  t.Fatalf("%s %s: %s %v", method, id, code, out)
@@ -144,7 +144,7 @@ func TestFreshOrdinaryUserJobsNeedNoAdminSetup(t *testing.T) {
144
144
  }
145
145
  }
146
146
  id := ownerSubmit(t, e, bob, "fresh")
147
- for _, method := range []string{"bg.jobs.get", "bg.jobs.cancel"} {
147
+ for _, method := range []string{"bg.jobs.get", "bg.jobs.cancel", "bg.jobs.pause", "bg.jobs.resume"} {
148
148
  if code, _ := e.call(carol, method, `{"id":"`+id+`"}`); code != "not_found" {
149
149
  t.Fatalf("other owner %s: %s", method, code)
150
150
  }
@@ -91,6 +91,8 @@ var bgSample = map[string]string{
91
91
  "bg.jobs.list": `{}`,
92
92
  "bg.jobs.get": `{"id":"` + strings.Repeat("a", 32) + `"}`,
93
93
  "bg.jobs.cancel": `{"id":"` + strings.Repeat("a", 32) + `"}`,
94
+ "bg.jobs.pause": `{"id":"` + strings.Repeat("a", 32) + `"}`,
95
+ "bg.jobs.resume": `{"id":"` + strings.Repeat("a", 32) + `"}`,
94
96
  "bg.jobs.result": `{"id":"` + strings.Repeat("a", 32) + `"}`,
95
97
  }
96
98
 
@@ -262,6 +264,28 @@ func TestBackgroundCancelQueuedJob(t *testing.T) {
262
264
  }
263
265
  }
264
266
 
267
+ func TestBackgroundPauseAndResumeQueuedJob(t *testing.T) {
268
+ e := newBgEnv(t)
269
+ _, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
270
+ idp := `{"id":"` + data(out)["job"].(map[string]any)["id"].(string) + `"}`
271
+ for method, state := range map[string]string{"bg.jobs.pause": "paused", "bg.jobs.resume": "queued"} {
272
+ if code, out := e.call(alice, method, idp); code != "" || data(out)["job"].(map[string]any)["state"] != state {
273
+ t.Fatalf("%s: %s %v", method, code, out)
274
+ }
275
+ }
276
+ if code, _ := e.call(alice, "bg.jobs.cancel", idp); code != "" {
277
+ t.Fatal(code)
278
+ }
279
+ for _, m := range []string{"bg.jobs.pause", "bg.jobs.resume"} {
280
+ if code, _ := e.call(alice, m, idp); code != "conflict" {
281
+ t.Fatalf("%s after cancel: %q", m, code)
282
+ }
283
+ if code, _ := e.call(alice, m, `{"id":"`+strings.Repeat("0", 32)+`"}`); code != "not_found" {
284
+ t.Fatalf("%s unknown id: %q", m, code)
285
+ }
286
+ }
287
+ }
288
+
265
289
  func TestBackgroundWorkerAliveExpires(t *testing.T) {
266
290
  if workerAlive(background.WorkerInfo{At: time.Now().Add(-time.Minute)}, time.Now()) || !workerAlive(background.WorkerInfo{At: time.Now()}, time.Now()) {
267
291
  t.Fatal("a stale heartbeat is not alive")
@@ -0,0 +1,94 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "hash"
12
+ "io"
13
+
14
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
15
+ )
16
+
17
+ // hashCheckpointEvery spaces HashWork's checkpoints; a resumed job repeats at
18
+ // most this much hashing. A Work over larger input saves less often.
19
+ const hashCheckpointEvery = 64 << 10
20
+
21
+ // hashState is HashWork's checkpoint: the bytes already hashed and the hash
22
+ // state after them.
23
+ type hashState struct {
24
+ Offset int64 `json:"offset"`
25
+ State []byte `json:"state"`
26
+ }
27
+
28
+ // HashWork is the App's sha256-v1 Work: it reports progress as it reads and
29
+ // checkpoints the hash state, so a job paused by its owner or by a package
30
+ // stop resumes where it left off instead of from the start.
31
+ func HashWork(ctx context.Context, t *background.Task) (background.Result, error) {
32
+ h, done, err := resumeHash(t.Job.Checkpoint, t.Input)
33
+ if err != nil {
34
+ return background.Result{}, err
35
+ }
36
+ total, saved := int64(t.Job.InputBytes), done
37
+ buf := make([]byte, 32<<10)
38
+ for {
39
+ if err := ctx.Err(); err != nil {
40
+ return background.Result{}, err
41
+ }
42
+ n, err := t.Input.Read(buf)
43
+ h.Write(buf[:n])
44
+ done += int64(n)
45
+ t.Report(background.Progress{Bytes: done, TotalBytes: total})
46
+ if errors.Is(err, io.EOF) {
47
+ return background.Result{SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: int(done)}, nil
48
+ }
49
+ if err != nil {
50
+ return background.Result{}, err
51
+ }
52
+ if done-saved >= hashCheckpointEvery {
53
+ state, err := saveHash(h, done)
54
+ if err == nil {
55
+ err = t.Checkpoint(state)
56
+ }
57
+ if err != nil {
58
+ return background.Result{}, err
59
+ }
60
+ saved = done
61
+ }
62
+ }
63
+ }
64
+
65
+ func saveHash(h hash.Hash, offset int64) (string, error) {
66
+ state, err := h.(encoding.BinaryMarshaler).MarshalBinary()
67
+ if err != nil {
68
+ return "", err
69
+ }
70
+ out, err := json.Marshal(hashState{Offset: offset, State: state})
71
+ return string(out), err
72
+ }
73
+
74
+ // resumeHash continues the hash a checkpoint saved, past the input it covered.
75
+ func resumeHash(checkpoint string, in io.Reader) (hash.Hash, int64, error) {
76
+ h := sha256.New()
77
+ if checkpoint == "" {
78
+ return h, 0, nil
79
+ }
80
+ var saved hashState
81
+ if err := json.Unmarshal([]byte(checkpoint), &saved); err != nil {
82
+ return nil, 0, fmt.Errorf("checkpoint: %w", err)
83
+ }
84
+ if saved.Offset < 0 {
85
+ return nil, 0, errors.New("checkpoint: negative offset")
86
+ }
87
+ if err := h.(encoding.BinaryUnmarshaler).UnmarshalBinary(saved.State); err != nil {
88
+ return nil, 0, fmt.Errorf("checkpoint: %w", err)
89
+ }
90
+ if _, err := io.CopyN(io.Discard, in, saved.Offset); err != nil {
91
+ return nil, 0, fmt.Errorf("checkpoint: %w", err)
92
+ }
93
+ return h, saved.Offset, nil
94
+ }
@@ -0,0 +1,95 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "io"
9
+ "path/filepath"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
14
+ )
15
+
16
+ func TestHashWorkResumesFromItsCheckpoint(t *testing.T) {
17
+ head, tail := bytes.Repeat([]byte("h"), 1000), bytes.Repeat([]byte("t"), 3000)
18
+ h := sha256.New()
19
+ h.Write(head)
20
+ saved, err := saveHash(h, int64(len(head)))
21
+ if err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ // The input's first bytes differ from what the checkpoint hashed: only a
25
+ // Work that skips them and continues the saved hash reaches this digest.
26
+ input := append(bytes.Repeat([]byte("x"), len(head)), tail...)
27
+ task := &background.Task{Job: background.Job{Checkpoint: saved}, Input: bytes.NewReader(input)}
28
+ got, err := HashWork(context.Background(), task)
29
+ want := sha256.Sum256(append(head, tail...))
30
+ if err != nil || got.SHA256 != hex.EncodeToString(want[:]) || got.Bytes != len(input) {
31
+ t.Fatalf("%+v %v", got, err)
32
+ }
33
+ }
34
+
35
+ func TestHashWorkRefusesAForeignCheckpoint(t *testing.T) {
36
+ for _, saved := range []string{"{", `{"offset":-1,"state":""}`, `{"offset":3,"state":"AAAA"}`} {
37
+ task := &background.Task{Job: background.Job{Checkpoint: saved}, Input: bytes.NewReader([]byte("abc"))}
38
+ if _, err := HashWork(context.Background(), task); err == nil {
39
+ t.Errorf("%s: accepted", saved)
40
+ }
41
+ }
42
+ }
43
+
44
+ // stopAfter ends the worker's context once n bytes have been read, like a
45
+ // package stop in the middle of a job.
46
+ type stopAfter struct {
47
+ io.Reader
48
+ n int
49
+ stop context.CancelFunc
50
+ }
51
+
52
+ func (s *stopAfter) Read(p []byte) (int, error) {
53
+ n, err := s.Reader.Read(p)
54
+ if s.n -= n; s.n <= 0 {
55
+ s.stop()
56
+ }
57
+ return n, err
58
+ }
59
+
60
+ func TestHashWorkReportsProgressAndCheckpointsBeforeAStop(t *testing.T) {
61
+ store := background.NewStore(filepath.Join(t.TempDir(), "background"))
62
+ ctx := context.Background()
63
+ data := bytes.Repeat([]byte("0123456789abcdef"), 16<<10)
64
+ job, _, err := store.Submit(ctx, background.SubmitRequest{Key: "k", Name: "a.bin", Data: data, Submitter: background.Submitter{User: "alice", UID: "1024"}})
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ worker, stop := context.WithCancel(ctx)
69
+ runner := &background.Runner{Store: store, Timeout: time.Minute, CancelPoll: 10 * time.Millisecond, Work: func(ctx context.Context, t *background.Task) (background.Result, error) {
70
+ t.Input = &stopAfter{Reader: t.Input, n: 3 * int(hashCheckpointEvery) / 2, stop: stop}
71
+ return HashWork(ctx, t)
72
+ }}
73
+ claimed, err := store.Claim(ctx)
74
+ if err != nil || claimed == nil {
75
+ t.Fatalf("%+v %v", claimed, err)
76
+ }
77
+ runner.Process(worker, claimed)
78
+ paused, err := store.Get(ctx, job.ID)
79
+ if err != nil || paused.State != background.StatePaused || paused.Checkpoint == "" || paused.Progress == nil ||
80
+ paused.Progress.Bytes < hashCheckpointEvery || paused.Progress.TotalBytes != int64(len(data)) {
81
+ t.Fatalf("%+v %+v %v", paused, paused.Progress, err)
82
+ }
83
+ if err := store.Recover(ctx); err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ claimed, err = store.Claim(ctx)
87
+ if err != nil || claimed == nil || claimed.Checkpoint != paused.Checkpoint {
88
+ t.Fatalf("%+v %v", claimed, err)
89
+ }
90
+ runner.Work = HashWork
91
+ runner.Process(ctx, claimed)
92
+ if done, err := store.Get(ctx, job.ID); err != nil || done.State != background.StateSucceeded || done.Progress.Bytes != int64(len(data)) {
93
+ t.Fatalf("%+v %v", done, err)
94
+ }
95
+ }