473 lines
18 KiB
Go
473 lines
18 KiB
Go
// Package deploymentconfig 负责加载并严格校验 daemon 显式给出的本地部署配置。
|
||
// 该包绝不推断部署类型、可执行文件路径、systemd 单元名称、槽位路径或健康端点,
|
||
// 上述部署契约必须全部由配置文件显式提供,缺失或与内置契约不符都会返回错误。
|
||
package deploymentconfig
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/pelletier/go-toml/v2"
|
||
)
|
||
|
||
const (
|
||
// DefaultPath daemon 入口唯一使用的默认配置路径。
|
||
DefaultPath = "/etc/yms-daemon/yms-daemon.toml"
|
||
|
||
// EnvironmentDev 表示开发环境。
|
||
EnvironmentDev = "dev"
|
||
// EnvironmentProd 表示生产环境。
|
||
EnvironmentProd = "prod"
|
||
|
||
// BackendTypeNative 表示原生后端运行时(systemd 单元 + JAR 文件)。
|
||
BackendTypeNative = "native"
|
||
// BackendTypeContainer 表示容器后端运行时。
|
||
BackendTypeContainer = "container"
|
||
|
||
// BackendPort8080 蓝绿发布中 8080 槽位的端口号。
|
||
BackendPort8080 = 8080
|
||
// BackendPort8081 蓝绿发布中 8081 槽位的端口号。
|
||
BackendPort8081 = 8081
|
||
// NodeSsrPort18910 Node SSR 的第一个宿主槽位端口。
|
||
NodeSsrPort18910 = 18910
|
||
// NodeSsrPort28910 Node SSR 的第二个宿主槽位端口。
|
||
NodeSsrPort28910 = 28910
|
||
)
|
||
|
||
const (
|
||
// nativeReleaseDir 原生后端发布包(JAR)的固定存放目录。
|
||
nativeReleaseDir = "/home/yms/lib/releases"
|
||
// nativeActiveJAR 原生后端当前生效的主 JAR 路径。
|
||
nativeActiveJAR = "/home/yms/lib/glory-soft-yms.jar"
|
||
// nativeUnit8080 8080 槽位对应的 systemd 服务单元名称。
|
||
nativeUnit8080 = "yms-backend@8080.service"
|
||
// nativeUnit8081 8081 槽位对应的 systemd 服务单元名称。
|
||
nativeUnit8081 = "yms-backend@8081.service"
|
||
// nativeSlotJAR8080 8080 槽位对应的 JAR 文件路径。
|
||
nativeSlotJAR8080 = "/home/yms/lib/glory-soft-yms-8080.jar"
|
||
// nativeSlotJAR8081 8081 槽位对应的 JAR 文件路径。
|
||
nativeSlotJAR8081 = "/home/yms/lib/glory-soft-yms-8081.jar"
|
||
// nativeHealthURL8080 8080 槽位的健康检查端点地址。
|
||
nativeHealthURL8080 = "http://127.0.0.1:8080/yms/actuator/health"
|
||
// nativeHealthURL8081 8081 槽位的健康检查端点地址。
|
||
nativeHealthURL8081 = "http://127.0.0.1:8081/yms/actuator/health"
|
||
// containerName8080 容器后端 8080 槽位的容器名称。
|
||
containerName8080 = "backend-8080"
|
||
// containerName8081 容器后端 8081 槽位的容器名称。
|
||
containerName8081 = "backend-8081"
|
||
// nodeSsrContainerName18910 Node SSR 18910 槽位的容器名称。
|
||
nodeSsrContainerName18910 = "node-ssr-18910"
|
||
// nodeSsrContainerName28910 Node SSR 28910 槽位的容器名称。
|
||
nodeSsrContainerName28910 = "node-ssr-28910"
|
||
// nodeSsrHealthURL18910 Node SSR 18910 槽位的健康检查地址。
|
||
nodeSsrHealthURL18910 = "http://127.0.0.1:18910/health"
|
||
// nodeSsrHealthURL28910 Node SSR 28910 槽位的健康检查地址。
|
||
nodeSsrHealthURL28910 = "http://127.0.0.1:28910/health"
|
||
)
|
||
|
||
const (
|
||
// ContainerConfigSource 容器后端配置在宿主侧的源路径。
|
||
ContainerConfigSource = "/home/yms/conf/yms.yaml"
|
||
// ContainerConfigTarget 容器后端配置在容器内的目标路径。
|
||
ContainerConfigTarget = "/app/config/yms.yaml"
|
||
// ContainerTmpSource 容器后端临时目录在宿主侧的源路径。
|
||
ContainerTmpSource = "/home/yms/tmp"
|
||
// ContainerTmpTarget 容器后端临时目录在容器内的目标路径。
|
||
ContainerTmpTarget = "/home/yms/tmp"
|
||
// ContainerPortEnvironment 用于向容器传递后端端口的环境变量名。
|
||
ContainerPortEnvironment = "SERVER_PORT"
|
||
// ContainerConfigEnvironment 用于向容器传递 Spring 配置位置的环境变量名。
|
||
ContainerConfigEnvironment = "SPRING_CONFIG_LOCATION"
|
||
// ContainerConfigLocation 容器内 Spring 配置文件的 file: 地址。
|
||
ContainerConfigLocation = "file:/app/config/yms.yaml"
|
||
)
|
||
|
||
// Config daemon 当前能够理解的完整本地部署配置。
|
||
// 其结构与 TOML 文件中的 daemon、backend 两张顶层表一一对应,加载时做严格解码与校验。
|
||
type Config struct {
|
||
// Daemon 记录与环境相关、与具体运行时无关的机器级配置。
|
||
Daemon Daemon `toml:"daemon"`
|
||
// Backend 记录显式选定的后端运行时及其蓝绿槽位。
|
||
Backend Backend `toml:"backend"`
|
||
// NodeSsr 记录 Node SSR 容器运行时及其蓝绿槽位。
|
||
NodeSsr NodeSsr `toml:"nodeSsr"`
|
||
}
|
||
|
||
// Daemon 描述与具体组件运行时无关的机器级行为。
|
||
type Daemon struct {
|
||
// Environment 必须是 EnvironmentDev 或 EnvironmentProd 之一。
|
||
Environment string `toml:"environment"`
|
||
}
|
||
|
||
// Backend 描述显式选定的后端运行时类型及其蓝绿槽位。
|
||
type Backend struct {
|
||
// Type 必须是 BackendTypeNative 或 BackendTypeContainer 之一。
|
||
Type string `toml:"type"`
|
||
// ReleaseDir 仅原生后端使用,必须是 nativeReleaseDir 对应的绝对路径。
|
||
ReleaseDir string `toml:"release_dir"`
|
||
// ActiveJAR 仅原生后端使用,必须是 nativeActiveJAR 对应的绝对路径。
|
||
ActiveJAR string `toml:"active_jar"`
|
||
// SystemctlPath systemctl 可执行文件的绝对路径。
|
||
SystemctlPath string `toml:"systemctl_path"`
|
||
// StopGraceSeconds 容器后端停止旧容器时,发送停止信号后到强制终止前的等待秒数。
|
||
// 可省略;省略时执行器使用默认值 30。原生后端不使用该字段。
|
||
StopGraceSeconds *int `toml:"stop_grace_seconds"`
|
||
// Slot 包含 8080、8081 两个受支持槽位的精确取值。
|
||
Slot BackendSlots `toml:"slot"`
|
||
}
|
||
|
||
// BackendSlots 列出当前部署契约唯一支持的两个后端端口槽位。
|
||
type BackendSlots struct {
|
||
// Port8080 8080 端口的槽位配置。
|
||
Port8080 BackendSlot `toml:"8080"`
|
||
// Port8081 8081 端口的槽位配置。
|
||
Port8081 BackendSlot `toml:"8081"`
|
||
}
|
||
|
||
// BackendSlot 包含一个原生或容器后端槽位的精确取值。
|
||
// 不同运行时类型下使用的字段不同:原生后端使用 Unit、JAR,容器后端使用 ContainerName。
|
||
type BackendSlot struct {
|
||
// Unit 原生后端槽位对应的 systemd 服务单元名称。
|
||
Unit string `toml:"unit"`
|
||
// JAR 原生后端槽位对应的 JAR 文件绝对路径。
|
||
JAR string `toml:"jar"`
|
||
// ContainerName 容器后端槽位对应的容器名称。
|
||
ContainerName string `toml:"container_name"`
|
||
// HealthEndpoint 槽位健康检查的 URL 地址。
|
||
HealthEndpoint string `toml:"health_endpoint"`
|
||
}
|
||
|
||
// NodeSsr 描述 Node SSR 容器运行时及其蓝绿槽位。
|
||
type NodeSsr struct {
|
||
// Type 当前只接受 container。
|
||
Type string `toml:"type"`
|
||
// StopGraceSeconds 停止旧容器时的优雅退出等待秒数,可省略。
|
||
StopGraceSeconds *int `toml:"stop_grace_seconds"`
|
||
// Slot 包含 18910、28910 两个宿主端口槽位。
|
||
Slot NodeSsrSlots `toml:"slot"`
|
||
}
|
||
|
||
// NodeSsrSlots 列出 Node SSR 的两个精确宿主端口槽位。
|
||
type NodeSsrSlots struct {
|
||
Port18910 NodeSsrSlot `toml:"18910"`
|
||
Port28910 NodeSsrSlot `toml:"28910"`
|
||
}
|
||
|
||
// NodeSsrSlot 包含一个 Node SSR 容器槽位的容器名与健康检查地址。
|
||
type NodeSsrSlot struct {
|
||
ContainerName string `toml:"container_name"`
|
||
HealthEndpoint string `toml:"health_endpoint"`
|
||
}
|
||
|
||
// Load 打开 path 指向的部署配置文件,进行严格的 TOML 解码与契约校验,返回解析后的 Config。
|
||
// 它要求 path 是绝对路径且为普通文件,解码时禁用未知字段(任何大小写、拼写或层级不一致的
|
||
// 键都会被拒绝),解码后调用 Validate 精确校验取值。任一步失败都会返回带上下文的 error。
|
||
func Load(path string) (Config, error) {
|
||
if err := validateAbsolutePath("deployment configuration", path); err != nil {
|
||
return Config{}, err
|
||
}
|
||
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return Config{}, fmt.Errorf("open deployment configuration %s: %w", path, err)
|
||
}
|
||
defer file.Close()
|
||
|
||
info, err := file.Stat()
|
||
if err != nil {
|
||
return Config{}, fmt.Errorf("inspect deployment configuration %s: %w", path, err)
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
return Config{}, fmt.Errorf("deployment configuration is not a regular file: %s", path)
|
||
}
|
||
|
||
document, err := io.ReadAll(file)
|
||
if err != nil {
|
||
return Config{}, fmt.Errorf("read deployment configuration %s: %w", path, err)
|
||
}
|
||
if err := validateExactDocumentKeys(document); err != nil {
|
||
return Config{}, fmt.Errorf("decode deployment configuration %s: %w", path, err)
|
||
}
|
||
|
||
var config Config
|
||
if err := toml.NewDecoder(bytes.NewReader(document)).DisallowUnknownFields().Decode(&config); err != nil {
|
||
return Config{}, fmt.Errorf("decode deployment configuration %s: %w", path, err)
|
||
}
|
||
if err := config.Validate(); err != nil {
|
||
return Config{}, fmt.Errorf("validate deployment configuration %s: %w", path, err)
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
// validateExactDocumentKeys 对原始 TOML 文档做逐层键名校验,确保 daemon、backend 及
|
||
// backend.slot 下每一张表都只包含契约允许的确切字段,任何未知字段(大小写、拼写或层级不符)都会被拒绝。
|
||
func validateExactDocumentKeys(document []byte) error {
|
||
var root map[string]any
|
||
if err := toml.Unmarshal(document, &root); err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(root, "", "daemon", "backend", "nodeSsr"); err != nil {
|
||
return err
|
||
}
|
||
daemon, err := exactTable(root, "", "daemon")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(daemon, "daemon", "environment"); err != nil {
|
||
return err
|
||
}
|
||
backend, err := exactTable(root, "", "backend")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(backend, "backend", "type", "release_dir", "active_jar", "systemctl_path", "stop_grace_seconds", "slot"); err != nil {
|
||
return err
|
||
}
|
||
slots, err := exactTable(backend, "backend", "slot")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(slots, "backend.slot", "8080", "8081"); err != nil {
|
||
return err
|
||
}
|
||
for _, port := range []string{"8080", "8081"} {
|
||
slot, err := exactTable(slots, "backend.slot", port)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(slot, "backend.slot."+port, "unit", "jar", "container_name", "health_endpoint"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
nodeSsrValue, found := root["nodeSsr"]
|
||
if !found {
|
||
return nil
|
||
}
|
||
if _, ok := nodeSsrValue.(map[string]any); !ok {
|
||
return errors.New("nodeSsr must be a table")
|
||
}
|
||
nodeSsr, err := exactTable(root, "", "nodeSsr")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(nodeSsr, "nodeSsr", "type", "stop_grace_seconds", "slot"); err != nil {
|
||
return err
|
||
}
|
||
nodeSlots, err := exactTable(nodeSsr, "nodeSsr", "slot")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(nodeSlots, "nodeSsr.slot", "18910", "28910"); err != nil {
|
||
return err
|
||
}
|
||
for _, port := range []string{"18910", "28910"} {
|
||
slot, err := exactTable(nodeSlots, "nodeSsr.slot", port)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectUnknownKeys(slot, "nodeSsr.slot."+port, "container_name", "health_endpoint"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// exactTable 从 table 中取出名为 key 的子表并返回;若 key 不存在或对应值不是 TOML 表,
|
||
// 则返回错误。parent 用于拼出带前缀的完整字段名,以生成可读的错误信息。
|
||
func exactTable(table map[string]any, parent string, key string) (map[string]any, error) {
|
||
value, found := table[key]
|
||
field := key
|
||
if parent != "" {
|
||
field = parent + "." + key
|
||
}
|
||
if !found {
|
||
return nil, fmt.Errorf("%s table is required", field)
|
||
}
|
||
nested, ok := value.(map[string]any)
|
||
if !ok {
|
||
return nil, fmt.Errorf("%s must be a table", field)
|
||
}
|
||
return nested, nil
|
||
}
|
||
|
||
// rejectUnknownKeys 检查 table 是否只包含 allowed 中列出的键;存在其他键时,按字典序
|
||
// 返回第一个未知键的拒绝错误。parent 用于拼出带前缀的完整字段名。
|
||
func rejectUnknownKeys(table map[string]any, parent string, allowed ...string) error {
|
||
known := make(map[string]struct{}, len(allowed))
|
||
for _, key := range allowed {
|
||
known[key] = struct{}{}
|
||
}
|
||
keys := make([]string, 0, len(table))
|
||
for key := range table {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, key := range keys {
|
||
if _, found := known[key]; found {
|
||
continue
|
||
}
|
||
field := key
|
||
if parent != "" {
|
||
field = parent + "." + key
|
||
}
|
||
return fmt.Errorf("unknown deployment configuration field %s", field)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Validate 拒绝不完整或与内置部署契约不一致的本地部署标识符。
|
||
// 它依次校验 daemon.environment、backend.systemctl_path 的绝对路径约束,再按 backend.type
|
||
// 分支精确校验原生或容器槽位的 unit、jar、container_name、health_endpoint 等字段。
|
||
func (c Config) Validate() error {
|
||
switch c.Daemon.Environment {
|
||
case EnvironmentDev, EnvironmentProd:
|
||
default:
|
||
return fmt.Errorf("daemon.environment must be %q or %q", EnvironmentDev, EnvironmentProd)
|
||
}
|
||
if err := validateAbsolutePath("backend.systemctl_path", c.Backend.SystemctlPath); err != nil {
|
||
return err
|
||
}
|
||
if c.Backend.StopGraceSeconds != nil && *c.Backend.StopGraceSeconds <= 0 {
|
||
return errors.New("backend.stop_grace_seconds must be a positive integer")
|
||
}
|
||
switch c.Backend.Type {
|
||
case BackendTypeNative:
|
||
if c.Backend.Slot.Port8080.ContainerName != "" || c.Backend.Slot.Port8081.ContainerName != "" {
|
||
return errors.New("native backend slots do not accept container_name")
|
||
}
|
||
if c.Backend.ReleaseDir != nativeReleaseDir {
|
||
return fmt.Errorf("backend.release_dir must be %q", nativeReleaseDir)
|
||
}
|
||
if c.Backend.ActiveJAR != nativeActiveJAR {
|
||
return fmt.Errorf("backend.active_jar must be %q", nativeActiveJAR)
|
||
}
|
||
if err := validateNativeSlot("backend.slot.8080", c.Backend.Slot.Port8080, nativeUnit8080, nativeSlotJAR8080, nativeHealthURL8080); err != nil {
|
||
return err
|
||
}
|
||
if err := validateNativeSlot("backend.slot.8081", c.Backend.Slot.Port8081, nativeUnit8081, nativeSlotJAR8081, nativeHealthURL8081); err != nil {
|
||
return err
|
||
}
|
||
case BackendTypeContainer:
|
||
if c.Backend.ReleaseDir != "" || c.Backend.ActiveJAR != "" {
|
||
return errors.New("container backend does not accept release_dir or active_jar")
|
||
}
|
||
if c.Backend.Slot.Port8080.Unit != "" || c.Backend.Slot.Port8080.JAR != "" || c.Backend.Slot.Port8081.Unit != "" || c.Backend.Slot.Port8081.JAR != "" {
|
||
return errors.New("container backend slots do not accept unit or jar")
|
||
}
|
||
if err := validateContainerSlot("backend.slot.8080", c.Backend.Slot.Port8080, containerName8080, nativeHealthURL8080); err != nil {
|
||
return err
|
||
}
|
||
if err := validateContainerSlot("backend.slot.8081", c.Backend.Slot.Port8081, containerName8081, nativeHealthURL8081); err != nil {
|
||
return err
|
||
}
|
||
default:
|
||
return fmt.Errorf("backend.type must be %q or %q", BackendTypeNative, BackendTypeContainer)
|
||
}
|
||
if c.NodeSsr.Type == "" {
|
||
return nil
|
||
}
|
||
if c.NodeSsr.Type != BackendTypeContainer {
|
||
return fmt.Errorf("nodeSsr.type must be %q", BackendTypeContainer)
|
||
}
|
||
if c.NodeSsr.StopGraceSeconds != nil && *c.NodeSsr.StopGraceSeconds <= 0 {
|
||
return errors.New("nodeSsr.stop_grace_seconds must be a positive integer")
|
||
}
|
||
if err := validateNodeSsrSlot("nodeSsr.slot.18910", c.NodeSsr.Slot.Port18910, nodeSsrContainerName18910, nodeSsrHealthURL18910); err != nil {
|
||
return err
|
||
}
|
||
if err := validateNodeSsrSlot("nodeSsr.slot.28910", c.NodeSsr.Slot.Port28910, nodeSsrContainerName28910, nodeSsrHealthURL28910); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SlotForNodeSsrPort 返回 Node SSR 宿主端口对应的槽位配置。
|
||
func (n NodeSsr) SlotForNodeSsrPort(port int) (NodeSsrSlot, error) {
|
||
switch port {
|
||
case NodeSsrPort18910:
|
||
return n.Slot.Port18910, nil
|
||
case NodeSsrPort28910:
|
||
return n.Slot.Port28910, nil
|
||
default:
|
||
return NodeSsrSlot{}, fmt.Errorf("unsupported Node SSR port: %d", port)
|
||
}
|
||
}
|
||
|
||
// SlotForPort 返回 port 对应的后端槽位配置,仅支持 BackendPort8080 或 BackendPort8081,
|
||
// 其他端口返回带端口号的错误。
|
||
func (b Backend) SlotForPort(port int) (BackendSlot, error) {
|
||
switch port {
|
||
case BackendPort8080:
|
||
return b.Slot.Port8080, nil
|
||
case BackendPort8081:
|
||
return b.Slot.Port8081, nil
|
||
default:
|
||
return BackendSlot{}, fmt.Errorf("unsupported native backend port: %d", port)
|
||
}
|
||
}
|
||
|
||
// validateNativeSlot 校验原生槽位 slot 的 Unit、JAR、HealthEndpoint 是否分别等于
|
||
// unit、jar、endpoint,任一不符即返回带 field 前缀的错误。
|
||
func validateNativeSlot(field string, slot BackendSlot, unit string, jar string, endpoint string) error {
|
||
if slot.Unit != unit {
|
||
return fmt.Errorf("%s.unit must be %q", field, unit)
|
||
}
|
||
if slot.JAR != jar {
|
||
return fmt.Errorf("%s.jar must be %q", field, jar)
|
||
}
|
||
if slot.HealthEndpoint != endpoint {
|
||
return fmt.Errorf("%s.health_endpoint must be %q", field, endpoint)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateContainerSlot 校验容器槽位 slot 的 ContainerName、HealthEndpoint 是否分别等于
|
||
// containerName、endpoint,任一不符即返回带 field 前缀的错误。
|
||
func validateContainerSlot(field string, slot BackendSlot, containerName string, endpoint string) error {
|
||
if slot.ContainerName != containerName {
|
||
return fmt.Errorf("%s.container_name must be %q", field, containerName)
|
||
}
|
||
if slot.HealthEndpoint != endpoint {
|
||
return fmt.Errorf("%s.health_endpoint must be %q", field, endpoint)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateNodeSsrSlot 校验 Node SSR 槽位的容器名称和健康检查地址。
|
||
func validateNodeSsrSlot(field string, slot NodeSsrSlot, containerName string, endpoint string) error {
|
||
if slot.ContainerName != containerName {
|
||
return fmt.Errorf("%s.container_name must be %q", field, containerName)
|
||
}
|
||
if slot.HealthEndpoint != endpoint {
|
||
return fmt.Errorf("%s.health_endpoint must be %q", field, endpoint)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateAbsolutePath 校验 value 是一个非空、无首尾空白、经过 Clean 且不含 NUL 字节的绝对路径,
|
||
// 用于保证配置中的路径标识符不会被意外篡改。field 用于拼出错误信息。
|
||
func validateAbsolutePath(field string, value string) error {
|
||
if value == "" {
|
||
return fmt.Errorf("%s is required", field)
|
||
}
|
||
if strings.TrimSpace(value) != value {
|
||
return fmt.Errorf("%s must not contain surrounding whitespace", field)
|
||
}
|
||
if !filepath.IsAbs(value) {
|
||
return fmt.Errorf("%s must be an absolute path", field)
|
||
}
|
||
if filepath.Clean(value) != value {
|
||
return fmt.Errorf("%s must be a clean absolute path", field)
|
||
}
|
||
if strings.ContainsRune(value, '\x00') {
|
||
return errors.New(field + " contains a NUL byte")
|
||
}
|
||
return nil
|
||
}
|