@synopackageland/templates 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +62 -2
- package/package.json +11 -4
- package/templates/_shared/AGENTS.md +149 -0
- package/templates/_shared/CLAUDE.md +1 -0
- package/templates/_shared/GO-LICENSE +27 -0
- package/templates/_shared/GO-PATENTS +22 -0
- package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
- package/templates/_shared/third-party-notices.mjs +55 -0
- package/templates/binary/LICENSES/GO-LICENSE +27 -0
- package/templates/binary/LICENSES/GO-PATENTS +22 -0
- package/templates/binary/dsm-package.yaml +46 -0
- package/templates/binary/example/main.go +16 -0
- package/templates/binary/gitignore +2 -0
- package/templates/binary/i18n/messages/de.json +4 -0
- package/templates/binary/i18n/messages/en.json +4 -0
- package/templates/binary/i18n/messages/es.json +4 -0
- package/templates/binary/i18n/messages/fr.json +4 -0
- package/templates/binary/i18n/messages/it.json +4 -0
- package/templates/binary/i18n/messages/ja.json +4 -0
- package/templates/binary/i18n/messages/ko.json +4 -0
- package/templates/binary/i18n/messages/zh-CN.json +4 -0
- package/templates/binary/i18n/messages/zh-TW.json +4 -0
- package/templates/binary/ui/app.css +4 -0
- package/templates/binary/ui/app.js +32 -0
- package/templates/compose/compose.yaml +8 -0
- package/templates/compose/synology-app.yaml +16 -0
- package/templates/native-go/README.md +372 -0
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
- package/templates/native-go/backend/cmd/app/main.go +101 -0
- package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
- package/templates/native-go/backend/go.mod +9 -0
- package/templates/native-go/backend/go.sum +4 -0
- package/templates/native-go/backend/internal/api/permissions.go +26 -0
- package/templates/native-go/backend/internal/api/permissions.json +5 -0
- package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
- package/templates/native-go/backend/internal/app/background.go +249 -0
- package/templates/native-go/backend/internal/app/background_none.go +8 -0
- package/templates/native-go/backend/internal/app/background_owner.go +38 -0
- package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
- package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
- package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
- package/templates/native-go/backend/internal/app/background_test.go +320 -0
- package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
- package/templates/native-go/backend/internal/app/delegate_config_test.go +36 -0
- package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
- package/templates/native-go/backend/internal/app/identity.go +20 -0
- package/templates/native-go/backend/internal/app/identity_test.go +15 -0
- package/templates/native-go/backend/internal/app/migrations.go +41 -0
- package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
- package/templates/native-go/backend/internal/app/portal.go +30 -0
- package/templates/native-go/backend/internal/app/portal_test.go +55 -0
- package/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/templates/native-go/backend/internal/app/routes.go +137 -0
- package/templates/native-go/backend/internal/app/routes_test.go +207 -0
- package/templates/native-go/backend/internal/app/session_files.go +9 -0
- package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
- package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
- package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
- package/templates/native-go/dsm-package.yaml +71 -0
- package/templates/native-go/gitignore +1 -0
- package/templates/native-go/i18n/glossary.json +663 -0
- package/templates/native-go/i18n/messages/de.json +29 -0
- package/templates/native-go/i18n/messages/en.json +29 -0
- package/templates/native-go/i18n/messages/es.json +29 -0
- package/templates/native-go/i18n/messages/fr.json +29 -0
- package/templates/native-go/i18n/messages/it.json +29 -0
- package/templates/native-go/i18n/messages/ja.json +29 -0
- package/templates/native-go/i18n/messages/ko.json +29 -0
- package/templates/native-go/i18n/messages/zh-CN.json +29 -0
- package/templates/native-go/i18n/messages/zh-TW.json +29 -0
- package/templates/native-go/ui/app.css +45 -0
- package/templates/native-go/ui/app.js +111 -0
- package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
- package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
- package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
- package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
- package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
- package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
- package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
- package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +128 -0
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +237 -0
- package/templates/native-go-root-ops/root/whoami.sh +3 -0
- package/templates/native-go-vue/README.md +51 -0
- package/templates/native-go-vue/frontend/DESIGN.md +61 -0
- package/templates/native-go-vue/frontend/package-lock.json +2550 -0
- package/templates/native-go-vue/frontend/package.json +23 -0
- package/templates/native-go-vue/frontend/src/App.vue +90 -0
- package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
- package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
- package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
- package/templates/native-go-vue/frontend/src/main.ts +33 -0
- package/templates/native-go-vue/frontend/src/style.css +27 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
- package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
- package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
- package/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/templates/native-go-vue/frontend/vite.config.ts +11 -0
- package/templates/native-go-vue/gitignore +2 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
4
|
+
|
|
5
|
+
// backgroundRoutes serves the owner-scoped background job API. Jobs run
|
|
6
|
+
// in the package-user worker unit (conf/systemd), never as root.
|
|
7
|
+
func (cfg Config) backgroundRoutes() []api.Route { return BackgroundRoutes(cfg.DataDir) }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
7
|
+
"github.com/synopackageland/syno-pkg-land-sdk/background"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"syscall"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func init() {
|
|
18
|
+
// The generic route tests need valid params for every background route.
|
|
19
|
+
// The fixture has no DSM cookie; import is refused before reading a file.
|
|
20
|
+
// No jobs or schedules exist in its fresh data directory.
|
|
21
|
+
const id = `{"id":"missing"}`
|
|
22
|
+
sampleParams["bg.jobs.purge"] = `{"owner":{"user":"missing","uid":"9999"}}`
|
|
23
|
+
sampleParams["bg.schedules.list"] = `{}`
|
|
24
|
+
sampleParams["bg.schedules.create"] = `{"key":"schedule","jobId":"missing","intervalSeconds":3600,"consent":true}`
|
|
25
|
+
sampleParams["bg.schedules.disable"] = id
|
|
26
|
+
sampleParams["bg.schedules.delete"] = id
|
|
27
|
+
sampleParams["bg.schedules.enable"] = `{"id":"missing","consent":true}`
|
|
28
|
+
sampleOutcome["bg.schedules.create"] = "not_found"
|
|
29
|
+
sampleOutcome["bg.schedules.disable"] = "not_found"
|
|
30
|
+
sampleOutcome["bg.schedules.delete"] = "not_found"
|
|
31
|
+
sampleOutcome["bg.schedules.enable"] = "not_found"
|
|
32
|
+
sampleParams["bg.status"] = `{}`
|
|
33
|
+
sampleParams["bg.browse"] = `{}`
|
|
34
|
+
sampleParams["bg.jobs.submit"] = `{"key":"k1","folder":"/share","relative":"a.bin"}`
|
|
35
|
+
sampleParams["bg.jobs.list"] = `{}`
|
|
36
|
+
sampleParams["bg.jobs.get"] = id
|
|
37
|
+
sampleParams["bg.jobs.cancel"] = id
|
|
38
|
+
sampleParams["bg.jobs.result"] = id
|
|
39
|
+
sampleOutcome["bg.browse"] = "session_required"
|
|
40
|
+
sampleOutcome["bg.jobs.submit"] = "session_required"
|
|
41
|
+
sampleOutcome["bg.jobs.get"] = "not_found"
|
|
42
|
+
sampleOutcome["bg.jobs.cancel"] = "not_found"
|
|
43
|
+
sampleOutcome["bg.jobs.result"] = "not_found"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestBackgroundBusyErrorCode(t *testing.T) {
|
|
47
|
+
err := backgroundError(background.ErrBusy)
|
|
48
|
+
var e *api.Error
|
|
49
|
+
if !errors.As(err, &e) || e.Code != "jobs_busy" {
|
|
50
|
+
t.Fatalf("busy mapping: %v", err)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestBackgroundRequestSubmitBusyAndProductionConstruction(t *testing.T) {
|
|
55
|
+
e := newBgEnv(t)
|
|
56
|
+
dir := filepath.Join(t.TempDir(), "background")
|
|
57
|
+
e.store = background.NewRequestStore(dir)
|
|
58
|
+
if _, err := e.store.List(context.Background()); err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
f, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_RDWR, 0)
|
|
62
|
+
if err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
defer f.Close()
|
|
66
|
+
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
70
|
+
start := time.Now()
|
|
71
|
+
code, out := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"])
|
|
72
|
+
if code != "jobs_busy" || time.Since(start) > background.RequestLockTimeout+time.Second {
|
|
73
|
+
t.Fatalf("busy submit: %s %+v", code, out)
|
|
74
|
+
}
|
|
75
|
+
// The actual production assembly must use the same bounded wait policy.
|
|
76
|
+
srv, err := api.NewServerWithPermissions(alice, []byte(`{"schema":1,"default":"administrators","methods":{}}`), BackgroundRoutes(filepath.Dir(dir))...)
|
|
77
|
+
if err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
r := httptest.NewRequest("POST", "/api.cgi", strings.NewReader(`{"method":"bg.jobs.list","params":{}}`))
|
|
81
|
+
r.Header.Set("Content-Type", "application/json")
|
|
82
|
+
w := httptest.NewRecorder()
|
|
83
|
+
srv.ServeHTTP(w, r)
|
|
84
|
+
if !strings.Contains(w.Body.String(), `"jobs_busy"`) {
|
|
85
|
+
t.Fatal(w.Body.String())
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {expect,test,vi} from 'vitest'
|
|
3
|
+
import {nextTick} from 'vue'
|
|
4
|
+
import {mount} from '../src/main'
|
|
5
|
+
import type {Context} from '@synopackageland/sdk'
|
|
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,{locale:'en',call,reopen:()=>()=>{}} as unknown as Context)
|
|
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
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"net/http"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/synopackageland/syno-pkg-land-sdk/api"
|
|
14
|
+
"github.com/synopackageland/syno-pkg-land-sdk/delegate"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func (cfg Config) rootRoutes() []api.Route {
|
|
18
|
+
return []api.Route{
|
|
19
|
+
api.Handle("executor.control", cfg.executorControl),
|
|
20
|
+
api.Handle("executor.status", cfg.executorStatus),
|
|
21
|
+
api.HandleRequest("root.run", cfg.executorRootRun),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// API permissions default to administrators for unlisted methods. Check here
|
|
26
|
+
// too: control mutates the fixed package-owned control file before DSM run.
|
|
27
|
+
func (cfg Config) executorControl(_ context.Context, caller api.Caller, raw json.RawMessage) (any, error) {
|
|
28
|
+
if !caller.Admin() {
|
|
29
|
+
return nil, errors.New("admin_required")
|
|
30
|
+
}
|
|
31
|
+
var in struct {
|
|
32
|
+
Action string `json:"action"`
|
|
33
|
+
}
|
|
34
|
+
if err := decodeParams(raw, &in); err != nil {
|
|
35
|
+
return nil, err
|
|
36
|
+
}
|
|
37
|
+
if in.Action != "start" && in.Action != "stop" && in.Action != "purge" {
|
|
38
|
+
return nil, api.BadRequest("executor_control_invalid")
|
|
39
|
+
}
|
|
40
|
+
dir := filepath.Join(cfg.DataDir, "executor")
|
|
41
|
+
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
42
|
+
return nil, err
|
|
43
|
+
}
|
|
44
|
+
info, err := os.Lstat(dir)
|
|
45
|
+
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
46
|
+
return nil, errors.New("executor_control_directory_invalid")
|
|
47
|
+
}
|
|
48
|
+
if err := writeFileAtomic(filepath.Join(dir, "control"), []byte(in.Action)); err != nil {
|
|
49
|
+
return nil, err
|
|
50
|
+
}
|
|
51
|
+
return map[string]bool{"written": true}, nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The executor closes its socket while paused: the package is stopped, or an
|
|
55
|
+
// upgrade changed the pins and awaits administrator approval.
|
|
56
|
+
func (cfg Config) executorStatus(ctx context.Context, _ api.Caller, _ json.RawMessage) (any, error) {
|
|
57
|
+
var status json.RawMessage
|
|
58
|
+
err := cfg.executor().Call(ctx, "status", nil, nil, &status)
|
|
59
|
+
var refusal *api.Error
|
|
60
|
+
if errors.As(err, &refusal) && refusal.Code == delegate.CodeExecutorUnavailable {
|
|
61
|
+
next := "Start the package."
|
|
62
|
+
if cfg.startRequested() {
|
|
63
|
+
next = "The package is running: an administrator approves background access again in Administrator setup; the executor log names the cause."
|
|
64
|
+
}
|
|
65
|
+
return map[string]any{"ready": false, "state": "paused", "reason": "executor_not_listening", "next": next}, nil
|
|
66
|
+
}
|
|
67
|
+
return status, err
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Package start and stop write the lifecycle mailbox; its last request tells a
|
|
71
|
+
// stopped package from a running one whose executor stays paused.
|
|
72
|
+
func (cfg Config) startRequested() bool {
|
|
73
|
+
f, err := os.Open(filepath.Join(cfg.DataDir, "executor", "control"))
|
|
74
|
+
if err != nil {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
defer f.Close()
|
|
78
|
+
b := make([]byte, 16)
|
|
79
|
+
n, _ := f.Read(b)
|
|
80
|
+
return strings.TrimSpace(string(b[:n])) == "start"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func (cfg Config) executorRootRun(ctx context.Context, caller api.Caller, req *http.Request, raw json.RawMessage) (any, error) {
|
|
84
|
+
if !caller.Admin() {
|
|
85
|
+
return nil, errors.New("admin_required")
|
|
86
|
+
}
|
|
87
|
+
var in struct {
|
|
88
|
+
Name string `json:"name"`
|
|
89
|
+
}
|
|
90
|
+
if err := decodeParams(raw, &in); err != nil {
|
|
91
|
+
return nil, err
|
|
92
|
+
}
|
|
93
|
+
// Executor independently verifies both session values and administrator identity.
|
|
94
|
+
auth, err := api.DSMSession(req)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return nil, err
|
|
97
|
+
}
|
|
98
|
+
if err := cfg.executorHoldsThisBuild(ctx); err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
var out json.RawMessage
|
|
102
|
+
if err := cfg.executor().Call(ctx, "root.run", auth, map[string]string{"name": in.Name}, &out); err != nil {
|
|
103
|
+
return nil, err
|
|
104
|
+
}
|
|
105
|
+
return out, nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// executorHoldsThisBuild refuses, as delegate does, an executor pinned to
|
|
109
|
+
// another build's policy or executor: its approved scripts are not this build's.
|
|
110
|
+
func (cfg Config) executorHoldsThisBuild(ctx context.Context) error {
|
|
111
|
+
var pins delegate.ExecutorStatus
|
|
112
|
+
if err := cfg.executor().Call(ctx, "status", nil, nil, &pins); err != nil {
|
|
113
|
+
return err
|
|
114
|
+
}
|
|
115
|
+
if (cfg.ExecutorDigest != "" && pins.ExecutorDigest != cfg.ExecutorDigest) || (cfg.PolicyDigest != "" && pins.PolicyDigest != cfg.PolicyDigest) {
|
|
116
|
+
return &api.Error{Code: delegate.CodePolicyDenied, Message: "the executor holds another build's approved policy; an administrator approves this build in Administrator setup"}
|
|
117
|
+
}
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// executorSocketBase is a variable so tests can use a temporary directory.
|
|
122
|
+
var executorSocketBase = "/run/synopkgland"
|
|
123
|
+
|
|
124
|
+
// executor keeps the executor's protocol v1 refusal codes (POLICY_DENIED,
|
|
125
|
+
// EXECUTOR_UNAVAILABLE, ...) as the API error code.
|
|
126
|
+
func (cfg Config) executor() *delegate.Client {
|
|
127
|
+
return &delegate.Client{Socket: filepath.Join(executorSocketBase, cfg.PackageID, "executor.sock"), Timeout: 30 * time.Second}
|
|
128
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"net"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
appapi "__PACKAGE__/internal/api"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func init() {
|
|
17
|
+
sampleParams["executor.control"] = `{"action":"stop"}`
|
|
18
|
+
sampleParams["executor.status"] = `{}`
|
|
19
|
+
sampleParams["root.run"] = `{"name":"whoami"}`
|
|
20
|
+
sampleOutcome["root.run"] = "session_required"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestExecutorControlAcceptsOnlyFixedActions(t *testing.T) {
|
|
24
|
+
cfg := testConfig(t)
|
|
25
|
+
for _, action := range []string{"start", "stop", "purge"} {
|
|
26
|
+
code, out := call(t, cfg, alice, "executor.control", `{"action":"`+action+`"}`)
|
|
27
|
+
if code != "" {
|
|
28
|
+
t.Fatalf("%s: %s %v", action, code, out)
|
|
29
|
+
}
|
|
30
|
+
b, err := os.ReadFile(filepath.Join(cfg.DataDir, "executor/control"))
|
|
31
|
+
if err != nil || string(b) != action {
|
|
32
|
+
t.Fatalf("control was not written: %s %v", b, err)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for _, raw := range []string{`{"action":"start;id"}`, `{"action":"start\nstop"}`, `{"action":""}`, `{"action":"stop","path":"/etc/passwd"}`} {
|
|
36
|
+
if code, _ := call(t, cfg, alice, "executor.control", raw); code != "bad_request" {
|
|
37
|
+
t.Fatalf("accepted %s: %s", raw, code)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestExecutorControlRejectsSymlinkDirectory(t *testing.T) {
|
|
43
|
+
cfg := testConfig(t)
|
|
44
|
+
outside := t.TempDir()
|
|
45
|
+
if err := os.Symlink(outside, filepath.Join(cfg.DataDir, "executor")); err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
if code, _ := call(t, cfg, alice, "executor.control", `{"action":"stop"}`); code == "" {
|
|
49
|
+
t.Fatal("accepted symlink")
|
|
50
|
+
}
|
|
51
|
+
if _, err := os.Stat(filepath.Join(outside, "control")); !os.IsNotExist(err) {
|
|
52
|
+
t.Fatal("wrote outside data directory")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
|
|
57
|
+
t.Setenv("HTTP_COOKIE", "")
|
|
58
|
+
if code, _ := call(t, testConfig(t), alice, "root.run", `{"name":"whoami"}`); code == "" {
|
|
59
|
+
t.Fatal("root.run accepted absent DSM cookie")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The executor closes its listener while paused (package stopped, or pins
|
|
64
|
+
// awaiting administrator approval). Status then answers, not "internal".
|
|
65
|
+
func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
|
|
66
|
+
cfg := testConfig(t)
|
|
67
|
+
useExecutorSocketBase(t)
|
|
68
|
+
check := func(name string) {
|
|
69
|
+
t.Helper()
|
|
70
|
+
code, envelope := call(t, cfg, alice, "executor.status", `{}`)
|
|
71
|
+
out, _ := envelope["data"].(map[string]any)
|
|
72
|
+
if code != "" || out["ready"] != false || out["state"] != "paused" || out["reason"] != "executor_not_listening" || out["next"] == "" || out["next"] == nil {
|
|
73
|
+
t.Fatalf("%s: %s %v", name, code, out)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
check("no socket")
|
|
77
|
+
if code, _ := call(t, cfg, bob, "executor.status", `{}`); code != "" {
|
|
78
|
+
t.Fatal("authenticated user cannot read a paused status", code)
|
|
79
|
+
}
|
|
80
|
+
dir := filepath.Join(executorSocketBase, cfg.PackageID)
|
|
81
|
+
os.MkdirAll(dir, 0700)
|
|
82
|
+
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(dir, "executor.sock"), Net: "unix"})
|
|
83
|
+
if err != nil {
|
|
84
|
+
t.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
ln.SetUnlinkOnClose(false)
|
|
87
|
+
ln.Close()
|
|
88
|
+
check("closed listener")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// next follows the last lifecycle request: a running package needs approval
|
|
92
|
+
// again, not a start.
|
|
93
|
+
func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
|
|
94
|
+
cfg := testConfig(t)
|
|
95
|
+
useExecutorSocketBase(t)
|
|
96
|
+
next := func() string {
|
|
97
|
+
t.Helper()
|
|
98
|
+
_, envelope := call(t, cfg, alice, "executor.status", `{}`)
|
|
99
|
+
out, _ := envelope["data"].(map[string]any)
|
|
100
|
+
s, _ := out["next"].(string)
|
|
101
|
+
return s
|
|
102
|
+
}
|
|
103
|
+
if !strings.HasPrefix(next(), "Start the package.") {
|
|
104
|
+
t.Fatal("no request:", next())
|
|
105
|
+
}
|
|
106
|
+
mailbox := filepath.Join(cfg.DataDir, "executor")
|
|
107
|
+
os.MkdirAll(mailbox, 0700)
|
|
108
|
+
for request, start := range map[string]bool{"stop\n": true, "start\n": false, "start": false} {
|
|
109
|
+
os.WriteFile(filepath.Join(mailbox, "control"), []byte(request), 0600)
|
|
110
|
+
if got := next(); strings.HasPrefix(got, "Start the package") != start || got == "" {
|
|
111
|
+
t.Fatalf("%q: %s", request, got)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// fakeExecutor answers each v1 request with replies[op] and records the ops.
|
|
117
|
+
type fakeExecutor struct {
|
|
118
|
+
mu sync.Mutex
|
|
119
|
+
ops []string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func (f *fakeExecutor) received() []string {
|
|
123
|
+
f.mu.Lock()
|
|
124
|
+
defer f.mu.Unlock()
|
|
125
|
+
return append([]string(nil), f.ops...)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func startFakeExecutor(t *testing.T, cfg Config, replies map[string]string) *fakeExecutor {
|
|
129
|
+
t.Helper()
|
|
130
|
+
useExecutorSocketBase(t)
|
|
131
|
+
dir := filepath.Join(executorSocketBase, cfg.PackageID)
|
|
132
|
+
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
133
|
+
t.Fatal(err)
|
|
134
|
+
}
|
|
135
|
+
ln, err := net.Listen("unix", filepath.Join(dir, "executor.sock"))
|
|
136
|
+
if err != nil {
|
|
137
|
+
t.Fatal(err)
|
|
138
|
+
}
|
|
139
|
+
t.Cleanup(func() { ln.Close() })
|
|
140
|
+
f := &fakeExecutor{}
|
|
141
|
+
go func() {
|
|
142
|
+
for {
|
|
143
|
+
conn, err := ln.Accept()
|
|
144
|
+
if err != nil {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
var req struct{ Op string }
|
|
148
|
+
line, _ := bufio.NewReader(conn).ReadBytes('\n')
|
|
149
|
+
json.Unmarshal(line, &req)
|
|
150
|
+
f.mu.Lock()
|
|
151
|
+
f.ops = append(f.ops, req.Op)
|
|
152
|
+
f.mu.Unlock()
|
|
153
|
+
conn.Write([]byte(replies[req.Op] + "\n"))
|
|
154
|
+
conn.Close()
|
|
155
|
+
}
|
|
156
|
+
}()
|
|
157
|
+
return f
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// useExecutorSocketBase points the CGI at a short temporary directory: unix
|
|
161
|
+
// socket paths are limited to about 100 bytes.
|
|
162
|
+
func useExecutorSocketBase(t *testing.T) {
|
|
163
|
+
t.Helper()
|
|
164
|
+
previous := executorSocketBase
|
|
165
|
+
base, err := os.MkdirTemp("/tmp", "exec")
|
|
166
|
+
if err != nil {
|
|
167
|
+
t.Fatal(err)
|
|
168
|
+
}
|
|
169
|
+
executorSocketBase = base
|
|
170
|
+
t.Cleanup(func() { executorSocketBase = previous; os.RemoveAll(base) })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// rootRun calls root.run as an administrator with a current DSM session.
|
|
174
|
+
func rootRun(t *testing.T, cfg Config, name string) (string, map[string]any) {
|
|
175
|
+
t.Helper()
|
|
176
|
+
permissions, err := appapi.Permissions()
|
|
177
|
+
if err != nil {
|
|
178
|
+
t.Fatal(err)
|
|
179
|
+
}
|
|
180
|
+
req := apiRequest("root.run", `{"name":"`+name+`"}`)
|
|
181
|
+
req.Header.Set("Cookie", "id=session")
|
|
182
|
+
req.Header.Set("X-SYNO-TOKEN", "token")
|
|
183
|
+
return serve(t, cfg, alice, req, permissions)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const readyStatus = `{"ok":true,"result":{"executor_digest":"e1","policy_digest":"p1","ready":true,"disabled":false,"version":"1"}}`
|
|
187
|
+
|
|
188
|
+
func pinnedConfig(t *testing.T) Config {
|
|
189
|
+
cfg := testConfig(t)
|
|
190
|
+
cfg.ExecutorDigest, cfg.PolicyDigest = "e1", "p1"
|
|
191
|
+
return cfg
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func TestExecutorRootRunRunsWhenTheExecutorHoldsThisBuildsPins(t *testing.T) {
|
|
195
|
+
cfg := pinnedConfig(t)
|
|
196
|
+
startFakeExecutor(t, cfg, map[string]string{"status": readyStatus, "root.run": `{"ok":true,"result":{"output":"0\n"}}`})
|
|
197
|
+
code, out := rootRun(t, cfg, "whoami")
|
|
198
|
+
if data, _ := out["data"].(map[string]any); code != "" || data["output"] != "0\n" {
|
|
199
|
+
t.Fatalf("%s %v", code, out)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Like delegate: an executor pinned to another build's policy or executor
|
|
204
|
+
// refuses the operation, it does not run it.
|
|
205
|
+
func TestExecutorRootRunRefusesAnExecutorPinnedToAnotherBuild(t *testing.T) {
|
|
206
|
+
for name, status := range map[string]string{
|
|
207
|
+
"policy": strings.Replace(readyStatus, `"p1"`, `"p2"`, 1),
|
|
208
|
+
"executor": strings.Replace(readyStatus, `"e1"`, `"e2"`, 1),
|
|
209
|
+
} {
|
|
210
|
+
cfg := pinnedConfig(t)
|
|
211
|
+
f := startFakeExecutor(t, cfg, map[string]string{"status": status, "root.run": `{"ok":true,"result":{"output":"0\n"}}`})
|
|
212
|
+
if code, out := rootRun(t, cfg, "whoami"); code != "POLICY_DENIED" {
|
|
213
|
+
t.Errorf("%s digest mismatch: %q %v", name, code, out)
|
|
214
|
+
}
|
|
215
|
+
for _, op := range f.received() {
|
|
216
|
+
if op == "root.run" {
|
|
217
|
+
t.Errorf("%s digest mismatch: root.run reached the executor", name)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Refusals keep the protocol v1 code (spec/protocol/v1/errors.json), never "internal".
|
|
224
|
+
func TestExecutorRootRunReportsTheExecutorsRefusalCode(t *testing.T) {
|
|
225
|
+
cfg := pinnedConfig(t)
|
|
226
|
+
useExecutorSocketBase(t)
|
|
227
|
+
if code, _ := rootRun(t, cfg, "whoami"); code != "EXECUTOR_UNAVAILABLE" {
|
|
228
|
+
t.Errorf("paused or awaiting approval (no socket): %q", code)
|
|
229
|
+
}
|
|
230
|
+
for _, refusal := range []string{"POLICY_DENIED", "INVALID_REQUEST", "EXECUTOR_PAUSED", "ROOT_FAILED", "ADMIN_REQUIRED"} {
|
|
231
|
+
cfg := pinnedConfig(t)
|
|
232
|
+
startFakeExecutor(t, cfg, map[string]string{"status": readyStatus, "root.run": `{"ok":false,"code":"` + refusal + `","message":"refused"}`})
|
|
233
|
+
if code, out := rootRun(t, cfg, "whoami"); code != refusal {
|
|
234
|
+
t.Errorf("%s: %q %v", refusal, code, out)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Native Go 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
|
+
`github.com/synopackageland/syno-pkg-land-sdk` version in `backend/go.mod`. 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: go 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
|
+
Import `themes/dsm7-embedded.css` and assign each mount a unique
|
|
41
|
+
`app.config.idPrefix` before mounting.
|
|
42
|
+
|
|
43
|
+
Start with AGENTS.md, `synopkgland skill list` and `synopkgland skill path`.
|
|
44
|
+
Read `synopkgland skill show ui-vue` for direct Vue component work and
|
|
45
|
+
`synopkgland skill show ui-json` for optional declarative specs and their
|
|
46
|
+
validate/render workflow. The existing host/backend lifecycle remains in place.
|
|
47
|
+
|
|
48
|
+
For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
|
|
49
|
+
`npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
|
|
50
|
+
Read its prompt and exported Vue docs/types before choosing component APIs.
|
|
51
|
+
Use `syno-pkg-land-ui` directly in npm scripts; keep AI guidance version-independent.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Native DSM Vue template
|
|
3
|
+
description: Existing package settings UI using the installed @synopackageland/ui.
|
|
4
|
+
typography:
|
|
5
|
+
body:
|
|
6
|
+
fontFamily: "var(--ui-font-sans)"
|
|
7
|
+
fontSize: "13px"
|
|
8
|
+
lineHeight: 1.5
|
|
9
|
+
title:
|
|
10
|
+
fontSize: "20px"
|
|
11
|
+
fontWeight: 600
|
|
12
|
+
state:
|
|
13
|
+
fontSize: "15px"
|
|
14
|
+
fontWeight: 600
|
|
15
|
+
spacing:
|
|
16
|
+
actions: "8px"
|
|
17
|
+
heading-gap: "16px"
|
|
18
|
+
section-gap: "24px"
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Design System: Native DSM Vue template
|
|
22
|
+
|
|
23
|
+
Read `AGENTS.md` at the project root and `synopkgland skill show ui-vue` before UI edits.
|
|
24
|
+
|
|
25
|
+
For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
|
|
26
|
+
`npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
|
|
27
|
+
Read its prompt and exported Vue docs/types before choosing component APIs.
|
|
28
|
+
Use `syno-pkg-land-ui` directly in npm scripts; keep AI guidance version-independent.
|
|
29
|
+
|
|
30
|
+
## Overview
|
|
31
|
+
|
|
32
|
+
The template runs inside a DSM package window. `AppShell` provides status and
|
|
33
|
+
optional background navigation. The base page shows package status and a greeting
|
|
34
|
+
editor; the root overlay adds its existing fixed-operation authorization gate.
|
|
35
|
+
|
|
36
|
+
Use the installed `@synopackageland/ui` controls and `themes/dsm7-embedded.css`.
|
|
37
|
+
The theme owns colors, typography, spacing and dialogs. Keep per-app modal scope,
|
|
38
|
+
a unique Vue ID prefix and vertical scrolling at the DSM minimum window size.
|
|
39
|
+
|
|
40
|
+
Refresh and save expose busy state and localized errors. Reopen refreshes status.
|
|
41
|
+
Each mount owns its state; unmount unsubscribes from host events and ignores late
|
|
42
|
+
responses. Backend route policies decide access.
|
|
43
|
+
|
|
44
|
+
### Opt-in background schedules (S1-a)
|
|
45
|
+
|
|
46
|
+
The `native-go-jobs` overlay extends this **Operate** surface within the inherited `AppRoot`, embedded theme, `SettingsSection`, Button and Callout system. It introduces no new palette or global tokens.
|
|
47
|
+
|
|
48
|
+
- **Private-copy consent:** “Schedule this copy” is available for the signed-in owner's retained, successful, unscheduled job copy. “Private copy: {name}” identifies the input; “Repeat every” offers “Hour”, “Day” and “Week”. “Create schedule” stays disabled until the user checks “I agree to keep and reprocess this fixed copy as the package user. No new source files or personal ACLs are read.” The help explains the eight-schedule limit and that missed runs are recorded, never replayed.
|
|
49
|
+
- **Ownership and recovery:** preserve user-and-UID owner isolation. Administrator rows expose “Owner: {user} (UID {uid})”. A worker change shows “The worker revision changed. An administrator must review and explicitly enable this schedule.” Only administrators, while background mode is on, see “Enable schedule”. Its confirmation names the exact private copy: “Enable the schedule for {name} with the current worker revision? I agree to reprocess this fixed copy. Past runs will not be replayed.” Actions are “Confirm enable” and “Cancel”.
|
|
50
|
+
- **Layout:** schedule name/state, owner, next run, missed runs and warnings stack above a grouped, wrapping action row. Long names and copy wrap within the available width. Keep the inherited vertical scrolling at the minimum window size; do not squeeze schedules into one viewport. The labeled select and consent checkbox retain visible keyboard focus using existing theme tokens.
|
|
51
|
+
- **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.
|
|
52
|
+
|
|
53
|
+
Evidence: `.herdr-project/syno-package-land-sdk-t-0129/library/ui-desktop.png` and `ui-minimum.png` are local component-fixture captures. The finish review disposition is **ship**, with all three material findings resolved. A fresh `normal1` NAS browser readback passed before the final UI adjustments; these captures validate the final adjustments locally and do not establish final DSM visual acceptance.
|
|
54
|
+
|
|
55
|
+
## Do's and Don'ts
|
|
56
|
+
|
|
57
|
+
- Do use existing public UI components, embedded theme tokens and state/recovery copy.
|
|
58
|
+
- Do retain permission gating, readable loading/empty/error states and per-instance cleanup.
|
|
59
|
+
- Do use the library's `Select`, `Checkbox`, `Input`, `Textarea`, `Form`, `FormItem`, `FilePicker` (`variant="compact"`, in the app locale) and `Link` for controls; there is no native `<input>` or `<a>`. Leave `Form` on its automatic direction (labels stack below 520px of container width) and let a `FormItem` bind its control, with no `direction="vertical"`, `role="group"` or `field.controlId`.
|
|
60
|
+
- Do not infer authority from frontend observations or scheduler history.
|
|
61
|
+
- Don't introduce a separate palette, component system or modal workaround around the library contract.
|