compose_validatr/
services.rs

1//! Service fields and validation
2
3mod blkio_config;
4mod build;
5mod deploy;
6mod healthcheck;
7mod logging;
8mod networks;
9mod ports;
10mod secrets;
11mod volumes;
12
13use crate::{compose::Compose, errors::ValidationError};
14use regex::Regex;
15use std::collections::HashMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::{compose::Validate, errors::ValidationErrors};
20
21/// Represents the top level [Service](https://docs.docker.com/compose/compose-file/05-services/) element
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct Service {
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub attach: Option<bool>,
26
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub build: Option<build::Build>,
29
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub blkio_config: Option<blkio_config::BlkioConfig>,
32
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub cpu_count: Option<u8>,
35
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub cpu_percent: Option<f32>,
38
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub cpu_shares: Option<u32>,
41
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub cpu_period: Option<String>,
44
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub cpu_quota: Option<String>,
47
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub cpu_rt_runtime: Option<String>,
50
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub cpu_rt_period: Option<String>,
53
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub cpus: Option<f32>, // deprecated
56
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub cpuset: Option<u8>,
59
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub cap_add: Option<Vec<Capabilities>>,
62
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub cap_drop: Option<Vec<Capabilities>>,
65
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub cgroup: Option<Cgroup>,
68
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub cgroup_parent: Option<String>,
71
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub command: Option<Command>,
74
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub configs: Option<Vec<Config>>,
77
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub container_name: Option<String>,
80
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub credential_spec: Option<CredentialSpec>,
83
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub depends_on: Option<DependsOn>,
86
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub deploy: Option<deploy::Deploy>,
89
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub device_cgroup_rules: Option<Vec<String>>,
92
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub devices: Option<Vec<String>>,
95
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub dns: Option<Labels>, // TODO: maybe validate as ipv4
98
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub dns_opt: Option<Vec<String>>,
101
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub dns_search: Option<Labels>,
104
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub domainname: Option<String>,
107
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub entrypoint: Option<Labels>,
110
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub env_file: Option<Labels>,
113
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub environment: Option<Labels>,
116
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub expose: Option<Vec<String>>,
119
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub extends: Option<Extends>,
122
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub annotations: Option<Labels>,
125
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub external_links: Option<Vec<String>>,
128
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub extra_hosts: Option<Labels>,
131
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub group_add: Option<Vec<String>>,
134
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub healthcheck: Option<healthcheck::HealthCheck>,
137
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub hostname: Option<String>,
140
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub image: Option<String>,
143
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub init: Option<bool>,
146
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub ipc: Option<String>,
149
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub uts: Option<String>,
152
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub isolation: Option<String>, // TODO: Verify this
155
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub labels: Option<Labels>,
158
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub links: Option<Vec<String>>,
161
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub logging: Option<logging::Logging>,
164
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub network_mode: Option<String>, // TODO: Maybe make enum for this?
167
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub networks: Option<networks::Networks>,
170
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub mac_address: Option<String>,
173
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub mem_limit: Option<String>, // deprecated
176
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub mem_reservation: Option<String>, // deprecated
179
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub mem_swappiness: Option<u8>,
182
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub memswap_limit: Option<String>,
185
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub oom_kill_disable: Option<bool>,
188
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub oom_score_adj: Option<i16>,
191
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub pid: Option<u32>,
194
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub pids_limit: Option<u32>,
197
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub platform: Option<String>,
200
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub ports: Option<ports::Ports>,
203
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub privileged: Option<bool>,
206
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub profiles: Option<Vec<String>>,
209
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub pull_policy: Option<PullPolicy>,
212
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub read_only: Option<bool>,
215
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub restart: Option<Restart>,
218
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub runtime: Option<String>,
221
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub scale: Option<u32>, // deprecated
224
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub secrets: Option<Vec<secrets::Secret>>,
227
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub secruity_opt: Option<Vec<String>>,
230
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub shm_size: Option<String>,
233
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub stdin_open: Option<String>,
236
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub stop_grace_period: Option<String>,
239
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub stop_signal: Option<String>,
242
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub storage_opt: Option<String>,
245
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub sysctls: Option<Labels>,
248
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub tmpfs: Option<Tmpfs>,
251
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub tty: Option<String>,
254
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub ulimits: Option<Ulimits>,
257
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub user: Option<String>,
260
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub userns_mode: Option<String>,
263
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub volumes: Option<Vec<volumes::Volumes>>,
266
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub volumes_from: Option<Vec<String>>,
269
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub working_dir: Option<String>,
272}
273
274#[derive(Debug, Clone, Deserialize, Serialize)]
275#[serde(untagged)]
276pub enum Labels {
277    List(Vec<String>),
278    Map(HashMap<String, String>),
279}
280
281#[derive(Debug, Clone, Deserialize, Serialize)]
282#[serde(untagged)]
283pub enum Tmpfs {
284    String(String),
285    List(Vec<String>),
286}
287
288#[derive(Debug, Clone, Deserialize, Serialize)]
289#[serde(untagged)]
290pub enum Config {
291    Short(String),
292    Long(ConfigDetails),
293}
294
295#[derive(Debug, Clone, Deserialize, Serialize)]
296pub struct ConfigDetails {
297    pub source: String,
298    pub target: String,
299    pub uid: String,
300    pub gid: String,
301    pub mode: String,
302}
303
304#[derive(Debug, Clone, Deserialize, Serialize)]
305#[serde(rename_all = "lowercase")]
306pub enum Cgroup {
307    Host,
308    Private,
309}
310
311#[derive(Debug, Clone, Deserialize, Serialize)]
312#[serde(untagged)]
313pub enum Command {
314    String(String),
315    List(Vec<String>),
316}
317
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct CredentialSpec {
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub file: Option<String>,
322
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub registry: Option<String>,
325
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub config: Option<String>, // must be valid config
328}
329
330#[derive(Debug, Clone, Deserialize, Serialize)]
331#[serde(untagged)]
332pub enum DependsOn {
333    List(Vec<String>), // must be valid services
334    Map(HashMap<String, DependsOnDetail>),
335}
336
337#[derive(Debug, Clone, Deserialize, Serialize)]
338pub struct DependsOnDetail {
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub restart: Option<bool>,
341
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub condition: Option<DependsOnCondition>,
344
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub required: Option<bool>,
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize)]
350#[serde(rename_all = "snake_case")]
351pub enum DependsOnCondition {
352    ServiceStarted,
353    ServiceHealthy,
354    ServiceCompletedSuccessfully,
355}
356
357#[derive(Debug, Clone, Deserialize, Serialize)]
358pub struct Extends {
359    // https://docs.docker.com/compose/compose-file/05-services/#extends
360    pub file: String,
361    pub service: String,
362}
363
364#[derive(Debug, Clone, Deserialize, Serialize)]
365#[serde(rename_all = "lowercase")]
366pub enum PullPolicy {
367    Always,
368    Never,
369    Missing,
370    Build,
371}
372
373#[derive(Debug, Clone, Deserialize, Serialize)]
374#[serde(rename_all = "lowercase")]
375pub enum Restart {
376    No,
377    Always,
378    OnFailure,
379    UnlessStopped,
380}
381
382#[derive(Debug, Clone, Deserialize, Serialize)]
383pub struct Ulimits {
384    pub nproc: u16,
385    pub nofile: Nofile,
386}
387
388#[derive(Debug, Clone, Deserialize, Serialize)]
389pub struct Nofile {
390    pub soft: u16,
391    pub hard: u16,
392}
393
394#[derive(Debug, Clone, Deserialize, Serialize)]
395#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
396pub enum Capabilities {
397    All,
398    AuditControl,
399    AuditRead,
400    AuditWrite,
401    BlockSuspend,
402    Bpf,
403    CheckpointRestore,
404    Chown,
405    DacOverride,
406    DacReadSearch,
407    Fowner,
408    Fsetid,
409    IpcLock,
410    IpcOwner,
411    Kill,
412    Lease,
413    LinuxImmutable,
414    MacAdmin,
415    MacOverride,
416    Mknod,
417    NetAdmin,
418    NetBindService,
419    NetBroadcast,
420    NetRaw,
421    Perfmon,
422    Setgid,
423    Setfcap,
424    Setpcap,
425    Setuid,
426    SysAdmin,
427    SysBoot,
428    SysChroot,
429    SysModule,
430    SysNice,
431    SysPacct,
432    SysPtrace,
433    SysRawio,
434    SysResource,
435    SysTime,
436    SysTtyConfig,
437    Syslog,
438    WakeAlarm,
439}
440
441impl Service {
442    fn validate_blkio_config(&self, ctx: &Compose, errors: &mut ValidationErrors) {
443        self.blkio_config.as_ref().map(|b| b.validate(ctx, errors));
444    }
445
446    fn validate_build(&self, ctx: &Compose, errors: &mut ValidationErrors) {
447        self.build.as_ref().map(|b| b.validate(ctx, errors));
448    }
449
450    fn validate_deploy(&self, ctx: &Compose, errors: &mut ValidationErrors) {
451        self.deploy.as_ref().map(|d| d.validate(ctx, errors));
452    }
453
454    fn validate_healthcheck(&self, ctx: &Compose, errors: &mut ValidationErrors) {
455        self.healthcheck.as_ref().map(|h| h.validate(ctx, errors));
456    }
457
458    fn validate_logging(&self, ctx: &Compose, errors: &mut ValidationErrors) {
459        self.logging.as_ref().map(|l| l.validate(ctx, errors));
460    }
461
462    fn validate_networks(&self, ctx: &Compose, errors: &mut ValidationErrors) {
463        self.networks.as_ref().map(|n| n.validate(ctx, errors));
464    }
465
466    fn validate_ports(&self, ctx: &Compose, errors: &mut ValidationErrors) {
467        self.ports.as_ref().map(|p| p.validate(ctx, errors));
468    }
469
470    fn validate_secrets(&self, ctx: &Compose, errors: &mut ValidationErrors) {
471        self.secrets
472            .as_ref()
473            .map(|s| s.iter().for_each(|s| s.validate(ctx, errors)));
474    }
475
476    fn validate_volumes(&self, ctx: &Compose, errors: &mut ValidationErrors) {
477        self.volumes
478            .as_ref()
479            .map(|v| v.iter().for_each(|volume| volume.validate(ctx, errors)));
480    }
481
482    fn validate_configs(&self, ctx: &Compose, errors: &mut ValidationErrors) {
483        // configs must exist in top level configs
484
485        self.configs.as_ref().map(|c| {
486            c.iter().for_each(|config| match config {
487                Config::Short(c) => {
488                    ctx.configs.as_ref().map(|configs| {
489                        if !configs.contains_key(c) {
490                            errors.add_error(ValidationError::InvalidValue(format!(
491                                "Config is not defined: {}",
492                                c
493                            )))
494                        }
495                    });
496                }
497                Config::Long(c) => {
498                    ctx.configs.as_ref().map(|configs| {
499                        if !configs.contains_key(&c.source) {
500                            errors.add_error(ValidationError::InvalidValue(format!(
501                                "Config is not defined: {}",
502                                &c.source
503                            )))
504                        }
505                    });
506                }
507            })
508        });
509    }
510
511    fn validate_credential_spec(&self, ctx: &Compose, errors: &mut ValidationErrors) {
512        self.credential_spec.as_ref().map(|c| {
513            let result = c.config.as_ref().map(|cred| {
514                ctx.configs
515                    .as_ref()
516                    .map(|configs| configs.contains_key(cred))
517                    .is_some()
518            });
519            result.map(|r| {
520                if !r {
521                    errors.add_error(ValidationError::InvalidValue(
522                        "Credential Spec references unknown config".to_string(),
523                    ))
524                }
525            });
526        });
527    }
528
529    fn validate_container_name(&self, _: &Compose, errors: &mut ValidationErrors) {
530        let re = Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]+$").unwrap();
531        self.container_name.as_ref().map(|c| {
532            if !re.is_match(c) {
533                errors.add_error(ValidationError::InvalidValue(
534                    "Invalid container name".to_string(),
535                ));
536            }
537        });
538    }
539
540    fn validate_depends_on(&self, ctx: &Compose, errors: &mut ValidationErrors) {
541        self.depends_on.as_ref().map(|d| match d {
542            DependsOn::List(service) => {
543                let result = service.iter().any(|s| ctx.services.contains_key(s));
544                if !result {
545                    errors.add_error(ValidationError::InvalidValue(
546                        "Invalid service for depends_on".to_string(),
547                    ));
548                }
549            }
550            DependsOn::Map(service) => {
551                let result = service.iter().any(|s| ctx.services.contains_key(s.0));
552                if !result {
553                    errors.add_error(ValidationError::InvalidValue(
554                        "Invalid service for depends_on".to_string(),
555                    ));
556                }
557            }
558        });
559    }
560
561    fn validate_expose(&self, _: &Compose, errors: &mut ValidationErrors) {
562        self.expose.as_ref().map(|e| {
563            e.iter().all(|port| match port.parse::<u16>() {
564                Err(_) => {
565                    errors.add_error(ValidationError::InvalidValue("Invalid port".to_string()));
566                    false
567                }
568                _ => true,
569            })
570        });
571    }
572
573    fn validate_extends(&self, ctx: &Compose, errors: &mut ValidationErrors) {
574        self.extends.as_ref().map(|e| {
575            let result = ctx.services.contains_key(&e.service);
576            if result {
577                // Services that have dependencies on other services cannot be used as a base.
578                // Therefore, any key that introduces a dependency on another service is incompatible
579                // with extends. The non-exhaustive list of such keys is: links, volumes_from, container
580                // mode (in ipc, pid, network_mode and net), service mode (in ipc, pid and network_mode), depends_on.
581                let service = ctx.services.get(&e.service).unwrap();
582                service.depends_on.as_ref().map(|_| {
583                    errors.add_error(ValidationError::InvalidValue(
584                        "Extends cannot extend another service that has a depends_on".to_string(),
585                    ))
586                });
587                service.links.as_ref().map(|l| {
588                    if l.len() > 0 {
589                        errors.add_error(ValidationError::InvalidValue(
590                            "Extends cannot have any links".to_string(),
591                        ))
592                    }
593                });
594                service.volumes_from.as_ref().map(|v| {
595                    if v.len() > 0 {
596                        errors.add_error(ValidationError::InvalidValue(
597                            "Extends cannot have any volumes_from".to_string(),
598                        ))
599                    }
600                });
601                service.ipc.as_ref().map(|_| {
602                    errors.add_error(ValidationError::InvalidValue(
603                        "Extends cannot have an IPC mode".to_string(),
604                    ))
605                });
606                service.network_mode.as_ref().map(|n| {
607                    if n.starts_with("service:") {
608                        errors.add_error(ValidationError::InvalidValue(
609                            "Extends cannot extend a service that has a network dependency"
610                                .to_string(),
611                        ))
612                    }
613                });
614            } else {
615                errors.add_error(ValidationError::InvalidValue(
616                    "Extends references invalid service".to_string(),
617                ));
618            }
619        });
620    }
621}
622
623impl Validate for Service {
624    fn validate(&self, ctx: &Compose, errors: &mut ValidationErrors) {
625        self.validate_blkio_config(ctx, errors);
626        self.validate_build(ctx, errors);
627        self.validate_deploy(ctx, errors);
628        self.validate_healthcheck(ctx, errors);
629        self.validate_logging(ctx, errors);
630        self.validate_networks(ctx, errors);
631        self.validate_ports(ctx, errors);
632        self.validate_secrets(ctx, errors);
633        self.validate_volumes(ctx, errors);
634        self.validate_configs(ctx, errors);
635        self.validate_container_name(ctx, errors);
636        self.validate_credential_spec(ctx, errors);
637        self.validate_depends_on(ctx, errors);
638        self.validate_expose(ctx, errors);
639        self.validate_extends(ctx, errors);
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn depends_on_missing_service() {
649        let yaml = r#"
650        services:
651          gitlab:
652            image: gitlab/gitlab-ce:latest
653            container_name: gitlab
654            hostname: gitlab
655            restart: always
656            depends_on:
657              - hello_world
658        "#;
659
660        let compose = Compose::new(yaml);
661        assert!(compose.is_err());
662        assert!(compose.is_err_and(|e| e.all_errors().len() == 1));
663    }
664
665    #[test]
666    fn depends_on_valid_service() {
667        let yaml = r#"
668        services:
669          gitlab:
670            image: gitlab/gitlab-ce:latest
671            container_name: gitlab
672            hostname: gitlab
673            restart: always
674            depends_on:
675              - hello_world
676          hello_world:
677            image: gitlab/gitlab-ce:latest
678            container_name: gitlab
679            hostname: gitlab
680            restart: always
681        "#;
682
683        let compose = Compose::new(yaml);
684        assert!(compose.is_ok());
685    }
686
687    #[test]
688    fn valid_config() {
689        let yaml = r#"
690        services:
691          gitlab:
692            image: gitlab/gitlab-ce:latest
693            container_name: gitlab
694            hostname: gitlab
695            restart: always
696            configs:
697              - my_config
698              - my_other_config
699        configs:
700          my_config:
701            file: ./my_config.txt  
702          my_other_config:
703            external: true
704        "#;
705
706        let compose = Compose::new(yaml);
707        assert!(compose.is_ok());
708    }
709
710    #[test]
711    fn invalid_config() {
712        let yaml = r#"
713        services:
714          gitlab:
715            image: gitlab/gitlab-ce:latest
716            container_name: gitlab
717            hostname: gitlab
718            restart: always
719            configs:
720              - hello
721              - world
722        configs:
723          my_config:
724            file: ./my_config.txt  
725          my_other_config:
726            external: true
727        "#;
728
729        let compose = Compose::new(yaml);
730        dbg!(&compose);
731        assert!(compose.is_err());
732    }
733}