1use super::*;
2use crate::common::ScheduleDeletePolicy;
3use crate::error::{ValidationError, ValidationResult};
4use crate::snapshot::{Origin, SnapshotSpec};
5use crate::snapshot_policy::{CopyMethod, Hook, HttpRequestHook, SnapshotPolicySpec};
6use crate::snapshot_schedule::SnapshotScheduleSpec;
7
8fn validate_policy_repositories(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
31 let mut errs = Vec::new();
32 if let Err(e) = crate::snapshot_policy::policy_repositories(spec) {
33 errs.push(e);
34 }
35 for r in crate::snapshot_policy::repository_refs(spec) {
36 if let Err(e) = validate_repository_ref(r) {
37 errs.push(e);
38 }
39 }
40 let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
41 for (i, r) in spec.repositories.iter().enumerate() {
42 let key = crate::common::repo_key(r, "");
43 if let Some(&first) = seen.get(&key) {
44 errs.push(ValidationError::PolicyRepositoriesDuplicate {
45 key,
46 first,
47 second: i,
48 });
49 } else {
50 seen.insert(key, i);
51 }
52 }
53 if crate::snapshot_policy::is_multi_repo(spec) && spec.hooks.is_some() {
54 errs.push(ValidationError::PolicyHooksWithRepositories);
55 }
56 errs
57}
58
59pub fn validate_backup_config(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
61 let mut errs = Vec::new();
62 errs.extend(validate_policy_repositories(spec));
63 if spec.sources.is_empty() {
64 errs.push(ValidationError::MissingRequiredField {
65 field: "spec.sources (at least one source required)".to_string(),
66 });
67 }
68 for source in &spec.sources {
69 if let Err(e) = validate_source(source) {
70 errs.push(e);
71 }
72 }
73 if spec.sources.iter().any(|s| s.pvc_selector.is_some())
81 && spec.copy_method == crate::snapshot_policy::CopyMethod::Clone
82 && spec.group_by != Some(crate::snapshot_policy::GroupBy::None)
83 {
84 errs.push(ValidationError::InvalidFieldValue {
85 field: "spec.groupBy".to_string(),
86 reason: "`copyMethod: Clone` cannot do `groupBy: VolumeGroupSnapshot` (the \
87 default). Fix: use `copyMethod: Snapshot` for a consistency group, or set \
88 `groupBy: None`"
89 .to_string(),
90 });
91 }
92 if let Some(id) = &spec.identity {
98 if let Some(u) = &id.username
99 && let Err(e) = validate_identity_component("spec.identity.username", u)
100 {
101 errs.push(e);
102 }
103 if let Some(h) = &id.hostname
104 && let Err(e) = validate_identity_component("spec.identity.hostname", h)
105 {
106 errs.push(e);
107 }
108 }
109 for (i, source) in spec.sources.iter().enumerate() {
110 if let Some(p) = &source.source_path_override
111 && let Err(e) =
112 validate_source_path(&format!("spec.sources[{i}].sourcePathOverride"), p)
113 {
114 errs.push(e);
115 }
116 }
117 for (i, source) in spec.sources.iter().enumerate() {
127 if crate::snapshot_policy::source_mutates_live_volume(spec.copy_method, source)
128 && !source.acknowledge_live_mutation.unwrap_or(false)
129 {
130 errs.push(ValidationError::InvalidFieldValue {
131 field: format!("spec.sources[{i}].readOnly"),
132 reason: "copyMethod: Direct with readOnly: false lets the kubelet permanently \
133 change ownership of the live volume to the mover's fsGroup. Fix: use \
134 copyMethod: Snapshot/Clone, or set acknowledgeLiveMutation: true on \
135 this source if you mean it"
136 .to_string(),
137 });
138 }
139 }
140 if spec.volume_snapshot_class_name.is_some() && spec.sources.iter().any(|s| s.nfs.is_some()) {
147 errs.push(ValidationError::InvalidFieldValue {
148 field: "spec.volumeSnapshotClassName".to_string(),
149 reason: "does not apply to an NFS source, which cannot be CSI-snapshotted. Fix: \
150 remove volumeSnapshotClassName"
151 .to_string(),
152 });
153 }
154 if spec.volume_snapshot_class_name.is_some() && spec.sources.iter().any(|s| s.stream.is_some())
156 {
157 errs.push(ValidationError::InvalidFieldValue {
158 field: "spec.volumeSnapshotClassName".to_string(),
159 reason: "does not apply to a stream source, which mounts no volume. Fix: remove \
160 volumeSnapshotClassName"
161 .to_string(),
162 });
163 }
164 if spec.sources.len() > 1
170 && let Some(i) = spec.sources.iter().position(|s| s.stream.is_some())
171 {
172 errs.push(ValidationError::InvalidFieldValue {
173 field: format!("spec.sources[{i}].stream"),
174 reason: format!(
175 "a stream source must be the only source in its SnapshotPolicy, but this one \
176 has {}. Fix: move the stream source into its own SnapshotPolicy",
177 spec.sources.len()
178 ),
179 });
180 }
181 if let Some(m) = &spec.mover {
182 if let Err(e) = forbid_snapshot_inherit(
185 m,
186 "snapshotPolicy",
187 "is restore-only. Fix: use pvcConsumer or workloadSelector for a backup",
188 ) {
189 errs.push(e);
190 }
191 if let Err(e) = validate_mover(m, "SnapshotPolicy mover") {
192 errs.push(e);
193 }
194 }
195 if let Some(u) = &spec.upload
198 && let Some(mb) = u.limit_mb
199 && let Some(e) = require_min(
200 "SnapshotPolicy spec.upload.limitMb",
201 mb,
202 NumericBound::Megabytes,
203 )
204 {
205 errs.push(e);
206 }
207 if let Some(r) = &spec.retention
211 && retention_keeps_nothing(r)
212 {
213 errs.push(ValidationError::InvalidFieldValue {
214 field: "spec.retention".to_string(),
215 reason: "keeps no snapshots (every keep* is unset or 0), so every Snapshot would be \
216 pruned. Fix: set at least one (e.g. keepLatest: 1), or omit spec.retention \
217 to disable pruning"
218 .to_string(),
219 });
220 }
221 if let Some(v) = &spec.verification {
225 if let Some(q) = &v.quick {
226 match &q.schedule {
231 None => errs.push(ValidationError::InvalidFieldValue {
232 field: "spec.verification.quick.schedule".to_string(),
233 reason: "`verification.quick.cron` moved to \
234 `verification.quick.schedule.cron`. Fix: move your \
235 cron/jitter/timezone fields under `schedule:`"
236 .to_string(),
237 }),
238 Some(s) => {
239 if let Err(e) = validate_cron(&s.cron) {
240 errs.push(e);
241 }
242 if let Err(e) = validate_timezone(s.timezone.as_deref()) {
243 errs.push(e);
244 }
245 if let Err(e) = validate_jitter(
246 "spec.verification.quick.schedule.jitter",
247 s.jitter.as_deref(),
248 ) {
249 errs.push(e);
250 }
251 }
252 }
253 if let Some(p) = q.parallel
257 && let Some(e) = require_min(
258 "SnapshotPolicy spec.verification.quick.parallel",
259 p.into(),
260 NumericBound::Count,
261 )
262 {
263 errs.push(e);
264 }
265 if let Some(p) = q.file_parallelism
266 && let Some(e) = require_min(
267 "SnapshotPolicy spec.verification.quick.fileParallelism",
268 p.into(),
269 NumericBound::Count,
270 )
271 {
272 errs.push(e);
273 }
274 if let Some(p) = q.file_queue_length
275 && let Some(e) = require_min(
276 "SnapshotPolicy spec.verification.quick.fileQueueLength",
277 p.into(),
278 NumericBound::Count,
279 )
280 {
281 errs.push(e);
282 }
283 }
284 if let Some(d) = &v.deep {
285 if let Err(e) = validate_cron(&d.schedule.cron) {
286 errs.push(e);
287 }
288 if let Err(e) = validate_timezone(d.schedule.timezone.as_deref()) {
289 errs.push(e);
290 }
291 if let Err(e) = validate_jitter(
292 "spec.verification.deep.schedule.jitter",
293 d.schedule.jitter.as_deref(),
294 ) {
295 errs.push(e);
296 }
297 if let Some(p) = d.parallel
299 && let Some(e) = require_min(
300 "SnapshotPolicy spec.verification.deep.parallel",
301 p.into(),
302 NumericBound::Count,
303 )
304 {
305 errs.push(e);
306 }
307 }
308 if let Some(expr) = &v.success_expr
309 && let Err(e) = crate::success_expr::validate_success_expr(expr)
310 {
311 errs.push(e);
312 }
313 }
314 errs.extend(validate_staging(spec));
315 if let Some(pf) = &spec.preflight {
319 if let Some(t) = &pf.timeout
320 && crate::duration::parse_go_duration(t).is_none()
321 {
322 errs.push(ValidationError::InvalidFieldValue {
323 field: "spec.preflight.timeout".to_string(),
324 reason: format!(
325 "{t:?} is not a valid duration. Fix: use a Go-style duration like 10m or 1h; \
326 omit for the default (10m), or 0 to wait forever"
327 ),
328 });
329 }
330 let mut seen = std::collections::BTreeSet::new();
331 for (i, c) in pf.checks.iter().enumerate() {
332 let name = c.name.trim();
333 if name.is_empty() {
334 errs.push(ValidationError::MissingRequiredField {
335 field: format!("spec.preflight.checks[{i}].name"),
336 });
337 } else if !seen.insert(name.to_string()) {
338 errs.push(ValidationError::InvalidFieldValue {
339 field: format!("spec.preflight.checks[{i}].name"),
340 reason: format!(
341 "duplicate preflight check name {name:?}; names must be unique"
342 ),
343 });
344 }
345 if let Err(e) = crate::preflight::validate_preflight_expr(&c.expr) {
346 errs.push(e);
347 }
348 }
349 }
350 if let Some(h) = &spec.hooks {
354 for (list, hooks) in [
355 ("beforeSnapshot", &h.before_snapshot),
356 ("afterSnapshot", &h.after_snapshot),
357 ] {
358 for (i, hook) in hooks.iter().enumerate() {
359 if let Err(e) = validate_hook(list, i, hook) {
360 errs.push(e);
361 }
362 }
363 }
364 }
365 errs
366}
367
368fn validate_staging(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
381 let mut errs = Vec::new();
382 let Some(st) = &spec.staging else {
383 return errs;
384 };
385 if let Some(t) = &st.timeout
386 && crate::duration::parse_go_duration(t).is_none()
387 {
388 errs.push(ValidationError::InvalidFieldValue {
389 field: "spec.staging.timeout".to_string(),
390 reason: format!(
391 "{t:?} is not a valid duration. Fix: use a Go-style duration like 10m or 1h; \
392 omit for the default (10m), or 0 to wait forever"
393 ),
394 });
395 }
396 errs.extend(validate_access_modes(
397 "spec.staging.accessModes",
398 &st.access_modes,
399 ));
400 if st.access_modes.contains(&PvcAccessMode::ReadOnlyMany)
403 && let Some(i) = spec
404 .sources
405 .iter()
406 .position(|s| !crate::snapshot_policy::source_read_only(s))
407 {
408 errs.push(ValidationError::InvalidFieldValue {
409 field: format!("spec.sources[{i}].readOnly"),
410 reason: "readOnly: false cannot work when spec.staging.accessModes is \
411 [ReadOnlyMany], so the backup would never start. Fix: drop ReadOnlyMany, \
412 or drop readOnly: false"
413 .to_string(),
414 });
415 }
416 let overrides: Vec<&str> = [
417 (
418 "spec.staging.storageClassName",
419 st.storage_class_name.is_some(),
420 ),
421 ("spec.staging.accessModes", !st.access_modes.is_empty()),
422 ]
423 .into_iter()
424 .filter_map(|(name, present)| present.then_some(name))
425 .collect();
426 if overrides.is_empty() {
427 return errs;
428 }
429 let overrides = overrides.join(" / ");
430 match spec.copy_method {
431 CopyMethod::Direct => errs.push(ValidationError::InvalidFieldValue {
432 field: overrides.clone(),
433 reason: "copyMethod: Direct has no staged PVC to override. Fix: remove the \
434 override(s), or use copyMethod: Snapshot/Clone"
435 .to_string(),
436 }),
437 CopyMethod::Snapshot | CopyMethod::Clone => {}
438 }
439 if spec.sources.iter().any(|s| s.nfs.is_some()) {
440 errs.push(ValidationError::InvalidFieldValue {
441 field: overrides.clone(),
442 reason: "an NFS source is never staged, so there is no staged PVC to override. \
443 Fix: remove the override(s)"
444 .to_string(),
445 });
446 }
447 if spec.sources.iter().any(|s| s.stream.is_some()) {
448 errs.push(ValidationError::InvalidFieldValue {
449 field: overrides.clone(),
450 reason: "a stream source mounts no volume, so there is no staged PVC to override. \
451 Fix: remove the override(s)"
452 .to_string(),
453 });
454 }
455 let _ = overrides;
461 errs
462}
463
464fn validate_hook(list: &str, index: usize, hook: &Hook) -> ValidationResult {
468 let field = |leaf: &str| format!("spec.hooks.{list}[{index}].{leaf}");
469 let check_timeout = |leaf: &str, t: Option<&str>| -> ValidationResult {
470 if let Some(t) = t
471 && crate::duration::parse_go_duration(t).is_none()
472 {
473 return Err(ValidationError::InvalidFieldValue {
474 field: field(leaf),
475 reason: format!(
476 "{t:?} is not a valid duration. Fix: use a Go-style duration like 90s or 2m"
477 ),
478 });
479 }
480 Ok(())
481 };
482 match hook {
483 Hook::WorkloadExec(h) => {
484 if h.command.is_empty() {
485 return Err(ValidationError::MissingRequiredField {
486 field: field("workloadExec.command"),
487 });
488 }
489 check_timeout("workloadExec.timeout", h.timeout.as_deref())
490 }
491 Hook::RunJob(h) => check_timeout("runJob.timeout", h.timeout.as_deref()),
492 Hook::HttpRequest(h) => {
493 if !(h.url.starts_with("http://") || h.url.starts_with("https://")) {
494 return Err(ValidationError::InvalidFieldValue {
495 field: field("httpRequest.url"),
496 reason: format!(
497 "{:?} must be an absolute http:// or https:// URL the controller can \
498 reach (e.g. http://notifier.tools.svc:8080/fire)",
499 h.url
500 ),
501 });
502 }
503 if let Some(m) = &h.method {
504 const METHODS: [&str; 7] =
505 ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
506 if !METHODS.contains(&m.to_ascii_uppercase().as_str()) {
507 return Err(ValidationError::InvalidFieldValue {
508 field: field("httpRequest.method"),
509 reason: format!(
510 "{m:?} is not an HTTP method; use one of GET, POST (default), PUT, \
511 PATCH, DELETE, HEAD, OPTIONS"
512 ),
513 });
514 }
515 }
516 if let Some(e) = validate_http_hook_headers(h, list, index) {
517 return Err(e);
518 }
519 check_timeout("httpRequest.timeout", h.timeout.as_deref())
520 }
521 }
522}
523
524const MAX_HEADER_NAME_LEN: usize = 65_535;
529
530fn is_valid_header_name(name: &str) -> bool {
534 !name.is_empty()
535 && name.bytes().all(|b| {
536 matches!(b,
537 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
538 | b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+'
539 | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
540 })
541}
542
543fn header_name_error(name: &str, list: &str, i: usize, j: usize) -> Option<ValidationError> {
550 if !is_valid_header_name(name) {
551 return Some(ValidationError::InvalidFieldValue {
552 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
553 reason: format!(
554 "{name:?} is not a valid HTTP header name. Fix: use only letters, digits, and \
555 !#$%&'*+-.^_`|~ (no spaces)"
556 ),
557 });
558 }
559 if name.len() > MAX_HEADER_NAME_LEN {
560 return Some(ValidationError::InvalidFieldValue {
561 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
562 reason: format!(
563 "header name is {} bytes; the maximum is {MAX_HEADER_NAME_LEN}",
564 name.len()
565 ),
566 });
567 }
568 None
569}
570
571fn is_valid_header_value(value: &str) -> bool {
574 value
575 .bytes()
576 .all(|b| b == b'\t' || (b >= 0x20 && b != 0x7f))
577}
578
579fn url_has_userinfo(url: &str) -> bool {
581 let rest = url.split_once("://").map_or(url, |(_, r)| r);
582 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
583 authority.contains('@')
584}
585
586fn validate_http_hook_headers(
593 h: &HttpRequestHook,
594 list: &str,
595 i: usize,
596) -> Option<ValidationError> {
597 let mut seen: Vec<String> = Vec::new();
598 for (j, header) in h.headers.iter().enumerate() {
599 if let Some(e) = header_name_error(&header.name, list, i, j) {
600 return Some(e);
601 }
602 if !is_valid_header_value(&header.value) {
603 return Some(ValidationError::InvalidFieldValue {
604 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].value"),
605 reason: "header values cannot contain control characters (including CR/LF). \
606 Fix: put multi-line data in `body`"
607 .into(),
608 });
609 }
610 let lower = header.name.to_ascii_lowercase();
611 if seen.contains(&lower) {
612 return Some(ValidationError::InvalidFieldValue {
613 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
614 reason: format!(
615 "duplicate header {:?}. Fix: set it once, with comma-separated values if \
616 needed",
617 header.name
618 ),
619 });
620 }
621 seen.push(lower);
622 }
623 if url_has_userinfo(&h.url)
624 && h.headers
625 .iter()
626 .any(|hd| hd.name.eq_ignore_ascii_case("authorization"))
627 {
628 return Some(ValidationError::InvalidFieldValue {
629 field: format!("spec.hooks.{list}[{i}].httpRequest.headers"),
630 reason: "an Authorization header conflicts with credentials in the URL \
631 (user:pass@…). Fix: use one or the other"
632 .into(),
633 });
634 }
635 None
636}
637
638pub fn validate_backup(spec: &SnapshotSpec, origin: Option<Origin>) -> Vec<ValidationError> {
651 let mut errs = Vec::new();
652 if let Some(origin) = origin {
653 if let Err(e) = validate_backup_deletion_policy(origin, spec.deletion_policy) {
654 errs.push(e);
655 }
656 if let Err(e) = validate_backup_on_schedule_delete(origin, spec.on_schedule_delete) {
657 errs.push(e);
658 }
659 }
660 if let Some(fp) = &spec.failure_policy
661 && let Err(e) = validate_failure_policy(fp, "Snapshot")
662 {
663 errs.push(e);
664 }
665 errs.extend(validate_snapshot_tags(spec.tags.as_ref()));
666 if let Some(m) = &spec.mover {
667 if let Err(e) = forbid_snapshot_inherit(
675 m,
676 "snapshot",
677 "is restore-only. Fix: use pvcConsumer or workloadSelector for a backup",
678 ) {
679 errs.push(e);
680 }
681 if let Err(e) = validate_mover(m, "Snapshot mover") {
682 errs.push(e);
683 }
684 }
693 errs
694}
695
696pub const MAX_SNAPSHOT_TAGS: usize = 10;
699pub const MAX_SNAPSHOT_TAG_KEY_LEN: usize = 63;
701pub const MAX_SNAPSHOT_TAG_VALUE_LEN: usize = 256;
703
704pub fn snapshot_tag_error(key: &str, value: &str) -> Option<String> {
711 if key.is_empty() {
712 return Some("tag keys must be non-empty; remove the empty key".to_string());
713 }
714 if key.contains(':') {
715 return Some(format!(
716 "tag key {key:?} contains a colon. Fix: use a key without `:`"
717 ));
718 }
719 if key.starts_with("kopiur") {
720 return Some(format!(
721 "tag key {key:?} uses the reserved `kopiur` prefix. Fix: use a key that does not \
722 start with `kopiur`"
723 ));
724 }
725 if key.len() > MAX_SNAPSHOT_TAG_KEY_LEN {
726 return Some(format!(
727 "tag key is {} bytes; the maximum is {MAX_SNAPSHOT_TAG_KEY_LEN}",
728 key.len()
729 ));
730 }
731 if value.len() > MAX_SNAPSHOT_TAG_VALUE_LEN {
732 return Some(format!(
733 "tag value is {} bytes; the maximum is {MAX_SNAPSHOT_TAG_VALUE_LEN}",
734 value.len()
735 ));
736 }
737 None
738}
739
740pub fn validate_snapshot_tags(
744 tags: Option<&std::collections::BTreeMap<String, String>>,
745) -> Vec<ValidationError> {
746 let mut errs = Vec::new();
747 let Some(tags) = tags else {
748 return errs;
749 };
750 if tags.len() > MAX_SNAPSHOT_TAGS {
751 errs.push(ValidationError::InvalidFieldValue {
752 field: "spec.tags".to_string(),
753 reason: format!(
754 "{} tags; at most {MAX_SNAPSHOT_TAGS} are allowed per Snapshot",
755 tags.len()
756 ),
757 });
758 }
759 for (key, value) in tags {
760 if let Some(reason) = snapshot_tag_error(key, value) {
761 errs.push(ValidationError::InvalidFieldValue {
762 field: format!("spec.tags[{key:?}]"),
763 reason,
764 });
765 }
766 }
767 errs
768}
769
770pub fn validate_backup_on_schedule_delete(
778 origin: Origin,
779 value: Option<ScheduleDeletePolicy>,
780) -> ValidationResult {
781 match origin {
782 Origin::Discovered | Origin::Adopted | Origin::Replicated => match value {
783 None => Ok(()),
784 Some(v) => Err(ValidationError::DiscoveredCannotSetOnScheduleDelete {
785 origin: origin.label_value(),
786 got: format!("{v:?}"),
787 }),
788 },
789 Origin::Scheduled | Origin::Manual => Ok(()),
790 }
791}
792
793pub fn validate_schedule_policy_target(spec: &SnapshotScheduleSpec) -> ValidationResult {
797 match (spec.policy_ref.is_some(), spec.policy_selector.is_some()) {
798 (true, true) => Err(ValidationError::MutuallyExclusive {
799 a: "policyRef".to_string(),
800 b: "policySelector".to_string(),
801 context: "SnapshotSchedule".to_string(),
802 }),
803 (false, false) => Err(ValidationError::MissingRequiredField {
804 field: "exactly one of spec.policyRef or spec.policySelector".to_string(),
805 }),
806 _ => Ok(()),
807 }
808}
809
810pub fn validate_backup_schedule(spec: &SnapshotScheduleSpec) -> Vec<ValidationError> {
812 let mut errs = Vec::new();
813 if let Err(e) = validate_schedule_policy_target(spec) {
814 errs.push(e);
815 }
816 if let Err(e) = validate_cron(&spec.schedule.cron) {
817 errs.push(e);
818 }
819 if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
820 errs.push(e);
821 }
822 if let Err(e) = validate_jitter("spec.schedule.jitter", spec.schedule.jitter.as_deref()) {
823 errs.push(e);
824 }
825 errs
826}