1#![allow(clippy::manual_unwrap_or_default)]
3use darling::{FromDeriveInput, FromMeta};
4use proc_macro2::{Ident, Literal, Span, TokenStream};
5use quote::{ToTokens, TokenStreamExt as _};
6use syn::{parse_quote, Data, DeriveInput, Expr, Path, Visibility};
7
8#[derive(Debug, FromDeriveInput)]
10#[darling(attributes(kube))]
11struct KubeAttrs {
12 group: String,
13 version: String,
14 kind: String,
15 doc: Option<String>,
16 #[darling(rename = "root")]
17 kind_struct: Option<String>,
18 plural: Option<String>,
20 singular: Option<String>,
22 #[darling(default)]
23 namespaced: bool,
24 #[darling(multiple, rename = "derive")]
25 derives: Vec<String>,
26 schema: Option<SchemaMode>,
27 status: Option<String>,
28 #[darling(multiple, rename = "category")]
29 categories: Vec<String>,
30 #[darling(multiple, rename = "shortname")]
31 shortnames: Vec<String>,
32 #[darling(multiple, rename = "printcolumn")]
33 printcolums: Vec<String>,
34 #[darling(multiple)]
35 selectable: Vec<String>,
36 scale: Option<String>,
37 #[darling(default)]
38 crates: Crates,
39 #[darling(multiple, rename = "annotation")]
40 annotations: Vec<KVTuple>,
41 #[darling(multiple, rename = "label")]
42 labels: Vec<KVTuple>,
43 #[darling(multiple, rename = "rule")]
44 rules: Vec<Expr>,
45
46 #[darling(default = default_storage_arg)]
50 storage: bool,
51
52 #[darling(default = default_served_arg)]
56 served: bool,
57}
58
59#[derive(Debug)]
60struct KVTuple(String, String);
61
62impl FromMeta for KVTuple {
63 fn from_list(items: &[darling::ast::NestedMeta]) -> darling::Result<Self> {
64 if items.len() == 2 {
65 if let (
66 darling::ast::NestedMeta::Lit(syn::Lit::Str(key)),
67 darling::ast::NestedMeta::Lit(syn::Lit::Str(value)),
68 ) = (&items[0], &items[1])
69 {
70 return Ok(KVTuple(key.value(), value.value()));
71 }
72 }
73
74 Err(darling::Error::unsupported_format(
75 "expected `\"key\", \"value\"` format",
76 ))
77 }
78}
79
80impl From<(&'static str, &'static str)> for KVTuple {
81 fn from((key, value): (&'static str, &'static str)) -> Self {
82 Self(key.to_string(), value.to_string())
83 }
84}
85
86impl ToTokens for KVTuple {
87 fn to_tokens(&self, tokens: &mut TokenStream) {
88 let (k, v) = (&self.0, &self.1);
89 tokens.append_all(quote! { (#k, #v) });
90 }
91}
92
93fn default_storage_arg() -> bool {
94 true
96}
97
98fn default_served_arg() -> bool {
99 true
101}
102
103#[derive(Debug, FromMeta)]
104struct Crates {
105 #[darling(default = "Self::default_kube")]
106 kube: Path,
107 #[darling(default = "Self::default_kube_core")]
108 kube_core: Path,
109 #[darling(default = "Self::default_k8s_openapi")]
110 k8s_openapi: Path,
111 #[darling(default = "Self::default_schemars")]
112 schemars: Path,
113 #[darling(default = "Self::default_serde")]
114 serde: Path,
115 #[darling(default = "Self::default_serde_json")]
116 serde_json: Path,
117 #[darling(default = "Self::default_std")]
118 std: Path,
119}
120
121impl Default for Crates {
124 fn default() -> Self {
125 Self::from_list(&[]).unwrap()
126 }
127}
128
129impl Crates {
130 fn default_kube_core() -> Path {
131 parse_quote! { ::kube::core } }
133
134 fn default_kube() -> Path {
135 parse_quote! { ::kube }
136 }
137
138 fn default_k8s_openapi() -> Path {
139 parse_quote! { ::k8s_openapi }
140 }
141
142 fn default_schemars() -> Path {
143 parse_quote! { ::schemars }
144 }
145
146 fn default_serde() -> Path {
147 parse_quote! { ::serde }
148 }
149
150 fn default_serde_json() -> Path {
151 parse_quote! { ::serde_json }
152 }
153
154 fn default_std() -> Path {
155 parse_quote! { ::std }
156 }
157}
158
159#[derive(Debug, PartialEq, Eq, Clone, Copy)]
160enum SchemaMode {
161 Disabled,
162 Manual,
163 Derived,
164}
165
166impl SchemaMode {
167 fn derive(self) -> bool {
168 match self {
169 SchemaMode::Disabled => false,
170 SchemaMode::Manual => false,
171 SchemaMode::Derived => true,
172 }
173 }
174
175 fn use_in_crd(self) -> bool {
176 match self {
177 SchemaMode::Disabled => false,
178 SchemaMode::Manual => true,
179 SchemaMode::Derived => true,
180 }
181 }
182}
183
184impl FromMeta for SchemaMode {
185 fn from_string(value: &str) -> darling::Result<Self> {
186 match value {
187 "disabled" => Ok(SchemaMode::Disabled),
188 "manual" => Ok(SchemaMode::Manual),
189 "derived" => Ok(SchemaMode::Derived),
190 x => Err(darling::Error::unknown_value(x)),
191 }
192 }
193}
194
195pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
196 let derive_input: DeriveInput = match syn::parse2(input) {
197 Err(err) => return err.to_compile_error(),
198 Ok(di) => di,
199 };
200 match derive_input.data {
202 Data::Struct(_) | Data::Enum(_) => {}
203 _ => {
204 return syn::Error::new_spanned(
205 &derive_input.ident,
206 r#"Unions can not #[derive(CustomResource)]"#,
207 )
208 .to_compile_error()
209 }
210 }
211
212 let kube_attrs = match KubeAttrs::from_derive_input(&derive_input) {
213 Err(err) => return err.write_errors(),
214 Ok(attrs) => attrs,
215 };
216
217 let KubeAttrs {
218 group,
219 kind,
220 kind_struct,
221 version,
222 doc,
223 namespaced,
224 derives,
225 schema: schema_mode,
226 status,
227 plural,
228 singular,
229 categories,
230 shortnames,
231 printcolums,
232 selectable,
233 scale,
234 rules,
235 storage,
236 served,
237 crates:
238 Crates {
239 kube_core,
240 kube,
241 k8s_openapi,
242 schemars,
243 serde,
244 serde_json,
245 std,
246 },
247 annotations,
248 labels,
249 } = kube_attrs;
250
251 let struct_name = kind_struct.unwrap_or_else(|| kind.clone());
252 if derive_input.ident == struct_name {
253 return syn::Error::new_spanned(
254 derive_input.ident,
255 r#"#[derive(CustomResource)] `kind = "..."` must not equal the struct name (this is generated)"#,
256 )
257 .to_compile_error();
258 }
259 let visibility = derive_input.vis;
260 let ident = derive_input.ident;
261
262 let rootident = Ident::new(&struct_name, Span::call_site());
269 let rootident_str = rootident.to_string();
270
271 let StatusInformation {
273 field: status_field,
274 default: status_default,
275 impl_hasstatus,
276 } = process_status(&rootident, &status, &visibility, &kube_core);
277 let has_status = status.is_some();
278 let serialize_status = if has_status {
279 quote! {
280 if let Some(status) = &self.status {
281 obj.serialize_field("status", &status)?;
282 }
283 }
284 } else {
285 quote! {}
286 };
287 let has_status_value = if has_status {
288 quote! { self.status.is_some() }
289 } else {
290 quote! { false }
291 };
292
293 let mut derive_paths: Vec<Path> = vec![
294 syn::parse_quote! { #serde::Deserialize },
295 syn::parse_quote! { Clone },
296 syn::parse_quote! { Debug },
297 ];
298 let mut has_default = false;
299 for d in &derives {
300 if d == "Default" {
301 has_default = true; } else {
303 match syn::parse_str(d) {
304 Err(err) => return err.to_compile_error(),
305 Ok(d) => derive_paths.push(d),
306 }
307 }
308 }
309
310 let schema_mode = schema_mode.unwrap_or(SchemaMode::Derived);
312 let schemars_skip = schema_mode.derive().then_some(quote! { #[schemars(skip)] });
316 if schema_mode.derive() && !rules.is_empty() {
317 derive_paths.push(syn::parse_quote! { #kube::CELSchema });
318 } else if schema_mode.derive() {
319 derive_paths.push(syn::parse_quote! { #schemars::JsonSchema });
320 }
321
322 let struct_rules: Option<Vec<TokenStream>> =
323 (!rules.is_empty()).then(|| rules.iter().map(|r| quote! {rule = #r,}).collect());
324 let struct_rules = struct_rules.map(|r| quote! { #[cel_validate(#(#r)*)]});
325
326 let meta_annotations = if !annotations.is_empty() {
327 quote! { Some(std::collections::BTreeMap::from([#((#annotations.0.to_string(), #annotations.1.to_string()),)*])) }
328 } else {
329 quote! { None }
330 };
331
332 let meta_labels = if !labels.is_empty() {
333 quote! { Some(std::collections::BTreeMap::from([#((#labels.0.to_string(), #labels.1.to_string()),)*])) }
334 } else {
335 quote! { None }
336 };
337
338 let docstr =
339 doc.unwrap_or_else(|| format!(" Auto-generated derived type for {ident} via `CustomResource`"));
340 let quoted_serde = Literal::string(&serde.to_token_stream().to_string());
341 let root_obj = quote! {
342 #[doc = #docstr]
343 #[automatically_derived]
344 #[allow(missing_docs)]
345 #[derive(#(#derive_paths),*)]
346 #[serde(rename_all = "camelCase")]
347 #[serde(crate = #quoted_serde)]
348 #struct_rules
349 #visibility struct #rootident {
350 #schemars_skip
351 #visibility metadata: #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta,
352 #visibility spec: #ident,
353 #status_field
354 }
355 impl #rootident {
356 pub fn new(name: &str, spec: #ident) -> Self {
358 Self {
359 metadata: #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
360 annotations: #meta_annotations,
361 labels: #meta_labels,
362 name: Some(name.to_string()),
363 ..Default::default()
364 },
365 spec: spec,
366 #status_default
367 }
368 }
369 }
370 impl #serde::Serialize for #rootident {
371 fn serialize<S: #serde::Serializer>(&self, ser: S) -> #std::result::Result<S::Ok, S::Error> {
372 use #serde::ser::SerializeStruct;
373 let mut obj = ser.serialize_struct(#rootident_str, 4 + usize::from(#has_status_value))?;
374 obj.serialize_field("apiVersion", &<#rootident as #kube_core::Resource>::api_version(&()))?;
375 obj.serialize_field("kind", &<#rootident as #kube_core::Resource>::kind(&()))?;
376 obj.serialize_field("metadata", &self.metadata)?;
377 obj.serialize_field("spec", &self.spec)?;
378 #serialize_status
379 obj.end()
380 }
381 }
382 };
383
384 let name = singular.unwrap_or_else(|| kind.to_ascii_lowercase());
386 let plural = plural.unwrap_or_else(|| to_plural(&name));
387 let (scope, scope_quote) = if namespaced {
388 ("Namespaced", quote! { #kube_core::NamespaceResourceScope })
389 } else {
390 ("Cluster", quote! { #kube_core::ClusterResourceScope })
391 };
392
393 let api_ver = format!("{group}/{version}");
394 let impl_resource = quote! {
395 impl #kube_core::Resource for #rootident {
396 type DynamicType = ();
397 type Scope = #scope_quote;
398
399 fn group(_: &()) -> std::borrow::Cow<'_, str> {
400 #group.into()
401 }
402
403 fn kind(_: &()) -> std::borrow::Cow<'_, str> {
404 #kind.into()
405 }
406
407 fn version(_: &()) -> std::borrow::Cow<'_, str> {
408 #version.into()
409 }
410
411 fn api_version(_: &()) -> std::borrow::Cow<'_, str> {
412 #api_ver.into()
413 }
414
415 fn plural(_: &()) -> std::borrow::Cow<'_, str> {
416 #plural.into()
417 }
418
419 fn meta(&self) -> &#k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
420 &self.metadata
421 }
422
423 fn meta_mut(&mut self) -> &mut #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
424 &mut self.metadata
425 }
426 }
427 };
428
429 let impl_default = if has_default {
431 quote! {
432 impl Default for #rootident {
433 fn default() -> Self {
434 Self {
435 metadata: #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta::default(),
436 spec: Default::default(),
437 #status_default
438 }
439 }
440 }
441 }
442 } else {
443 quote! {}
444 };
445
446 let printers = format!("[ {} ]", printcolums.join(",")); let fields: Vec<String> = selectable
451 .iter()
452 .map(|s| format!(r#"{{ "jsonPath": "{s}" }}"#))
453 .collect();
454 let fields = format!("[ {} ]", fields.join(","));
455 let scale_code = if let Some(s) = scale { s } else { "".to_string() };
456
457 let apiext = quote! {
459 #k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1
460 };
461 let extver = quote! {
462 #kube_core::crd::v1
463 };
464
465 let shortnames_slice = {
466 let names = shortnames
467 .iter()
468 .map(|name| quote! { #name, })
469 .collect::<TokenStream>();
470 quote! { &[#names] }
471 };
472
473 let categories_json = serde_json::to_string(&categories).unwrap();
474 let short_json = serde_json::to_string(&shortnames).unwrap();
475 let crd_meta_name = format!("{plural}.{group}");
476
477 let mut crd_meta = TokenStream::new();
478 crd_meta.extend(quote! { "name": #crd_meta_name });
479
480 if !annotations.is_empty() {
481 crd_meta.extend(quote! { , "annotations": #meta_annotations });
482 }
483
484 if !labels.is_empty() {
485 crd_meta.extend(quote! { , "labels": #meta_labels });
486 }
487
488 let schemagen = if schema_mode.use_in_crd() {
489 quote! {
490 let gen = #schemars::gen::SchemaSettings::openapi3()
492 .with(|s| {
493 s.inline_subschemas = true;
494 s.meta_schema = None;
495 })
496 .with_visitor(#kube_core::schema::StructuralSchemaRewriter)
497 .into_generator();
498 let schema = gen.into_root_schema_for::<Self>();
499 }
500 } else {
501 quote! {
505 let schema: Option<#k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::JSONSchemaProps> = None;
506 }
507 };
508
509 let selectable = if !selectable.is_empty() {
510 quote! { "selectableFields": fields, }
511 } else {
512 quote! {}
513 };
514
515 let compile_constraints = if !selectable.is_empty() {
517 quote! {
518 #k8s_openapi::k8s_if_le_1_29! {
519 compile_error!("selectable fields require Kubernetes >= 1.30");
520 }
521 }
522 } else {
523 quote! {}
524 };
525
526 let jsondata = quote! {
527 #schemagen
528
529 let jsondata = #serde_json::json!({
530 "metadata": {
531 #crd_meta
532 },
533 "spec": {
534 "group": #group,
535 "scope": #scope,
536 "names": {
537 "categories": categories,
538 "plural": #plural,
539 "singular": #name,
540 "kind": #kind,
541 "shortNames": shorts
542 },
543 "versions": [{
544 "name": #version,
545 "served": #served,
546 "storage": #storage,
547 "schema": {
548 "openAPIV3Schema": schema,
549 },
550 "additionalPrinterColumns": columns,
551 #selectable
552 "subresources": subres,
553 }],
554 }
555 });
556 };
557
558 let impl_crd = quote! {
560 impl #extver::CustomResourceExt for #rootident {
561
562 fn crd() -> #apiext::CustomResourceDefinition {
563 let columns : Vec<#apiext::CustomResourceColumnDefinition> = #serde_json::from_str(#printers).expect("valid printer column json");
564 #k8s_openapi::k8s_if_ge_1_30! {
565 let fields : Vec<#apiext::SelectableField> = #serde_json::from_str(#fields).expect("valid selectableField column json");
566 }
567 let scale: Option<#apiext::CustomResourceSubresourceScale> = if #scale_code.is_empty() {
568 None
569 } else {
570 #serde_json::from_str(#scale_code).expect("valid scale subresource json")
571 };
572 let categories: Vec<String> = #serde_json::from_str(#categories_json).expect("valid categories");
573 let shorts : Vec<String> = #serde_json::from_str(#short_json).expect("valid shortnames");
574 let subres = if #has_status {
575 if let Some(s) = &scale {
576 #serde_json::json!({
577 "status": {},
578 "scale": scale
579 })
580 } else {
581 #serde_json::json!({"status": {} })
582 }
583 } else {
584 #serde_json::json!({})
585 };
586
587 #jsondata
588 #serde_json::from_value(jsondata)
589 .expect("valid custom resource from #[kube(attrs..)]")
590 }
591
592 fn crd_name() -> &'static str {
593 #crd_meta_name
594 }
595
596 fn api_resource() -> #kube_core::dynamic::ApiResource {
597 #kube_core::dynamic::ApiResource::erase::<Self>(&())
598 }
599
600 fn shortnames() -> &'static [&'static str] {
601 #shortnames_slice
602 }
603 }
604 };
605
606 let impl_hasspec = generate_hasspec(&ident, &rootident, &kube_core);
607
608 quote! {
610 #compile_constraints
611 #root_obj
612 #impl_resource
613 #impl_default
614 #impl_crd
615 #impl_hasspec
616 #impl_hasstatus
617 }
618}
619
620fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) -> TokenStream {
630 quote! {
631 impl #kube_core::object::HasSpec for #root_ident {
632 type Spec = #spec_ident;
633
634 fn spec(&self) -> &#spec_ident {
635 &self.spec
636 }
637
638 fn spec_mut(&mut self) -> &mut #spec_ident {
639 &mut self.spec
640 }
641 }
642 }
643}
644
645struct StatusInformation {
646 field: TokenStream,
648 default: TokenStream,
650 impl_hasstatus: TokenStream,
652}
653
654fn process_status(
667 root_ident: &Ident,
668 status: &Option<String>,
669 visibility: &Visibility,
670 kube_core: &Path,
671) -> StatusInformation {
672 if let Some(status_name) = &status {
673 let ident = format_ident!("{}", status_name);
674 StatusInformation {
675 field: quote! {
676 #[serde(skip_serializing_if = "Option::is_none")]
677 #visibility status: Option<#ident>,
678 },
679 default: quote! { status: None, },
680 impl_hasstatus: quote! {
681 impl #kube_core::object::HasStatus for #root_ident {
682
683 type Status = #ident;
684
685 fn status(&self) -> Option<&#ident> {
686 self.status.as_ref()
687 }
688
689 fn status_mut(&mut self) -> &mut Option<#ident> {
690 &mut self.status
691 }
692 }
693 },
694 }
695 } else {
696 let empty_quote = quote! {};
697 StatusInformation {
698 field: empty_quote.clone(),
699 default: empty_quote.clone(),
700 impl_hasstatus: empty_quote,
701 }
702 }
703}
704
705fn to_plural(word: &str) -> String {
709 if word.ends_with('s')
711 || word.ends_with('x')
712 || word.ends_with('z')
713 || word.ends_with("ch")
714 || word.ends_with("sh")
715 {
716 return format!("{word}es");
717 }
718
719 if word.ends_with('y') {
722 if let Some(c) = word.chars().nth(word.len() - 2) {
723 if !matches!(c, 'a' | 'e' | 'i' | 'o' | 'u') {
724 let mut chars = word.chars();
726 chars.next_back();
727 return format!("{}ies", chars.as_str());
728 }
729 }
730 }
731
732 format!("{word}s")
734}
735
736#[cfg(test)]
737mod tests {
738 use std::{env, fs};
739
740 use super::*;
741
742 #[test]
743 fn test_parse_default() {
744 let input = quote! {
745 #[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)]
746 #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)]
747 struct FooSpec { foo: String }
748 };
749 let input = syn::parse2(input).unwrap();
750 let kube_attrs = KubeAttrs::from_derive_input(&input).unwrap();
751 assert_eq!(kube_attrs.group, "clux.dev".to_string());
752 assert_eq!(kube_attrs.version, "v1".to_string());
753 assert_eq!(kube_attrs.kind, "Foo".to_string());
754 assert!(kube_attrs.namespaced);
755 }
756
757 #[test]
758 fn test_derive_crd() {
759 let path = env::current_dir().unwrap().join("tests").join("crd_enum_test.rs");
760 let file = fs::File::open(path).unwrap();
761 runtime_macros::emulate_derive_macro_expansion(file, &[("CustomResource", derive)]).unwrap();
762
763 let path = env::current_dir()
764 .unwrap()
765 .join("tests")
766 .join("crd_schema_test.rs");
767 let file = fs::File::open(path).unwrap();
768 runtime_macros::emulate_derive_macro_expansion(file, &[("CustomResource", derive)]).unwrap();
769 }
770}