Skip to content

Commit 7734b14

Browse files
committed
feat(bash): add systemd manager install flow
1 parent 39d9e24 commit 7734b14

7 files changed

Lines changed: 288 additions & 5 deletions

File tree

scripts/bash/systemd-service-manager/build.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ MODULES=(
1717
"${SCRIPT_DIR}/lib/parser-service.sh"
1818
"${SCRIPT_DIR}/lib/parser-timer.sh"
1919
"${SCRIPT_DIR}/lib/schedule.sh"
20+
"${SCRIPT_DIR}/lib/render-service.sh"
21+
"${SCRIPT_DIR}/lib/render-timer.sh"
22+
"${SCRIPT_DIR}/lib/systemd.sh"
2023
"${SCRIPT_DIR}/lib/validate.sh"
2124
"${SCRIPT_DIR}/commands/init.sh"
2225
"${SCRIPT_DIR}/commands/list.sh"

scripts/bash/systemd-service-manager/commands/install.sh

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,70 @@ ssm_cmd_install() {
1414

1515
local project_dir
1616
project_dir="$(ssm_find_project_dir "${SSM_CLI_PROJECT_DIR:-}")"
17+
local render_dir
18+
render_dir="$(mktemp -d)"
19+
trap 'rm -rf '"'"${render_dir}"'"'' RETURN
20+
21+
local source_file=""
22+
local scope="system"
1723

1824
case "${target_kind}" in
1925
service)
2026
ssm_parse_service_config "${project_dir}" "${target_name}"
2127
ssm_require_safe_name "UNIT_PREFIX" "${UNIT_PREFIX}"
22-
printf '%s\n' "$(ssm_service_unit_name "${target_name}")"
28+
source_file="$(ssm_service_config_path "${project_dir}" "${target_name}")"
29+
scope="${SSM_SERVICE_SCOPE}"
30+
local service_unit_file="${render_dir}/$(ssm_service_unit_name "${target_name}")"
31+
ssm_render_service_unit "${source_file}" >"${service_unit_file}"
32+
ssm_verify_unit_file "${service_unit_file}" || ssm_die "systemd-analyze verify failed for ${service_unit_file}"
33+
if [[ "${SSM_CLI_DRY_RUN}" == "1" ]]; then
34+
printf '%s\n' "$(basename "${service_unit_file}")"
35+
return 0
36+
fi
37+
mkdir -p "$(ssm_unit_dir_for_scope "${scope}")"
38+
cp "${service_unit_file}" "$(ssm_unit_dir_for_scope "${scope}")/"
39+
ssm_daemon_reload "${scope}"
2340
;;
2441
timer)
2542
ssm_parse_timer_config "${project_dir}" "${target_name}"
2643
ssm_require_safe_name "UNIT_PREFIX" "${UNIT_PREFIX}"
27-
ssm_resolve_schedule "${SCHEDULE}" >/dev/null
28-
printf '%s\n' "$(ssm_timer_unit_name "${target_name}")"
29-
if [[ "${TARGET_TYPE}" == "task" || "${TARGET_TYPE}" == "service" ]]; then
30-
printf '%s\n' "$(ssm_timer_task_unit_name "${target_name}")"
44+
source_file="$(ssm_timer_config_path "${project_dir}" "${target_name}")"
45+
scope="${SSM_TIMER_SCOPE}"
46+
local schedule_block
47+
schedule_block="$(ssm_resolve_schedule "${SCHEDULE}")"
48+
local task_unit_name
49+
task_unit_name="$(ssm_timer_task_unit_name "${target_name}")"
50+
local task_unit_file="${render_dir}/${task_unit_name}"
51+
local timer_unit_file="${render_dir}/$(ssm_timer_unit_name "${target_name}")"
52+
local task_exec_command=""
53+
54+
if [[ "${TARGET_TYPE}" == "service" ]]; then
55+
local target_unit
56+
target_unit="$(ssm_service_unit_name "${TARGET_NAME}")"
57+
if [[ "${scope}" == "user" ]]; then
58+
task_exec_command="/usr/bin/env bash -lc 'systemctl --user ${ACTION:-restart} ${target_unit}'"
59+
else
60+
task_exec_command="/usr/bin/env bash -lc 'systemctl ${ACTION:-restart} ${target_unit}'"
61+
fi
62+
else
63+
task_exec_command="${COMMAND}"
3164
fi
65+
66+
ssm_render_task_service_unit "${source_file}" "${task_exec_command}" >"${task_unit_file}"
67+
ssm_render_timer_unit "${source_file}" "${task_unit_name}" "${schedule_block}" >"${timer_unit_file}"
68+
ssm_verify_unit_file "${task_unit_file}" || ssm_die "systemd-analyze verify failed for ${task_unit_file}"
69+
ssm_verify_unit_file "${timer_unit_file}" || ssm_die "systemd-analyze verify failed for ${timer_unit_file}"
70+
71+
if [[ "${SSM_CLI_DRY_RUN}" == "1" ]]; then
72+
printf '%s\n' "$(basename "${timer_unit_file}")"
73+
printf '%s\n' "$(basename "${task_unit_file}")"
74+
return 0
75+
fi
76+
77+
mkdir -p "$(ssm_unit_dir_for_scope "${scope}")"
78+
cp "${task_unit_file}" "$(ssm_unit_dir_for_scope "${scope}")/"
79+
cp "${timer_unit_file}" "$(ssm_unit_dir_for_scope "${scope}")/"
80+
ssm_daemon_reload "${scope}"
3281
;;
3382
*)
3483
ssm_die "Unknown install target kind: ${target_kind}"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# shellcheck shell=bash
2+
3+
if [[ -n "${SSM_RENDER_SERVICE_LOADED:-}" ]]; then
4+
return 0
5+
fi
6+
SSM_RENDER_SERVICE_LOADED=1
7+
8+
# 渲染常驻 service unit,保持最小字段集合和统一 managed header。
9+
ssm_render_service_unit() {
10+
local source_file="$1"
11+
cat <<EOF
12+
# Managed by systemd-service-manager
13+
# Source: ${source_file}
14+
[Unit]
15+
Description=${DESCRIPTION:-${SSM_SERVICE_NAME}}
16+
${AFTER:+After=${AFTER}}
17+
${WANTS:+Wants=${WANTS}}
18+
19+
[Service]
20+
Type=simple
21+
WorkingDirectory=${WORKDIR:-${DEFAULT_WORKDIR:-/tmp}}
22+
ExecStart=${COMMAND}
23+
${USER:+User=${USER}}
24+
${GROUP:+Group=${GROUP}}
25+
Restart=${RESTART:-on-failure}
26+
RestartSec=${RESTART_SEC:-5s}
27+
28+
[Install]
29+
WantedBy=${WANTED_BY:-multi-user.target}
30+
EOF
31+
}
32+
33+
# 渲染 timer 触发的一次性 task/service wrapper unit。
34+
ssm_render_task_service_unit() {
35+
local source_file="$1"
36+
local exec_command="$2"
37+
cat <<EOF
38+
# Managed by systemd-service-manager
39+
# Source: ${source_file}
40+
[Unit]
41+
Description=${DESCRIPTION:-${SSM_TIMER_NAME}}
42+
43+
[Service]
44+
Type=oneshot
45+
WorkingDirectory=${WORKDIR:-${DEFAULT_WORKDIR:-/tmp}}
46+
ExecStart=${exec_command}
47+
${USER:+User=${USER}}
48+
${GROUP:+Group=${GROUP}}
49+
EOF
50+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# shellcheck shell=bash
2+
3+
if [[ -n "${SSM_RENDER_TIMER_LOADED:-}" ]]; then
4+
return 0
5+
fi
6+
SSM_RENDER_TIMER_LOADED=1
7+
8+
# 渲染 timer unit,保持 schedule 解析和 managed header 分离。
9+
ssm_render_timer_unit() {
10+
local source_file="$1"
11+
local unit_name="$2"
12+
local schedule_block="$3"
13+
cat <<EOF
14+
# Managed by systemd-service-manager
15+
# Source: ${source_file}
16+
[Unit]
17+
Description=${DESCRIPTION:-${SSM_TIMER_NAME}}
18+
19+
[Timer]
20+
Unit=${unit_name}
21+
${schedule_block}
22+
Persistent=${PERSISTENT:-true}
23+
${RANDOMIZED_DELAY:+RandomizedDelaySec=${RANDOMIZED_DELAY}}
24+
25+
[Install]
26+
WantedBy=timers.target
27+
EOF
28+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# shellcheck shell=bash
2+
3+
if [[ -n "${SSM_SYSTEMD_LOADED:-}" ]]; then
4+
return 0
5+
fi
6+
SSM_SYSTEMD_LOADED=1
7+
8+
# 返回 system scope unit 目录,测试可通过环境变量覆写。
9+
ssm_system_unit_dir() {
10+
printf '%s\n' "${SSM_SYSTEM_UNIT_DIR:-/etc/systemd/system}"
11+
}
12+
13+
# 返回 user scope unit 目录,测试可通过环境变量覆写。
14+
ssm_user_unit_dir() {
15+
printf '%s\n' "${SSM_USER_UNIT_DIR:-${HOME}/.config/systemd/user}"
16+
}
17+
18+
# 按 scope 选择最终写入目录。
19+
ssm_unit_dir_for_scope() {
20+
local scope="$1"
21+
if [[ "${scope}" == "user" ]]; then
22+
ssm_user_unit_dir
23+
return 0
24+
fi
25+
26+
ssm_system_unit_dir
27+
}
28+
29+
# 按 scope 包装 systemctl 调用,减少命令层分支。
30+
ssm_systemctl() {
31+
local scope="$1"
32+
shift
33+
if [[ "${scope}" == "user" ]]; then
34+
systemctl --user "$@"
35+
else
36+
systemctl "$@"
37+
fi
38+
}
39+
40+
# 写入新 unit 后统一 reload 对应 scope 的 systemd 配置。
41+
ssm_daemon_reload() {
42+
local scope="$1"
43+
ssm_systemctl "${scope}" daemon-reload
44+
}
45+
46+
# 在落盘前用 systemd-analyze 校验 unit 语法。
47+
ssm_verify_unit_file() {
48+
local unit_file="$1"
49+
systemd-analyze verify "${unit_file}" >/dev/null 2>&1
50+
}

scripts/bash/systemd-service-manager/main.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ if [[ -z "${SSM_STANDALONE:-}" ]]; then
1818
source "${SCRIPT_DIR}/lib/parser-timer.sh"
1919
# shellcheck source=scripts/bash/systemd-service-manager/lib/schedule.sh
2020
source "${SCRIPT_DIR}/lib/schedule.sh"
21+
# shellcheck source=scripts/bash/systemd-service-manager/lib/render-service.sh
22+
source "${SCRIPT_DIR}/lib/render-service.sh"
23+
# shellcheck source=scripts/bash/systemd-service-manager/lib/render-timer.sh
24+
source "${SCRIPT_DIR}/lib/render-timer.sh"
25+
# shellcheck source=scripts/bash/systemd-service-manager/lib/systemd.sh
26+
source "${SCRIPT_DIR}/lib/systemd.sh"
2127
# shellcheck source=scripts/bash/systemd-service-manager/lib/validate.sh
2228
source "${SCRIPT_DIR}/lib/validate.sh"
2329
# shellcheck source=scripts/bash/systemd-service-manager/commands/init.sh
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { afterEach, describe, expect, it } from 'vitest'
4+
import {
5+
cleanupWorkspace,
6+
createWorkspace,
7+
installMockCommand,
8+
runSource,
9+
} from './test-utils'
10+
11+
const workspaces: ReturnType<typeof createWorkspace>[] = []
12+
13+
afterEach(() => {
14+
while (workspaces.length > 0) {
15+
const workspace = workspaces.pop()
16+
if (workspace) {
17+
cleanupWorkspace(workspace)
18+
}
19+
}
20+
})
21+
22+
describe('install command', () => {
23+
it('prints generated unit names in dry-run mode without writing files', async () => {
24+
const workspace = createWorkspace()
25+
workspaces.push(workspace)
26+
27+
installMockCommand(
28+
workspace,
29+
'systemd-analyze',
30+
'#!/usr/bin/env bash\nexit 0\n',
31+
)
32+
33+
const projectRoot = path.join(
34+
workspace.managerHome,
35+
'tests',
36+
'fixtures',
37+
'project-basic',
38+
)
39+
40+
const result = await runSource(workspace, [
41+
'install',
42+
'service',
43+
'api',
44+
'--project',
45+
projectRoot,
46+
'--dry-run',
47+
])
48+
49+
expect(result.exitCode).toBe(0)
50+
expect(result.stdout).toContain('myapp-api.service')
51+
expect(fs.readdirSync(workspace.fakeSystemDir)).toHaveLength(0)
52+
})
53+
54+
it('writes service and timer units to the selected scope', async () => {
55+
const workspace = createWorkspace()
56+
workspaces.push(workspace)
57+
58+
installMockCommand(
59+
workspace,
60+
'systemd-analyze',
61+
'#!/usr/bin/env bash\nexit 0\n',
62+
)
63+
installMockCommand(
64+
workspace,
65+
'systemctl',
66+
'#!/usr/bin/env bash\nprintf "%s\\n" "$*" >>"${SSM_SYSTEMCTL_LOG}"\nexit 0\n',
67+
)
68+
69+
const projectRoot = path.join(
70+
workspace.managerHome,
71+
'tests',
72+
'fixtures',
73+
'project-basic',
74+
)
75+
76+
const result = await runSource(
77+
workspace,
78+
['install', 'timer', 'cleanup', '--project', projectRoot],
79+
{
80+
SSM_SYSTEMCTL_LOG: path.join(workspace.root, 'systemctl.log'),
81+
},
82+
)
83+
84+
expect(result.exitCode).toBe(0)
85+
expect(
86+
fs.existsSync(path.join(workspace.fakeSystemDir, 'myapp-cleanup.timer')),
87+
).toBe(true)
88+
expect(
89+
fs.existsSync(
90+
path.join(workspace.fakeSystemDir, 'myapp-task-cleanup.service'),
91+
),
92+
).toBe(true)
93+
expect(
94+
fs.readFileSync(path.join(workspace.root, 'systemctl.log'), 'utf8'),
95+
).toContain('daemon-reload')
96+
})
97+
})

0 commit comments

Comments
 (0)