#!/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:]))