1use crate::deserialize::*;
2use crate::dofigen_struct::*;
3use regex::Regex;
4use serde::de::{Error as DeError, value::Error};
5use std::str::FromStr;
6use struct_patch::Patch;
7use url::Url;
8
9const GIT_HTTP_REPO_REGEX: &str = "https?://(?:.+@)?[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\\.git(?:#[a-zA-Z0-9_/.-]*(?::[a-zA-Z0-9_/-]+)?)?";
10const GIT_SSH_REPO_REGEX: &str = "[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+:[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:#[a-zA-Z0-9_/.-]+)?(?::[a-zA-Z0-9_/-]+)?";
11const URL_REGEX: &str = "https?://(?:.+@)?[a-zA-Z0-9_-]+(?:\\.[a-zA-Z0-9_-]+)+(/[a-zA-Z0-9_.-]+)*";
12
13macro_rules! impl_parsable_patch {
14 ($struct:ty, $patch:ty, $param:ident, $expression:expr_2021) => {
15 impl Patch<ParsableStruct<$patch>> for $struct {
16 fn apply(&mut self, patch: ParsableStruct<$patch>) {
17 self.apply(patch.0);
18 }
19
20 fn into_patch(self) -> ParsableStruct<$patch> {
21 ParsableStruct(self.into_patch())
22 }
23
24 fn into_patch_by_diff(self, previous_struct: Self) -> ParsableStruct<$patch> {
25 ParsableStruct(self.into_patch_by_diff(previous_struct))
26 }
27
28 fn new_empty_patch() -> ParsableStruct<$patch> {
29 ParsableStruct(Self::new_empty_patch())
30 }
31 }
32
33 impl From<ParsableStruct<$patch>> for $struct {
34 fn from(value: ParsableStruct<$patch>) -> Self {
35 value.0.into()
36 }
37 }
38
39 impl FromStr for $patch {
40 type Err = Error;
41
42 fn from_str($param: &str) -> std::result::Result<Self, Self::Err> {
43 $expression
44 }
45 }
46 };
47}
48
49impl_parsable_patch!(ImageName, ImageNamePatch, s, {
50 let regex = Regex::new(r"^(?:(?<host>[^:\/.]+(?:\.[^:\/.]+)+)(?::(?<port>\d{1,5}))?\/)?(?<path>[a-zA-Z0-9-]{1,63}(?:\/[a-zA-Z0-9-]{1,63})*)(?:(?<version_char>[:@])(?<version_value>[a-zA-Z0-9_.:-]{1,128}))?$").unwrap();
51 let Some(captures) = regex.captures(s) else {
52 return Err(Error::custom("Not matching image name pattern"));
53 };
54 Ok(ImageNamePatch {
55 host: Some(captures.name("host").map(|m| m.as_str().into())),
56 port: Some(captures.name("port").map(|m| m.as_str().parse().unwrap())),
57 path: Some(captures["path"].into()),
58 version: Some(
59 match (
60 captures.name("version_char").map(|m| m.as_str()),
61 captures.name("version_value"),
62 ) {
63 (Some(":"), Some(value)) => Some(ImageVersion::Tag(value.as_str().into())),
64 (Some("@"), Some(value)) => Some(ImageVersion::Digest(value.as_str().into())),
65 (None, None) => None,
66 _ => return Err(Error::custom("Invalid version format")),
67 },
68 ),
69 platform: Some(None),
70 })
71});
72
73impl_parsable_patch!(CopyResource, CopyResourcePatch, s, {
74 let parts_regex = format!(
75 r"^(?:(?<git>(?:{git_http}|{git_ssh}))|(?<url>{url})|\S+)(?: (?:{git_http}|{git_ssh}|{url}|\S+))*(?: \S+)?$",
76 git_http = GIT_HTTP_REPO_REGEX,
77 git_ssh = GIT_SSH_REPO_REGEX,
78 url = URL_REGEX
79 );
80 let regex = Regex::new(parts_regex.as_str()).unwrap();
81 let Some(captures) = regex.captures(s) else {
82 return Err(Error::custom("Not matching copy resources pattern"));
83 };
84 if captures.name("git").is_some() {
85 return Ok(CopyResourcePatch::AddGitRepo(s.parse().unwrap()));
86 }
87 if captures.name("url").is_some() {
88 return Ok(CopyResourcePatch::Add(s.parse().unwrap()));
89 }
90 Ok(CopyResourcePatch::Copy(s.parse().unwrap()))
91});
92
93impl_parsable_patch!(Copy, CopyPatch, s, {
94 let mut parts: Vec<String> = s.split(" ").map(|s| s.into()).collect();
95 let target = if parts.len() > 1 { parts.pop() } else { None };
96 Ok(Self {
97 paths: Some(parts.into_patch()),
98 options: Some(CopyOptionsPatch {
99 target: Some(target),
100 chmod: Some(None),
101 chown: Some(None),
102 link: Some(None),
103 }),
104 from: Some(FromContextPatch::default()),
105 exclude: Some(VecPatch::default()),
106 parents: Some(None),
107 })
108});
109
110impl_parsable_patch!(AddGitRepo, AddGitRepoPatch, s, {
111 let (repo, target) = match &s.split(" ").collect::<Vec<&str>>().as_slice() {
112 &[repo, target] => (repo.to_string(), Some(target.to_string())),
113 &[repo] => (repo.to_string(), None),
114 _ => return Err(Error::custom("Invalid add git repo format")),
115 };
116 Ok(Self {
117 repo: Some(repo),
118 options: Some(CopyOptionsPatch {
119 target: Some(target),
120 chmod: Some(None),
121 chown: Some(None),
122 link: Some(None),
123 }),
124 keep_git_dir: Some(None),
125 checksum: Some(None),
126 exclude: Some(VecPatch::default()),
127 })
128});
129
130impl_parsable_patch!(Add, AddPatch, s, {
131 let mut parts: Vec<_> = s.split(" ").collect();
132 let target = if parts.len() > 1 {
133 parts.pop().map(str::to_string)
134 } else {
135 None
136 };
137 let parts: Vec<_> = parts
138 .iter()
139 .map(|s| {
140 Url::parse(s)
141 .map(Resource::Url)
142 .ok()
143 .unwrap_or(Resource::File(s.into()))
144 })
145 .collect();
146 Ok(Self {
147 files: Some(parts.into_patch()),
148 options: Some(CopyOptionsPatch {
149 target: Some(target),
150 chmod: Some(None),
151 chown: Some(None),
152 link: Some(None),
153 }),
154 checksum: Some(None),
155 unpack: Some(None),
156 })
157});
158
159impl_parsable_patch!(User, UserPatch, s, {
160 let regex = Regex::new(r"^(?<user>[a-zA-Z0-9_-]+)(?::(?<group>[a-zA-Z0-9_-]+))?$").unwrap();
161 let Some(captures) = regex.captures(s) else {
162 return Err(Error::custom("Not matching chown pattern"));
163 };
164 Ok(Self {
165 user: Some(captures["user"].into()),
166 group: Some(captures.name("group").map(|m| m.as_str().into())),
167 })
168});
169
170impl_parsable_patch!(Port, PortPatch, s, {
171 let regex = Regex::new(r"^(?<port>\d+)(?:/(?<protocol>(tcp|udp)))?$").unwrap();
172 let Some(captures) = regex.captures(s) else {
173 return Err(Error::custom("Not matching chown pattern"));
174 };
175 Ok(Self {
176 port: Some(captures["port"].parse().map_err(Error::custom)?),
177 protocol: Some(captures.name("protocol").map(|m| match m.as_str() {
178 "tcp" => PortProtocol::Tcp,
179 "udp" => PortProtocol::Udp,
180 _ => unreachable!(),
181 })),
182 })
183});
184
185impl_parsable_patch!(Bind, BindPatch, s, {
186 let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
187 let Some(captures) = regex.captures(s) else {
188 return Err(Error::custom("Not matching bind pattern"));
189 };
190
191 let target = Some(captures["target"].to_string());
192 Ok(Self {
193 source: Some(
194 captures
195 .name("source")
196 .map(|m| m.as_str().into())
197 .or(target.clone()),
198 ),
199 target,
200 from: captures.name("from").map(|m| {
201 let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
202 let from = m.as_str();
203 match from_type {
204 "image" => {
205 FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
206 }
207 "builder" => FromContextPatch::FromBuilder(from.into()),
208 "context" => FromContextPatch::FromContext(Some(from.into())),
209 _ => unreachable!(),
210 }
211 }),
212
213 readwrite: Some(None),
214 })
215});
216
217impl_parsable_patch!(Cache, CachePatch, s, {
218 let regex = Regex::new(r"^(?:(?:(?P<fromType>image|builder|context)\((?P<from>[^:]+)\):)?(?P<source>\S+) )?(?P<target>\S+)$").unwrap();
219 let Some(captures) = regex.captures(s) else {
220 return Err(Error::custom("Not matching bind pattern"));
221 };
222
223 let target = Some(captures["target"].to_string());
224 Ok(Self {
225 source: Some(captures.name("source").map(|m| m.as_str().into())),
226 target,
227 from: captures.name("from").map(|m| {
228 let from_type = captures.name("fromType").map(|m| m.as_str()).unwrap();
229 let from = m.as_str();
230 match from_type {
231 "image" => {
232 FromContextPatch::FromImage(ImageNamePatch::from_str(from).unwrap().into())
233 }
234 "builder" => FromContextPatch::FromBuilder(from.into()),
235 "context" => FromContextPatch::FromContext(Some(from.into())),
236 _ => unreachable!(),
237 }
238 }),
239 chmod: Some(None),
240 chown: Some(None),
241 id: Some(None),
242 readonly: Some(None),
243 sharing: Some(None),
244 })
245});
246
247impl_parsable_patch!(TmpFs, TmpFsPatch, s, {
248 Ok(Self {
249 target: Some(s.into()),
250 size: Some(None),
251 })
252});
253
254#[cfg(test)]
255mod test_from_str {
256 use super::*;
257 use pretty_assertions_sorted::assert_eq_sorted;
258
259 mod image_name {
260 use pretty_assertions_sorted::assert_eq_sorted;
261
262 use super::*;
263
264 #[test]
265 fn simple() {
266 let input = "example/image";
267 let result = ImageNamePatch::from_str(input).unwrap();
268 assert_eq_sorted!(result.host, Some(None));
269 assert_eq_sorted!(result.path, Some("example/image".into()));
270 assert_eq_sorted!(result.port, Some(None));
271 assert_eq_sorted!(result.version, Some(None));
272 }
273
274 #[test]
275 fn with_host() {
276 let input = "docker.io/example/image";
277 let result = ImageNamePatch::from_str(input).unwrap();
278 assert_eq_sorted!(result.host, Some(Some("docker.io".into())));
279 assert_eq_sorted!(result.path, Some("example/image".into()));
280 assert_eq_sorted!(result.port, Some(None));
281 assert_eq_sorted!(result.version, Some(None));
282 }
283
284 #[test]
285 fn with_tag() {
286 let input = "example/image:tag";
287 let result = ImageNamePatch::from_str(input).unwrap();
288 assert_eq_sorted!(result.host, Some(None));
289 assert_eq_sorted!(result.path, Some("example/image".into()));
290 assert_eq_sorted!(result.port, Some(None));
291 assert_eq_sorted!(result.version, Some(Some(ImageVersion::Tag("tag".into()))));
292 }
293
294 #[test]
295 fn with_digest() {
296 let input = "example/image@sha256:my-sha";
297 let result = ImageNamePatch::from_str(input).unwrap();
298 assert_eq_sorted!(result.host, Some(None));
299 assert_eq_sorted!(result.path, Some("example/image".into()));
300 assert_eq_sorted!(result.port, Some(None));
301 assert_eq_sorted!(
302 result.version,
303 Some(Some(ImageVersion::Digest("sha256:my-sha".into())))
304 );
305 }
306
307 #[test]
308 fn full() {
309 let input = "registry.my-host.io:5001/example/image:stable";
310 let result = ImageNamePatch::from_str(input).unwrap();
311 assert_eq_sorted!(result.host, Some(Some("registry.my-host.io".into())));
312 assert_eq_sorted!(result.path, Some("example/image".into()));
313 assert_eq_sorted!(result.port, Some(Some(5001)));
314 assert_eq_sorted!(
315 result.version,
316 Some(Some(ImageVersion::Tag("stable".into())))
317 );
318 }
319
320 #[test]
321 fn with_arg() {
322 let input = "example/image:${VERSION}";
323 let result = ImageNamePatch::from_str(input);
324 assert!(result.is_err());
325 }
326 }
327
328 mod copy {
329
330 use super::*;
331
332 #[test]
333 fn simple() {
334 let result = CopyPatch::from_str("src").unwrap();
335 assert_eq_sorted!(
336 result,
337 CopyPatch {
338 paths: Some(vec!["src".to_string()].into_patch()),
339 options: Some(CopyOptionsPatch {
340 target: Some(None),
341 chown: Some(None),
342 chmod: Some(None),
343 link: Some(None),
344 }),
345 from: Some(FromContextPatch::default()),
346 exclude: Some(VecPatch::default()),
347 parents: Some(None),
348 }
349 );
350 }
351
352 #[test]
353 fn with_target_option() {
354 let result = CopyPatch::from_str("src /app").unwrap();
355 assert_eq_sorted!(
356 result,
357 CopyPatch {
358 paths: Some(vec!["src".to_string()].into_patch()),
359 options: Some(CopyOptionsPatch {
360 target: Some(Some("/app".into())),
361 chown: Some(None),
362 chmod: Some(None),
363 link: Some(None),
364 }),
365 from: Some(FromContextPatch::default()),
366 exclude: Some(VecPatch::default()),
367 parents: Some(None),
368 }
369 );
370 }
371
372 #[test]
373 fn with_multiple_sources_and_target() {
374 let result = CopyPatch::from_str("src1 src2 /app").unwrap();
375 assert_eq_sorted!(
376 result,
377 CopyPatch {
378 paths: Some(vec!["src1".to_string(), "src2".to_string()].into_patch()),
379 options: Some(CopyOptionsPatch {
380 target: Some(Some("/app".into())),
381 chown: Some(None),
382 chmod: Some(None),
383 link: Some(None),
384 }),
385 from: Some(FromContextPatch::default()),
386 exclude: Some(VecPatch::default()),
387 parents: Some(None),
388 }
389 );
390 }
391 }
392
393 mod add_git_repo {
394
395 use super::*;
396
397 #[test]
398 fn ssh() {
399 let result = AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
400 assert_eq_sorted!(
401 result,
402 AddGitRepoPatch {
403 repo: Some("git@github.com:lenra-io/dofigen.git".into()),
404 options: Some(CopyOptionsPatch {
405 target: Some(None),
406 chown: Some(None),
407 chmod: Some(None),
408 link: Some(None),
409 }),
410 keep_git_dir: Some(None),
411 checksum: Some(None),
412 exclude: Some(VecPatch::default()),
413 }
414 );
415 }
416
417 #[test]
418 fn ssh_with_target() {
419 let result =
420 AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git /app").unwrap();
421 assert_eq_sorted!(
422 result,
423 AddGitRepoPatch {
424 repo: Some("git@github.com:lenra-io/dofigen.git".into()),
425 options: Some(CopyOptionsPatch {
426 target: Some(Some("/app".into())),
427 chown: Some(None),
428 chmod: Some(None),
429 link: Some(None),
430 }),
431 keep_git_dir: Some(None),
432 checksum: Some(None),
433 exclude: Some(VecPatch::default()),
434 }
435 );
436 }
437
438 #[test]
439 fn http() {
440 let result =
441 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
442 assert_eq_sorted!(
443 result,
444 AddGitRepoPatch {
445 repo: Some("https://github.com/lenra-io/dofigen.git".into()),
446 options: Some(CopyOptionsPatch {
447 target: Some(None),
448 chown: Some(None),
449 chmod: Some(None),
450 link: Some(None),
451 }),
452 keep_git_dir: Some(None),
453 checksum: Some(None),
454 exclude: Some(VecPatch::default()),
455 }
456 );
457 }
458
459 #[test]
460 fn http_with_target() {
461 let result =
462 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git /app").unwrap();
463 assert_eq_sorted!(
464 result,
465 AddGitRepoPatch {
466 repo: Some("https://github.com/lenra-io/dofigen.git".into()),
467 options: Some(CopyOptionsPatch {
468 target: Some(Some("/app".into())),
469 chown: Some(None),
470 chmod: Some(None),
471 link: Some(None),
472 }),
473 keep_git_dir: Some(None),
474 checksum: Some(None),
475 exclude: Some(VecPatch::default()),
476 }
477 );
478 }
479 }
480
481 mod add {
482 use struct_patch::Patch;
483
484 use crate::{CopyOptions, Resource};
485
486 use super::*;
487
488 #[test]
489 fn simple() {
490 let result =
491 AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
492 .unwrap();
493 assert_eq_sorted!(
494 result,
495 Add {
496 files: vec![Resource::Url(
497 "https://github.com/lenra-io/dofigen/raw/main/README.md"
498 .parse()
499 .unwrap()
500 )],
501 options: CopyOptions::default(),
502 ..Default::default()
503 }
504 .into_patch()
505 );
506 }
507
508 #[test]
509 fn with_target_option() {
510 let result =
511 AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md /app")
512 .unwrap();
513 assert_eq_sorted!(
514 result,
515 Add {
516 files: vec![Resource::Url(
517 "https://github.com/lenra-io/dofigen/raw/main/README.md"
518 .parse()
519 .unwrap()
520 )],
521 options: CopyOptions {
522 target: Some("/app".into()),
523 ..Default::default()
524 },
525 ..Default::default()
526 }
527 .into_patch()
528 );
529 }
530
531 #[test]
532 fn with_multiple_sources_and_target() {
533 let result = AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md https://github.com/lenra-io/dofigen/raw/main/LICENSE /app").unwrap();
534 assert_eq_sorted!(
535 result,
536 Add {
537 files: vec![
538 Resource::Url(
539 "https://github.com/lenra-io/dofigen/raw/main/README.md"
540 .parse()
541 .unwrap()
542 ),
543 Resource::Url(
544 "https://github.com/lenra-io/dofigen/raw/main/LICENSE"
545 .parse()
546 .unwrap()
547 )
548 ],
549 options: CopyOptions {
550 target: Some("/app".into()),
551 ..Default::default()
552 },
553 ..Default::default()
554 }
555 .into_patch()
556 );
557 }
558 }
559
560 mod copy_resources {
561 use super::*;
562
563 #[test]
564 fn copy() {
565 let result = CopyResourcePatch::from_str("src").unwrap();
566 assert_eq_sorted!(
567 result,
568 CopyResourcePatch::Copy(CopyPatch::from_str("src").unwrap())
569 );
570 }
571
572 #[test]
573 fn add_git_repo_ssh() {
574 let result =
575 CopyResourcePatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap();
576 assert_eq_sorted!(
577 result,
578 CopyResourcePatch::AddGitRepo(
579 AddGitRepoPatch::from_str("git@github.com:lenra-io/dofigen.git").unwrap()
580 )
581 );
582 }
583
584 #[test]
585 fn add_git_repo_http() {
586 let result =
587 CopyResourcePatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap();
588 assert_eq_sorted!(
589 result,
590 CopyResourcePatch::AddGitRepo(
591 AddGitRepoPatch::from_str("https://github.com/lenra-io/dofigen.git").unwrap()
592 )
593 );
594 }
595
596 #[test]
597 fn add() {
598 let result = CopyResourcePatch::from_str(
599 "https://github.com/lenra-io/dofigen/raw/main/README.md",
600 )
601 .unwrap();
602 assert_eq_sorted!(
603 result,
604 CopyResourcePatch::Add(
605 AddPatch::from_str("https://github.com/lenra-io/dofigen/raw/main/README.md")
606 .unwrap()
607 )
608 );
609 }
610 }
611
612 mod user {
613 use pretty_assertions_sorted::assert_eq_sorted;
614
615 use super::*;
616
617 #[test]
618 fn user() {
619 let result = UserPatch::from_str("user").unwrap();
620
621 assert_eq_sorted!(result.user, Some("user".into()));
622 assert_eq_sorted!(result.group, Some(None));
623 }
624
625 #[test]
626 fn with_group() {
627 let result = UserPatch::from_str("user:group").unwrap();
628
629 assert_eq_sorted!(result.user, Some("user".into()));
630 assert_eq_sorted!(result.group, Some(Some("group".into())));
631 }
632
633 #[test]
634 fn uid() {
635 let result = UserPatch::from_str("1000").unwrap();
636
637 assert_eq_sorted!(result.user, Some("1000".into()));
638 assert_eq_sorted!(result.group, Some(None));
639 }
640
641 #[test]
642 fn uid_with_gid() {
643 let result = UserPatch::from_str("1000:1000").unwrap();
644
645 assert_eq_sorted!(result.user, Some("1000".into()));
646 assert_eq_sorted!(result.group, Some(Some("1000".into())));
647 }
648
649 #[test]
650 fn invalid_username() {
651 let result = UserPatch::from_str("user*name");
652
653 assert!(result.is_err());
654 }
655
656 #[test]
657 fn invalid_extra() {
658 let result = UserPatch::from_str("user:group:extra");
659
660 assert!(result.is_err());
661 }
662 }
663
664 mod port {
665
666 use super::*;
667
668 #[test]
669 fn simple() {
670 let result = PortPatch::from_str("80").unwrap();
671
672 assert_eq_sorted!(result.port, Some(80));
673 assert_eq_sorted!(result.protocol, Some(None));
674 }
675
676 #[test]
677 fn with_tcp_protocol() {
678 let result = PortPatch::from_str("80/tcp").unwrap();
679
680 assert_eq_sorted!(result.port, Some(80));
681 assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Tcp)));
682 }
683
684 #[test]
685 fn with_udp_protocol() {
686 let result = PortPatch::from_str("80/udp").unwrap();
687
688 assert_eq_sorted!(result.port, Some(80));
689 assert_eq_sorted!(result.protocol, Some(Some(PortProtocol::Udp)));
690 }
691
692 #[test]
693 fn invalid() {
694 let result = PortPatch::from_str("80/invalid");
695
696 assert!(result.is_err());
697 }
698 }
699}