@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.
- package/README.md +27 -4
- package/package.json +2 -2
- package/templates/_shared/AGENTS.md +95 -18
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +15 -6
- package/templates/native-go/backend/cmd/background-worker/main.go +5 -1
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/backend/internal/app/background.go +19 -8
- package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
- package/templates/native-go/backend/internal/app/background_test.go +24 -0
- package/templates/native-go/backend/internal/app/background_work.go +94 -0
- package/templates/native-go/backend/internal/app/background_work_test.go +95 -0
- package/templates/native-go/backend/internal/app/portal.go +49 -4
- package/templates/native-go/backend/internal/app/portal_test.go +128 -0
- package/templates/native-go/backend/internal/app/routes.go +6 -4
- package/templates/native-go/backend/internal/app/uninstall.go +19 -0
- package/templates/native-go/backend/internal/app/uninstall_test.go +33 -0
- package/templates/native-go/dsm-package.yaml +18 -0
- package/templates/native-go-delegate/backend/cmd/background-worker/main.go +1 -1
- package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +2 -0
- package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +4 -0
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +21 -0
- package/templates/native-go-vue/frontend/package-lock.json +8 -8
- package/templates/native-go-vue/frontend/package.json +2 -2
- package/templates/native-rust/README.md +381 -0
- package/templates/native-rust/backend/Cargo.lock +125 -0
- package/templates/native-rust/backend/Cargo.toml +24 -0
- package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +207 -0
- package/templates/native-rust/backend/permissions.json +5 -0
- package/templates/native-rust/backend/rust-toolchain.toml +4 -0
- package/templates/native-rust/backend/src/app/background/mod.rs +424 -0
- package/templates/native-rust/backend/src/app/background/owner.rs +77 -0
- package/templates/native-rust/backend/src/app/background/owner_tests.rs +174 -0
- package/templates/native-rust/backend/src/app/background/schedule.rs +126 -0
- package/templates/native-rust/backend/src/app/background/schedule_tests.rs +108 -0
- package/templates/native-rust/backend/src/app/background/tests.rs +505 -0
- package/templates/native-rust/backend/src/app/background/work.rs +78 -0
- package/templates/native-rust/backend/src/app/background/work_tests.rs +123 -0
- package/templates/native-rust/backend/src/app/background_routes.rs +22 -0
- package/templates/native-rust/backend/src/app/delegate_config.rs +188 -0
- package/templates/native-rust/backend/src/app/delegate_routes.rs +22 -0
- package/templates/native-rust/backend/src/app/identity.rs +56 -0
- package/templates/native-rust/backend/src/app/migrations.rs +79 -0
- package/templates/native-rust/backend/src/app/mod.rs +30 -0
- package/templates/native-rust/backend/src/app/portal.rs +317 -0
- package/templates/native-rust/backend/src/app/root_routes.rs +23 -0
- package/templates/native-rust/backend/src/app/routes.rs +315 -0
- package/templates/native-rust/backend/src/app/session_files.rs +19 -0
- package/templates/native-rust/backend/src/app/shared_folder.rs +383 -0
- package/templates/native-rust/backend/src/app/test_support.rs +200 -0
- package/templates/native-rust/backend/src/app/uninstall.rs +38 -0
- package/templates/native-rust/backend/src/bin/api.rs +72 -0
- package/templates/native-rust/backend/src/bin/background-worker.rs +37 -0
- package/templates/native-rust/backend/src/build_env.rs +155 -0
- package/templates/native-rust/backend/src/lib.rs +6 -0
- package/templates/native-rust/backend/src/permissions.rs +54 -0
- package/templates/native-rust/backend/src/process.rs +91 -0
- package/templates/native-rust/dsm-package.yaml +89 -0
- package/templates/native-rust/gitignore +2 -0
- package/templates/native-rust/i18n/glossary.json +663 -0
- package/templates/native-rust/i18n/messages/de.json +29 -0
- package/templates/native-rust/i18n/messages/en.json +29 -0
- package/templates/native-rust/i18n/messages/es.json +29 -0
- package/templates/native-rust/i18n/messages/fr.json +29 -0
- package/templates/native-rust/i18n/messages/it.json +29 -0
- package/templates/native-rust/i18n/messages/ja.json +29 -0
- package/templates/native-rust/i18n/messages/ko.json +29 -0
- package/templates/native-rust/i18n/messages/zh-CN.json +29 -0
- package/templates/native-rust/i18n/messages/zh-TW.json +29 -0
- package/templates/native-rust/ui/app.css +45 -0
- package/templates/native-rust/ui/app.js +111 -0
- package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +92 -0
- package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +52 -0
- package/templates/native-rust-jobs/backend/src/app/background_routes.rs +109 -0
- package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +475 -0
- package/templates/native-rust-vue/README.md +55 -0
- package/templates/native-rust-vue/gitignore +3 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
use std::sync::Arc;
|
|
2
|
+
|
|
3
|
+
use syno_pkg_land_sdk::api::Route;
|
|
4
|
+
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
use super::test_support::{Sample, sample};
|
|
7
|
+
use super::{BackgroundApi, Config, system_uid_lookup};
|
|
8
|
+
|
|
9
|
+
impl Config {
|
|
10
|
+
/// Serves the owner-scoped background job API. Jobs run in the
|
|
11
|
+
/// package-user worker unit (conf/systemd), never as root.
|
|
12
|
+
pub(super) fn background_routes(self: &Arc<Self>) -> Vec<Route> {
|
|
13
|
+
let uid_lookup = self.uid_lookup.clone().unwrap_or_else(system_uid_lookup);
|
|
14
|
+
BackgroundApi::production(&self.data_dir, uid_lookup).routes()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Valid params for every background route. The fixture has no DSM cookie, so
|
|
19
|
+
/// import is refused before reading a file, and its fresh data directory has
|
|
20
|
+
/// no jobs or schedules.
|
|
21
|
+
#[cfg(test)]
|
|
22
|
+
pub(super) fn samples() -> Vec<Sample> {
|
|
23
|
+
let id = r#"{"id":"missing"}"#;
|
|
24
|
+
vec![
|
|
25
|
+
sample(
|
|
26
|
+
"bg.jobs.purge",
|
|
27
|
+
r#"{"owner":{"user":"missing","uid":"9999"}}"#,
|
|
28
|
+
"",
|
|
29
|
+
),
|
|
30
|
+
sample("bg.schedules.list", "{}", ""),
|
|
31
|
+
sample(
|
|
32
|
+
"bg.schedules.create",
|
|
33
|
+
r#"{"key":"schedule","jobId":"missing","intervalSeconds":3600,"consent":true}"#,
|
|
34
|
+
"not_found",
|
|
35
|
+
),
|
|
36
|
+
sample("bg.schedules.disable", id, "not_found"),
|
|
37
|
+
sample("bg.schedules.delete", id, "not_found"),
|
|
38
|
+
sample(
|
|
39
|
+
"bg.schedules.enable",
|
|
40
|
+
r#"{"id":"missing","consent":true}"#,
|
|
41
|
+
"not_found",
|
|
42
|
+
),
|
|
43
|
+
sample("bg.status", "{}", ""),
|
|
44
|
+
sample("bg.browse", "{}", "session_required"),
|
|
45
|
+
sample(
|
|
46
|
+
"bg.jobs.submit",
|
|
47
|
+
r#"{"key":"k1","folder":"/share","relative":"a.bin"}"#,
|
|
48
|
+
"session_required",
|
|
49
|
+
),
|
|
50
|
+
sample("bg.jobs.list", "{}", ""),
|
|
51
|
+
sample("bg.jobs.get", id, "not_found"),
|
|
52
|
+
sample("bg.jobs.cancel", id, "not_found"),
|
|
53
|
+
sample("bg.jobs.pause", id, "not_found"),
|
|
54
|
+
sample("bg.jobs.resume", id, "not_found"),
|
|
55
|
+
sample("bg.jobs.result", id, "not_found"),
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[cfg(test)]
|
|
60
|
+
mod tests {
|
|
61
|
+
use std::time::{Duration, Instant};
|
|
62
|
+
|
|
63
|
+
use syno_pkg_land_sdk::background::{REQUEST_LOCK_TIMEOUT, Store};
|
|
64
|
+
use syno_pkg_land_sdk::ctx::Context;
|
|
65
|
+
|
|
66
|
+
use super::super::test_support::*;
|
|
67
|
+
|
|
68
|
+
/// Holds the spool's journal lock, as a busy worker does.
|
|
69
|
+
struct Flock(std::fs::File);
|
|
70
|
+
|
|
71
|
+
impl Flock {
|
|
72
|
+
fn exclusive(path: &std::path::Path) -> Flock {
|
|
73
|
+
use std::os::fd::AsRawFd;
|
|
74
|
+
let file = std::fs::OpenOptions::new()
|
|
75
|
+
.read(true)
|
|
76
|
+
.write(true)
|
|
77
|
+
.open(path)
|
|
78
|
+
.unwrap();
|
|
79
|
+
// SAFETY: the descriptor is open for the file's lifetime.
|
|
80
|
+
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
81
|
+
assert_eq!(rc, 0, "{}", std::io::Error::last_os_error());
|
|
82
|
+
Flock(file)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
impl Drop for Flock {
|
|
87
|
+
fn drop(&mut self) {
|
|
88
|
+
use std::os::fd::AsRawFd;
|
|
89
|
+
// SAFETY: the descriptor is open for the file's lifetime.
|
|
90
|
+
unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[test]
|
|
95
|
+
fn a_busy_spool_is_reported_within_the_request_budget() {
|
|
96
|
+
let fixture = Fixture::new();
|
|
97
|
+
let dir = fixture.cfg.data_dir.join("background");
|
|
98
|
+
// The first read creates the spool and its lock.
|
|
99
|
+
Store::new_request(&dir)
|
|
100
|
+
.list(&Context::background())
|
|
101
|
+
.unwrap();
|
|
102
|
+
let _held = Flock::exclusive(&dir.join("lock"));
|
|
103
|
+
|
|
104
|
+
let start = Instant::now();
|
|
105
|
+
let (code, out) = call(&fixture.cfg, &alice(), "bg.jobs.list", "{}");
|
|
106
|
+
assert_eq!(code, "jobs_busy", "{out}");
|
|
107
|
+
assert!(start.elapsed() < REQUEST_LOCK_TIMEOUT + Duration::from_secs(1));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
use std::io::Read;
|
|
2
|
+
use std::os::unix::fs::DirBuilderExt;
|
|
3
|
+
use std::sync::Arc;
|
|
4
|
+
use std::time::Duration;
|
|
5
|
+
|
|
6
|
+
use serde::Deserialize;
|
|
7
|
+
use serde_json::Value;
|
|
8
|
+
use syno_pkg_land_sdk::BoxError;
|
|
9
|
+
use syno_pkg_land_sdk::api::{self, Caller, Route};
|
|
10
|
+
use syno_pkg_land_sdk::ctx::Context;
|
|
11
|
+
use syno_pkg_land_sdk::delegate::{
|
|
12
|
+
self, Auth, CODE_EXECUTOR_UNAVAILABLE, CODE_POLICY_DENIED, Client, ExecutorClient,
|
|
13
|
+
ExecutorStatus,
|
|
14
|
+
};
|
|
15
|
+
use syno_pkg_land_sdk::http::Request;
|
|
16
|
+
use syno_pkg_land_sdk::jsonx::Empty;
|
|
17
|
+
|
|
18
|
+
use super::Config;
|
|
19
|
+
use super::routes::{decode_params, write_file_atomic};
|
|
20
|
+
#[cfg(test)]
|
|
21
|
+
use super::test_support::{Sample, sample};
|
|
22
|
+
|
|
23
|
+
impl Config {
|
|
24
|
+
pub(super) fn root_routes(self: &Arc<Self>) -> Vec<Route> {
|
|
25
|
+
vec![
|
|
26
|
+
self.handle("executor.control", Config::executor_control),
|
|
27
|
+
self.handle("executor.status", Config::executor_status),
|
|
28
|
+
self.handle_request("root.run", Config::executor_root_run),
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// API permissions default to administrators for unlisted methods. Check
|
|
33
|
+
/// here too: control mutates the fixed package-owned control file before
|
|
34
|
+
/// DSM runs it.
|
|
35
|
+
fn executor_control(
|
|
36
|
+
&self,
|
|
37
|
+
_: &Context,
|
|
38
|
+
caller: &Caller,
|
|
39
|
+
raw: &[u8],
|
|
40
|
+
) -> Result<Value, BoxError> {
|
|
41
|
+
api::require_admin(caller)?;
|
|
42
|
+
#[derive(Default, Deserialize)]
|
|
43
|
+
#[serde(deny_unknown_fields)]
|
|
44
|
+
struct Params {
|
|
45
|
+
#[serde(default)]
|
|
46
|
+
action: String,
|
|
47
|
+
}
|
|
48
|
+
let params: Params = decode_params(raw)?;
|
|
49
|
+
if !["start", "stop", "purge"].contains(¶ms.action.as_str()) {
|
|
50
|
+
return Err(api::Error::bad_request("executor_control_invalid").into());
|
|
51
|
+
}
|
|
52
|
+
let dir = self.data_dir.join("executor");
|
|
53
|
+
std::fs::DirBuilder::new()
|
|
54
|
+
.recursive(true)
|
|
55
|
+
.mode(0o700)
|
|
56
|
+
.create(&dir)?;
|
|
57
|
+
let info = std::fs::symlink_metadata(&dir)?;
|
|
58
|
+
if !info.is_dir() || info.file_type().is_symlink() {
|
|
59
|
+
return Err("executor_control_directory_invalid".into());
|
|
60
|
+
}
|
|
61
|
+
write_file_atomic(&dir.join("control"), params.action.as_bytes())?;
|
|
62
|
+
Ok(serde_json::json!({"written": true}))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// The executor closes its socket while paused: the package is stopped,
|
|
66
|
+
/// or an upgrade changed the pins and awaits administrator approval.
|
|
67
|
+
fn executor_status(&self, ctx: &Context, _: &Caller, _: &[u8]) -> Result<Value, BoxError> {
|
|
68
|
+
match delegate::call::<_, Value>(&*self.executor(), ctx, "status", &Auth::new(), &Empty {})
|
|
69
|
+
{
|
|
70
|
+
Err(err)
|
|
71
|
+
if api::find_error::<api::Error>(&*err)
|
|
72
|
+
.is_some_and(|refusal| refusal.code == CODE_EXECUTOR_UNAVAILABLE) =>
|
|
73
|
+
{
|
|
74
|
+
let next = if self.start_requested() {
|
|
75
|
+
"The package is running: an administrator approves background access again in Administrator setup; the executor log names the cause."
|
|
76
|
+
} else {
|
|
77
|
+
"Start the package."
|
|
78
|
+
};
|
|
79
|
+
Ok(serde_json::json!({
|
|
80
|
+
"ready": false, "state": "paused",
|
|
81
|
+
"reason": "executor_not_listening", "next": next,
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
status => status,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Package start and stop write the lifecycle mailbox; its last request
|
|
89
|
+
/// tells a stopped package from a running one whose executor stays paused.
|
|
90
|
+
fn start_requested(&self) -> bool {
|
|
91
|
+
let Ok(mut file) = std::fs::File::open(self.data_dir.join("executor").join("control"))
|
|
92
|
+
else {
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
let mut buf = [0u8; 16];
|
|
96
|
+
let n = file.read(&mut buf).unwrap_or(0);
|
|
97
|
+
String::from_utf8_lossy(&buf[..n]).trim() == "start"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn executor_root_run(
|
|
101
|
+
&self,
|
|
102
|
+
ctx: &Context,
|
|
103
|
+
caller: &Caller,
|
|
104
|
+
req: &Request,
|
|
105
|
+
raw: &[u8],
|
|
106
|
+
) -> Result<Value, BoxError> {
|
|
107
|
+
api::require_admin(caller)?;
|
|
108
|
+
#[derive(Default, Deserialize)]
|
|
109
|
+
#[serde(deny_unknown_fields)]
|
|
110
|
+
struct Params {
|
|
111
|
+
#[serde(default)]
|
|
112
|
+
name: String,
|
|
113
|
+
}
|
|
114
|
+
let params: Params = decode_params(raw)?;
|
|
115
|
+
// The executor independently verifies both session values and the
|
|
116
|
+
// administrator identity.
|
|
117
|
+
let auth = api::dsm_session(req)?;
|
|
118
|
+
self.executor_holds_this_build(ctx)?;
|
|
119
|
+
delegate::call(
|
|
120
|
+
&*self.executor(),
|
|
121
|
+
ctx,
|
|
122
|
+
"root.run",
|
|
123
|
+
&auth,
|
|
124
|
+
&serde_json::json!({"name": params.name}),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Refuses, as delegate does, an executor pinned to another build's
|
|
129
|
+
/// policy or executor: its approved scripts are not this build's.
|
|
130
|
+
fn executor_holds_this_build(&self, ctx: &Context) -> Result<(), BoxError> {
|
|
131
|
+
let pins: ExecutorStatus =
|
|
132
|
+
delegate::call(&*self.executor(), ctx, "status", &Auth::new(), &Empty {})?;
|
|
133
|
+
if (!self.executor_digest.is_empty() && pins.executor_digest != self.executor_digest)
|
|
134
|
+
|| (!self.policy_digest.is_empty() && pins.policy_digest != self.policy_digest)
|
|
135
|
+
{
|
|
136
|
+
return Err(api::Error::new(
|
|
137
|
+
CODE_POLICY_DENIED,
|
|
138
|
+
"the executor holds another build's approved policy; an administrator approves this build in Administrator setup",
|
|
139
|
+
)
|
|
140
|
+
.into());
|
|
141
|
+
}
|
|
142
|
+
Ok(())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Keeps the executor's protocol v1 refusal codes (POLICY_DENIED,
|
|
146
|
+
/// EXECUTOR_UNAVAILABLE, ...) as the API error code.
|
|
147
|
+
fn executor(&self) -> Arc<dyn ExecutorClient> {
|
|
148
|
+
self.executor.clone().unwrap_or_else(|| {
|
|
149
|
+
Arc::new(Client {
|
|
150
|
+
timeout: Duration::from_secs(30),
|
|
151
|
+
..Client::local(&self.package_id)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Valid params for the root routes. root.run needs a DSM session the
|
|
158
|
+
/// fixture does not have.
|
|
159
|
+
#[cfg(test)]
|
|
160
|
+
pub(super) fn samples() -> Vec<Sample> {
|
|
161
|
+
vec![
|
|
162
|
+
sample("executor.control", r#"{"action":"stop"}"#, ""),
|
|
163
|
+
sample("executor.status", "{}", ""),
|
|
164
|
+
sample("root.run", r#"{"name":"whoami"}"#, "session_required"),
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[cfg(test)]
|
|
169
|
+
mod tests {
|
|
170
|
+
use std::io::{BufRead, BufReader, Write};
|
|
171
|
+
use std::os::unix::net::UnixListener;
|
|
172
|
+
use std::path::PathBuf;
|
|
173
|
+
use std::sync::Mutex;
|
|
174
|
+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
175
|
+
|
|
176
|
+
use super::super::test_support::*;
|
|
177
|
+
use super::*;
|
|
178
|
+
use crate::permissions::permissions;
|
|
179
|
+
|
|
180
|
+
/// Unix socket paths are limited to about 100 bytes, so the executor's
|
|
181
|
+
/// fake lives in a short directory under /tmp.
|
|
182
|
+
struct SocketBase(PathBuf);
|
|
183
|
+
|
|
184
|
+
impl SocketBase {
|
|
185
|
+
fn new() -> Self {
|
|
186
|
+
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
187
|
+
let base = PathBuf::from(format!(
|
|
188
|
+
"/tmp/exec-{}-{}",
|
|
189
|
+
std::process::id(),
|
|
190
|
+
NEXT.fetch_add(1, Ordering::SeqCst)
|
|
191
|
+
));
|
|
192
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
193
|
+
std::fs::create_dir_all(&base).unwrap();
|
|
194
|
+
SocketBase(base)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn socket(&self, cfg: &Config) -> PathBuf {
|
|
198
|
+
let dir = self.0.join(&cfg.package_id);
|
|
199
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
200
|
+
dir.join("executor.sock")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Points cfg at an executor under this base.
|
|
204
|
+
fn attach(&self, cfg: &mut Config) {
|
|
205
|
+
cfg.executor = Some(Arc::new(Client {
|
|
206
|
+
socket: self.socket(cfg).to_string_lossy().into_owned(),
|
|
207
|
+
timeout: Duration::from_secs(5),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
impl Drop for SocketBase {
|
|
213
|
+
fn drop(&mut self) {
|
|
214
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Answers each v1 request with replies[op] and records the ops.
|
|
219
|
+
struct FakeExecutor {
|
|
220
|
+
ops: Arc<Mutex<Vec<String>>>,
|
|
221
|
+
stop: Arc<AtomicBool>,
|
|
222
|
+
socket: PathBuf,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
impl FakeExecutor {
|
|
226
|
+
fn start(base: &SocketBase, cfg: &mut Config, replies: &[(&str, String)]) -> Self {
|
|
227
|
+
base.attach(cfg);
|
|
228
|
+
let socket = base.socket(cfg);
|
|
229
|
+
let listener = UnixListener::bind(&socket).unwrap();
|
|
230
|
+
let replies: Vec<(String, String)> = replies
|
|
231
|
+
.iter()
|
|
232
|
+
.map(|(op, reply)| (op.to_string(), reply.clone()))
|
|
233
|
+
.collect();
|
|
234
|
+
let ops = Arc::new(Mutex::new(Vec::new()));
|
|
235
|
+
let stop = Arc::new(AtomicBool::new(false));
|
|
236
|
+
let (seen, done) = (ops.clone(), stop.clone());
|
|
237
|
+
std::thread::spawn(move || {
|
|
238
|
+
for conn in listener.incoming() {
|
|
239
|
+
if done.load(Ordering::SeqCst) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let Ok(mut conn) = conn else { return };
|
|
243
|
+
let mut line = String::new();
|
|
244
|
+
let _ = BufReader::new(&conn).read_line(&mut line);
|
|
245
|
+
let request: Value = serde_json::from_str(&line).unwrap_or_default();
|
|
246
|
+
let op = request["op"].as_str().unwrap_or("").to_string();
|
|
247
|
+
let reply = replies
|
|
248
|
+
.iter()
|
|
249
|
+
.find(|(name, _)| *name == op)
|
|
250
|
+
.map(|(_, reply)| reply.clone())
|
|
251
|
+
.unwrap_or_default();
|
|
252
|
+
seen.lock().unwrap().push(op);
|
|
253
|
+
let _ = writeln!(conn, "{reply}");
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
FakeExecutor { ops, stop, socket }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn received(&self) -> Vec<String> {
|
|
260
|
+
self.ops.lock().unwrap().clone()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
impl Drop for FakeExecutor {
|
|
265
|
+
fn drop(&mut self) {
|
|
266
|
+
self.stop.store(true, Ordering::SeqCst);
|
|
267
|
+
let _ = std::os::unix::net::UnixStream::connect(&self.socket);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
fn data(out: &Value) -> &Value {
|
|
272
|
+
&out["data"]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn executor_control_accepts_only_fixed_actions() {
|
|
277
|
+
let fixture = Fixture::new();
|
|
278
|
+
let cfg = &fixture.cfg;
|
|
279
|
+
for action in ["start", "stop", "purge"] {
|
|
280
|
+
let (code, out) = call(
|
|
281
|
+
cfg,
|
|
282
|
+
&alice(),
|
|
283
|
+
"executor.control",
|
|
284
|
+
&format!(r#"{{"action":"{action}"}}"#),
|
|
285
|
+
);
|
|
286
|
+
assert_eq!(code, "", "{action}: {out}");
|
|
287
|
+
let written = std::fs::read_to_string(cfg.data_dir.join("executor/control")).unwrap();
|
|
288
|
+
assert_eq!(written, action);
|
|
289
|
+
}
|
|
290
|
+
for raw in [
|
|
291
|
+
r#"{"action":"start;id"}"#,
|
|
292
|
+
r#"{"action":"start\nstop"}"#,
|
|
293
|
+
r#"{"action":""}"#,
|
|
294
|
+
r#"{"action":"stop","path":"/etc/passwd"}"#,
|
|
295
|
+
] {
|
|
296
|
+
assert_eq!(
|
|
297
|
+
call(cfg, &alice(), "executor.control", raw).0,
|
|
298
|
+
"bad_request",
|
|
299
|
+
"{raw}"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#[test]
|
|
305
|
+
fn executor_control_rejects_a_symlinked_directory() {
|
|
306
|
+
let fixture = Fixture::new();
|
|
307
|
+
let outside = fixture.scratch();
|
|
308
|
+
std::os::unix::fs::symlink(&outside, fixture.cfg.data_dir.join("executor")).unwrap();
|
|
309
|
+
let (code, _) = call(
|
|
310
|
+
&fixture.cfg,
|
|
311
|
+
&alice(),
|
|
312
|
+
"executor.control",
|
|
313
|
+
r#"{"action":"stop"}"#,
|
|
314
|
+
);
|
|
315
|
+
assert_ne!(code, "", "accepted symlink");
|
|
316
|
+
assert!(
|
|
317
|
+
!outside.join("control").exists(),
|
|
318
|
+
"wrote outside data directory"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn root_run_requires_the_current_dsm_cookie() {
|
|
324
|
+
let fixture = Fixture::new();
|
|
325
|
+
let (code, _) = call(&fixture.cfg, &alice(), "root.run", r#"{"name":"whoami"}"#);
|
|
326
|
+
assert_ne!(code, "", "root.run accepted an absent DSM cookie");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// The executor closes its listener while paused (package stopped, or pins
|
|
330
|
+
/// awaiting administrator approval). Status then answers, not "internal".
|
|
331
|
+
#[test]
|
|
332
|
+
fn executor_status_reports_a_paused_executor() {
|
|
333
|
+
let mut fixture = Fixture::new();
|
|
334
|
+
let base = SocketBase::new();
|
|
335
|
+
base.attach(&mut fixture.cfg);
|
|
336
|
+
let check = |name: &str| {
|
|
337
|
+
let (code, out) = call(&fixture.cfg, &alice(), "executor.status", "{}");
|
|
338
|
+
let d = data(&out);
|
|
339
|
+
assert_eq!(code, "", "{name}: {out}");
|
|
340
|
+
assert_eq!(d["ready"], false, "{name}: {out}");
|
|
341
|
+
assert_eq!(d["state"], "paused", "{name}: {out}");
|
|
342
|
+
assert_eq!(d["reason"], "executor_not_listening", "{name}: {out}");
|
|
343
|
+
assert!(
|
|
344
|
+
!d["next"].as_str().unwrap_or("").is_empty(),
|
|
345
|
+
"{name}: {out}"
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
check("no socket");
|
|
349
|
+
let (code, _) = call(&fixture.cfg, &bob(), "executor.status", "{}");
|
|
350
|
+
assert_eq!(
|
|
351
|
+
code, "",
|
|
352
|
+
"an authenticated user cannot read a paused status"
|
|
353
|
+
);
|
|
354
|
+
drop(UnixListener::bind(base.socket(&fixture.cfg)).unwrap());
|
|
355
|
+
check("closed listener");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// next follows the last lifecycle request: a running package needs
|
|
359
|
+
/// approval again, not a start.
|
|
360
|
+
#[test]
|
|
361
|
+
fn executor_status_next_follows_the_lifecycle_request() {
|
|
362
|
+
let mut fixture = Fixture::new();
|
|
363
|
+
let base = SocketBase::new();
|
|
364
|
+
base.attach(&mut fixture.cfg);
|
|
365
|
+
let next = || {
|
|
366
|
+
let (_, out) = call(&fixture.cfg, &alice(), "executor.status", "{}");
|
|
367
|
+
data(&out)["next"].as_str().unwrap_or("").to_string()
|
|
368
|
+
};
|
|
369
|
+
assert!(
|
|
370
|
+
next().starts_with("Start the package."),
|
|
371
|
+
"no request: {}",
|
|
372
|
+
next()
|
|
373
|
+
);
|
|
374
|
+
let mailbox = fixture.cfg.data_dir.join("executor");
|
|
375
|
+
std::fs::create_dir_all(&mailbox).unwrap();
|
|
376
|
+
for (request, start) in [("stop\n", true), ("start\n", false), ("start", false)] {
|
|
377
|
+
std::fs::write(mailbox.join("control"), request).unwrap();
|
|
378
|
+
let got = next();
|
|
379
|
+
assert_eq!(
|
|
380
|
+
got.starts_with("Start the package"),
|
|
381
|
+
start,
|
|
382
|
+
"{request:?}: {got}"
|
|
383
|
+
);
|
|
384
|
+
assert!(!got.is_empty());
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const READY: &str = r#"{"ok":true,"result":{"executor_digest":"e1","policy_digest":"p1","ready":true,"disabled":false,"version":"1"}}"#;
|
|
389
|
+
const WHOAMI: &str = r#"{"ok":true,"result":{"output":"0\n"}}"#;
|
|
390
|
+
|
|
391
|
+
fn pinned() -> Fixture {
|
|
392
|
+
let mut fixture = Fixture::new();
|
|
393
|
+
fixture.cfg.executor_digest = "e1".into();
|
|
394
|
+
fixture.cfg.policy_digest = "p1".into();
|
|
395
|
+
fixture
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/// root.run as an administrator with a current DSM session.
|
|
399
|
+
fn root_run(cfg: &Config, name: &str) -> (String, Value) {
|
|
400
|
+
let mut req = api_request("root.run", &format!(r#"{{"name":"{name}"}}"#));
|
|
401
|
+
req.header.set("Cookie", "id=session");
|
|
402
|
+
req.header.set("X-SYNO-TOKEN", "token");
|
|
403
|
+
serve(cfg, &alice(), req, permissions().unwrap())
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#[test]
|
|
407
|
+
fn root_run_runs_when_the_executor_holds_this_builds_pins() {
|
|
408
|
+
let mut fixture = pinned();
|
|
409
|
+
let base = SocketBase::new();
|
|
410
|
+
let _executor = FakeExecutor::start(
|
|
411
|
+
&base,
|
|
412
|
+
&mut fixture.cfg,
|
|
413
|
+
&[("status", READY.into()), ("root.run", WHOAMI.into())],
|
|
414
|
+
);
|
|
415
|
+
let (code, out) = root_run(&fixture.cfg, "whoami");
|
|
416
|
+
assert_eq!(code, "", "{out}");
|
|
417
|
+
assert_eq!(data(&out)["output"], "0\n");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Like delegate: an executor pinned to another build's policy or
|
|
421
|
+
/// executor refuses the operation, it does not run it.
|
|
422
|
+
#[test]
|
|
423
|
+
fn root_run_refuses_an_executor_pinned_to_another_build() {
|
|
424
|
+
for (name, status) in [
|
|
425
|
+
("policy", READY.replacen("\"p1\"", "\"p2\"", 1)),
|
|
426
|
+
("executor", READY.replacen("\"e1\"", "\"e2\"", 1)),
|
|
427
|
+
] {
|
|
428
|
+
let mut fixture = pinned();
|
|
429
|
+
let base = SocketBase::new();
|
|
430
|
+
let executor = FakeExecutor::start(
|
|
431
|
+
&base,
|
|
432
|
+
&mut fixture.cfg,
|
|
433
|
+
&[("status", status), ("root.run", WHOAMI.into())],
|
|
434
|
+
);
|
|
435
|
+
let (code, out) = root_run(&fixture.cfg, "whoami");
|
|
436
|
+
assert_eq!(code, "POLICY_DENIED", "{name} digest mismatch: {out}");
|
|
437
|
+
assert!(
|
|
438
|
+
!executor.received().contains(&"root.run".to_string()),
|
|
439
|
+
"{name} digest mismatch: root.run reached the executor"
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// Refusals keep the protocol v1 code (spec/protocol/v1/errors.json),
|
|
445
|
+
/// never "internal".
|
|
446
|
+
#[test]
|
|
447
|
+
fn root_run_reports_the_executors_refusal_code() {
|
|
448
|
+
let mut fixture = pinned();
|
|
449
|
+
let base = SocketBase::new();
|
|
450
|
+
base.attach(&mut fixture.cfg);
|
|
451
|
+
let (code, _) = root_run(&fixture.cfg, "whoami");
|
|
452
|
+
assert_eq!(
|
|
453
|
+
code, "EXECUTOR_UNAVAILABLE",
|
|
454
|
+
"paused or awaiting approval (no socket)"
|
|
455
|
+
);
|
|
456
|
+
for refusal in [
|
|
457
|
+
"POLICY_DENIED",
|
|
458
|
+
"INVALID_REQUEST",
|
|
459
|
+
"EXECUTOR_PAUSED",
|
|
460
|
+
"ROOT_FAILED",
|
|
461
|
+
"ADMIN_REQUIRED",
|
|
462
|
+
] {
|
|
463
|
+
let mut fixture = pinned();
|
|
464
|
+
let base = SocketBase::new();
|
|
465
|
+
let reply = format!(r#"{{"ok":false,"code":"{refusal}","message":"refused"}}"#);
|
|
466
|
+
let _executor = FakeExecutor::start(
|
|
467
|
+
&base,
|
|
468
|
+
&mut fixture.cfg,
|
|
469
|
+
&[("status", READY.into()), ("root.run", reply)],
|
|
470
|
+
);
|
|
471
|
+
let (code, out) = root_run(&fixture.cfg, "whoami");
|
|
472
|
+
assert_eq!(code, refusal, "{out}");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Native Rust with Vue
|
|
2
|
+
|
|
3
|
+
Edit `frontend/src/App.vue` and `main.ts` to build a DSM-window app with the
|
|
4
|
+
public `@synopackageland/ui` components and embedded theme. The shell, sign-in,
|
|
5
|
+
files, portal, jobs and delegate components come from `@synopackageland/sdk/vue`
|
|
6
|
+
(types from `@synopackageland/sdk`); its version must equal the
|
|
7
|
+
`syno-pkg-land-sdk` tag in `backend/Cargo.toml`. See
|
|
8
|
+
<https://github.com/synopackageland/syno-pkg-land-sdk> for the components and their props.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
cd frontend
|
|
12
|
+
npm ci
|
|
13
|
+
npm test
|
|
14
|
+
npm run build
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The base app reads package status and edits the package greeting through
|
|
18
|
+
`ctx.call`. Each window has its own Vue mount, request state and host
|
|
19
|
+
subscriptions; unmount disposes subscriptions and ignores late responses.
|
|
20
|
+
|
|
21
|
+
`--with root-ops` adds the existing DSM approval gate for fixed `root/*.sh`
|
|
22
|
+
operations. `--with jobs` adds package-account jobs and schedules; it combines with root-ops and files. `--with files` enables current-session File Station access,
|
|
23
|
+
and the background picker uses the retained session provider.
|
|
24
|
+
|
|
25
|
+
With `portal:` declared (`synopkgland add portal`, executor Apps only), the status page shows
|
|
26
|
+
the SDK's `PortalSettings`: the DSM Application Portal link, the alias form
|
|
27
|
+
for administrators, and the Control Panel shortcut.
|
|
28
|
+
|
|
29
|
+
Use public library controls, the embedded theme, per-app modal scope and unique
|
|
30
|
+
Vue ID prefixes. Backend route permissions and DSM checks remain authoritative.
|
|
31
|
+
Generated build and unit tests do not establish NAS runtime acceptance.
|
|
32
|
+
|
|
33
|
+
The manifest is dsm-package.yaml schema 2: backend.kind: rust and capabilities.files: session, jobs: true, identity: standalone, rootOps: true. Use --with login to declare standalone identity. Existing projects use synopkgland add <cap>...; app entrypoints stay app-owned, SDK conflicts are refused. check, inspect, build, deploy and status accept --json.
|
|
34
|
+
|
|
35
|
+
Use the installed `@synopackageland/ui`; package.json pins its version.
|
|
36
|
+
`AppRoot` defaults to app modal scope and stays bounded at `height: 100%`;
|
|
37
|
+
`AppLayout wide-layout="window"` keeps the DSM shell in window mode even when
|
|
38
|
+
maximized. Only whole-page web/Portal apps should opt into document scope or
|
|
39
|
+
`wide-layout="web"`. The DSM host sets the 700px minimum window width.
|
|
40
|
+
In a DSM window (`ctx.host.platform().host === 'dsm'`) `AppRoot` takes
|
|
41
|
+
`dialog-bounds="viewport"`, so Dialogs can be dragged out of the window, and
|
|
42
|
+
its `activate` event calls `ctx.host.activateWindow()` to bring the window to
|
|
43
|
+
the front. Elsewhere Dialogs stay inside the App.
|
|
44
|
+
Import `themes/dsm7-embedded.css` and assign each mount a unique
|
|
45
|
+
`app.config.idPrefix` before mounting.
|
|
46
|
+
|
|
47
|
+
Start with AGENTS.md, `synopkgland skill list` and `synopkgland skill path`.
|
|
48
|
+
Read `synopkgland skill show ui-vue` for direct Vue component work and
|
|
49
|
+
`synopkgland skill show ui-json` for optional declarative specs and their
|
|
50
|
+
validate/render workflow. The existing host/backend lifecycle remains in place.
|
|
51
|
+
|
|
52
|
+
For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
|
|
53
|
+
`npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
|
|
54
|
+
Read its prompt and exported Vue docs/types before choosing component APIs.
|
|
55
|
+
Use `syno-pkg-land-ui` directly in npm scripts; keep AI guidance version-independent.
|