Files
yms-daemon/internal/deploymentconfig/config.go
T

479 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 {
if c.Daemon.Environment != EnvironmentDev && c.Daemon.Environment != EnvironmentProd {
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 err := validateBackend(c.Backend); err != nil {
return err
}
return validateNodeSsr(c.NodeSsr)
}
func validateBackend(backend Backend) error {
if backend.StopGraceSeconds != nil && *backend.StopGraceSeconds <= 0 {
return errors.New("backend.stop_grace_seconds must be a positive integer")
}
switch backend.Type {
case BackendTypeNative:
if backend.Slot.Port8080.ContainerName != "" || backend.Slot.Port8081.ContainerName != "" {
return errors.New("native backend slots do not accept container_name")
}
if backend.ReleaseDir != nativeReleaseDir {
return fmt.Errorf("backend.release_dir must be %q", nativeReleaseDir)
}
if backend.ActiveJAR != nativeActiveJAR {
return fmt.Errorf("backend.active_jar must be %q", nativeActiveJAR)
}
return validateNativeBackendSlots(backend.Slot)
case BackendTypeContainer:
if backend.ReleaseDir != "" || backend.ActiveJAR != "" {
return errors.New("container backend does not accept release_dir or active_jar")
}
if backend.Slot.Port8080.Unit != "" || backend.Slot.Port8080.JAR != "" || backend.Slot.Port8081.Unit != "" || backend.Slot.Port8081.JAR != "" {
return errors.New("container backend slots do not accept unit or jar")
}
if err := validateContainerSlot("backend.slot.8080", backend.Slot.Port8080, containerName8080, nativeHealthURL8080); err != nil {
return err
}
return validateContainerSlot("backend.slot.8081", backend.Slot.Port8081, containerName8081, nativeHealthURL8081)
default:
return fmt.Errorf("backend.type must be %q or %q", BackendTypeNative, BackendTypeContainer)
}
}
func validateNativeBackendSlots(slots BackendSlots) error {
if err := validateNativeSlot("backend.slot.8080", slots.Port8080, nativeUnit8080, nativeSlotJAR8080, nativeHealthURL8080); err != nil {
return err
}
return validateNativeSlot("backend.slot.8081", slots.Port8081, nativeUnit8081, nativeSlotJAR8081, nativeHealthURL8081)
}
func validateNodeSsr(node NodeSsr) error {
if node.Type == "" {
return nil
}
if node.Type != BackendTypeContainer {
return fmt.Errorf("nodeSsr.type must be %q", BackendTypeContainer)
}
if node.StopGraceSeconds != nil && *node.StopGraceSeconds <= 0 {
return errors.New("nodeSsr.stop_grace_seconds must be a positive integer")
}
if err := validateNodeSsrSlot("nodeSsr.slot.18910", node.Slot.Port18910, nodeSsrContainerName18910, nodeSsrHealthURL18910); err != nil {
return err
}
return validateNodeSsrSlot("nodeSsr.slot.28910", node.Slot.Port28910, nodeSsrContainerName28910, nodeSsrHealthURL28910)
}
// 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
}