feat(services): add standalone service config model
- commit
- d9c6a560d05d670da7c4c860cd31b5acf6594213
- author
- brice · · 2026-06-30 14:46 UTC
- parent
- f7cd760
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +552 -0
M internal/app/run.go +2 -0
| @@ -85,6 +85,7 @@ import ( | |||
| 85 | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 86 | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 87 | 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 88 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 88 | 89 | "github.com/bnema/gordon/internal/usecase/traffic" | |
| 89 | 90 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | |
| 90 | 91 | ||
| @@ -179,6 +180,7 @@ type Config struct { | |||
| 179 | 180 | EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"` | |
| 180 | 181 | Traffic traffic.Config `mapstructure:"traffic"` | |
| 181 | 182 | NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"` | |
| 183 | + | Services []servicecfg.Config `mapstructure:"services"` | |
| 182 | 184 | ||
| 183 | 185 | Backups struct { | |
| 184 | 186 | // Legacy database backup keys. Prefer backups.databases.* for new configs. | |
| @@ -85,6 +85,7 @@ import ( | |||||
| 85 | "github.com/bnema/gordon/internal/usecase/publictls" | 85 | "github.com/bnema/gordon/internal/usecase/publictls" | ||
| 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 86 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 87 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| 88 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |||
| 88 | "github.com/bnema/gordon/internal/usecase/traffic" | 89 | "github.com/bnema/gordon/internal/usecase/traffic" | ||
| 89 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | 90 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | ||
| 90 | 91 | ||||
| @@ -179,6 +180,7 @@ type Config struct { | |||||
| 179 | EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"` | 180 | EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"` | ||
| 180 | Traffic traffic.Config `mapstructure:"traffic"` | 181 | Traffic traffic.Config `mapstructure:"traffic"` | ||
| 181 | NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"` | 182 | NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"` | ||
| 183 | + | Services []servicecfg.Config `mapstructure:"services"` | |||
| 182 | 184 | ||||
| 183 | Backups struct { | 185 | Backups struct { | ||
| 184 | // Legacy database backup keys. Prefer backups.databases.* for new configs. | 186 | // Legacy database backup keys. Prefer backups.databases.* for new configs. | ||
A internal/domain/service.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "net" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | StandaloneServiceReadinessNone = "none" | |
| 14 | + | StandaloneServiceReadinessTCP = "tcp" | |
| 15 | + | StandaloneServiceReadinessLog = "log" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type StandaloneService struct { | |
| 19 | + | Name string | |
| 20 | + | Image string | |
| 21 | + | Enabled bool | |
| 22 | + | Env []string | |
| 23 | + | EnvFile string | |
| 24 | + | Secrets []StandaloneServiceSecretRef | |
| 25 | + | Readiness StandaloneServiceReadiness | |
| 26 | + | Cleanup StandaloneServiceCleanup | |
| 27 | + | Ports []StandaloneServicePort | |
| 28 | + | Volumes []StandaloneServiceVolume | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type StandaloneServicePort struct { | |
| 32 | + | Name string | |
| 33 | + | Container int | |
| 34 | + | Protocol NetworkProtocol | |
| 35 | + | Publish string | |
| 36 | + | Private bool | |
| 37 | + | TrustedCIDRs []string | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type StandaloneServiceVolume struct { | |
| 41 | + | Source string | |
| 42 | + | Target string | |
| 43 | + | ReadOnly bool | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | type StandaloneServiceSecretRef struct { | |
| 47 | + | Name string | |
| 48 | + | Key string | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type StandaloneServiceReadiness struct { | |
| 52 | + | Type string | |
| 53 | + | Path string | |
| 54 | + | Contains string | |
| 55 | + | Timeout time.Duration | |
| 56 | + | TimeoutSet bool | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type StandaloneServiceCleanup struct { | |
| 60 | + | PreserveVolumes bool | |
| 61 | + | RemoveContainer bool | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (s StandaloneService) WithDefaults() StandaloneService { | |
| 65 | + | if s.Readiness.Type == "" { | |
| 66 | + | s.Readiness.Type = StandaloneServiceReadinessNone | |
| 67 | + | } | |
| 68 | + | s.Cleanup = s.Cleanup.WithDefaults() | |
| 69 | + | return s | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup { | |
| 73 | + | return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true} | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (s StandaloneService) Validate() error { | |
| 77 | + | s = s.WithDefaults() | |
| 78 | + | if strings.TrimSpace(s.Name) == "" { | |
| 79 | + | return fmt.Errorf("standalone service name is required") | |
| 80 | + | } | |
| 81 | + | if s.Enabled && strings.TrimSpace(s.Image) == "" { | |
| 82 | + | return fmt.Errorf("standalone service %q image is required when enabled", s.Name) | |
| 83 | + | } | |
| 84 | + | if s.EnvFile != "" && strings.TrimSpace(s.EnvFile) == "" { | |
| 85 | + | return fmt.Errorf("standalone service %q env_file must be non-empty when set", s.Name) | |
| 86 | + | } | |
| 87 | + | if err := validateStandaloneServicePorts(s); err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | if err := validateStandaloneServiceVolumes(s); err != nil { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | if err := validateStandaloneServiceSecrets(s); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | if err := validateStandaloneServiceReadiness(s); err != nil { | |
| 97 | + | return err | |
| 98 | + | } | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func validateStandaloneServicePorts(s StandaloneService) error { | |
| 103 | + | seen := make(map[string]struct{}, len(s.Ports)) | |
| 104 | + | for i, port := range s.Ports { | |
| 105 | + | name := strings.TrimSpace(port.Name) | |
| 106 | + | if name == "" { | |
| 107 | + | return fmt.Errorf("standalone service %q port %d name is required", s.Name, i) | |
| 108 | + | } | |
| 109 | + | if _, ok := seen[name]; ok { | |
| 110 | + | return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name) | |
| 111 | + | } | |
| 112 | + | seen[name] = struct{}{} | |
| 113 | + | if port.Container <= 0 || port.Container > 65535 { | |
| 114 | + | return fmt.Errorf("standalone service %q port %q container port must be 1-65535", s.Name, name) | |
| 115 | + | } | |
| 116 | + | if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP { | |
| 117 | + | return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name) | |
| 118 | + | } | |
| 119 | + | if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | for _, cidr := range port.TrustedCIDRs { | |
| 123 | + | if strings.TrimSpace(cidr) == "" { | |
| 124 | + | return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", s.Name, name) | |
| 125 | + | } | |
| 126 | + | if _, _, err := net.ParseCIDR(cidr); err != nil { | |
| 127 | + | return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", s.Name, name, cidr, err) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | return nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func validateStandaloneServicePublish(serviceName, portName, publish string) error { | |
| 135 | + | if publish == "" { | |
| 136 | + | return nil | |
| 137 | + | } | |
| 138 | + | host, port, err := net.SplitHostPort(publish) | |
| 139 | + | if err != nil { | |
| 140 | + | return fmt.Errorf("standalone service %q port %q publish address %q is invalid: %w", serviceName, portName, publish, err) | |
| 141 | + | } | |
| 142 | + | if strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" { | |
| 143 | + | return fmt.Errorf("standalone service %q port %q publish address %q must include host and port", serviceName, portName, publish) | |
| 144 | + | } | |
| 145 | + | portNumber, err := strconv.Atoi(port) | |
| 146 | + | if err != nil || portNumber < 1 || portNumber > 65535 { | |
| 147 | + | return fmt.Errorf("standalone service %q port %q publish address %q must include a valid port", serviceName, portName, publish) | |
| 148 | + | } | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func validateStandaloneServiceVolumes(s StandaloneService) error { | |
| 153 | + | for i, volume := range s.Volumes { | |
| 154 | + | if strings.TrimSpace(volume.Target) == "" { | |
| 155 | + | return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i) | |
| 156 | + | } | |
| 157 | + | if !filepath.IsAbs(volume.Target) { | |
| 158 | + | return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func validateStandaloneServiceSecrets(s StandaloneService) error { | |
| 165 | + | for i, secret := range s.Secrets { | |
| 166 | + | if strings.TrimSpace(secret.Name) == "" { | |
| 167 | + | return fmt.Errorf("standalone service %q secret %d name is required", s.Name, i) | |
| 168 | + | } | |
| 169 | + | if strings.TrimSpace(secret.Key) == "" { | |
| 170 | + | return fmt.Errorf("standalone service %q secret %d key is required", s.Name, i) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func validateStandaloneServiceReadiness(s StandaloneService) error { | |
| 177 | + | readinessType := s.Readiness.Type | |
| 178 | + | if readinessType == "" { | |
| 179 | + | readinessType = StandaloneServiceReadinessNone | |
| 180 | + | } | |
| 181 | + | if s.Readiness.TimeoutSet && s.Readiness.Timeout <= 0 { | |
| 182 | + | return fmt.Errorf("standalone service %q readiness timeout must be positive when set", s.Name) | |
| 183 | + | } | |
| 184 | + | switch readinessType { | |
| 185 | + | case StandaloneServiceReadinessNone, StandaloneServiceReadinessTCP: | |
| 186 | + | return nil | |
| 187 | + | case StandaloneServiceReadinessLog: | |
| 188 | + | if strings.TrimSpace(s.Readiness.Path) == "" { | |
| 189 | + | return fmt.Errorf("standalone service %q log readiness path is required", s.Name) | |
| 190 | + | } | |
| 191 | + | if strings.TrimSpace(s.Readiness.Contains) == "" { | |
| 192 | + | return fmt.Errorf("standalone service %q log readiness contains is required", s.Name) | |
| 193 | + | } | |
| 194 | + | return nil | |
| 195 | + | default: | |
| 196 | + | return fmt.Errorf("standalone service %q readiness type must be none, tcp, or log", s.Name) | |
| 197 | + | } | |
| 198 | + | } | |
| @@ -0,0 +1,198 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "net" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | StandaloneServiceReadinessNone = "none" | |||
| 14 | + | StandaloneServiceReadinessTCP = "tcp" | |||
| 15 | + | StandaloneServiceReadinessLog = "log" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type StandaloneService struct { | |||
| 19 | + | Name string | |||
| 20 | + | Image string | |||
| 21 | + | Enabled bool | |||
| 22 | + | Env []string | |||
| 23 | + | EnvFile string | |||
| 24 | + | Secrets []StandaloneServiceSecretRef | |||
| 25 | + | Readiness StandaloneServiceReadiness | |||
| 26 | + | Cleanup StandaloneServiceCleanup | |||
| 27 | + | Ports []StandaloneServicePort | |||
| 28 | + | Volumes []StandaloneServiceVolume | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type StandaloneServicePort struct { | |||
| 32 | + | Name string | |||
| 33 | + | Container int | |||
| 34 | + | Protocol NetworkProtocol | |||
| 35 | + | Publish string | |||
| 36 | + | Private bool | |||
| 37 | + | TrustedCIDRs []string | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type StandaloneServiceVolume struct { | |||
| 41 | + | Source string | |||
| 42 | + | Target string | |||
| 43 | + | ReadOnly bool | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | type StandaloneServiceSecretRef struct { | |||
| 47 | + | Name string | |||
| 48 | + | Key string | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | type StandaloneServiceReadiness struct { | |||
| 52 | + | Type string | |||
| 53 | + | Path string | |||
| 54 | + | Contains string | |||
| 55 | + | Timeout time.Duration | |||
| 56 | + | TimeoutSet bool | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | type StandaloneServiceCleanup struct { | |||
| 60 | + | PreserveVolumes bool | |||
| 61 | + | RemoveContainer bool | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (s StandaloneService) WithDefaults() StandaloneService { | |||
| 65 | + | if s.Readiness.Type == "" { | |||
| 66 | + | s.Readiness.Type = StandaloneServiceReadinessNone | |||
| 67 | + | } | |||
| 68 | + | s.Cleanup = s.Cleanup.WithDefaults() | |||
| 69 | + | return s | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup { | |||
| 73 | + | return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true} | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (s StandaloneService) Validate() error { | |||
| 77 | + | s = s.WithDefaults() | |||
| 78 | + | if strings.TrimSpace(s.Name) == "" { | |||
| 79 | + | return fmt.Errorf("standalone service name is required") | |||
| 80 | + | } | |||
| 81 | + | if s.Enabled && strings.TrimSpace(s.Image) == "" { | |||
| 82 | + | return fmt.Errorf("standalone service %q image is required when enabled", s.Name) | |||
| 83 | + | } | |||
| 84 | + | if s.EnvFile != "" && strings.TrimSpace(s.EnvFile) == "" { | |||
| 85 | + | return fmt.Errorf("standalone service %q env_file must be non-empty when set", s.Name) | |||
| 86 | + | } | |||
| 87 | + | if err := validateStandaloneServicePorts(s); err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | if err := validateStandaloneServiceVolumes(s); err != nil { | |||
| 91 | + | return err | |||
| 92 | + | } | |||
| 93 | + | if err := validateStandaloneServiceSecrets(s); err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | if err := validateStandaloneServiceReadiness(s); err != nil { | |||
| 97 | + | return err | |||
| 98 | + | } | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func validateStandaloneServicePorts(s StandaloneService) error { | |||
| 103 | + | seen := make(map[string]struct{}, len(s.Ports)) | |||
| 104 | + | for i, port := range s.Ports { | |||
| 105 | + | name := strings.TrimSpace(port.Name) | |||
| 106 | + | if name == "" { | |||
| 107 | + | return fmt.Errorf("standalone service %q port %d name is required", s.Name, i) | |||
| 108 | + | } | |||
| 109 | + | if _, ok := seen[name]; ok { | |||
| 110 | + | return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name) | |||
| 111 | + | } | |||
| 112 | + | seen[name] = struct{}{} | |||
| 113 | + | if port.Container <= 0 || port.Container > 65535 { | |||
| 114 | + | return fmt.Errorf("standalone service %q port %q container port must be 1-65535", s.Name, name) | |||
| 115 | + | } | |||
| 116 | + | if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP { | |||
| 117 | + | return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name) | |||
| 118 | + | } | |||
| 119 | + | if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil { | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | for _, cidr := range port.TrustedCIDRs { | |||
| 123 | + | if strings.TrimSpace(cidr) == "" { | |||
| 124 | + | return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", s.Name, name) | |||
| 125 | + | } | |||
| 126 | + | if _, _, err := net.ParseCIDR(cidr); err != nil { | |||
| 127 | + | return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", s.Name, name, cidr, err) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | return nil | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func validateStandaloneServicePublish(serviceName, portName, publish string) error { | |||
| 135 | + | if publish == "" { | |||
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | host, port, err := net.SplitHostPort(publish) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return fmt.Errorf("standalone service %q port %q publish address %q is invalid: %w", serviceName, portName, publish, err) | |||
| 141 | + | } | |||
| 142 | + | if strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" { | |||
| 143 | + | return fmt.Errorf("standalone service %q port %q publish address %q must include host and port", serviceName, portName, publish) | |||
| 144 | + | } | |||
| 145 | + | portNumber, err := strconv.Atoi(port) | |||
| 146 | + | if err != nil || portNumber < 1 || portNumber > 65535 { | |||
| 147 | + | return fmt.Errorf("standalone service %q port %q publish address %q must include a valid port", serviceName, portName, publish) | |||
| 148 | + | } | |||
| 149 | + | return nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func validateStandaloneServiceVolumes(s StandaloneService) error { | |||
| 153 | + | for i, volume := range s.Volumes { | |||
| 154 | + | if strings.TrimSpace(volume.Target) == "" { | |||
| 155 | + | return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i) | |||
| 156 | + | } | |||
| 157 | + | if !filepath.IsAbs(volume.Target) { | |||
| 158 | + | return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func validateStandaloneServiceSecrets(s StandaloneService) error { | |||
| 165 | + | for i, secret := range s.Secrets { | |||
| 166 | + | if strings.TrimSpace(secret.Name) == "" { | |||
| 167 | + | return fmt.Errorf("standalone service %q secret %d name is required", s.Name, i) | |||
| 168 | + | } | |||
| 169 | + | if strings.TrimSpace(secret.Key) == "" { | |||
| 170 | + | return fmt.Errorf("standalone service %q secret %d key is required", s.Name, i) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | return nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func validateStandaloneServiceReadiness(s StandaloneService) error { | |||
| 177 | + | readinessType := s.Readiness.Type | |||
| 178 | + | if readinessType == "" { | |||
| 179 | + | readinessType = StandaloneServiceReadinessNone | |||
| 180 | + | } | |||
| 181 | + | if s.Readiness.TimeoutSet && s.Readiness.Timeout <= 0 { | |||
| 182 | + | return fmt.Errorf("standalone service %q readiness timeout must be positive when set", s.Name) | |||
| 183 | + | } | |||
| 184 | + | switch readinessType { | |||
| 185 | + | case StandaloneServiceReadinessNone, StandaloneServiceReadinessTCP: | |||
| 186 | + | return nil | |||
| 187 | + | case StandaloneServiceReadinessLog: | |||
| 188 | + | if strings.TrimSpace(s.Readiness.Path) == "" { | |||
| 189 | + | return fmt.Errorf("standalone service %q log readiness path is required", s.Name) | |||
| 190 | + | } | |||
| 191 | + | if strings.TrimSpace(s.Readiness.Contains) == "" { | |||
| 192 | + | return fmt.Errorf("standalone service %q log readiness contains is required", s.Name) | |||
| 193 | + | } | |||
| 194 | + | return nil | |||
| 195 | + | default: | |||
| 196 | + | return fmt.Errorf("standalone service %q readiness type must be none, tcp, or log", s.Name) | |||
| 197 | + | } | |||
| 198 | + | } | |||
A internal/domain/service_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T) { | |
| 11 | + | svc := StandaloneService{Enabled: true, Image: "redis:7"} | |
| 12 | + | require.ErrorContains(t, svc.Validate(), "service name") | |
| 13 | + | ||
| 14 | + | svc = StandaloneService{Enabled: true, Name: "cache"} | |
| 15 | + | require.ErrorContains(t, svc.Validate(), "image") | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) { | |
| 19 | + | svc := StandaloneService{Name: "cache", Enabled: false} | |
| 20 | + | require.NoError(t, svc.Validate()) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestStandaloneServiceValidatePorts(t *testing.T) { | |
| 24 | + | valid := StandaloneService{ | |
| 25 | + | Name: "game", | |
| 26 | + | Image: "game:latest", | |
| 27 | + | Enabled: true, | |
| 28 | + | Ports: []StandaloneServicePort{ | |
| 29 | + | {Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, | |
| 30 | + | {Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true}, | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | require.NoError(t, valid.Validate()) | |
| 34 | + | ||
| 35 | + | duplicate := valid | |
| 36 | + | duplicate.Ports = append(duplicate.Ports, StandaloneServicePort{Name: "game", Container: 28017, Protocol: NetworkProtocolUDP}) | |
| 37 | + | require.ErrorContains(t, duplicate.Validate(), "duplicate port") | |
| 38 | + | ||
| 39 | + | badProtocol := valid | |
| 40 | + | badProtocol.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocol("sctp")}} | |
| 41 | + | require.ErrorContains(t, badProtocol.Validate(), "protocol") | |
| 42 | + | ||
| 43 | + | badPublish := valid | |
| 44 | + | badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}} | |
| 45 | + | require.ErrorContains(t, badPublish.Validate(), "valid port") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | |
| 49 | + | svc := StandaloneService{Name: "cache", Image: "redis:7", Enabled: true} | |
| 50 | + | require.NoError(t, svc.Validate(), "volumes may be omitted") | |
| 51 | + | ||
| 52 | + | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}} | |
| 53 | + | require.ErrorContains(t, svc.Validate(), "absolute") | |
| 54 | + | ||
| 55 | + | svc.Volumes = nil | |
| 56 | + | svc.EnvFile = " " | |
| 57 | + | require.ErrorContains(t, svc.Validate(), "env_file") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestStandaloneServiceValidateReadiness(t *testing.T) { | |
| 61 | + | svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true} | |
| 62 | + | for _, readinessType := range []string{"", "none", "tcp", "log"} { | |
| 63 | + | t.Run(readinessType, func(t *testing.T) { | |
| 64 | + | candidate := svc | |
| 65 | + | candidate.Readiness = StandaloneServiceReadiness{Type: readinessType, Path: "/tmp/server.log", Contains: "ready", Timeout: time.Minute} | |
| 66 | + | require.NoError(t, candidate.Validate()) | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | svc.Readiness = StandaloneServiceReadiness{Type: "udp"} | |
| 71 | + | require.ErrorContains(t, svc.Validate(), "readiness") | |
| 72 | + | ||
| 73 | + | for _, timeout := range []time.Duration{0, -time.Second} { | |
| 74 | + | svc.Readiness = StandaloneServiceReadiness{Type: "tcp", Timeout: timeout, TimeoutSet: true} | |
| 75 | + | require.ErrorContains(t, svc.Validate(), "readiness timeout") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | svc.Readiness = StandaloneServiceReadiness{Type: "log", Path: "/tmp/server.log"} | |
| 79 | + | require.ErrorContains(t, svc.Validate(), "contains") | |
| 80 | + | ||
| 81 | + | svc.Readiness = StandaloneServiceReadiness{Type: "log", Contains: "ready"} | |
| 82 | + | require.ErrorContains(t, svc.Validate(), "path") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) { | |
| 86 | + | svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}.WithDefaults() | |
| 87 | + | require.True(t, svc.Cleanup.PreserveVolumes) | |
| 88 | + | require.True(t, svc.Cleanup.RemoveContainer) | |
| 89 | + | require.NoError(t, svc.Validate()) | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T) { | |||
| 11 | + | svc := StandaloneService{Enabled: true, Image: "redis:7"} | |||
| 12 | + | require.ErrorContains(t, svc.Validate(), "service name") | |||
| 13 | + | ||||
| 14 | + | svc = StandaloneService{Enabled: true, Name: "cache"} | |||
| 15 | + | require.ErrorContains(t, svc.Validate(), "image") | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) { | |||
| 19 | + | svc := StandaloneService{Name: "cache", Enabled: false} | |||
| 20 | + | require.NoError(t, svc.Validate()) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestStandaloneServiceValidatePorts(t *testing.T) { | |||
| 24 | + | valid := StandaloneService{ | |||
| 25 | + | Name: "game", | |||
| 26 | + | Image: "game:latest", | |||
| 27 | + | Enabled: true, | |||
| 28 | + | Ports: []StandaloneServicePort{ | |||
| 29 | + | {Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, | |||
| 30 | + | {Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true}, | |||
| 31 | + | }, | |||
| 32 | + | } | |||
| 33 | + | require.NoError(t, valid.Validate()) | |||
| 34 | + | ||||
| 35 | + | duplicate := valid | |||
| 36 | + | duplicate.Ports = append(duplicate.Ports, StandaloneServicePort{Name: "game", Container: 28017, Protocol: NetworkProtocolUDP}) | |||
| 37 | + | require.ErrorContains(t, duplicate.Validate(), "duplicate port") | |||
| 38 | + | ||||
| 39 | + | badProtocol := valid | |||
| 40 | + | badProtocol.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocol("sctp")}} | |||
| 41 | + | require.ErrorContains(t, badProtocol.Validate(), "protocol") | |||
| 42 | + | ||||
| 43 | + | badPublish := valid | |||
| 44 | + | badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}} | |||
| 45 | + | require.ErrorContains(t, badPublish.Validate(), "valid port") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | |||
| 49 | + | svc := StandaloneService{Name: "cache", Image: "redis:7", Enabled: true} | |||
| 50 | + | require.NoError(t, svc.Validate(), "volumes may be omitted") | |||
| 51 | + | ||||
| 52 | + | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}} | |||
| 53 | + | require.ErrorContains(t, svc.Validate(), "absolute") | |||
| 54 | + | ||||
| 55 | + | svc.Volumes = nil | |||
| 56 | + | svc.EnvFile = " " | |||
| 57 | + | require.ErrorContains(t, svc.Validate(), "env_file") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestStandaloneServiceValidateReadiness(t *testing.T) { | |||
| 61 | + | svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true} | |||
| 62 | + | for _, readinessType := range []string{"", "none", "tcp", "log"} { | |||
| 63 | + | t.Run(readinessType, func(t *testing.T) { | |||
| 64 | + | candidate := svc | |||
| 65 | + | candidate.Readiness = StandaloneServiceReadiness{Type: readinessType, Path: "/tmp/server.log", Contains: "ready", Timeout: time.Minute} | |||
| 66 | + | require.NoError(t, candidate.Validate()) | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | svc.Readiness = StandaloneServiceReadiness{Type: "udp"} | |||
| 71 | + | require.ErrorContains(t, svc.Validate(), "readiness") | |||
| 72 | + | ||||
| 73 | + | for _, timeout := range []time.Duration{0, -time.Second} { | |||
| 74 | + | svc.Readiness = StandaloneServiceReadiness{Type: "tcp", Timeout: timeout, TimeoutSet: true} | |||
| 75 | + | require.ErrorContains(t, svc.Validate(), "readiness timeout") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | svc.Readiness = StandaloneServiceReadiness{Type: "log", Path: "/tmp/server.log"} | |||
| 79 | + | require.ErrorContains(t, svc.Validate(), "contains") | |||
| 80 | + | ||||
| 81 | + | svc.Readiness = StandaloneServiceReadiness{Type: "log", Contains: "ready"} | |||
| 82 | + | require.ErrorContains(t, svc.Validate(), "path") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) { | |||
| 86 | + | svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}.WithDefaults() | |||
| 87 | + | require.True(t, svc.Cleanup.PreserveVolumes) | |||
| 88 | + | require.True(t, svc.Cleanup.RemoveContainer) | |||
| 89 | + | require.NoError(t, svc.Validate()) | |||
| 90 | + | } | |||
A internal/usecase/services/config.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package services | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type Config struct { | |
| 12 | + | Name string `mapstructure:"name"` | |
| 13 | + | Image string `mapstructure:"image"` | |
| 14 | + | Enabled bool `mapstructure:"enabled"` | |
| 15 | + | Env []string `mapstructure:"env"` | |
| 16 | + | EnvFile string `mapstructure:"env_file"` | |
| 17 | + | Secrets []SecretRefConfig `mapstructure:"secrets"` | |
| 18 | + | Readiness ReadinessConfig `mapstructure:"readiness"` | |
| 19 | + | Cleanup CleanupConfig `mapstructure:"cleanup"` | |
| 20 | + | Ports []PortConfig `mapstructure:"ports"` | |
| 21 | + | Volumes []VolumeConfig `mapstructure:"volumes"` | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type PortConfig struct { | |
| 25 | + | Name string `mapstructure:"name"` | |
| 26 | + | Container int `mapstructure:"container"` | |
| 27 | + | Protocol domain.NetworkProtocol `mapstructure:"protocol"` | |
| 28 | + | Publish string `mapstructure:"publish"` | |
| 29 | + | Private bool `mapstructure:"private"` | |
| 30 | + | TrustedCIDRs []string `mapstructure:"trusted_cidrs"` | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type VolumeConfig struct { | |
| 34 | + | Source string `mapstructure:"source"` | |
| 35 | + | Target string `mapstructure:"target"` | |
| 36 | + | ReadOnly bool `mapstructure:"read_only"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | type ResolvedVolumeMount struct { | |
| 40 | + | Source string | |
| 41 | + | Target string | |
| 42 | + | ReadOnly bool | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | type SecretRefConfig struct { | |
| 46 | + | Name string `mapstructure:"name"` | |
| 47 | + | Key string `mapstructure:"key"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type ReadinessConfig struct { | |
| 51 | + | Type string `mapstructure:"type"` | |
| 52 | + | Path string `mapstructure:"path"` | |
| 53 | + | Contains string `mapstructure:"contains"` | |
| 54 | + | Timeout string `mapstructure:"timeout"` | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type CleanupConfig struct { | |
| 58 | + | PreserveVolumes *bool `mapstructure:"preserve_volumes"` | |
| 59 | + | RemoveContainer *bool `mapstructure:"remove_container"` | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func ToDomain(configs []Config) ([]domain.StandaloneService, error) { | |
| 63 | + | services := make([]domain.StandaloneService, 0, len(configs)) | |
| 64 | + | seenNames := make(map[string]struct{}, len(configs)) | |
| 65 | + | for i, cfg := range configs { | |
| 66 | + | svc, err := cfg.ToDomain() | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, fmt.Errorf("service config %d: %w", i, err) | |
| 69 | + | } | |
| 70 | + | name := strings.TrimSpace(svc.Name) | |
| 71 | + | if _, ok := seenNames[name]; ok { | |
| 72 | + | return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name) | |
| 73 | + | } | |
| 74 | + | seenNames[name] = struct{}{} | |
| 75 | + | services = append(services, svc) | |
| 76 | + | } | |
| 77 | + | return services, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (c Config) ToDomain() (domain.StandaloneService, error) { | |
| 81 | + | readiness, err := c.readinessToDomain() | |
| 82 | + | if err != nil { | |
| 83 | + | return domain.StandaloneService{}, err | |
| 84 | + | } | |
| 85 | + | svc := domain.StandaloneService{ | |
| 86 | + | Name: c.Name, | |
| 87 | + | Image: c.Image, | |
| 88 | + | Enabled: c.Enabled, | |
| 89 | + | Env: append([]string(nil), c.Env...), | |
| 90 | + | EnvFile: c.EnvFile, | |
| 91 | + | Secrets: secretRefsToDomain(c.Secrets), | |
| 92 | + | Readiness: readiness, | |
| 93 | + | Cleanup: c.Cleanup.toDomain(), | |
| 94 | + | Ports: portsToDomain(c.Ports), | |
| 95 | + | Volumes: volumesToDomain(c.Volumes), | |
| 96 | + | } | |
| 97 | + | if err := svc.Validate(); err != nil { | |
| 98 | + | return domain.StandaloneService{}, err | |
| 99 | + | } | |
| 100 | + | return svc, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (c Config) readinessToDomain() (domain.StandaloneServiceReadiness, error) { | |
| 104 | + | readinessType := c.Readiness.Type | |
| 105 | + | if readinessType == "" { | |
| 106 | + | readinessType = domain.StandaloneServiceReadinessNone | |
| 107 | + | } | |
| 108 | + | readiness := domain.StandaloneServiceReadiness{Type: readinessType, Path: c.Readiness.Path, Contains: c.Readiness.Contains} | |
| 109 | + | if c.Readiness.Timeout != "" { | |
| 110 | + | timeout, err := time.ParseDuration(c.Readiness.Timeout) | |
| 111 | + | if err != nil { | |
| 112 | + | return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout %q is invalid: %w", c.Readiness.Timeout, err) | |
| 113 | + | } | |
| 114 | + | if timeout <= 0 { | |
| 115 | + | return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout must be positive when set") | |
| 116 | + | } | |
| 117 | + | readiness.Timeout = timeout | |
| 118 | + | readiness.TimeoutSet = true | |
| 119 | + | } | |
| 120 | + | return readiness, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup { | |
| 124 | + | cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true} | |
| 125 | + | if c.PreserveVolumes != nil { | |
| 126 | + | cleanup.PreserveVolumes = *c.PreserveVolumes | |
| 127 | + | } | |
| 128 | + | if c.RemoveContainer != nil { | |
| 129 | + | cleanup.RemoveContainer = *c.RemoveContainer | |
| 130 | + | } | |
| 131 | + | return cleanup | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort { | |
| 135 | + | ports := make([]domain.StandaloneServicePort, 0, len(configs)) | |
| 136 | + | for _, cfg := range configs { | |
| 137 | + | ports = append(ports, domain.StandaloneServicePort{ | |
| 138 | + | Name: cfg.Name, | |
| 139 | + | Container: cfg.Container, | |
| 140 | + | Protocol: cfg.Protocol, | |
| 141 | + | Publish: cfg.Publish, | |
| 142 | + | Private: cfg.Private, | |
| 143 | + | TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...), | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | return ports | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func volumesToDomain(configs []VolumeConfig) []domain.StandaloneServiceVolume { | |
| 150 | + | volumes := make([]domain.StandaloneServiceVolume, 0, len(configs)) | |
| 151 | + | for _, cfg := range configs { | |
| 152 | + | volumes = append(volumes, domain.StandaloneServiceVolume{Source: cfg.Source, Target: cfg.Target, ReadOnly: cfg.ReadOnly}) | |
| 153 | + | } | |
| 154 | + | return volumes | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func secretRefsToDomain(configs []SecretRefConfig) []domain.StandaloneServiceSecretRef { | |
| 158 | + | secrets := make([]domain.StandaloneServiceSecretRef, 0, len(configs)) | |
| 159 | + | for _, cfg := range configs { | |
| 160 | + | secrets = append(secrets, domain.StandaloneServiceSecretRef{Name: cfg.Name, Key: cfg.Key}) | |
| 161 | + | } | |
| 162 | + | return secrets | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.StandaloneServiceVolume, imageVolumePaths []string) []ResolvedVolumeMount { | |
| 166 | + | if len(explicitVolumes) > 0 { | |
| 167 | + | mounts := make([]ResolvedVolumeMount, 0, len(explicitVolumes)) | |
| 168 | + | for _, volume := range explicitVolumes { | |
| 169 | + | mounts = append(mounts, ResolvedVolumeMount{Source: volume.Source, Target: volume.Target, ReadOnly: volume.ReadOnly}) | |
| 170 | + | } | |
| 171 | + | return mounts | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths)) | |
| 175 | + | for _, path := range imageVolumePaths { | |
| 176 | + | mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path}) | |
| 177 | + | } | |
| 178 | + | return mounts | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string { | |
| 182 | + | return fmt.Sprintf("%s-%s-%s", | |
| 183 | + | prefix, | |
| 184 | + | strings.ReplaceAll(serviceName, ".", "-"), | |
| 185 | + | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 186 | + | } | |
| @@ -0,0 +1,186 @@ | |||||
| 1 | + | package services | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type Config struct { | |||
| 12 | + | Name string `mapstructure:"name"` | |||
| 13 | + | Image string `mapstructure:"image"` | |||
| 14 | + | Enabled bool `mapstructure:"enabled"` | |||
| 15 | + | Env []string `mapstructure:"env"` | |||
| 16 | + | EnvFile string `mapstructure:"env_file"` | |||
| 17 | + | Secrets []SecretRefConfig `mapstructure:"secrets"` | |||
| 18 | + | Readiness ReadinessConfig `mapstructure:"readiness"` | |||
| 19 | + | Cleanup CleanupConfig `mapstructure:"cleanup"` | |||
| 20 | + | Ports []PortConfig `mapstructure:"ports"` | |||
| 21 | + | Volumes []VolumeConfig `mapstructure:"volumes"` | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type PortConfig struct { | |||
| 25 | + | Name string `mapstructure:"name"` | |||
| 26 | + | Container int `mapstructure:"container"` | |||
| 27 | + | Protocol domain.NetworkProtocol `mapstructure:"protocol"` | |||
| 28 | + | Publish string `mapstructure:"publish"` | |||
| 29 | + | Private bool `mapstructure:"private"` | |||
| 30 | + | TrustedCIDRs []string `mapstructure:"trusted_cidrs"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type VolumeConfig struct { | |||
| 34 | + | Source string `mapstructure:"source"` | |||
| 35 | + | Target string `mapstructure:"target"` | |||
| 36 | + | ReadOnly bool `mapstructure:"read_only"` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type ResolvedVolumeMount struct { | |||
| 40 | + | Source string | |||
| 41 | + | Target string | |||
| 42 | + | ReadOnly bool | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | type SecretRefConfig struct { | |||
| 46 | + | Name string `mapstructure:"name"` | |||
| 47 | + | Key string `mapstructure:"key"` | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | type ReadinessConfig struct { | |||
| 51 | + | Type string `mapstructure:"type"` | |||
| 52 | + | Path string `mapstructure:"path"` | |||
| 53 | + | Contains string `mapstructure:"contains"` | |||
| 54 | + | Timeout string `mapstructure:"timeout"` | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type CleanupConfig struct { | |||
| 58 | + | PreserveVolumes *bool `mapstructure:"preserve_volumes"` | |||
| 59 | + | RemoveContainer *bool `mapstructure:"remove_container"` | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func ToDomain(configs []Config) ([]domain.StandaloneService, error) { | |||
| 63 | + | services := make([]domain.StandaloneService, 0, len(configs)) | |||
| 64 | + | seenNames := make(map[string]struct{}, len(configs)) | |||
| 65 | + | for i, cfg := range configs { | |||
| 66 | + | svc, err := cfg.ToDomain() | |||
| 67 | + | if err != nil { | |||
| 68 | + | return nil, fmt.Errorf("service config %d: %w", i, err) | |||
| 69 | + | } | |||
| 70 | + | name := strings.TrimSpace(svc.Name) | |||
| 71 | + | if _, ok := seenNames[name]; ok { | |||
| 72 | + | return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name) | |||
| 73 | + | } | |||
| 74 | + | seenNames[name] = struct{}{} | |||
| 75 | + | services = append(services, svc) | |||
| 76 | + | } | |||
| 77 | + | return services, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (c Config) ToDomain() (domain.StandaloneService, error) { | |||
| 81 | + | readiness, err := c.readinessToDomain() | |||
| 82 | + | if err != nil { | |||
| 83 | + | return domain.StandaloneService{}, err | |||
| 84 | + | } | |||
| 85 | + | svc := domain.StandaloneService{ | |||
| 86 | + | Name: c.Name, | |||
| 87 | + | Image: c.Image, | |||
| 88 | + | Enabled: c.Enabled, | |||
| 89 | + | Env: append([]string(nil), c.Env...), | |||
| 90 | + | EnvFile: c.EnvFile, | |||
| 91 | + | Secrets: secretRefsToDomain(c.Secrets), | |||
| 92 | + | Readiness: readiness, | |||
| 93 | + | Cleanup: c.Cleanup.toDomain(), | |||
| 94 | + | Ports: portsToDomain(c.Ports), | |||
| 95 | + | Volumes: volumesToDomain(c.Volumes), | |||
| 96 | + | } | |||
| 97 | + | if err := svc.Validate(); err != nil { | |||
| 98 | + | return domain.StandaloneService{}, err | |||
| 99 | + | } | |||
| 100 | + | return svc, nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (c Config) readinessToDomain() (domain.StandaloneServiceReadiness, error) { | |||
| 104 | + | readinessType := c.Readiness.Type | |||
| 105 | + | if readinessType == "" { | |||
| 106 | + | readinessType = domain.StandaloneServiceReadinessNone | |||
| 107 | + | } | |||
| 108 | + | readiness := domain.StandaloneServiceReadiness{Type: readinessType, Path: c.Readiness.Path, Contains: c.Readiness.Contains} | |||
| 109 | + | if c.Readiness.Timeout != "" { | |||
| 110 | + | timeout, err := time.ParseDuration(c.Readiness.Timeout) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout %q is invalid: %w", c.Readiness.Timeout, err) | |||
| 113 | + | } | |||
| 114 | + | if timeout <= 0 { | |||
| 115 | + | return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout must be positive when set") | |||
| 116 | + | } | |||
| 117 | + | readiness.Timeout = timeout | |||
| 118 | + | readiness.TimeoutSet = true | |||
| 119 | + | } | |||
| 120 | + | return readiness, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup { | |||
| 124 | + | cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true} | |||
| 125 | + | if c.PreserveVolumes != nil { | |||
| 126 | + | cleanup.PreserveVolumes = *c.PreserveVolumes | |||
| 127 | + | } | |||
| 128 | + | if c.RemoveContainer != nil { | |||
| 129 | + | cleanup.RemoveContainer = *c.RemoveContainer | |||
| 130 | + | } | |||
| 131 | + | return cleanup | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort { | |||
| 135 | + | ports := make([]domain.StandaloneServicePort, 0, len(configs)) | |||
| 136 | + | for _, cfg := range configs { | |||
| 137 | + | ports = append(ports, domain.StandaloneServicePort{ | |||
| 138 | + | Name: cfg.Name, | |||
| 139 | + | Container: cfg.Container, | |||
| 140 | + | Protocol: cfg.Protocol, | |||
| 141 | + | Publish: cfg.Publish, | |||
| 142 | + | Private: cfg.Private, | |||
| 143 | + | TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...), | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | return ports | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func volumesToDomain(configs []VolumeConfig) []domain.StandaloneServiceVolume { | |||
| 150 | + | volumes := make([]domain.StandaloneServiceVolume, 0, len(configs)) | |||
| 151 | + | for _, cfg := range configs { | |||
| 152 | + | volumes = append(volumes, domain.StandaloneServiceVolume{Source: cfg.Source, Target: cfg.Target, ReadOnly: cfg.ReadOnly}) | |||
| 153 | + | } | |||
| 154 | + | return volumes | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func secretRefsToDomain(configs []SecretRefConfig) []domain.StandaloneServiceSecretRef { | |||
| 158 | + | secrets := make([]domain.StandaloneServiceSecretRef, 0, len(configs)) | |||
| 159 | + | for _, cfg := range configs { | |||
| 160 | + | secrets = append(secrets, domain.StandaloneServiceSecretRef{Name: cfg.Name, Key: cfg.Key}) | |||
| 161 | + | } | |||
| 162 | + | return secrets | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.StandaloneServiceVolume, imageVolumePaths []string) []ResolvedVolumeMount { | |||
| 166 | + | if len(explicitVolumes) > 0 { | |||
| 167 | + | mounts := make([]ResolvedVolumeMount, 0, len(explicitVolumes)) | |||
| 168 | + | for _, volume := range explicitVolumes { | |||
| 169 | + | mounts = append(mounts, ResolvedVolumeMount{Source: volume.Source, Target: volume.Target, ReadOnly: volume.ReadOnly}) | |||
| 170 | + | } | |||
| 171 | + | return mounts | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths)) | |||
| 175 | + | for _, path := range imageVolumePaths { | |||
| 176 | + | mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path}) | |||
| 177 | + | } | |||
| 178 | + | return mounts | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string { | |||
| 182 | + | return fmt.Sprintf("%s-%s-%s", | |||
| 183 | + | prefix, | |||
| 184 | + | strings.ReplaceAll(serviceName, ".", "-"), | |||
| 185 | + | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |||
| 186 | + | } | |||
A internal/usecase/services/config_test.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package services | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestToDomainRejectsDuplicateServiceNames(t *testing.T) { | |
| 13 | + | configs := []Config{ | |
| 14 | + | {Name: "cache", Image: "redis:7", Enabled: true}, | |
| 15 | + | {Name: "cache", Image: "redis:7", Enabled: true}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | _, err := ToDomain(configs) | |
| 19 | + | ||
| 20 | + | require.ErrorContains(t, err, "duplicate service name") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) { | |
| 24 | + | for _, timeout := range []string{"0s", "-1s"} { | |
| 25 | + | t.Run(timeout, func(t *testing.T) { | |
| 26 | + | cfg := Config{ | |
| 27 | + | Name: "cache", | |
| 28 | + | Image: "redis:7", | |
| 29 | + | Enabled: true, | |
| 30 | + | Readiness: ReadinessConfig{ | |
| 31 | + | Type: domain.StandaloneServiceReadinessTCP, | |
| 32 | + | Timeout: timeout, | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | _, err := cfg.ToDomain() | |
| 37 | + | ||
| 38 | + | require.ErrorContains(t, err, "readiness timeout") | |
| 39 | + | require.ErrorContains(t, err, "positive") | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) { | |
| 45 | + | explicit := []domain.StandaloneServiceVolume{ | |
| 46 | + | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | |
| 47 | + | {Source: "/srv/config", Target: "/config", ReadOnly: true}, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | mounts := ResolveVolumeMounts("gordon", "rust.example.com", explicit, []string{"/ignored"}) | |
| 51 | + | ||
| 52 | + | assert.Equal(t, []ResolvedVolumeMount{ | |
| 53 | + | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | |
| 54 | + | {Source: "/srv/config", Target: "/config", ReadOnly: true}, | |
| 55 | + | }, mounts) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *testing.T) { | |
| 59 | + | mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"}) | |
| 60 | + | ||
| 61 | + | assert.Equal(t, []ResolvedVolumeMount{ | |
| 62 | + | {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust"}, | |
| 63 | + | {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data"}, | |
| 64 | + | }, mounts) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestResolveVolumeMountsReturnsEmptyForStatelessImage(t *testing.T) { | |
| 68 | + | mounts := ResolveVolumeMounts("gordon", "stateless.example.com", nil, nil) | |
| 69 | + | ||
| 70 | + | assert.Empty(t, mounts) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestManagedServiceVolumeNameIsDeterministicAndGordonCompatible(t *testing.T) { | |
| 74 | + | assert.Equal(t, "gordon-rust-example-com-var-lib-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/var/lib/data")) | |
| 75 | + | assert.Equal(t, "gordon-rust-example-com-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/data")) | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package services | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestToDomainRejectsDuplicateServiceNames(t *testing.T) { | |||
| 13 | + | configs := []Config{ | |||
| 14 | + | {Name: "cache", Image: "redis:7", Enabled: true}, | |||
| 15 | + | {Name: "cache", Image: "redis:7", Enabled: true}, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | _, err := ToDomain(configs) | |||
| 19 | + | ||||
| 20 | + | require.ErrorContains(t, err, "duplicate service name") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) { | |||
| 24 | + | for _, timeout := range []string{"0s", "-1s"} { | |||
| 25 | + | t.Run(timeout, func(t *testing.T) { | |||
| 26 | + | cfg := Config{ | |||
| 27 | + | Name: "cache", | |||
| 28 | + | Image: "redis:7", | |||
| 29 | + | Enabled: true, | |||
| 30 | + | Readiness: ReadinessConfig{ | |||
| 31 | + | Type: domain.StandaloneServiceReadinessTCP, | |||
| 32 | + | Timeout: timeout, | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | _, err := cfg.ToDomain() | |||
| 37 | + | ||||
| 38 | + | require.ErrorContains(t, err, "readiness timeout") | |||
| 39 | + | require.ErrorContains(t, err, "positive") | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) { | |||
| 45 | + | explicit := []domain.StandaloneServiceVolume{ | |||
| 46 | + | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | |||
| 47 | + | {Source: "/srv/config", Target: "/config", ReadOnly: true}, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | mounts := ResolveVolumeMounts("gordon", "rust.example.com", explicit, []string{"/ignored"}) | |||
| 51 | + | ||||
| 52 | + | assert.Equal(t, []ResolvedVolumeMount{ | |||
| 53 | + | {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false}, | |||
| 54 | + | {Source: "/srv/config", Target: "/config", ReadOnly: true}, | |||
| 55 | + | }, mounts) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *testing.T) { | |||
| 59 | + | mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"}) | |||
| 60 | + | ||||
| 61 | + | assert.Equal(t, []ResolvedVolumeMount{ | |||
| 62 | + | {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust"}, | |||
| 63 | + | {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data"}, | |||
| 64 | + | }, mounts) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestResolveVolumeMountsReturnsEmptyForStatelessImage(t *testing.T) { | |||
| 68 | + | mounts := ResolveVolumeMounts("gordon", "stateless.example.com", nil, nil) | |||
| 69 | + | ||||
| 70 | + | assert.Empty(t, mounts) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestManagedServiceVolumeNameIsDeterministicAndGordonCompatible(t *testing.T) { | |||
| 74 | + | assert.Equal(t, "gordon-rust-example-com-var-lib-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/var/lib/data")) | |||
| 75 | + | assert.Equal(t, "gordon-rust-example-com-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/data")) | |||
| 76 | + | } | |||