@synopackageland/templates 0.26.5 → 0.27.1
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.
- package/README.md +21 -8
- package/package.json +1 -1
- package/templates/_shared/AGENTS.md +7 -2
- package/templates/native-go/README.md +11 -0
- package/templates/native-go/backend/internal/app/background.go +52 -41
- package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
- package/templates/native-go/backend/internal/app/background_schedule_test.go +0 -4
- package/templates/native-go/backend/internal/app/background_test.go +59 -104
- package/templates/native-go-delegate/backend/internal/app/delegate.go +0 -6
- package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +3 -6
- package/templates/native-go-jobs/frontend/test/background-shell.test.ts +19 -0
- package/templates/native-go-vue/frontend/DESIGN.md +6 -9
- package/templates/native-go-vue/frontend/src/App.vue +2 -4
- package/templates/native-node/README.md +11 -1
- package/templates/native-node-jobs/backend/src/jobs.ts +4 -31
- package/templates/native-node-jobs/backend/test/jobs.test.ts +9 -42
- package/templates/native-python/README.md +11 -1
- package/templates/native-python-jobs/backend/app/jobs.py +2 -34
- package/templates/native-python-jobs/backend/tests/test_jobs.py +7 -38
- package/templates/native-rust/README.md +11 -1
- package/templates/native-rust-jobs/backend/src/jobs.rs +21 -142
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +0 -59
|
@@ -43,16 +43,13 @@ Refresh and save expose busy state and localized errors. Reopen refreshes status
|
|
|
43
43
|
Each mount owns its state; unmount unsubscribes from host events and ignores late
|
|
44
44
|
responses. Backend route policies decide access.
|
|
45
45
|
|
|
46
|
-
###
|
|
46
|
+
### Background jobs
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
- **Errors and cleanup:** danger alerts map known error codes to localized safe copy, with a localized fallback rather than raw server text. The catalogs cover nine locales. Preserve per-mount disposal: stop polling, dispose the picker, revoke result download URLs and prevent pending responses from restoring disposed state. Deletion confirmation explains that queued jobs cancel, running jobs drain and finished results remain.
|
|
54
|
-
|
|
55
|
-
This layout is checked against the template's local component fixtures at the desktop size and at the DSM minimum window size, including the states and recovery copy above. That validates the component contract only; visual acceptance on a specific DSM release is the App developer's own check.
|
|
48
|
+
Job producers import their own input into the App data directory. The backend
|
|
49
|
+
keeps submission, progress, cancellation and private-copy scheduling contracts;
|
|
50
|
+
the shell does not pick or import NAS files. App-specific job controls can use
|
|
51
|
+
the installed SDK and UI components. Keep explicit consent for schedules and
|
|
52
|
+
user-and-UID ownership isolation, and stop polling on unmount.
|
|
56
53
|
|
|
57
54
|
## Do's and Don'ts
|
|
58
55
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
|
3
3
|
import {Button, Callout, Form, FormActions, FormItem, KeyValueRow, SettingsSection, Textarea} from '@synopackageland/ui'
|
|
4
4
|
import {bridgeErrorCopy, sharedFolderAccessFor, type AppStatus, type Context, type ShellPage} from '@synopackageland/sdk'
|
|
5
|
-
import {AppShell,
|
|
5
|
+
import {AppShell, FilesPage, LoginPage, PortalSettings, SharedFolderSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
|
|
6
6
|
import {appCopy} from './i18n'
|
|
7
7
|
import CapabilityPanels from './CapabilityPanels.vue'
|
|
8
8
|
import ExecutorGate, {holdsPages} from './ExecutorGate.vue'
|
|
@@ -66,11 +66,10 @@ async function refresh() {
|
|
|
66
66
|
}
|
|
67
67
|
// The header Refresh also reloads the panels below and reads the portal again.
|
|
68
68
|
const panels = ref<{refresh(): unknown}>()
|
|
69
|
-
const background = ref<{refresh(): unknown}>()
|
|
70
69
|
const portalReads = ref(0)
|
|
71
70
|
const portalKey = computed(() => `${app.value?.user}:${portalReads.value}`)
|
|
72
71
|
function refreshAll() {
|
|
73
|
-
void refresh(); void panels.value?.refresh();
|
|
72
|
+
void refresh(); void panels.value?.refresh(); portalReads.value++
|
|
74
73
|
}
|
|
75
74
|
async function save() {
|
|
76
75
|
if(disposed || busy.value) return
|
|
@@ -122,7 +121,6 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
|
|
|
122
121
|
<CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
|
|
123
122
|
<FilesPage v-if="page==='status'" :ctx="ctx" />
|
|
124
123
|
<div data-page="background" v-show="page==='background'">
|
|
125
|
-
<BackgroundPanel v-if="backgroundEnabled && app" ref="background" :key="app.user" :ctx="ctx" />
|
|
126
124
|
</div>
|
|
127
125
|
</div>
|
|
128
126
|
</ExecutorGate>
|
|
@@ -111,7 +111,7 @@ rebuilding the SPK.
|
|
|
111
111
|
|
|
112
112
|
| `--with` | Methods | Runtime services |
|
|
113
113
|
| --- | --- | --- |
|
|
114
|
-
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `
|
|
114
|
+
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
|
|
115
115
|
| `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
|
|
116
116
|
| `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
|
|
117
117
|
|
|
@@ -158,3 +158,13 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
|
|
|
158
158
|
| Build hosts | macOS, Linux |
|
|
159
159
|
| Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
|
|
160
160
|
| DSM | 7.1 or later declared (`os_min_ver`) |
|
|
161
|
+
|
|
162
|
+
## Background job input
|
|
163
|
+
|
|
164
|
+
Jobs process an existing file imported by the App's producer into its private
|
|
165
|
+
data directory. The submission params are `{key, name, input}`: an idempotency
|
|
166
|
+
key, a display name and an absolute path inside that directory. The SDK keeps a
|
|
167
|
+
bounded private copy for the worker; no browser session or NAS picker is used.
|
|
168
|
+
Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
|
|
169
|
+
cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
|
|
170
|
+
services; the runtime checks the input path and binds ownership to the caller.
|
|
@@ -1,38 +1,11 @@
|
|
|
1
|
-
// Background jobs
|
|
2
|
-
|
|
3
|
-
// The SDK's worker runs each job as the package account: it hashes the job's
|
|
4
|
-
// input (SHA-256) and keeps the result for the job's owner, the caller. The
|
|
5
|
-
// runtime serves files.read with the caller's DSM session and bg.* for the
|
|
6
|
-
// caller's own jobs only.
|
|
7
|
-
import { copyFileSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
8
|
-
import { randomUUID } from 'node:crypto'
|
|
9
|
-
import { basename, join } from 'node:path'
|
|
10
|
-
import { AppError, type App, type Context } from '@synopackageland/app'
|
|
1
|
+
// Background jobs over files already imported into the App data directory.
|
|
2
|
+
import type { App } from '@synopackageland/app'
|
|
11
3
|
import { strings } from './params.js'
|
|
12
4
|
import type { Config } from './routes.js'
|
|
13
5
|
import { service } from './service.js'
|
|
14
6
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export function register(app: App, cfg: Config): void {
|
|
19
|
-
app.method('hash.submit', (ctx, params) => submit(cfg, ctx, params))
|
|
7
|
+
export function register(app: App, _cfg: Config): void {
|
|
8
|
+
app.method('hash.submit', (ctx, params) => service(ctx, 'bg.submit', strings(params, 'key', 'name', 'input')))
|
|
20
9
|
app.method('hash.get', (ctx, params) => service(ctx, 'bg.get', strings(params, 'id')))
|
|
21
10
|
app.method('hash.cancel', (ctx, params) => service(ctx, 'bg.cancel', strings(params, 'id')))
|
|
22
11
|
}
|
|
23
|
-
|
|
24
|
-
async function submit(cfg: Config, ctx: Context, params: unknown) {
|
|
25
|
-
const { key, path } = strings(params, 'key', 'path')
|
|
26
|
-
const read = await service<{ path: string; size: number }>(ctx, 'files.read', { path })
|
|
27
|
-
if (read.size > MAX_INPUT) throw new AppError('limit_exceeded', 'The file is larger than allowed.')
|
|
28
|
-
// bg.submit takes its input from the data directory; the call's spool is elsewhere.
|
|
29
|
-
const inputs = join(cfg.dataDir, 'hash-inputs')
|
|
30
|
-
mkdirSync(inputs, { recursive: true, mode: 0o700 })
|
|
31
|
-
const input = join(inputs, randomUUID())
|
|
32
|
-
copyFileSync(read.path, input)
|
|
33
|
-
try {
|
|
34
|
-
return await service(ctx, 'bg.submit', { key, name: basename(path), input })
|
|
35
|
-
} finally {
|
|
36
|
-
unlinkSync(input)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
@@ -1,64 +1,31 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join
|
|
4
|
+
import { join } from 'node:path'
|
|
5
5
|
import { afterEach, beforeEach, test } from 'node:test'
|
|
6
6
|
import { build, type Config } from '../src/routes.js'
|
|
7
|
-
import {
|
|
7
|
+
import { call } from './support.js'
|
|
8
8
|
|
|
9
9
|
const JOB = { job: { id: 'j1', state: 'queued' }, duplicate: false }
|
|
10
10
|
let root: string
|
|
11
11
|
let cfg: Config
|
|
12
|
-
let submitted: Record<string, unknown>[]
|
|
13
12
|
|
|
14
13
|
beforeEach(() => {
|
|
15
14
|
root = mkdtempSync(join(tmpdir(), 'app-'))
|
|
16
15
|
cfg = { packageId: 'ExampleApp', dataDir: join(root, 'var'), packagesRoot: join(root, 'packages') }
|
|
17
16
|
mkdirSync(cfg.dataDir)
|
|
18
|
-
submitted = []
|
|
19
17
|
})
|
|
20
18
|
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// The runtime reads the input file while it answers bg.submit.
|
|
29
|
-
function submit(params: unknown) {
|
|
30
|
-
const p = params as { input: string }
|
|
31
|
-
submitted.push({ ...p, content: readFileSync(p.input, 'utf8') })
|
|
32
|
-
return JOB
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
test('hash.submit hashes a copy of the caller\'s file', async () => {
|
|
36
|
-
const services = { 'files.read': () => spool(Buffer.from('hello')), 'bg.submit': submit }
|
|
37
|
-
const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/report.txt' }, { services })
|
|
38
|
-
assert.deepEqual(reply.data, JOB)
|
|
39
|
-
assert.deepEqual(reply.services.map(([op]) => op), ['files.read', 'bg.submit'])
|
|
40
|
-
assert.deepEqual(reply.services[0]![1], { path: '/home/report.txt' })
|
|
41
|
-
const [job] = submitted as [{ key: string; name: string; content: string; input: string }]
|
|
42
|
-
assert.deepEqual([job.key, job.name, job.content], ['k1', 'report.txt', 'hello'])
|
|
43
|
-
assert.ok(job.input.startsWith(cfg.dataDir + sep), job.input)
|
|
44
|
-
assert.equal(existsSync(job.input), false, 'the copy is removed once submitted')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('hash.submit refuses a file over the job input limit', async () => {
|
|
48
|
-
const services = { 'files.read': () => spool(Buffer.alloc(512 * 1024 + 1)), 'bg.submit': submit }
|
|
49
|
-
const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/big.bin' }, { services })
|
|
50
|
-
assert.equal(reply.error?.code, 'limit_exceeded')
|
|
51
|
-
assert.deepEqual(submitted, [])
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('hash.submit passes on the runtime\'s refusal', async () => {
|
|
55
|
-
const services = { 'files.read': () => { throw new Refusal('read_failed', 'Unable to read the file.') } }
|
|
56
|
-
const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/a' }, { services })
|
|
57
|
-
assert.equal(reply.error?.code, 'read_failed')
|
|
20
|
+
test('hash.submit passes imported input to the runtime', async () => {
|
|
21
|
+
const params = { key: 'k1', name: 'report.txt', input: join(cfg.dataDir, 'report.txt') }
|
|
22
|
+
const reply = await call(build(cfg), 'hash.submit', params, { services: { 'bg.submit': () => JOB } })
|
|
23
|
+
assert.deepEqual([reply.data, reply.services], [JOB, [['bg.submit', params]]])
|
|
24
|
+
assert.deepEqual(readdirSync(cfg.dataDir), [])
|
|
58
25
|
})
|
|
59
26
|
|
|
60
27
|
test('hash.submit refuses invalid params before any service', async () => {
|
|
61
|
-
for (const params of [null, { key: 'k1' }, { key: 1,
|
|
28
|
+
for (const params of [null, { key: 'k1' }, { key: 1, name: 'a', input: '/app/a' }, { key: 'k1', name: 'a', input: '/app/a', x: 1 }]) {
|
|
62
29
|
const reply = await call(build(cfg), 'hash.submit', params)
|
|
63
30
|
assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
|
|
64
31
|
}
|
|
@@ -108,7 +108,7 @@ rebuilding the SPK.
|
|
|
108
108
|
|
|
109
109
|
| `--with` | Methods | Runtime services |
|
|
110
110
|
| --- | --- | --- |
|
|
111
|
-
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `
|
|
111
|
+
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
|
|
112
112
|
| `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
|
|
113
113
|
| `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
|
|
114
114
|
|
|
@@ -155,3 +155,13 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
|
|
|
155
155
|
| Build hosts | macOS, Linux |
|
|
156
156
|
| Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
|
|
157
157
|
| DSM | 7.1 or later declared (`os_min_ver`) |
|
|
158
|
+
|
|
159
|
+
## Background job input
|
|
160
|
+
|
|
161
|
+
Jobs process an existing file imported by the App's producer into its private
|
|
162
|
+
data directory. The submission params are `{key, name, input}`: an idempotency
|
|
163
|
+
key, a display name and an absolute path inside that directory. The SDK keeps a
|
|
164
|
+
bounded private copy for the worker; no browser session or NAS picker is used.
|
|
165
|
+
Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
|
|
166
|
+
cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
|
|
167
|
+
services; the runtime checks the input path and binds ownership to the caller.
|
|
@@ -1,40 +1,8 @@
|
|
|
1
|
-
"""Background jobs
|
|
2
|
-
|
|
3
|
-
The SDK's worker runs each job as the package account: it hashes the job's
|
|
4
|
-
input (SHA-256) and keeps the result for the job's owner, the caller. The
|
|
5
|
-
runtime serves files.read with the caller's DSM session and bg.* for the
|
|
6
|
-
caller's own jobs only.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import os
|
|
10
|
-
import shutil
|
|
11
|
-
import tempfile
|
|
12
|
-
|
|
13
|
-
from synopkgland_app import Error
|
|
14
|
-
|
|
1
|
+
"""Background jobs over files already imported into the App data directory."""
|
|
15
2
|
from params import fields
|
|
16
3
|
|
|
17
|
-
# A job's input is at most 512 KiB, copied into the job when it is submitted.
|
|
18
|
-
MAX_INPUT = 512 * 1024
|
|
19
|
-
|
|
20
4
|
|
|
21
5
|
def register(app, cfg):
|
|
22
|
-
app.method("hash.submit")(lambda ctx, params: submit(
|
|
6
|
+
app.method("hash.submit")(lambda ctx, params: ctx.runtime("bg.submit", fields(params, key=str, name=str, input=str)))
|
|
23
7
|
app.method("hash.get")(lambda ctx, params: ctx.runtime("bg.get", fields(params, id=str)))
|
|
24
8
|
app.method("hash.cancel")(lambda ctx, params: ctx.runtime("bg.cancel", fields(params, id=str)))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def submit(cfg, ctx, params):
|
|
28
|
-
p = fields(params, key=str, path=str)
|
|
29
|
-
read = ctx.runtime("files.read", {"path": p["path"]})
|
|
30
|
-
if read["size"] > MAX_INPUT:
|
|
31
|
-
raise Error("limit_exceeded", "The file is larger than allowed.")
|
|
32
|
-
# bg.submit takes its input from the data directory; the call's spool is elsewhere.
|
|
33
|
-
inputs = os.path.join(cfg.data_dir, "hash-inputs")
|
|
34
|
-
os.makedirs(inputs, mode=0o700, exist_ok=True)
|
|
35
|
-
with open(read["path"], "rb") as source, tempfile.NamedTemporaryFile(dir=inputs, delete=False) as copy:
|
|
36
|
-
shutil.copyfileobj(source, copy)
|
|
37
|
-
try:
|
|
38
|
-
return ctx.runtime("bg.submit", {"key": p["key"], "name": os.path.basename(p["path"]), "input": copy.name})
|
|
39
|
-
finally:
|
|
40
|
-
os.unlink(copy.name)
|
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import tempfile
|
|
3
3
|
import unittest
|
|
4
4
|
|
|
5
|
-
from support import
|
|
5
|
+
from support import call
|
|
6
6
|
|
|
7
7
|
import routes
|
|
8
8
|
|
|
@@ -16,46 +16,15 @@ class JobsTest(unittest.TestCase):
|
|
|
16
16
|
self.data = os.path.join(self.dir.name, "var")
|
|
17
17
|
os.mkdir(self.data)
|
|
18
18
|
self.app = routes.build(routes.Config("ExampleApp", self.data))
|
|
19
|
-
self.submitted = []
|
|
20
19
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def submit(self, params):
|
|
28
|
-
# The runtime reads the input file while it answers bg.submit.
|
|
29
|
-
with open(params["input"], "rb") as f:
|
|
30
|
-
self.submitted.append(dict(params, content=f.read()))
|
|
31
|
-
return JOB
|
|
32
|
-
|
|
33
|
-
def test_submit_hashes_a_copy_of_the_callers_file(self):
|
|
34
|
-
services = {"files.read": lambda p: self.spool(b"hello"), "bg.submit": self.submit}
|
|
35
|
-
reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/report.txt"}, services=services)
|
|
36
|
-
self.assertEqual(reply.data, JOB)
|
|
37
|
-
self.assertEqual([op for op, _ in reply.services], ["files.read", "bg.submit"])
|
|
38
|
-
self.assertEqual(reply.services[0][1], {"path": "/home/report.txt"})
|
|
39
|
-
[submitted] = self.submitted
|
|
40
|
-
self.assertEqual((submitted["key"], submitted["name"], submitted["content"]), ("k1", "report.txt", b"hello"))
|
|
41
|
-
self.assertTrue(submitted["input"].startswith(self.data + os.sep))
|
|
42
|
-
self.assertFalse(os.path.exists(submitted["input"]), "the copy is removed once submitted")
|
|
43
|
-
|
|
44
|
-
def test_submit_refuses_a_file_over_the_job_input_limit(self):
|
|
45
|
-
services = {"files.read": lambda p: self.spool(b"x" * (512 * 1024 + 1)), "bg.submit": self.submit}
|
|
46
|
-
reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/big.bin"}, services=services)
|
|
47
|
-
self.assertEqual(reply.error["code"], "limit_exceeded")
|
|
48
|
-
self.assertEqual(self.submitted, [])
|
|
49
|
-
|
|
50
|
-
def test_submit_passes_on_the_runtimes_refusal(self):
|
|
51
|
-
def unreadable(params):
|
|
52
|
-
raise Error("read_failed", "Unable to read the file.")
|
|
53
|
-
|
|
54
|
-
reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/a"}, services={"files.read": unreadable})
|
|
55
|
-
self.assertEqual(reply.error["code"], "read_failed")
|
|
20
|
+
def test_submit_passes_imported_input_to_the_runtime(self):
|
|
21
|
+
params = {"key": "k1", "name": "report.txt", "input": os.path.join(self.data, "report.txt")}
|
|
22
|
+
reply = call(self.app, "hash.submit", params, services={"bg.submit": lambda p: JOB})
|
|
23
|
+
self.assertEqual((reply.data, reply.services), (JOB, [("bg.submit", params)]))
|
|
24
|
+
self.assertEqual(os.listdir(self.data), [])
|
|
56
25
|
|
|
57
26
|
def test_submit_refuses_invalid_params_before_any_service(self):
|
|
58
|
-
for params in [None, {"key": "k1"}, {"key": 1, "
|
|
27
|
+
for params in [None, {"key": "k1"}, {"key": 1, "name": "a", "input": "/app/a"}, {"key": "k1", "name": "a", "input": "/app/a", "x": 1}]:
|
|
59
28
|
with self.subTest(params=params):
|
|
60
29
|
reply = call(self.app, "hash.submit", params)
|
|
61
30
|
self.assertEqual((reply.error["code"], reply.services), ("bad_request", []))
|
|
@@ -117,7 +117,7 @@ rebuilding the SPK.
|
|
|
117
117
|
|
|
118
118
|
| `--with` | Methods | Runtime services |
|
|
119
119
|
| --- | --- | --- |
|
|
120
|
-
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `
|
|
120
|
+
| `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
|
|
121
121
|
| `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
|
|
122
122
|
| `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
|
|
123
123
|
|
|
@@ -164,3 +164,13 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
|
|
|
164
164
|
| Build hosts | macOS, Linux |
|
|
165
165
|
| Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
|
|
166
166
|
| DSM | 7.1 or later declared (`os_min_ver`) |
|
|
167
|
+
|
|
168
|
+
## Background job input
|
|
169
|
+
|
|
170
|
+
Jobs process an existing file imported by the App's producer into its private
|
|
171
|
+
data directory. The submission params are `{key, name, input}`: an idempotency
|
|
172
|
+
key, a display name and an absolute path inside that directory. The SDK keeps a
|
|
173
|
+
bounded private copy for the worker; no browser session or NAS picker is used.
|
|
174
|
+
Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
|
|
175
|
+
cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
|
|
176
|
+
services; the runtime checks the input path and binds ownership to the caller.
|
|
@@ -1,90 +1,41 @@
|
|
|
1
|
-
//! Background jobs
|
|
2
|
-
|
|
3
|
-
//! The SDK's worker runs each job as the package account: it hashes the job's
|
|
4
|
-
//! input (SHA-256) and keeps the result for the job's owner, the caller. The
|
|
5
|
-
//! runtime serves files.read with the caller's DSM session and bg.* for the
|
|
6
|
-
//! caller's own jobs only.
|
|
7
|
-
|
|
8
|
-
use crate::routes::{Config, internal};
|
|
1
|
+
//! Background jobs over files already imported into the App data directory.
|
|
2
|
+
use crate::routes::Config;
|
|
9
3
|
use serde::Deserialize;
|
|
10
4
|
use serde_json::{Value, json};
|
|
11
|
-
use std::os::unix::fs::DirBuilderExt;
|
|
12
|
-
use std::path::{Path, PathBuf};
|
|
13
5
|
use syno_pkg_land_app::{App, Context, Error, Params};
|
|
14
6
|
|
|
15
|
-
/// A job's input is at most 512 KiB, copied into the job when it is submitted.
|
|
16
|
-
const MAX_INPUT: u64 = 512 * 1024;
|
|
17
|
-
|
|
18
7
|
#[derive(Deserialize)]
|
|
19
8
|
#[serde(deny_unknown_fields)]
|
|
20
9
|
struct Submit {
|
|
21
10
|
key: String,
|
|
22
|
-
|
|
11
|
+
name: String,
|
|
12
|
+
input: String,
|
|
23
13
|
}
|
|
24
|
-
|
|
25
14
|
#[derive(Deserialize)]
|
|
26
15
|
#[serde(deny_unknown_fields)]
|
|
27
16
|
struct JobId {
|
|
28
17
|
id: String,
|
|
29
18
|
}
|
|
30
19
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
app.method("hash.submit", move |ctx, params| submit(&cfg, ctx, params));
|
|
20
|
+
pub fn register(app: &mut App, _cfg: &Config) {
|
|
21
|
+
app.method("hash.submit", |ctx, params| -> Result<Value, Error> {
|
|
22
|
+
let Submit { key, name, input } = params.decode()?;
|
|
23
|
+
ctx.runtime(
|
|
24
|
+
"bg.submit",
|
|
25
|
+
&json!({"key": key, "name": name, "input": input}),
|
|
26
|
+
)
|
|
27
|
+
});
|
|
40
28
|
app.method("hash.get", |ctx, params| job(ctx, "bg.get", params));
|
|
41
29
|
app.method("hash.cancel", |ctx, params| job(ctx, "bg.cancel", params));
|
|
42
30
|
}
|
|
43
|
-
|
|
44
31
|
fn job(ctx: &Context, op: &str, params: &Params) -> Result<Value, Error> {
|
|
45
32
|
let JobId { id } = params.decode()?;
|
|
46
33
|
ctx.runtime(op, &json!({"id": id}))
|
|
47
34
|
}
|
|
48
|
-
|
|
49
|
-
fn submit(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
|
|
50
|
-
let p: Submit = params.decode()?;
|
|
51
|
-
let read: Read = ctx.runtime("files.read", &json!({"path": p.path}))?;
|
|
52
|
-
if read.size > MAX_INPUT {
|
|
53
|
-
return Err(Error::new(
|
|
54
|
-
"limit_exceeded",
|
|
55
|
-
"The file is larger than allowed.",
|
|
56
|
-
));
|
|
57
|
-
}
|
|
58
|
-
// bg.submit takes its input from the data directory; the call's spool is elsewhere.
|
|
59
|
-
let inputs = cfg.data_dir.join("hash-inputs");
|
|
60
|
-
std::fs::DirBuilder::new()
|
|
61
|
-
.recursive(true)
|
|
62
|
-
.mode(0o700)
|
|
63
|
-
.create(&inputs)
|
|
64
|
-
.map_err(internal)?;
|
|
65
|
-
let copy = inputs.join(&ctx.request_id);
|
|
66
|
-
std::fs::copy(&read.path, ©).map_err(internal)?;
|
|
67
|
-
let name = Path::new(&p.path)
|
|
68
|
-
.file_name()
|
|
69
|
-
.map_or(String::new(), |n| n.to_string_lossy().into_owned());
|
|
70
|
-
let submitted = ctx.runtime(
|
|
71
|
-
"bg.submit",
|
|
72
|
-
&json!({"key": p.key, "name": name, "input": copy}),
|
|
73
|
-
);
|
|
74
|
-
let _ = std::fs::remove_file(©);
|
|
75
|
-
submitted
|
|
76
|
-
}
|
|
77
|
-
|
|
78
35
|
#[cfg(test)]
|
|
79
36
|
mod tests {
|
|
80
37
|
use super::*;
|
|
81
38
|
use crate::test_support::{Services, TempDir, call};
|
|
82
|
-
use serde_json::{Value, json};
|
|
83
|
-
use std::sync::{Arc, Mutex};
|
|
84
|
-
|
|
85
|
-
/// What bg.submit was asked: its params and the input it read.
|
|
86
|
-
type Submitted = Arc<Mutex<Vec<(Value, Vec<u8>)>>>;
|
|
87
|
-
|
|
88
39
|
fn setup() -> (TempDir, Config) {
|
|
89
40
|
let dir = TempDir::new();
|
|
90
41
|
let cfg = Config {
|
|
@@ -100,102 +51,30 @@ mod tests {
|
|
|
100
51
|
json!({"job": {"id": "j1", "state": "queued"}, "duplicate": false})
|
|
101
52
|
}
|
|
102
53
|
|
|
103
|
-
/// files.read answers a spool file with content; bg.submit records its params and the input it read.
|
|
104
|
-
fn services(dir: &TempDir, content: Vec<u8>, submitted: &Submitted) -> Services {
|
|
105
|
-
let spool = dir.path().join("spool");
|
|
106
|
-
std::fs::write(&spool, &content).unwrap();
|
|
107
|
-
let read = json!({"path": spool, "size": content.len()});
|
|
108
|
-
let submitted = submitted.clone();
|
|
109
|
-
[
|
|
110
|
-
(
|
|
111
|
-
"files.read",
|
|
112
|
-
Box::new(move |_: &Value| Ok(read.clone())) as _,
|
|
113
|
-
),
|
|
114
|
-
(
|
|
115
|
-
"bg.submit",
|
|
116
|
-
Box::new(move |p: &Value| {
|
|
117
|
-
// The runtime reads the input file while it answers bg.submit.
|
|
118
|
-
let input = std::fs::read(p["input"].as_str().unwrap()).unwrap();
|
|
119
|
-
submitted.lock().unwrap().push((p.clone(), input));
|
|
120
|
-
Ok(job())
|
|
121
|
-
}) as _,
|
|
122
|
-
),
|
|
123
|
-
]
|
|
124
|
-
.into()
|
|
125
|
-
}
|
|
126
|
-
|
|
127
54
|
#[test]
|
|
128
|
-
fn
|
|
129
|
-
let (dir, cfg) = setup();
|
|
130
|
-
let submitted = Arc::new(Mutex::new(Vec::new()));
|
|
131
|
-
let services = services(&dir, b"hello".to_vec(), &submitted);
|
|
132
|
-
let reply = call(
|
|
133
|
-
crate::routes::build(cfg.clone()),
|
|
134
|
-
"hash.submit",
|
|
135
|
-
json!({"key": "k1", "path": "/home/report.txt"}),
|
|
136
|
-
"alice",
|
|
137
|
-
&services,
|
|
138
|
-
);
|
|
139
|
-
assert_eq!(reply.data, job());
|
|
140
|
-
let ops: Vec<_> = reply.services.iter().map(|(op, _)| op.as_str()).collect();
|
|
141
|
-
assert_eq!(ops, ["files.read", "bg.submit"]);
|
|
142
|
-
assert_eq!(reply.services[0].1, json!({"path": "/home/report.txt"}));
|
|
143
|
-
let submitted = submitted.lock().unwrap();
|
|
144
|
-
let (params, content) = &submitted[0];
|
|
145
|
-
assert_eq!(
|
|
146
|
-
(¶ms["key"], ¶ms["name"], content.as_slice()),
|
|
147
|
-
(&json!("k1"), &json!("report.txt"), b"hello".as_slice())
|
|
148
|
-
);
|
|
149
|
-
let input = params["input"].as_str().unwrap();
|
|
150
|
-
assert!(input.starts_with(cfg.data_dir.to_str().unwrap()), "{input}");
|
|
151
|
-
assert!(
|
|
152
|
-
!std::path::Path::new(input).exists(),
|
|
153
|
-
"the copy is removed once submitted"
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
#[test]
|
|
158
|
-
fn submit_refuses_a_file_over_the_job_input_limit() {
|
|
159
|
-
let (dir, cfg) = setup();
|
|
160
|
-
let submitted = Arc::new(Mutex::new(Vec::new()));
|
|
161
|
-
let services = services(&dir, vec![0; MAX_INPUT as usize + 1], &submitted);
|
|
162
|
-
let reply = call(
|
|
163
|
-
crate::routes::build(cfg),
|
|
164
|
-
"hash.submit",
|
|
165
|
-
json!({"key": "k1", "path": "/home/big.bin"}),
|
|
166
|
-
"alice",
|
|
167
|
-
&services,
|
|
168
|
-
);
|
|
169
|
-
assert_eq!(reply.code(), "limit_exceeded");
|
|
170
|
-
assert!(submitted.lock().unwrap().is_empty());
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
#[test]
|
|
174
|
-
fn submit_passes_on_the_runtimes_refusal() {
|
|
55
|
+
fn submit_passes_imported_input_to_the_runtime() {
|
|
175
56
|
let (_dir, cfg) = setup();
|
|
176
|
-
let
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
)]
|
|
180
|
-
.into();
|
|
57
|
+
let params =
|
|
58
|
+
json!({"key":"k1","name":"report.txt","input":cfg.data_dir.join("report.txt")});
|
|
59
|
+
let services: Services = [("bg.submit", Box::new(|_: &Value| Ok(job())) as _)].into();
|
|
181
60
|
let reply = call(
|
|
182
61
|
crate::routes::build(cfg),
|
|
183
62
|
"hash.submit",
|
|
184
|
-
|
|
63
|
+
params.clone(),
|
|
185
64
|
"alice",
|
|
186
65
|
&services,
|
|
187
66
|
);
|
|
188
|
-
assert_eq!(reply.
|
|
67
|
+
assert_eq!(reply.data, job());
|
|
68
|
+
assert_eq!(reply.services, vec![("bg.submit".to_string(), params)]);
|
|
189
69
|
}
|
|
190
|
-
|
|
191
70
|
#[test]
|
|
192
71
|
fn submit_refuses_invalid_params_before_any_service() {
|
|
193
72
|
let (_dir, cfg) = setup();
|
|
194
73
|
for params in [
|
|
195
74
|
Value::Null,
|
|
196
75
|
json!({"key": "k1"}),
|
|
197
|
-
json!({"key": 1, "
|
|
198
|
-
json!({"key": "k1", "
|
|
76
|
+
json!({"key": 1, "name": "a", "input": "/app/a"}),
|
|
77
|
+
json!({"key": "k1", "name": "a", "input": "/app/a", "x": 1}),
|
|
199
78
|
] {
|
|
200
79
|
let reply = call(
|
|
201
80
|
crate::routes::build(cfg.clone()),
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import {expect,test,vi} from 'vitest'
|
|
3
|
-
import {nextTick} from 'vue'
|
|
4
|
-
import {mount} from '../src/main'
|
|
5
|
-
import {dsmWindow} from './context'
|
|
6
|
-
async function settle(){for(let i=0;i<20;i++){await Promise.resolve();await nextTick()}}
|
|
7
|
-
// The CLI turns the background page on for jobs (and delegate) projects only.
|
|
8
|
-
test('a jobs project mounts the background panel for the signed-in user',async()=>{
|
|
9
|
-
const call=vi.fn(async(method:string)=>{
|
|
10
|
-
if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
|
|
11
|
-
throw {code:'unknown_method'}
|
|
12
|
-
})
|
|
13
|
-
const root=document.createElement('div');document.body.append(root)
|
|
14
|
-
const app=mount(root,dsmWindow({call}))
|
|
15
|
-
try{
|
|
16
|
-
await settle()
|
|
17
|
-
expect(call.mock.calls.some(([method])=>method.startsWith('bg.'))).toBe(true)
|
|
18
|
-
expect(root.querySelector('[data-page="background"]')).not.toBeNull()
|
|
19
|
-
}finally{app.unmount();root.remove()}
|
|
20
|
-
})
|
|
21
|
-
test('the background panel pauses a running job and resumes a paused one',async()=>{
|
|
22
|
-
const submitter={user:'alice',uid:'1024'}
|
|
23
|
-
const jobs=[{id:'run',name:'a.bin',state:'running',submitter},{id:'held',name:'b.bin',state:'paused',pauseReason:'user',submitter}]
|
|
24
|
-
const call=vi.fn(async(method:string):Promise<unknown>=>{
|
|
25
|
-
if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
|
|
26
|
-
if(method==='bg.status')return {owner:submitter,admin:false,revision:1,worker:{username:'pkg',alive:true},limits:{maxInputBytes:1024,maxQueued:8,maxOwnerPending:4,maxOwnerBytes:4096,jobTimeoutSeconds:60}}
|
|
27
|
-
if(method==='bg.jobs.list')return {jobs}
|
|
28
|
-
if(method==='bg.schedules.list')return {schedules:[]}
|
|
29
|
-
return {}
|
|
30
|
-
})
|
|
31
|
-
const root=document.createElement('div');document.body.append(root)
|
|
32
|
-
const app=mount(root,dsmWindow({call}))
|
|
33
|
-
const button=(id:string,label:string)=>Array.from(root.querySelectorAll(`[data-job-id="${id}"] button`)).find(b=>b.textContent===label) as HTMLButtonElement|undefined
|
|
34
|
-
try{
|
|
35
|
-
await settle()
|
|
36
|
-
button('run','Pause')!.click();await settle()
|
|
37
|
-
expect(call).toHaveBeenCalledWith('bg.jobs.pause',{id:'run'})
|
|
38
|
-
button('held','Resume')!.click();await settle()
|
|
39
|
-
expect(call).toHaveBeenCalledWith('bg.jobs.resume',{id:'held'})
|
|
40
|
-
}finally{app.unmount();root.remove()}
|
|
41
|
-
})
|
|
42
|
-
test('the header Refresh reloads the background panel',async()=>{
|
|
43
|
-
const call=vi.fn(async(method:string):Promise<unknown>=>{
|
|
44
|
-
if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
|
|
45
|
-
if(method==='bg.status')return {owner:{user:'alice',uid:'1024'},admin:false,revision:1,worker:{username:'pkg',alive:true},limits:{maxInputBytes:1024,maxQueued:8,maxOwnerPending:4,maxOwnerBytes:4096,jobTimeoutSeconds:60}}
|
|
46
|
-
if(method==='bg.jobs.list')return {jobs:[]}
|
|
47
|
-
if(method==='bg.schedules.list')return {schedules:[]}
|
|
48
|
-
return {}
|
|
49
|
-
})
|
|
50
|
-
const root=document.createElement('div');document.body.append(root)
|
|
51
|
-
const app=mount(root,dsmWindow({call}))
|
|
52
|
-
const reads=()=>call.mock.calls.filter(([method])=>method==='bg.jobs.list').length
|
|
53
|
-
try{
|
|
54
|
-
await settle();const before=reads()
|
|
55
|
-
// The header button comes first; the panels below may have their own.
|
|
56
|
-
;(Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='Refresh') as HTMLButtonElement).click();await settle()
|
|
57
|
-
expect(reads()).toBeGreaterThan(before)
|
|
58
|
-
}finally{app.unmount();root.remove()}
|
|
59
|
-
})
|