1package domain
2
3import (
4 "fmt"
5 "net"
6 "path"
7 "strconv"
8 "strings"
9 "time"
10)
11
12const (
13 StandaloneServiceReadinessNone = "none"
14 StandaloneServiceReadinessTCP = "tcp"
15 StandaloneServiceReadinessLog = "log"
16)
17
18type 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
31type StandaloneServiceStatus struct {
32 Name string
33 ContainerID string
34 ContainerName string
35 Status ContainerStatus
36 ConfigHash string
37}
38
39type StandaloneServicePort struct {
40 Name string
41 Container int
42 Protocol NetworkProtocol
43 Publish string
44 Private bool
45 Public bool
46 TrustedCIDRs []string
47}
48
49type StandaloneServiceVolume struct {
50 Source string
51 Target string
52 ReadOnly bool
53}
54
55type StandaloneServiceSecretRef struct {
56 Name string
57 Key string
58}
59
60type StandaloneServiceReadiness struct {
61 Type string
62 Path string
63 Contains string
64 Timeout time.Duration
65 TimeoutSet bool
66}
67
68type StandaloneServiceCleanup struct {
69 PreserveVolumes bool
70 RemoveContainer bool
71}
72
73func (s StandaloneService) WithDefaults() StandaloneService {
74 if s.Readiness.Type == "" {
75 s.Readiness.Type = StandaloneServiceReadinessNone
76 }
77 s.Cleanup = s.Cleanup.WithDefaults()
78 return s
79}
80
81func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
82 if !c.PreserveVolumes && !c.RemoveContainer {
83 return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
84 }
85 return c
86}
87
88func (s StandaloneService) Validate() error {
89 s = s.WithDefaults()
90 name := strings.TrimSpace(s.Name)
91 if name == "" {
92 return fmt.Errorf("standalone service name is required")
93 }
94 if s.Name != name {
95 return fmt.Errorf("standalone service name %q must not include leading or trailing whitespace", s.Name)
96 }
97 if s.Enabled && strings.TrimSpace(s.Image) == "" {
98 return fmt.Errorf("standalone service %q image is required when enabled", s.Name)
99 }
100 if s.EnvFile != "" && strings.TrimSpace(s.EnvFile) == "" {
101 return fmt.Errorf("standalone service %q env_file must be non-empty when set", s.Name)
102 }
103 if err := validateStandaloneServicePorts(s); err != nil {
104 return err
105 }
106 if err := validateStandaloneServiceVolumes(s); err != nil {
107 return err
108 }
109 if err := validateStandaloneServiceSecrets(s); err != nil {
110 return err
111 }
112 if err := validateStandaloneServiceReadiness(s); err != nil {
113 return err
114 }
115 return nil
116}
117
118func validateStandaloneServicePorts(s StandaloneService) error {
119 seen := make(map[string]struct{}, len(s.Ports))
120 for i, port := range s.Ports {
121 name, err := validateStandaloneServicePort(s.Name, i, port)
122 if err != nil {
123 return err
124 }
125 if _, ok := seen[name]; ok {
126 return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)
127 }
128 seen[name] = struct{}{}
129 }
130 return nil
131}
132
133func validateStandaloneServicePort(serviceName string, index int, port StandaloneServicePort) (string, error) {
134 name := strings.TrimSpace(port.Name)
135 if name == "" {
136 return "", fmt.Errorf("standalone service %q port %d name is required", serviceName, index)
137 }
138 if port.Name != name {
139 return "", fmt.Errorf("standalone service %q port %q name must not include leading or trailing whitespace", serviceName, port.Name)
140 }
141 if port.Container <= 0 || port.Container > 65535 {
142 return "", fmt.Errorf("standalone service %q port %q container port must be 1-65535", serviceName, name)
143 }
144 if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
145 return "", fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", serviceName, name)
146 }
147 if port.Private && port.Public {
148 return "", fmt.Errorf("standalone service %q port %q cannot be both private and public", serviceName, name)
149 }
150 if err := validateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
151 return "", err
152 }
153 if port.Private && port.Publish != "" {
154 if err := validatePrivateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
155 return "", err
156 }
157 }
158 if err := validateStandaloneServiceTrustedCIDRs(serviceName, name, port.TrustedCIDRs); err != nil {
159 return "", err
160 }
161 return name, nil
162}
163
164func validateStandaloneServiceTrustedCIDRs(serviceName, portName string, cidrs []string) error {
165 for _, cidr := range cidrs {
166 if strings.TrimSpace(cidr) == "" {
167 return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", serviceName, portName)
168 }
169 if _, _, err := net.ParseCIDR(cidr); err != nil {
170 return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", serviceName, portName, cidr, err)
171 }
172 }
173 return nil
174}
175
176func validateStandaloneServicePublish(serviceName, portName, publish string) error {
177 if publish == "" {
178 return nil
179 }
180 host, port, err := net.SplitHostPort(publish)
181 if err != nil {
182 return fmt.Errorf("standalone service %q port %q publish address %q is invalid: %w", serviceName, portName, publish, err)
183 }
184 if strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" {
185 return fmt.Errorf("standalone service %q port %q publish address %q must include host and port", serviceName, portName, publish)
186 }
187 portNumber, err := strconv.Atoi(port)
188 if err != nil || portNumber < 1 || portNumber > 65535 {
189 return fmt.Errorf("standalone service %q port %q publish address %q must include a valid port", serviceName, portName, publish)
190 }
191 return nil
192}
193
194func validatePrivateStandaloneServicePublish(serviceName, portName, publish string) error {
195 host, _, err := net.SplitHostPort(publish)
196 if err != nil {
197 return err
198 }
199 ip := net.ParseIP(host)
200 if ip == nil || !ip.IsLoopback() {
201 return fmt.Errorf("standalone service %q private port %q publish host %q must be loopback", serviceName, portName, host)
202 }
203 return nil
204}
205
206func validateStandaloneServiceVolumes(s StandaloneService) error {
207 seenTargets := make(map[string]struct{}, len(s.Volumes))
208 for i, volume := range s.Volumes {
209 target := strings.TrimSpace(volume.Target)
210 if target == "" {
211 return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
212 }
213 if !path.IsAbs(target) {
214 return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
215 }
216 if _, ok := seenTargets[target]; ok {
217 return fmt.Errorf("standalone service %q duplicate volume target %q", s.Name, target)
218 }
219 seenTargets[target] = struct{}{}
220 }
221 return nil
222}
223
224func validateStandaloneServiceSecrets(s StandaloneService) error {
225 for i, secret := range s.Secrets {
226 if strings.TrimSpace(secret.Name) == "" {
227 return fmt.Errorf("standalone service %q secret %d name is required", s.Name, i)
228 }
229 if strings.TrimSpace(secret.Key) == "" {
230 return fmt.Errorf("standalone service %q secret %d key is required", s.Name, i)
231 }
232 }
233 return nil
234}
235
236func validateStandaloneServiceReadiness(s StandaloneService) error {
237 readinessType := s.Readiness.Type
238 if readinessType == "" {
239 readinessType = StandaloneServiceReadinessNone
240 }
241 if s.Readiness.TimeoutSet && s.Readiness.Timeout <= 0 {
242 return fmt.Errorf("standalone service %q readiness timeout must be positive when set", s.Name)
243 }
244 switch readinessType {
245 case StandaloneServiceReadinessNone, StandaloneServiceReadinessTCP:
246 return nil
247 case StandaloneServiceReadinessLog:
248 if strings.TrimSpace(s.Readiness.Path) == "" {
249 return fmt.Errorf("standalone service %q log readiness path is required", s.Name)
250 }
251 if strings.TrimSpace(s.Readiness.Contains) == "" {
252 return fmt.Errorf("standalone service %q log readiness contains is required", s.Name)
253 }
254 return nil
255 default:
256 return fmt.Errorf("standalone service %q readiness type must be none, tcp, or log", s.Name)
257 }
258}