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