feat: add prefixed service initializer and packaged deployment

This commit is contained in:
shiran
2026-09-09 11:00:31 +08:00
parent 1e2366998e
commit 4da89b784e
9 changed files with 355 additions and 40 deletions
+9 -9
View File
@@ -1,4 +1,7 @@
name: 'Build & Deploy'
env:
DEPLOY_DIR: /root
on:
push:
tags:
@@ -17,15 +20,14 @@ jobs:
go build -ldflags="-s -w" -o server ./cmd/main_program
go build -ldflags="-s -w" -o cli ./cmd/cli_control
go build -ldflags="-s -w" -o scheduler ./cmd/scheduler
go build -ldflags="-s -w" -o initializer .
tar -czf deployment.tar.gz server cli scheduler initializer
- name: Save artifact
uses: actions/upload-artifact@v3
with:
name: build-artifacts
path: |
./server
./cli
./scheduler
path: ./deployment.tar.gz
deploy:
needs: build
@@ -53,11 +55,9 @@ jobs:
while read -r ip; do
if [ -n "$ip" ]; then
echo "Deploying to $ip..."
scp -o StrictHostKeyChecking=no server cli scheduler ${{ vars.ROOT_USER_NAME }}@"$ip":/root/
ssh -n ${{ vars.ROOT_USER_NAME }}@"$ip" << 'ENDSSH'
chmod +x /root/server /root/cli /root/scheduler
systemctl restart server cli scheduler
ENDSSH
scp -o StrictHostKeyChecking=no deployment.tar.gz ${{ vars.ROOT_USER_NAME }}@"$ip":"$DEPLOY_DIR/deployment.tar.gz"
ssh -n ${{ vars.ROOT_USER_NAME }}@"$ip" \
"set -e; bash '$DEPLOY_DIR/stop.sh'; tar -xzf '$DEPLOY_DIR/deployment.tar.gz' -C '$DEPLOY_DIR'; chmod +x '$DEPLOY_DIR/server' '$DEPLOY_DIR/cli' '$DEPLOY_DIR/scheduler' '$DEPLOY_DIR/initializer'; bash '$DEPLOY_DIR/start.sh'"
echo "Deployment to $ip completed"
fi
done < server_list.txt
+5
View File
@@ -11,6 +11,11 @@
/vendor/
/logs/
/tmp/
/.service-prefix
/server
/cli
/scheduler
/initializer
__debug_bin*
.idea/
+23 -7
View File
@@ -52,6 +52,7 @@ go run ./cmd/scheduler # 定时任务调度器
go build -ldflags="-s -w" -o server ./cmd/main_program
go build -ldflags="-s -w" -o cli ./cmd/cli_control
go build -ldflags="-s -w" -o scheduler ./cmd/scheduler
go build -ldflags="-s -w" -o initializer .
```
## 模板使用步骤
@@ -269,9 +270,20 @@ go build -ldflags="-s -w" -o scheduler ./cmd/scheduler
### 首次安装(systemd 服务注册)
`initializer` 已内置 `.env.example``install.sh`、启停脚本和 `deploy/*.service`。将它与三个业务二进制放到部署目录后执行:
```bash
chmod +x install.sh start.sh stop.sh restart.sh server cli scheduler
sudo bash install.sh
chmod +x initializer server cli scheduler
sudo ./initializer
# 请输入项目前缀(例如 myapp
```
初始化工具会自动释放部署文件、添加执行权限、把前缀保存到 `.service-prefix`,并在 `.env` 不存在时根据 `.env.example` 自动创建它,随后执行 `install.sh`。已有 `.env` 会被保留,不会覆盖其中的密钥和环境配置。注册出的服务名为 `myapp-server``myapp-cli``myapp-scheduler`
无人值守初始化时可直接传入前缀:
```bash
sudo ./initializer -prefix myapp
```
### 日常运维
@@ -285,11 +297,11 @@ bash restart.sh # 重启全部
单独管理:
```bash
systemctl status server # 主服务状态
systemctl status scheduler # 调度器状态
systemctl restart scheduler # 只重启调度器
journalctl -u server -f # 主服务实时日志
journalctl -u scheduler -f # 调度器实时日志
systemctl status myapp-server # 主服务状态
systemctl status myapp-scheduler # 调度器状态
systemctl restart myapp-scheduler # 只重启调度器
journalctl -u myapp-server -f # 主服务实时日志
journalctl -u myapp-scheduler -f # 调度器实时日志
```
### systemd 服务特性
@@ -309,7 +321,9 @@ journalctl -u scheduler -f # 调度器实时日志
├── server # HTTP 主服务
├── cli # httplog 上报
├── scheduler # 定时任务调度器
├── initializer # 首次安装工具(内置部署资源)
├── .env # 环境配置
├── .service-prefix # 初始化时保存的 systemd 服务名前缀
├── deploy/
│ ├── server.service
│ ├── cli.service
@@ -322,6 +336,8 @@ journalctl -u scheduler -f # 调度器实时日志
└── cron_*.log # 定时任务专用日志
```
CI 会将 `server``cli``scheduler``initializer` 打包为 `deployment.tar.gz` 后上传。部署目录由工作流顶部的 `DEPLOY_DIR` 统一配置(默认 `/root`)。压缩包上传完成后,服务器依次执行 `stop.sh`、覆盖解压、添加执行权限和 `start.sh`;启停脚本会读取 `.service-prefix` 操作带项目前缀的三个服务。首次部署前需要先运行一次 `initializer`
## 内置功能清单
- Hertz HTTP 框架 + 路由分组
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"bufio"
"embed"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
)
// deploymentAssets contains everything needed to register and manage the services.
//
//go:embed .env.example install.sh start.sh stop.sh restart.sh deploy/*.service
var deploymentAssets embed.FS
var validServicePrefix = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
var assetModes = map[string]os.FileMode{
"install.sh": 0o755,
"start.sh": 0o755,
"stop.sh": 0o755,
"restart.sh": 0o755,
".env.example": 0o644,
"deploy/server.service": 0o644,
"deploy/cli.service": 0o644,
"deploy/scheduler.service": 0o644,
}
func main() {
prefixFlag := flag.String("prefix", "", "systemd 服务名前缀;未指定时交互输入")
flag.Parse()
if runtime.GOOS != "linux" {
fatalf("初始化工具只能在 Linux 服务器上运行")
}
if err := requireRoot(); err != nil {
fatalf("%v", err)
}
appDir, err := executableDir()
if err != nil {
fatalf("获取程序目录失败: %v", err)
}
prefix, err := resolvePrefix(*prefixFlag, os.Stdin, os.Stdout)
if err != nil {
fatalf("读取项目前缀失败: %v", err)
}
if err := extractAssets(appDir); err != nil {
fatalf("释放部署文件失败: %v", err)
}
envCreated, err := initializeEnv(appDir)
if err != nil {
fatalf("初始化 .env 失败: %v", err)
}
if err := os.WriteFile(filepath.Join(appDir, ".service-prefix"), []byte(prefix+"\n"), 0o644); err != nil {
fatalf("保存项目前缀失败: %v", err)
}
fmt.Printf("部署文件已释放到 %s\n", appDir)
if envCreated {
fmt.Println("已根据 .env.example 初始化 .env,请按实际环境修改配置")
} else {
fmt.Println("检测到已有 .env,已保留现有配置")
}
fmt.Printf("正在注册服务: %s-server, %s-cli, %s-scheduler\n", prefix, prefix, prefix)
install := exec.Command("bash", filepath.Join(appDir, "install.sh"))
install.Dir = appDir
install.Stdin = os.Stdin
install.Stdout = os.Stdout
install.Stderr = os.Stderr
if err := install.Run(); err != nil {
fatalf("install.sh 执行失败: %v", err)
}
}
func requireRoot() error {
uid, err := exec.Command("id", "-u").Output()
if err != nil {
return fmt.Errorf("无法检查当前用户权限: %w", err)
}
if strings.TrimSpace(string(uid)) != "0" {
return errors.New("请使用 root 权限运行: sudo ./initializer")
}
return nil
}
func executableDir() (string, error) {
filename, err := os.Executable()
if err != nil {
return "", err
}
filename, err = filepath.Abs(filename)
if err != nil {
return "", err
}
return filepath.Dir(filename), nil
}
func resolvePrefix(value string, in io.Reader, out io.Writer) (string, error) {
prefix := strings.TrimSpace(value)
if prefix == "" {
if _, err := fmt.Fprint(out, "请输入项目前缀(例如 myapp: "); err != nil {
return "", err
}
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
prefix = strings.TrimSpace(line)
}
if !validServicePrefix.MatchString(prefix) {
return "", fmt.Errorf("%q 不是有效前缀,只允许字母、数字、点、下划线和连字符,且必须以字母或数字开头", prefix)
}
return prefix, nil
}
func extractAssets(appDir string) error {
for name, mode := range assetModes {
content, err := deploymentAssets.ReadFile(name)
if err != nil {
return fmt.Errorf("读取 %s: %w", name, err)
}
target := filepath.Join(appDir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("创建 %s: %w", filepath.Dir(target), err)
}
if err := os.WriteFile(target, content, mode); err != nil {
return fmt.Errorf("写入 %s: %w", target, err)
}
if err := os.Chmod(target, mode); err != nil {
return fmt.Errorf("设置 %s 权限: %w", target, err)
}
}
return nil
}
func initializeEnv(appDir string) (bool, error) {
target := filepath.Join(appDir, ".env")
if _, err := os.Stat(target); err == nil {
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
content, err := deploymentAssets.ReadFile(".env.example")
if err != nil {
return false, err
}
if err := os.WriteFile(target, content, 0o600); err != nil {
return false, err
}
return true, nil
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "错误: "+format+"\n", args...)
os.Exit(1)
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolvePrefixFromFlag(t *testing.T) {
prefix, err := resolvePrefix(" demo-api ", strings.NewReader(""), &bytes.Buffer{})
if err != nil {
t.Fatalf("resolvePrefix() error = %v", err)
}
if prefix != "demo-api" {
t.Fatalf("resolvePrefix() = %q, want %q", prefix, "demo-api")
}
}
func TestInitializeEnvCreatesAndPreservesConfig(t *testing.T) {
dir := t.TempDir()
created, err := initializeEnv(dir)
if err != nil {
t.Fatalf("initializeEnv() error = %v", err)
}
if !created {
t.Fatal("initializeEnv() created = false, want true")
}
target := filepath.Join(dir, ".env")
want, err := deploymentAssets.ReadFile(".env.example")
if err != nil {
t.Fatalf("read embedded .env.example: %v", err)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read generated .env: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatal("generated .env does not match embedded .env.example")
}
const custom = "SECRET=keep-me\n"
if err := os.WriteFile(target, []byte(custom), 0o600); err != nil {
t.Fatalf("write existing .env: %v", err)
}
created, err = initializeEnv(dir)
if err != nil {
t.Fatalf("second initializeEnv() error = %v", err)
}
if created {
t.Fatal("second initializeEnv() created = true, want false")
}
got, err = os.ReadFile(target)
if err != nil {
t.Fatalf("read preserved .env: %v", err)
}
if string(got) != custom {
t.Fatalf("existing .env was overwritten: got %q", got)
}
}
func TestResolvePrefixFromInput(t *testing.T) {
var output bytes.Buffer
prefix, err := resolvePrefix("", strings.NewReader("template_1\n"), &output)
if err != nil {
t.Fatalf("resolvePrefix() error = %v", err)
}
if prefix != "template_1" {
t.Fatalf("resolvePrefix() = %q, want %q", prefix, "template_1")
}
if !strings.Contains(output.String(), "请输入项目前缀") {
t.Fatalf("prompt = %q, want prefix prompt", output.String())
}
}
func TestResolvePrefixRejectsUnsafeValues(t *testing.T) {
values := []string{"", "-demo", "demo api", "demo;reboot", "项目"}
for _, value := range values {
t.Run(value, func(t *testing.T) {
if _, err := resolvePrefix(value, strings.NewReader(""), &bytes.Buffer{}); err == nil {
t.Fatalf("resolvePrefix(%q) succeeded, want error", value)
}
})
}
}
+33 -16
View File
@@ -1,11 +1,23 @@
#!/bin/bash
set -e
set -euo pipefail
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$APP_DIR/deploy"
SERVICE_DIR="/etc/systemd/system"
LOG_DIR="$APP_DIR/logs"
SERVICES="server cli scheduler"
PREFIX_FILE="$APP_DIR/.service-prefix"
SERVICE_SUFFIXES=(server cli scheduler)
if [ ! -f "$PREFIX_FILE" ]; then
echo "缺少 $PREFIX_FILE,请先运行 initializer 完成初始化"
exit 1
fi
SERVICE_PREFIX="$(tr -d '\r\n' < "$PREFIX_FILE")"
if [[ ! "$SERVICE_PREFIX" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
echo "无效的服务前缀: $SERVICE_PREFIX"
exit 1
fi
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 权限运行: sudo bash install.sh"
@@ -16,27 +28,32 @@ mkdir -p "$LOG_DIR"
echo "=== 安装 systemd 服务 ==="
for svc in $SERVICES; do
for svc in "${SERVICE_SUFFIXES[@]}"; do
src="$DEPLOY_DIR/${svc}.service"
service_name="${SERVICE_PREFIX}-${svc}"
if [ ! -f "$src" ]; then
echo "[${svc}] service 文件不存在: $src"
continue
exit 1
fi
sed "s|WorkingDirectory=/root|WorkingDirectory=$APP_DIR|g; \
s|ExecStart=/root/|ExecStart=$APP_DIR/|g; \
s|EnvironmentFile=-/root/.env|EnvironmentFile=-$APP_DIR/.env|g; \
s|/root/logs/|$LOG_DIR/|g" \
"$src" > "$SERVICE_DIR/${svc}.service"
escaped_app_dir="$(printf '%s' "$APP_DIR" | sed 's/[&|\\]/\\&/g')"
sed "s|/root|$escaped_app_dir|g" "$src" > "$SERVICE_DIR/${service_name}.service"
echo "[${svc}] 已安装到 $SERVICE_DIR/${svc}.service"
echo "[${service_name}] 已安装到 $SERVICE_DIR/${service_name}.service"
done
systemctl daemon-reload
for svc in $SERVICES; do
systemctl enable "$svc"
echo "[${svc}] 已设置开机自启"
for svc in "${SERVICE_SUFFIXES[@]}"; do
service_name="${SERVICE_PREFIX}-${svc}"
systemctl enable "$service_name"
echo "[${service_name}] 已设置开机自启"
done
for binary in server cli scheduler; do
if [ -f "$APP_DIR/$binary" ]; then
chmod +x "$APP_DIR/$binary"
fi
done
echo ""
@@ -45,6 +62,6 @@ echo "使用方式:"
echo " bash start.sh # 启动全部"
echo " bash stop.sh # 停止全部"
echo " bash restart.sh # 重启全部"
echo " systemctl status server # 查看主服务状态"
echo " systemctl status scheduler # 查看调度器状态"
echo " journalctl -u scheduler -f # 查看调度器实时日志"
echo " systemctl status ${SERVICE_PREFIX}-server # 查看主服务状态"
echo " systemctl status ${SERVICE_PREFIX}-scheduler # 查看调度器状态"
echo " journalctl -u ${SERVICE_PREFIX}-scheduler -f # 查看调度器实时日志"
+10 -3
View File
@@ -1,10 +1,17 @@
#!/bin/bash
SERVICES="server cli scheduler"
set -euo pipefail
for svc in $SERVICES; do
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
PREFIX_FILE="$APP_DIR/.service-prefix"
[ -f "$PREFIX_FILE" ] || { echo "缺少 $PREFIX_FILE,请先运行 initializer"; exit 1; }
SERVICE_PREFIX="$(tr -d '\r\n' < "$PREFIX_FILE")"
[[ "$SERVICE_PREFIX" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || { echo "无效的服务前缀: $SERVICE_PREFIX"; exit 1; }
SERVICES=("${SERVICE_PREFIX}-server" "${SERVICE_PREFIX}-cli" "${SERVICE_PREFIX}-scheduler")
for svc in "${SERVICES[@]}"; do
systemctl restart "$svc"
echo "[$svc] 已重启"
done
echo ""
systemctl status $SERVICES --no-pager -l
systemctl status "${SERVICES[@]}" --no-pager -l
+10 -3
View File
@@ -1,7 +1,14 @@
#!/bin/bash
SERVICES="server cli scheduler"
set -euo pipefail
for svc in $SERVICES; do
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
PREFIX_FILE="$APP_DIR/.service-prefix"
[ -f "$PREFIX_FILE" ] || { echo "缺少 $PREFIX_FILE,请先运行 initializer"; exit 1; }
SERVICE_PREFIX="$(tr -d '\r\n' < "$PREFIX_FILE")"
[[ "$SERVICE_PREFIX" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || { echo "无效的服务前缀: $SERVICE_PREFIX"; exit 1; }
SERVICES=("${SERVICE_PREFIX}-server" "${SERVICE_PREFIX}-cli" "${SERVICE_PREFIX}-scheduler")
for svc in "${SERVICES[@]}"; do
if systemctl is-active --quiet "$svc"; then
echo "[$svc] 已在运行"
else
@@ -11,4 +18,4 @@ for svc in $SERVICES; do
done
echo ""
systemctl status $SERVICES --no-pager -l
systemctl status "${SERVICES[@]}" --no-pager -l
+9 -2
View File
@@ -1,7 +1,14 @@
#!/bin/bash
SERVICES="server cli scheduler"
set -euo pipefail
for svc in $SERVICES; do
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
PREFIX_FILE="$APP_DIR/.service-prefix"
[ -f "$PREFIX_FILE" ] || { echo "缺少 $PREFIX_FILE,请先运行 initializer"; exit 1; }
SERVICE_PREFIX="$(tr -d '\r\n' < "$PREFIX_FILE")"
[[ "$SERVICE_PREFIX" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || { echo "无效的服务前缀: $SERVICE_PREFIX"; exit 1; }
SERVICES=("${SERVICE_PREFIX}-server" "${SERVICE_PREFIX}-cli" "${SERVICE_PREFIX}-scheduler")
for svc in "${SERVICES[@]}"; do
if systemctl is-active --quiet "$svc"; then
systemctl stop "$svc"
echo "[$svc] 已停止"