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