// 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 } } 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 != 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 }