diff --git a/docs/operations/current-ecs-deployment.md b/docs/operations/current-ecs-deployment.md index 1f35bc54..711c89ea 100644 --- a/docs/operations/current-ecs-deployment.md +++ b/docs/operations/current-ecs-deployment.md @@ -173,21 +173,24 @@ python3 tools/go_native_prod_smoke.py --date 2026-07-02 --timeout 8 ## 部署命令 -在开发机或 CI 构建 Linux amd64: +推荐使用仓库脚本发布完整 Go 原生 release。脚本会在本机/CI 构建 Linux amd64 四个二进制,打包上传到应用 ECS,切换 `/opt/lingniu-go-native/current`,逐个重启四个 systemd 服务,并在发布后运行聚合生产验收: ```bash -cd go/vehicle-gateway -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/gateway ./cmd/gateway -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/history-writer ./cmd/history-writer -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/stat-writer ./cmd/stat-writer -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/realtime-api ./cmd/realtime-api +python3 tools/go_native_deploy.py --date 2026-07-02 ``` -上传到 ECS 新 release 目录后切换: +脚本不会保存 SSH 或数据库密钥;认证仍使用当前操作环境可用的 SSH 凭据。需要跳过发布后验收时可显式追加 `--skip-acceptance`,但生产发布默认应保留验收。 + +脚本执行的发布动作等价于: ```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/gateway +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/history-writer +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/stat-writer +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/realtime-api +scp release.tar.gz root@115.29.187.205:/tmp/ +ln -sfn /opt/lingniu-go-native/releases/ /opt/lingniu-go-native/current systemctl daemon-reload -systemctl enable lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api systemctl restart lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api ``` diff --git a/tools/go_native_deploy.py b/tools/go_native_deploy.py new file mode 100644 index 00000000..6c6c1ed0 --- /dev/null +++ b/tools/go_native_deploy.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Build and deploy the Go native vehicle gateway release to ECS.""" + +from __future__ import annotations + +import argparse +import os +import pathlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +from dataclasses import dataclass + + +DEFAULT_APP_HOST = "115.29.187.205" +DEFAULT_KAFKA_HOST = "114.55.58.251" +DEFAULT_USER = "root" +DEFAULT_RELEASE_ROOT = "/opt/lingniu-go-native" +DEFAULT_GO_DIR = "go/vehicle-gateway" +BINARIES = [ + ("gateway", "./cmd/gateway"), + ("history-writer", "./cmd/history-writer"), + ("stat-writer", "./cmd/stat-writer"), + ("realtime-api", "./cmd/realtime-api"), +] +SERVICES = [ + "lingniu-go-gateway", + "lingniu-go-history-writer", + "lingniu-go-stat-writer", + "lingniu-go-realtime-api", +] + + +@dataclass(frozen=True) +class BuildCommand: + output_name: str + argv: list[str] + env: list[str] + cwd: str + + +def git_short_sha(cwd: str) -> str: + return run_capture(["git", "rev-parse", "--short=7", "HEAD"], cwd=cwd).strip() + + +def build_commands(go_dir: str, output_dir: str) -> list[BuildCommand]: + commands: list[BuildCommand] = [] + env = ["CGO_ENABLED=0", "GOOS=linux", "GOARCH=amd64"] + for output_name, package in BINARIES: + commands.append(BuildCommand( + output_name=output_name, + argv=[ + "go", + "build", + "-trimpath", + "-ldflags=-s -w", + "-o", + str(pathlib.Path(output_dir) / output_name), + package, + ], + env=env, + cwd=go_dir, + )) + return commands + + +def build_release(go_dir: str, output_dir: str) -> None: + for command in build_commands(go_dir, output_dir): + env = os.environ.copy() + for item in command.env: + key, value = item.split("=", 1) + env[key] = value + run(command.argv, cwd=command.cwd, env=env) + + +def create_archive(source_dir: str, archive_path: str) -> None: + with tarfile.open(archive_path, "w:gz") as archive: + for binary, _ in BINARIES: + archive.add(pathlib.Path(source_dir) / binary, arcname=binary) + + +def remote_switch_command(release: str, release_root: str, remote_archive: str) -> str: + if not safe_release_name(release): + raise ValueError("release must contain only letters, numbers, dot, underscore, or dash") + release_dir = release_root.rstrip("/") + "/releases/" + release + root = release_root.rstrip("/") + commands = [ + "set -euo pipefail", + "mkdir -p " + shell_quote(release_dir), + "tar -xzf " + shell_quote(remote_archive) + " -C " + shell_quote(release_dir), + "chmod +x " + shell_quote(release_dir) + "/*", + "ln -sfn " + shell_quote(release_dir) + " " + shell_quote(root + "/current"), + "systemctl daemon-reload", + ] + commands.extend("systemctl restart " + service for service in SERVICES) + commands.append("rm -f " + shell_quote(remote_archive)) + return " && ".join(commands) + + +def acceptance_command(app_host: str, kafka_host: str, date: str, timeout: float) -> list[str]: + command = [ + "python3", + "tools/go_prod_acceptance.py", + "--app-host", + app_host, + "--kafka-host", + kafka_host, + "--timeout", + str(int(timeout) if timeout == int(timeout) else timeout), + ] + if date: + command.extend(["--date", date]) + return command + + +def deploy(args: argparse.Namespace) -> None: + repo = pathlib.Path(args.repo).resolve() + go_dir = str(repo / args.go_dir) + release = args.release or git_short_sha(str(repo)) + with tempfile.TemporaryDirectory(prefix="lingniu-go-native-") as tmp: + output_dir = pathlib.Path(tmp) / "out" + output_dir.mkdir() + archive_path = pathlib.Path(tmp) / ("lingniu-go-native-" + release + ".tar.gz") + build_release(go_dir, str(output_dir)) + create_archive(str(output_dir), str(archive_path)) + remote_archive = "/tmp/" + archive_path.name + target = ssh_target(args.user, args.app_host) + run(["scp", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", str(archive_path), target + ":" + remote_archive]) + run([ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + target, + remote_switch_command(release, args.release_root, remote_archive), + ]) + if not args.skip_acceptance: + run(acceptance_command(args.app_host, args.kafka_host, args.date, args.timeout), cwd=str(repo)) + + +def ssh_target(user: str, host: str) -> str: + return user + "@" + host if user else host + + +def shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def safe_release_name(value: str) -> bool: + value = value.strip() + if not value: + return False + return all(r.isalnum() or r in "._-" for r in value) + + +def run(argv: list[str], cwd: str | None = None, env: dict[str, str] | None = None) -> None: + subprocess.run(argv, cwd=cwd, env=env, check=True) + + +def run_capture(argv: list[str], cwd: str | None = None) -> str: + return subprocess.run(argv, cwd=cwd, check=True, stdout=subprocess.PIPE, text=True).stdout + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=".") + parser.add_argument("--go-dir", default=DEFAULT_GO_DIR) + parser.add_argument("--app-host", default=DEFAULT_APP_HOST) + parser.add_argument("--kafka-host", default=DEFAULT_KAFKA_HOST) + parser.add_argument("--user", default=DEFAULT_USER) + parser.add_argument("--release-root", default=DEFAULT_RELEASE_ROOT) + parser.add_argument("--release", default="") + parser.add_argument("--date", default="") + parser.add_argument("--timeout", type=float, default=20.0) + parser.add_argument("--skip-acceptance", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + deploy(args) + except subprocess.CalledProcessError as exc: + print("command failed: " + " ".join(exc.cmd), file=sys.stderr) + return exc.returncode or 1 + except (OSError, shutil.Error) as exc: + print("deploy failed: " + str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/test_go_native_deploy.py b/tools/test_go_native_deploy.py new file mode 100644 index 00000000..9d7d25fb --- /dev/null +++ b/tools/test_go_native_deploy.py @@ -0,0 +1,50 @@ +import unittest + +from tools import go_native_deploy as deploy + + +class GoNativeDeployTest(unittest.TestCase): + def test_build_commands_cover_four_production_binaries(self): + commands = deploy.build_commands("/repo/go/vehicle-gateway", "/tmp/out") + + self.assertEqual([command.output_name for command in commands], [ + "gateway", + "history-writer", + "stat-writer", + "realtime-api", + ]) + for command in commands: + self.assertEqual(command.argv[:3], ["go", "build", "-trimpath"]) + self.assertIn("GOOS=linux", command.env) + self.assertIn("GOARCH=amd64", command.env) + self.assertIn("CGO_ENABLED=0", command.env) + + def test_remote_switch_command_creates_release_and_restarts_all_services(self): + command = deploy.remote_switch_command("409f55b", "/opt/lingniu-go-native", "/tmp/lingniu-go-native-409f55b.tar.gz") + + self.assertIn("/opt/lingniu-go-native/releases/409f55b", command) + self.assertIn("ln -sfn", command) + self.assertIn("systemctl restart lingniu-go-gateway", command) + self.assertIn("systemctl restart lingniu-go-history-writer", command) + self.assertIn("systemctl restart lingniu-go-stat-writer", command) + self.assertIn("systemctl restart lingniu-go-realtime-api", command) + + def test_acceptance_command_uses_deployed_hosts_and_date(self): + command = deploy.acceptance_command( + app_host="115.29.187.205", + kafka_host="114.55.58.251", + date="2026-07-02", + timeout=20, + ) + + self.assertEqual(command[:2], ["python3", "tools/go_prod_acceptance.py"]) + self.assertIn("--app-host", command) + self.assertIn("115.29.187.205", command) + self.assertIn("--kafka-host", command) + self.assertIn("114.55.58.251", command) + self.assertIn("--date", command) + self.assertIn("2026-07-02", command) + + +if __name__ == "__main__": + unittest.main()