From 4da89b784eeea44b353cdd72f418e9a06b0bc4e7 Mon Sep 17 00:00:00 2001 From: shiran Date: Wed, 9 Sep 2026 11:00:31 +0800 Subject: [PATCH] feat: add prefixed service initializer and packaged deployment --- .gitea/workflows/build.yaml | 18 ++-- .gitignore | 5 ++ README.md | 30 +++++-- initializer.go | 169 ++++++++++++++++++++++++++++++++++++ initializer_test.go | 87 +++++++++++++++++++ install.sh | 49 +++++++---- restart.sh | 13 ++- start.sh | 13 ++- stop.sh | 11 ++- 9 files changed, 355 insertions(+), 40 deletions(-) create mode 100644 initializer.go create mode 100644 initializer_test.go diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index f0a00dc..1da8538 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 6b2e814..051ca70 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ /vendor/ /logs/ /tmp/ +/.service-prefix +/server +/cli +/scheduler +/initializer __debug_bin* .idea/ diff --git a/README.md b/README.md index 36f640b..c0b5b47 100644 --- a/README.md +++ b/README.md @@ -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 框架 + 路由分组 diff --git a/initializer.go b/initializer.go new file mode 100644 index 0000000..870dbbc --- /dev/null +++ b/initializer.go @@ -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) +} diff --git a/initializer_test.go b/initializer_test.go new file mode 100644 index 0000000..f737a5d --- /dev/null +++ b/initializer_test.go @@ -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) + } + }) + } +} diff --git a/install.sh b/install.sh index 6c087ad..881ea3c 100644 --- a/install.sh +++ b/install.sh @@ -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 # 查看调度器实时日志" diff --git a/restart.sh b/restart.sh index 8016021..74aa125 100644 --- a/restart.sh +++ b/restart.sh @@ -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 diff --git a/start.sh b/start.sh index 3cba17b..02ad9de 100644 --- a/start.sh +++ b/start.sh @@ -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 diff --git a/stop.sh b/stop.sh index fb8c29c..0f7083e 100644 --- a/stop.sh +++ b/stop.sh @@ -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] 已停止"