diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 7f020cbe..3d6bb796 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "docfx": { - "version": "2.75.2", + "version": "2.75.3", "commands": ["docfx"] } } diff --git a/.editorconfig b/.editorconfig index e207f021..f1a68021 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,401 +1,401 @@ -root = true - -# All files -[*] -indent_style = space -max_line_length = 120 - -# Xml files -[*.xml] -indent_size = 2 - -# C# files -[*.cs] - -#### Core EditorConfig Options #### - -# Indentation and spacing -indent_size = 4 -tab_width = 4 - -# New line preferences -end_of_line = crlf -insert_final_newline = true - -#### Custom Analyzer Rules #### -# Until https://github.com/SonarSource/sonar-dotnet/issues/7624 is shipped. -dotnet_diagnostic.S3604.severity = none - -dotnet_diagnostic.SA0001.severity = none -dotnet_diagnostic.SA1010.severity = none -dotnet_diagnostic.SA1600.severity = none -dotnet_diagnostic.CS1591.severity = none -dotnet_diagnostic.SA1008.severity = suggestion -dotnet_diagnostic.SA1009.severity = suggestion -dotnet_diagnostic.SA1101.severity = none -dotnet_diagnostic.SA1106.severity = suggestion -dotnet_diagnostic.SA1118.severity = none -dotnet_diagnostic.SX1101.severity = suggestion -dotnet_diagnostic.SA1309.severity = none -dotnet_diagnostic.SX1309.severity = warning -dotnet_diagnostic.SA1313.severity = suggestion -dotnet_diagnostic.SA1501.severity = suggestion -dotnet_diagnostic.SA1503.severity = suggestion -dotnet_diagnostic.SA1623.severity = none -dotnet_diagnostic.SA1633.severity = none -dotnet_diagnostic.SA1642.severity = none -dotnet_diagnostic.SA1643.severity = none -dotnet_diagnostic.SA1649.severity = none -dotnet_diagnostic.RCS1029.severity = none -dotnet_diagnostic.RCS1090.severity = none - -#### Custom Coding Conventions #### -resharper_csharp_space_in_singleline_accessorholder = true -resharper_csharp_space_between_accessors_in_singleline_property = true -resharper_csharp_space_in_singleline_method = true -resharper_csharp_space_in_singleline_anonymous_method = true -resharper_csharp_space_within_single_line_array_initializer_braces = true -resharper_csharp_trailing_comma_in_multiline_lists = true -resharper_csharp_place_type_attribute_on_same_line = false -resharper_csharp_place_attribute_on_same_line = false - -#### .NET Coding Conventions #### -[*.{cs,vb}] - -# Organize usings -dotnet_separate_import_directive_groups = true -dotnet_sort_system_directives_first = true -file_header_template = unset - -# this. and Me. preferences -dotnet_style_qualification_for_event = false:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_property = false:silent - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent - -# Expression-level preferences -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_object_initializer = true:suggestion -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion - -# Field preferences -dotnet_style_readonly_field = true:warning - -# Parameter preferences -dotnet_code_quality_unused_parameters = all:suggestion - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = none - -#### C# Coding Conventions #### -[*.cs] - -# var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent - -# Expression-bodied members -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_lambdas = true:suggestion -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion -csharp_style_prefer_pattern_matching = true:silent -csharp_style_prefer_switch_expression = true:suggestion - -# Null-checking preferences -csharp_style_conditional_delegate_call = true:suggestion - -# Modifier preferences -csharp_prefer_static_local_function = true:warning -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent - -# Code-block preferences -csharp_prefer_braces = true:silent -csharp_prefer_simple_using_statement = true:suggestion - -# Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_pattern_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_throw_expression = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion -csharp_style_unused_value_expression_statement_preference = discard_variable:silent - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:silent - -#### C# Formatting Rules #### - -# New line preferences -csharp_new_line_before_catch = true -csharp_new_line_before_else = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all -csharp_new_line_between_query_expression_clauses = true - -# Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true -csharp_indent_labels = one_less_than_current -csharp_indent_switch_labels = true - -# Space preferences -csharp_space_after_cast = false -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_after_comma = true -csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false -csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false -csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_between_square_brackets = false - -# Wrapping preferences -csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = true - -#### Naming styles #### -[*.{cs,vb}] - -# Naming rules - -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion -dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces -dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase - -dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion -dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters -dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase - -dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods -dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties -dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.events_should_be_pascalcase.symbols = events -dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion -dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables -dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase - -dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion -dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants -dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase - -dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion -dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters -dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase - -dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields -dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion -dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields -dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase - -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase - -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums -dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions -dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase - -# Symbol specifications - -dotnet_naming_symbols.interfaces.applicable_kinds = interface -dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interfaces.required_modifiers = - -dotnet_naming_symbols.enums.applicable_kinds = enum -dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.enums.required_modifiers = - -dotnet_naming_symbols.events.applicable_kinds = event -dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.events.required_modifiers = - -dotnet_naming_symbols.methods.applicable_kinds = method -dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.methods.required_modifiers = - -dotnet_naming_symbols.properties.applicable_kinds = property -dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.properties.required_modifiers = - -dotnet_naming_symbols.public_fields.applicable_kinds = field -dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_fields.required_modifiers = - -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_fields.required_modifiers = - -dotnet_naming_symbols.private_static_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_static_fields.required_modifiers = static - -dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum -dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types_and_namespaces.required_modifiers = - -dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method -dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = - -dotnet_naming_symbols.type_parameters.applicable_kinds = namespace -dotnet_naming_symbols.type_parameters.applicable_accessibilities = * -dotnet_naming_symbols.type_parameters.required_modifiers = - -dotnet_naming_symbols.private_constant_fields.applicable_kinds = field -dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_constant_fields.required_modifiers = const - -dotnet_naming_symbols.local_variables.applicable_kinds = local -dotnet_naming_symbols.local_variables.applicable_accessibilities = local -dotnet_naming_symbols.local_variables.required_modifiers = - -dotnet_naming_symbols.local_constants.applicable_kinds = local -dotnet_naming_symbols.local_constants.applicable_accessibilities = local -dotnet_naming_symbols.local_constants.required_modifiers = const - -dotnet_naming_symbols.parameters.applicable_kinds = parameter -dotnet_naming_symbols.parameters.applicable_accessibilities = * -dotnet_naming_symbols.parameters.required_modifiers = - -dotnet_naming_symbols.public_constant_fields.applicable_kinds = field -dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_constant_fields.required_modifiers = const - -dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static - -dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static - -dotnet_naming_symbols.local_functions.applicable_kinds = local_function -dotnet_naming_symbols.local_functions.applicable_accessibilities = * -dotnet_naming_symbols.local_functions.required_modifiers = - -# Naming styles - -dotnet_naming_style.pascalcase.required_prefix = -dotnet_naming_style.pascalcase.required_suffix = -dotnet_naming_style.pascalcase.word_separator = -dotnet_naming_style.pascalcase.capitalization = pascal_case - -dotnet_naming_style.ipascalcase.required_prefix = I -dotnet_naming_style.ipascalcase.required_suffix = -dotnet_naming_style.ipascalcase.word_separator = -dotnet_naming_style.ipascalcase.capitalization = pascal_case - -dotnet_naming_style.tpascalcase.required_prefix = T -dotnet_naming_style.tpascalcase.required_suffix = -dotnet_naming_style.tpascalcase.word_separator = -dotnet_naming_style.tpascalcase.capitalization = pascal_case - -dotnet_naming_style._camelcase.required_prefix = _ -dotnet_naming_style._camelcase.required_suffix = -dotnet_naming_style._camelcase.word_separator = -dotnet_naming_style._camelcase.capitalization = camel_case - -dotnet_naming_style.camelcase.required_prefix = -dotnet_naming_style.camelcase.required_suffix = -dotnet_naming_style.camelcase.word_separator = -dotnet_naming_style.camelcase.capitalization = camel_case - -dotnet_naming_style.s_camelcase.required_prefix = s_ -dotnet_naming_style.s_camelcase.required_suffix = -dotnet_naming_style.s_camelcase.word_separator = -dotnet_naming_style.s_camelcase.capitalization = camel_case +root = true + +# All files +[*] +indent_style = space +max_line_length = 120 + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = true + +#### Custom Analyzer Rules #### +# Until https://github.com/SonarSource/sonar-dotnet/issues/7624 is shipped. +dotnet_diagnostic.S3604.severity = none + +dotnet_diagnostic.SA0001.severity = none +dotnet_diagnostic.SA1010.severity = none +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.CS1591.severity = none +dotnet_diagnostic.SA1008.severity = suggestion +dotnet_diagnostic.SA1009.severity = suggestion +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1106.severity = suggestion +dotnet_diagnostic.SA1118.severity = none +dotnet_diagnostic.SX1101.severity = suggestion +dotnet_diagnostic.SA1309.severity = none +dotnet_diagnostic.SX1309.severity = warning +dotnet_diagnostic.SA1313.severity = suggestion +dotnet_diagnostic.SA1501.severity = suggestion +dotnet_diagnostic.SA1503.severity = suggestion +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1642.severity = none +dotnet_diagnostic.SA1643.severity = none +dotnet_diagnostic.SA1649.severity = none +dotnet_diagnostic.RCS1029.severity = none +dotnet_diagnostic.RCS1090.severity = none + +#### Custom Coding Conventions #### +resharper_csharp_space_in_singleline_accessorholder = true +resharper_csharp_space_between_accessors_in_singleline_property = true +resharper_csharp_space_in_singleline_method = true +resharper_csharp_space_in_singleline_anonymous_method = true +resharper_csharp_space_within_single_line_array_initializer_braces = true +resharper_csharp_trailing_comma_in_multiline_lists = true +resharper_csharp_place_type_attribute_on_same_line = false +resharper_csharp_place_attribute_on_same_line = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c42c3331 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Normalize line endings in Git: https://www.aleksandrhovhannisyan.com/blog/crlf-vs-lf-normalizing-line-endings-in-git/ +* text=auto +*.cs text eol=crlf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index d39bd205..f06cb016 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,44 +1,44 @@ -name: Bug Report -description: "Create a report to help fix a problem." -title: "[bug]: " -labels: ["bug"] -body: -- type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report! -- type: textarea - id: description - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true -- type: textarea - id: reproduce - attributes: - label: To reproduce - description: Steps to reproduce the behaviour - placeholder: | - Steps to reproduce the behavior: - 1. Go to '...' - 2. Click on '....' - 3. Scroll down to '....' - 4. See error - validations: - required: true -- type: textarea - id: expected - attributes: - label: Expected behavior - description: A clear and concise description of what you expected to happen. -- type: textarea - id: screenshots - attributes: - label: Screenshots - description: If applicable, add screenshots to help explain your problem. -- type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. +name: Bug Report +description: "Create a report to help fix a problem." +title: "[bug]: " +labels: ["bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true +- type: textarea + id: reproduce + attributes: + label: To reproduce + description: Steps to reproduce the behaviour + placeholder: | + Steps to reproduce the behavior: + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true +- type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. +- type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/.github/ISSUE_TEMPLATE/documentation.yaml b/.github/ISSUE_TEMPLATE/documentation.yaml index ad2905a0..2cfa4a20 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yaml +++ b/.github/ISSUE_TEMPLATE/documentation.yaml @@ -1,12 +1,12 @@ -name: Documentation -description: "Suggest a topic that is not correctly documented (or not documented at all)" -title: "[docs]: " -labels: ["documentation"] -body: -- type: textarea - id: description - attributes: - label: Describe the missing piece of documentation - description: Describe what you miss in the docs (or what is wrong). - validations: - required: true +name: Documentation +description: "Suggest a topic that is not correctly documented (or not documented at all)" +title: "[docs]: " +labels: ["documentation"] +body: +- type: textarea + id: description + attributes: + label: Describe the missing piece of documentation + description: Describe what you miss in the docs (or what is wrong). + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 8639a4f6..55a9b0ca 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,26 +1,26 @@ -name: Feature Request -description: "Suggest a new feature for this project." -title: "[feature]: " -labels: ["enhancement"] -body: -- type: markdown - attributes: - value: | - Thanks for taking the time to fill out this feature request! -- type: textarea - id: description - attributes: - label: Is your feature request related to a problem? Please describe. - description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -- type: textarea - id: solution - attributes: - label: Describe the solution you would like - description: A clear and concise description of what you want to happen. - validations: - required: true -- type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. +name: Feature Request +description: "Suggest a new feature for this project." +title: "[feature]: " +labels: ["enhancement"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this feature request! +- type: textarea + id: description + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +- type: textarea + id: solution + attributes: + label: Describe the solution you would like + description: A clear and concise description of what you want to happen. + validations: + required: true +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/.releaserc.json b/.releaserc.json index 346a8edb..2b7563d3 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -5,6 +5,14 @@ "name": "maintenance/7.x", "range": "7.x" }, + { + "name": "maintenance/8.x", + "range": "8.x" + }, + { + "name": "maintenance/9.x", + "range": "9.x" + }, { "name": "main", "prerelease": "pre" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d814d829..ae2bb2d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,63 +1,63 @@ -# Contributing to KubeOps - -First of all, thank you for considering contributing to KubeOps. -This is an open souce project and shall be driven by the community. - -This document describes how contributions may be done and what is required -to develop on KubeOps. - -## Creating/Reporting Issues - -Feel free to open an issue in the [issues section](https://github.com/buehler/dotnet-operator-sdk/issues). -There are three issue templates: -- Bug: to report an issue/bug that prevents usage or is an inconvenience of KubeOps -- Feature request: to report a new feature that would enhance KubeOps -- Documentation: to report missing / wrong documentation - -Please search through the already created issues to find similarities. - -## Creating Pull Requests - -To directly contribute to the solution, create a fork of the repository -and implement your addition. Please keep in mind that reviewing takes some -time and is not done instantly. - -Please adhere to the linting rules and the general code style in the repository. -Also, add tests for your changes to ensure that the system works well -when other changes happen. - -The PR can have any name, but it would be nice if you adhere to -the repositories standard naming. Please name your PR -with [Convential Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary). - -**NOTE for breaking changes**: please state breaking changes -in the PR description. The review process will be faster when -breaking changes are well documented. - -A few examples: -- "fix: Null exception during watcher process" -- "feat(core): Add new functionality" -- "feat(testing): expose kubernetes client for testing" -- "refactor: changed this and that" -- "docs: Add docs about KubeOps core" - -The PR will be squashed and merged into the default branch. - -## Local Development - -To setup a local development environment, you'll need to perform the follwing steps: - -- Check out the repository (or your fork) -- If you want to run the Operator locally, you'll need some Kubernetes instance. - This can be any Kubernetes instance you'd like: - - Local Kubernetes in Docker for Mac/Windows - - minikube / any other local Kubernetes - - Deployed Kubernetes (e.g. GCP Kubernetes instance) -- You can now code your stuff. -- `tests/KubeOps.TestOperator` is a developed small operator that can be run - locally to test your implementations. -- Write tests for your changes -- Build the whole solution and check for linting errors / warnings. - **NOTE** that any warning will result in an error when building - with `Release` configuration. -- Do not change the linting rules without creating a discussion/issue first. +# Contributing to KubeOps + +First of all, thank you for considering contributing to KubeOps. +This is an open souce project and shall be driven by the community. + +This document describes how contributions may be done and what is required +to develop on KubeOps. + +## Creating/Reporting Issues + +Feel free to open an issue in the [issues section](https://github.com/buehler/dotnet-operator-sdk/issues). +There are three issue templates: +- Bug: to report an issue/bug that prevents usage or is an inconvenience of KubeOps +- Feature request: to report a new feature that would enhance KubeOps +- Documentation: to report missing / wrong documentation + +Please search through the already created issues to find similarities. + +## Creating Pull Requests + +To directly contribute to the solution, create a fork of the repository +and implement your addition. Please keep in mind that reviewing takes some +time and is not done instantly. + +Please adhere to the linting rules and the general code style in the repository. +Also, add tests for your changes to ensure that the system works well +when other changes happen. + +The PR can have any name, but it would be nice if you adhere to +the repositories standard naming. Please name your PR +with [Convential Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary). + +**NOTE for breaking changes**: please state breaking changes +in the PR description. The review process will be faster when +breaking changes are well documented. + +A few examples: +- "fix: Null exception during watcher process" +- "feat(core): Add new functionality" +- "feat(testing): expose kubernetes client for testing" +- "refactor: changed this and that" +- "docs: Add docs about KubeOps core" + +The PR will be squashed and merged into the default branch. + +## Local Development + +To setup a local development environment, you'll need to perform the follwing steps: + +- Check out the repository (or your fork) +- If you want to run the Operator locally, you'll need some Kubernetes instance. + This can be any Kubernetes instance you'd like: + - Local Kubernetes in Docker for Mac/Windows + - minikube / any other local Kubernetes + - Deployed Kubernetes (e.g. GCP Kubernetes instance) +- You can now code your stuff. +- `tests/KubeOps.TestOperator` is a developed small operator that can be run + locally to test your implementations. +- Write tests for your changes +- Build the whole solution and check for linting errors / warnings. + **NOTE** that any warning will result in an error when building + with `Release` configuration. +- Do not change the linting rules without creating a discussion/issue first. diff --git a/_old/src/KubeOps.Templates/KubeOps.Templates.csproj b/_old/src/KubeOps.Templates/KubeOps.Templates.csproj deleted file mode 100644 index dfe2f057..00000000 --- a/_old/src/KubeOps.Templates/KubeOps.Templates.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - true - Template - KubeOps.Templates - KubeOps Templates - dotnet-new templates Kubernetes Operator Sdk KubeOps - dotnet new templates for KubeOps operator sdk. - - netstandard2.0 - - true - false - content - $(NoWarn);NU5128 - - - - - - - - diff --git a/_old/src/KubeOps.Templates/README.md b/_old/src/KubeOps.Templates/README.md deleted file mode 100644 index b628808b..00000000 --- a/_old/src/KubeOps.Templates/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# KubeOps Dotnet New Templates - -To use the operator SDK as easy as possible, this -[Nuget Package](https://www.nuget.org/packages/KubeOps.Templates) -contains `dotnet new` templates. -These templates enable developers to create Kubernetes operators -with the simple dotnet new command in C# or F#. - -## Installation - -To install the template package, use the `dotnet` cli -(or you may use the exact version as provided in the link above): - -```bash -dotnet new --install KubeOps.Templates::* -``` - -As soon as the templates are installed, you may use them with: - -```bash -dotnet new operator -#or -dotnet new operator-empty -``` - -Note that several of the templates are available in multiple languages -of the .NET framework (i.e. C\# and F\#) and you may switch the -language with the `-lang` flag of `dotnet new`. - -## Templates - -### Empty Operator - -_Available Languages_: C\#, F\# - -_Type_: Generate a project - -_Templatename_: `operator-empty` - -_Example installation_: `dotnet new operator-empty -n MyOperator` - -_Description_: -This template contains the well known `Program.cs` -and `Startup.cs` files of any other `ASP.NET` project -and configures the startup file to use KubeObs. -No additional code is provided. - -### Demo Operator - -_Available Languages_: C\#, F\# - -_Type_: Generate a project - -_Templatename_: `operator` - -_Example installation_: `dotnet new operator -n MyOperator` - -_Description_: -This template contains the well known `Program.cs` -and `Startup.cs` files of any other `ASP.NET` project -and configures the startup file to use KubeObs. -In addition to the empty operator, an example file -for each "concept" is provided. You'll find an -example implementation of: - -- A resource controller -- A custom entity (that generates a CRD) -- A finalizer -- A validation webhook -- A mutation webhook - -This template is meant to show all possible elements -of KubeOps in one go. diff --git a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/.template.config/template.json b/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/.template.config/template.json deleted file mode 100644 index 95571791..00000000 --- a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/.template.config/template.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/template", - "author": "Christoph Bühler", - "classifications": [ - "Kubernetes", - "Operator", - "Empty" - ], - "identity": "KubeOps.Templates.EmptyOperator.CSharp", - "groupIdentity": "KubeOps.Templates.EmptyOperator", - "name": "Kubernetes Operator Empty", - "description": "Create an empty kubernetes operator with the KubeOps SDK", - "shortName": "operator-empty", - "tags": { - "language": "C#", - "type": "project" - }, - "sourceName": "GeneratedOperatorProject", - "defaultName": "Operator", - "preferNameDirectory": true, - "postActions": [ - { - "actionId": "B17581D1-C5C9-4489-8F0A-004BE667B814", - "description": "Add KubeOps SDK reference", - "continueOnError": false, - "manualInstructions": [ - { - "text": "Add the KubeOps package to your project via nuget" - } - ], - "args": { - "referenceType": "package", - "reference": "KubeOps", - "projectFileExtensions": ".csproj" - } - } - ] -} diff --git a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/GeneratedOperatorProject.csproj b/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/GeneratedOperatorProject.csproj deleted file mode 100644 index ed0382f2..00000000 --- a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/GeneratedOperatorProject.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - net6.0 - 10.0 - enable - true - - - diff --git a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/Program.cs b/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/Program.cs deleted file mode 100644 index fa905257..00000000 --- a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/Program.cs +++ /dev/null @@ -1,8 +0,0 @@ -using KubeOps.Operator; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddKubernetesOperator(); - -var app = builder.Build(); -app.UseKubernetesOperator(); -await app.RunOperatorAsync(args); diff --git a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/appsettings.Development.json b/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/appsettings.Development.json deleted file mode 100644 index 8983e0fc..00000000 --- a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/appsettings.json b/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/appsettings.json deleted file mode 100644 index d9d9a9bf..00000000 --- a/_old/src/KubeOps.Templates/Templates/EmptyOperator.CSharp/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/.template.config/template.json b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/.template.config/template.json deleted file mode 100644 index 4ae22cf6..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/.template.config/template.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/template", - "author": "Christoph Bühler", - "classifications": [ - "Kubernetes", - "Operator" - ], - "identity": "KubeOps.Templates.Operator.CSharp", - "groupIdentity": "KubeOps.Templates.Operator", - "name": "Kubernetes Operator", - "description": "Create a kubernetes operator with demo implementation", - "shortName": "operator", - "tags": { - "language": "C#", - "type": "project" - }, - "sourceName": "GeneratedOperatorProject", - "defaultName": "Operator", - "preferNameDirectory": true, - "postActions": [ - { - "actionId": "B17581D1-C5C9-4489-8F0A-004BE667B814", - "description": "Add KubeOps SDK reference", - "continueOnError": false, - "manualInstructions": [ - { - "text": "Add the KubeOps package to your project via nuget" - } - ], - "args": { - "referenceType": "package", - "reference": "KubeOps", - "projectFileExtensions": ".csproj" - } - } - ] -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Controller/DemoController.cs b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Controller/DemoController.cs deleted file mode 100644 index 784cc431..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Controller/DemoController.cs +++ /dev/null @@ -1,44 +0,0 @@ -using k8s.Models; -using KubeOps.Operator.Controller; -using KubeOps.Operator.Controller.Results; -using KubeOps.Operator.Finalizer; -using KubeOps.Operator.Rbac; -using GeneratedOperatorProject.Entities; -using GeneratedOperatorProject.Finalizer; - -namespace GeneratedOperatorProject.Controller; - -[EntityRbac(typeof(V1DemoEntity), Verbs = RbacVerb.All)] -public class DemoController : IResourceController -{ - private readonly ILogger _logger; - private readonly IFinalizerManager _finalizerManager; - - public DemoController(ILogger logger, IFinalizerManager finalizerManager) - { - _logger = logger; - _finalizerManager = finalizerManager; - } - - public async Task ReconcileAsync(V1DemoEntity entity) - { - _logger.LogInformation($"entity {entity.Name()} called {nameof(ReconcileAsync)}."); - await _finalizerManager.RegisterFinalizerAsync(entity); - - return ResourceControllerResult.RequeueEvent(TimeSpan.FromSeconds(15)); - } - - public Task StatusModifiedAsync(V1DemoEntity entity) - { - _logger.LogInformation($"entity {entity.Name()} called {nameof(StatusModifiedAsync)}."); - - return Task.CompletedTask; - } - - public Task DeletedAsync(V1DemoEntity entity) - { - _logger.LogInformation($"entity {entity.Name()} called {nameof(DeletedAsync)}."); - - return Task.CompletedTask; - } -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Entities/V1DemoEntity.cs b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Entities/V1DemoEntity.cs deleted file mode 100644 index 2da4970a..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Entities/V1DemoEntity.cs +++ /dev/null @@ -1,18 +0,0 @@ -using k8s.Models; -using KubeOps.Operator.Entities; - -namespace GeneratedOperatorProject.Entities; - -[KubernetesEntity(Group = "demo.kubeops.dev", ApiVersion = "v1", Kind = "DemoEntity")] -public class V1DemoEntity : CustomKubernetesEntity -{ - public class V1DemoEntitySpec - { - public string Username { get; set; } = string.Empty; - } - - public class V1DemoEntityStatus - { - public string DemoStatus { get; set; } = string.Empty; - } -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Finalizer/DemoFinalizer.cs b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Finalizer/DemoFinalizer.cs deleted file mode 100644 index 447e5a10..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Finalizer/DemoFinalizer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using k8s.Models; -using KubeOps.Operator.Finalizer; -using GeneratedOperatorProject.Entities; - -namespace GeneratedOperatorProject.Finalizer; - -public class DemoFinalizer : IResourceFinalizer -{ - private readonly ILogger _logger; - - public DemoFinalizer(ILogger logger) - { - _logger = logger; - } - - public Task FinalizeAsync(V1DemoEntity entity) - { - _logger.LogInformation($"entity {entity.Name()} called {nameof(FinalizeAsync)}."); - - return Task.CompletedTask; - } -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/GeneratedOperatorProject.csproj b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/GeneratedOperatorProject.csproj deleted file mode 100644 index ed0382f2..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/GeneratedOperatorProject.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - net6.0 - 10.0 - enable - true - - - diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Program.cs b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Program.cs deleted file mode 100644 index fa905257..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Program.cs +++ /dev/null @@ -1,8 +0,0 @@ -using KubeOps.Operator; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddKubernetesOperator(); - -var app = builder.Build(); -app.UseKubernetesOperator(); -await app.RunOperatorAsync(args); diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Webhooks/DemoMutator.cs b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Webhooks/DemoMutator.cs deleted file mode 100644 index c8cf9d36..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Webhooks/DemoMutator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using KubeOps.Operator.Webhooks; -using GeneratedOperatorProject.Entities; - -namespace GeneratedOperatorProject.Webhooks; - -public class DemoMutator : IMutationWebhook -{ - public AdmissionOperations Operations => AdmissionOperations.Create; - - public MutationResult Create(V1DemoEntity newEntity, bool dryRun) - { - newEntity.Spec.Username = "not foobar"; - return MutationResult.Modified(newEntity); - } -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Webhooks/DemoValidator.cs b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Webhooks/DemoValidator.cs deleted file mode 100644 index a6109f5c..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/Webhooks/DemoValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using KubeOps.Operator.Webhooks; -using GeneratedOperatorProject.Entities; - -namespace GeneratedOperatorProject.Webhooks; - -public class DemoValidator : IValidationWebhook -{ - public AdmissionOperations Operations => AdmissionOperations.Create; - - public ValidationResult Create(V1DemoEntity newEntity, bool dryRun) - => newEntity.Spec.Username == "forbiddenUsername" - ? ValidationResult.Fail(StatusCodes.Status400BadRequest, "Username is forbidden") - : ValidationResult.Success(); -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/appsettings.Development.json b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/appsettings.Development.json deleted file mode 100644 index 8983e0fc..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/appsettings.json b/_old/src/KubeOps.Templates/Templates/Operator.CSharp/appsettings.json deleted file mode 100644 index d9d9a9bf..00000000 --- a/_old/src/KubeOps.Templates/Templates/Operator.CSharp/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/_old/src/KubeOps/README.md b/_old/src/KubeOps/README.md deleted file mode 100644 index 9bf9d68b..00000000 --- a/_old/src/KubeOps/README.md +++ /dev/null @@ -1,1154 +0,0 @@ -# KubeOps - Kubernetes Operator SDK - -This package (sadly "DotnetOperatorSdk" is already taken on nuget, so its "KubeOps") -is a kubernetes operator sdk written in dotnet. It is heavily inspired by -["kubebuilder"](https://github.com/kubernetes-sigs/kubebuilder) -that provides the same and more functions for kubernetes operators in GoLang. - -## Getting Started - -This document should describe what steps you need to follow, to fire up your own operator. -This covers the basic installation of the operator sdk, further -clarification / documentation is in the specific sections. - -The operator sdk is designed as an extension to the Generic Web Host of Microsoft. -So you'll find method extensions for `IServiceCollection` and `IApplicationBuilder` -that activate and start the operator as a web application. - -### Terminology - -- `Entity`: A (C#) model - an entity - that is used in kubernetes. - An entity is the class for a kubernetes resource. -- `Resource` (or `TResource`): The type of a kubernetes resource. -- `Controller` or `ResourceController`: An instance of a resource manager - that is responsible for the reconciliation of an entity. -- `Finalizer`: A special resource manager that is attached to the entity - via identifier. The finalizers are called when an entity is deleted - on kubernetes. -- `Validator`: An implementation for a validation admission webhook. -- `CRD`: CustomResourceDefinition of kubernetes. - -### How To Start - -Using this sdk is pretty simple: - -- Create a new asp.net core application -- Install the package -- Replace the `Run` function in `Program.cs` -- Add the operator to `Startup.cs` -- Write entities / controllers / finalizers -- Go. - -> If you don't create an asp.net core application (template) -> please note that the output type of the application must be an "exe": -> `Exe` - -#### Install the package - -```bash -dotnet add package KubeOps -``` - -That's it. - -#### Update Entrypoint - -In your `Program.cs` file, replace `Build().Run()` with `Build().RunOperatorAsync(args)`: - -```csharp -public static class Program -{ - public static Task Main(string[] args) => - CreateHostBuilder(args) - .Build() - .RunOperatorAsync(args); - - private static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); -} -``` - -This adds the default commands (like run and the code generators) to your app. -The commands are documentated under the [CLI Commands](#commands) section. - -> Technically you don't need to replace the function, -> but if you don't, the other commands like yaml generation -> are not available to your application. Also namespacing is not -> possible via run flag. - -#### Add to Startup.cs - -```csharp -public class Startup -{ - /* snip... */ - public void ConfigureServices(IServiceCollection services) - { - services - .AddKubernetesOperator(); // config / settings here - - // your own dependencies - services.AddTransient(); - } - - public void Configure(IApplicationBuilder app) - { - // fire up the mappings for the operator - // this is technically not needed, but if you don't call this - // function, the healthchecks and mappings are not - // mapped to endpoints (therefore not callable) - app.UseKubernetesOperator(); - } -} -``` - -## Features - -As of now, the operator sdk supports - roughly - the following features: - -- Entities - - Normal entities - - Multi version entities -- Controller with all operations of an entity - - Reconcile - - StatusModified - - Deleted -- Finalizers for entities -- Webhooks - - Validation / validators -- Prometheus metrics for queues / caches / watchers -- Healthchecks, split up to "readiness" and "liveness" (or both) -- Commands for the operator (for exact documentation run: `dotnet run -- --help`) - - `Run`: Start the operator and run the asp.net application - - `Install`: Install the found CRD's into the actual configured - cluster in your kubeconfig - - `Uninstall`: Remove the CRDs from your cluster - - `Generate CRD`: Generate the yaml for your CRDs - - `Generate Docker`: Generate a dockerfile for your operator - - `Generate Installer`: Generate a kustomization yaml for your operator - - `Generate Operator`: Generate the yaml for your operator (rbac / role / etc) - - `Generate RBAC`: Generate rbac roles for your CRDs - -Other features and ideas are listed in the repository's -["issues"](https://github.com/buehler/dotnet-operator-sdk/issues). - -## Settings - -To configure the operator, use the `OperatorSettings` instance -that is configurable during the generic host extension method -`AddKubernetesOperator`. - -You can configure things like the name of the operator, -if it should use namespacing, and other elements like the -urls of metrics and lease durations for the leader election. - -All settings are well documented in the code docs. - -## Custom Entities - -The words `entity` and `resource` are kind of interchangeable. It strongly -depends on the context. The resource is the type of an object in kubernetes -which is defined by the default api or a CRD. While an entity is a class -in C# of such a resource. (CRD means "custom resource definition"). - -To write your own kubernetes entities, use the interfaces -provided by `k8s` or use the `CustomKubernetesEntity`. -There are two overloads with generics for the `Spec` and `Status` resource values. - -A "normal" entity does not provide any real value (i.e. most of the time). -Normally you need some kind of `Spec` to have data in your entity. - -The status is a subresource which can be updated without updating the -whole resource and is a flat key-value list (or should be) -of properties to represent the state of a resource. - -### Write Entities - -A custom entity could be: - -```csharp -class FooSpec -{ - public string? Test { get; set; } -} - -[KubernetesEntity(Group = "test", ApiVersion = "v1")] -public class Foo : CustomKubernetesEntity -{ -} -``` - -Now a CRD for your "Foo" class is generated on build -or via the cli commands. - -If you don't use the `CustomKubernetesEntity` base class, you need to - at least - use the appropriate interfaces from `k8s`: - -- `KubernetesObject` -- `IKubernetesObject` - -#### Ignoring Entities - -There are use-cases when you want to model / watch a custom entity from another -software engineer that are not part of the base models in `k8s`. - -To prevent the generator from creating yaml's for CRDs you don't own, use -the `IgnoreEntityAttribute`. - -So as an example, one could try to watch for Ambassador-Mappings with -the following entity: - -```csharp -public class MappingSpec -{ - public string Host { get; set; } -} - -[IgnoreEntity] -[KubernetesEntity(Group = "getambassador.io", ApiVersion = "v2")] -public class Mapping : CustomKubernetesEntity -{ -} -``` - -You need it to be a `KubernetesEntity` and a `IKubernetesObject`, but -you don't want a CRD generated for it (thus the `IgnoreEntity` attribute). - -### RBAC - -The operator (SDK) will generate the role config for your -operator to be installed. When your operator needs access to -Kubernetes objects, they must be mentioned with the -RBAC attributes. During build, the SDK scans the configured -types and generates the RBAC role that the operator needs -to function. - -There exist two versions of the attribute: -`KubeOps.Operator.Rbac.EntityRbacAttribute` and -`KubeOps.Operator.Rbac.GenericRbacAttribute`. - -The generic RBAC attribute will be translated into a `V1PolicyRole` -according to the properties set in the attribute. - -```csharp -[GenericRbac(Groups = new []{"apps"}, Resources = new[]{"deployments"}, Verbs = RbacVerb.All)] -``` - -The entity RBAC attribute is the elegant option to use -dotnet mechanisms. The CRD information is generated out of -the given types and then grouped by type and used RBAC verbs. -If you create multiple attributes with the same type, they are -concatenated. - -```csharp -[EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get | RbacVerb.Update)] -``` - -### Validation - -During CRD generation, the generated json schema uses the types -of the properties to create the openApi schema. - -You can use the various validator attributes to customize your crd: - -(all attributes are on properties with the exception of the Description) - -- `Description`: Describe the property or class -- `ExternalDocs`: Add a link to an external documentation -- `Items`: Customize MinItems / MaxItems and if the items should be unique -- `Length`: Customize the length of something -- `MultipleOf`: A number should be a multiple of -- `Pattern`: A valid ECMA script regex (e.g. `/\d*/`) -- `RangeMaximum`: The maximum of a value (with option to exclude the max itself) -- `RangeMinimum`: The minimum of a value (with option to exclude the min itself) -- `Required`: The field is listed in the required fields -- `PreserveUnknownFields`: Set the `X-Kubernetes-Preserve-Unknown-Fields` to `true` - -> For `Description`: if your project generates the XML documentation files -> for the result, the crd generator also searches for those files and a possible -> `` tag in the xml documentation. The attribute will take precedence though. - -```csharp -public class MappingSpec -{ - /// This is a comment. - [Description("This is another comment")] - public string Host { get; set; } -} -``` - -In the example above, the text of the attribute will be used. - -### Multi-Version Entities - -You can manage multiple versions of a CRD. To do this, you can -specify multiple classes as the "same" entity, but with different -versions. - -To mark multiple entity classes as the same, use exactly the same -`Kind`, `Group` and `PluralName` and differ in the `ApiVersion` -field. - -#### Version priority - -Sorting of the versions - and therefore determine which version should be -the `storage version` if no attribute is provided - is done by the kubernetes -rules of version sorting: - -Priority is as follows: - -1. General Availablility (i.e. `V1Foobar`, `V2Foobar`) -2. Beta Versions (i.e. `V11Beta13Foobar`, `V2Beta1Foobar`) -3. Alpha Versions (i.e. `V16Alpha13Foobar`, `V2Alpha10Foobar`) - -The parsed version numbers are sorted by the highest first, this leads -to the following version priority: - -``` -- v10 -- v2 -- v1 -- v11beta2 -- v10beta3 -- v3beta1 -- v12alpha1 -- v11alpha2 -``` - -This can also be reviewed in the -[Kubernetes documentation](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#version-priority). - -#### Storage Version - -To determine the storage version (of which one, and exactly one must exist) -the system uses the previously mentioned version priority to sort the versions -and picking the first one. To overwrite this behaviour, use the -`KubeOps.Operator.Entities.Annotations.StorageVersionAttribute` - -> When multiple `KubeOps.Operator.Entities.Annotations.StorageVersionAttribute` -> are used, the system will thrown an error. - -To overwrite a version, annotate the entity class with the attribute. - -#### Example - -##### Normal multiversion entity - -Note that the `Kind` - -```csharp -[KubernetesEntity( - ApiVersion = "v1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] -public class V1VersionedEntity : CustomKubernetesEntity -{ -} - -[KubernetesEntity( - ApiVersion = "v1beta1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] -public class V1Beta1VersionedEntity : CustomKubernetesEntity -{ -} - -[KubernetesEntity( - ApiVersion = "v1alpha1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] -public class V1Alpha1VersionedEntity : CustomKubernetesEntity -{ -} -``` - -The resulting storage version would be `V1VersionedEntity`. - -##### Overwritten storage version multi-version entity - -```csharp -[KubernetesEntity( - ApiVersion = "v1", - Kind = "AttributeVersionedEntity", - Group = "kubeops.test.dev", - PluralName = "attributeversionedentities")] -[StorageVersion] -public class V1AttributeVersionedEntity : CustomKubernetesEntity -{ -} - -[KubernetesEntity( - ApiVersion = "v2", - Kind = "AttributeVersionedEntity", - Group = "kubeops.test.dev", - PluralName = "attributeversionedentities")] -public class V2AttributeVersionedEntity : CustomKubernetesEntity -{ -} -``` - -The resulting storage version would be `V1AttributeVersionedEntity`. - -## Resource Controller - -When reconciling an entity of a `CRD`, one needs a controller to do so. -The controller abstracts the general complexity of watching the -resources on kubernetes and queueing of the events. - -When you want to create a controller for your (or any) entity, -read the following instructions. - -When you have controllers, they are automatically added to the -DI system via their `KubeOps.Operator.Controller.IResourceController` interface. - -Controllers are registered as **scoped** elements in the DI system. -Which means, they basically behave like asp.net api controllers. -You can use dependency injection with all types of dependencies. - -### Controller instance - -After you created a custom entity (like described in [Entities](#custom-entities)) -or you want to reconcile a given entity (from the `k8s.Models` namespace, -e.g. `V1ConfigMap`) you need to create a controller class -as you would do for a MVC or API controller in asp.net. - -Make sure you implement the `KubeOps.Operator.Controller.IResourceController` interface. - -```csharp -[EntityRbac(typeof(MyCustomEntity), Verbs = RbacVerb.All)] -public class FooCtrl : IResourceController -{ - // Implement the needed methods here. - // The interface provides default implementation which do a NOOP. - // Possible overwrites: - // "ReconcileAsync": when the operator sees the entity for the first time, it was modified or just fired an event, - // "StatusModifiedAsync" (i.e. when only the status was updated), - // "DeletedAsync" (i.e. when the entity was deleted and all finalizers are done) -} -``` - -### Namespaced controller - -To limit the operator (and therefore all controllers) to a specific -namespace in kubernetes, use the `KubeOps.Operator.OperatorSettings` -and configure a specific namespace when it is predefined. - -To use namespacing dynamically, run the application with the `--namespaced` -option. When given a name (i.e. `--namespaced=foobar`) the defined -namespace is used. When only the option is provided (i.e. `--namespaced`) -then the actual namespace is used that the pod runs in. - -### RBAC - -The entity rbac attribute does provide the information needed about -your needed roles / rules. - -Please configure all entities you want to manage with your -operator with such an entity rbac attribute. This generates -the rbac roles / role bindings for your operator and therefore -for the service account associated with the operator. - -#### EntityRbac - -The first possibility to configure rbac is with the `KubeOps.Operator.Rbac.EntityRbacAttribute` -attribute. - -The attribute takes a list of types (your entities) and a `KubeOps.Operator.Rbac.RbacVerb`. -The verbs define the needed permissions and roles for the given entity(ies). - -You can configure multiple types and even well known entities from kubernetes: - -```csharp -[EntityRbac(typeof(MyCustomEntity), Verbs = RbacVerb.All)] -[EntityRbac(typeof(V1Secret), typeof(V1ConfigMap), Verbs = RbacVerb.Get | RbacVerb.List)] -[EntityRbac(typeof(V1Deployment), Verbs = RbacVerb.Create | RbacVerb.Update | RbacVerb.Delete)] -``` - -#### GenericRbac - -The second possibility is to use the `KubeOps.Operator.Rbac.GenericRbacAttribute` -which takes a list of api groups, resources, versions and a selection of -RbacVerbs to configure the rbac rule: - -```csharp -[GenericRbac(Groups = new {"apps"}, Resources = new {"deployments"}, Verbs = RbacVerb.All)] -``` - -### Requeue - -The controller's methods (reconcile) have -a return value of `KubeOps.Operator.Controller.Results.ResourceControllerResult`. -There are multiple ways how a result of a controller can be created: - -- `null`: The controller will not requeue your entity / event. -- `KubeOps.Operator.Controller.Results.ResourceControllerResult.RequeueEvent`: - Return a result object with a `System.TimeSpan` that will requeue - the event and the entity after the time has passed. - -The requeue mechanism can be useful if you want to periodically check for a database -connection for example and update the status of a given entity. - -```csharp -/* snip... */ -public Task CreatedAsync(V1TestEntity resource) -{ - return Task.FromResult(ResourceControllerResult.RequeueEvent(TimeSpan.FromSeconds(15)); // This will requeue the event in 15 seconds. -} - -public Task CreatedAsync(V1TestEntity resource) -{ - return Task.FromResult(null); // This wont trigger a requeue. -} -/* snip... */ -``` - -### Error requeue - -If the function throws an error, the event is requeued with an exponential backoff. - -```csharp -/* snip... */ -public Task CreatedAsync(V1TestEntity resource) - // do something useful. - throw new Exception("¯\\_(ツ)_/¯"); -} -/* snip... */ -``` - -Each event that errors will be retried **four times**. - -## Events / Event Series - -Kubernetes knows "Events" which can be sort of attached to a resource -(i.e. a Kubernetes object). - -To create and use events, inject the @"KubeOps.Operator.Events.IEventManager" -into your controller. It is registered as a transient resource in the DI -container. - -### IEventManager - -#### Publish events - -The event manager allows you to either publish an event that you created -by yourself, or helps you publish events with predefined data. - -If you want to use the helper: - -```c# -// fetch from DI, or inject into your controller. -IEventManager manager = services.GetRequiredService; - -// Publish the event. -// This creates an event and publishes it. -// If the event was previously published, it is fetched -// and the "count" number is increased. This essentially -// creates an event-series. -await manager.PublishAsync(resource, "reason", "my fancy message"); -``` - -If you want full control over the event: - -```c# -// fetch from DI, or inject into your controller. -IEventManager manager = services.GetRequiredService; - -var @event = new Corev1Event - { - // ... fill out all fields. - } - -// Publish the event. -// This essentially calls IKubernetesClient.Save. -await manager.PublishAsync(@event); -``` - -#### Use publisher delegates - -If you don't want to call the `KubeOps.Operator.Events.IEventManager.PublishAsync` -all the time with the same arguments, you can create delegates. - -There exist two different delegates: - -- "AsyncStaticPublisher": Predefined event - on a predefined resource. -- "AsyncPublisher": Predefined event - on a variable resource. - -To use the static publisher: - -```c# -var publisher = manager.CreatePublisher(resource, "reason", "message"); -await publisher(); - -// and later on: -await publisher(); // again without specifying reason / message and so on. -``` - -To use the dynamic publisher: - -```c# -var publisher = manager.CreatePublisher("reason", "message"); -await publisher(resource); - -// and later on: -await publisher(resource); // again without specifying reason / message and so on. -``` - -The dynamic publisher can be used to predefine the event for your resources. - -As an example in a controller: - -```c# -public class TestController : IResourceController -{ - private readonly IEventManager.Publisher _publisher; - - public TestController(IEventManager eventManager) - { - _publisher = eventManager.CreatePublisher("reason", "my fancy message"); - } - - public Task CreatedAsync(V1TestEntity resource) - { - // Here, the event is published with predefined strings - // but for a "variable" resource. - await _publisher(resource); - return Task.FromResult(null); - } -} -``` - -## Finalizers - -A finalizer is a special type of software that can asynchronously -cleanup stuff for an entity that is being deleted. - -A finalizer is registered as an identifier in a kubernetes -object (i.e. in the yaml / json structure) and the object -wont be removed from the api until all finalizers are removed. - -If you write finalizer, they will be automatically added to the -DI system via their type `KubeOps.Operator.Finalizer.IResourceFinalizer` - -### Write a finalizer - -Use the correct interface (`KubeOps.Operator.Finalizer.IResourceFinalizer`). - -A finalizer can be as simple as: - -```csharp -public class TestEntityFinalizer : IResourceFinalizer -{ - private readonly IManager _manager; - - public TestEntityFinalizer(IManager manager) - { - _manager = manager; - } - - public Task FinalizeAsync(V1TestEntity resource) - { - _manager.Finalized(resource); - return Task.CompletedTask; - } -} -``` - -The interface also provides a way of overwriting the `Identifier` of the finalizer if you feed like it. - -When the finalizer successfully completed his job, it is automatically removed -from the finalizers list of the entity. The finalizers are registered -as scoped resources in DI. - -### Register a finalizer - -To attach a finalizer for a resource, call the -`KubeOps.Operator.Finalizer.IFinalizerManager.RegisterFinalizerAsync` -method in the controller during reconciliation. - -```csharp -public class TestController : IResourceController -{ - private readonly IFinalizerManager _manager; - - public TestController(IFinalizerManager manager) - { - _manager = manager; - } - - public async Task CreatedAsync(V1TestEntity resource) - { - // The type MyFinalizer must be an IResourceFinalizer - await _manager.RegisterFinalizerAsync(resource); - return null; - } -} -``` - -Alternatively, the `KubeOps.Operator.Finalizer.IFinalizerManager.RegisterAllFinalizersAsync` -method can be used to attach all finalizers known to the operator for that entity type. - -```csharp -public class TestController : IResourceController -{ - private readonly IFinalizerManager _manager; - - public TestController(IFinalizerManager manager) - { - _manager = manager; - } - - public async Task CreatedAsync(V1TestEntity resource) - { - await _manager.RegisterAllFinalizersAsync(resource); - return null; - } -} -``` - -### Unregistering a finalizer - -When a resource is finalized, the finalizer is removed automatically. -However, if you want to remove a finalizer before a resource is deleted/finalized, -you can use `KubeOps.Operator.Finalizer.IFinalizerManager.RemoveFinalizerAsync`. - -```csharp -public class TestController : IResourceController -{ - private readonly IFinalizerManager _manager; - - public TestController(IFinalizerManager manager) - { - _manager = manager; - } - - public async Task CreatedAsync(V1TestEntity resource) - { - await _manager.RemoveFinalizerAsync(resource); - return null; - } -} -``` - -## Webhooks - -Kubernetes supports various webhooks to extend the normal api behaviour -of the master api. Those are documented on the -[kubernetes website](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/). - -`KubeOps` supports the following webhooks out of the box: - -- Validator / Validation -- Mutator / Mutation - -The following documentation should give the user an overview -on how to implement a webhook what this implies to the written operator. - -At the courtesy of the kubernetes website, here is a diagram of the -process that runs for admission controllers and api requests: - -![admission controller phases](https://d33wubrfki0l68.cloudfront.net/af21ecd38ec67b3d81c1b762221b4ac777fcf02d/7c60e/images/blog/2019-03-21-a-guide-to-kubernetes-admission-controllers/admission-controller-phases.png) - -### General - -In general, if your operator contains _any_ registered (registered in the -DI) the build process that is provided via `KubeOps.targets` will -generate a CA certificate for you. - -So if you add a webhook to your operator the following changes -to the normal deployment of the operator will happen: - -1. During "after build" phase, the sdk will generate - a CA-certificate for self signed certificates for you. -2. The ca certificate and the corresponding key are added - to the deployment via kustomization config. -3. A special config is added to the deployment via - kustomization to use https. -4. The deployment of the operator now contains an `init-container` - that loads the `ca.pem` and `ca-key.pem` files and creates - a server certificate. Also, a service and the corresponding - webhook configurations are created. -5. When the operator starts, an additional https route is registered - with the created server certificate. - -When a webhook is registered, the specified operations will -trigger a POST call to the operator. - -> The certificates are generated with [cfssl](https://github.com/cloudflare/cfssl), -> an amazing tool from cloudflare that helps with the general hassle -> of creating CAs and certificates in general. - -> Make sure you commit the `ca.pem` / `ca-key.pem` file. -> During operator startup (init container) those files -> are needed. Since this represents a self signed certificate, -> and it is only used for cluster internal communication, -> it is no security issue to the system. The service is not -> exposed to the internet. - -> The `server.pem` and `server-key.pem` files are generated -> in the init container during pod startup. -> Each pod / instance of the operator gets its own server -> certificate but the CA must be shared among them. - -### Local development - -It is possible to test / debug webhooks locally. For this, you need -to implement the webhook and use assembly-scanning (or the -operator builder if you disabled scanning) to register -the webhook type. - -There are two possibilities to tell Kubernetes that it should -call your local running operator for the webhooks. The url -that Kubernetes addresses _must_ be an HTTPS address. - -#### Using `AddWebhookLocaltunnel` - -In your `Startup.cs` you can use the `IOperatorBuilder` -method `AddWebhookLocaltunnel` to add an automatic -localtunnel instance to your operator. - -This will cause the operator to register a hosted service that -creates a tunnel and then registers itself to Kubernetes -with the created proxy-url. Now all calls are automatically -forwarded via HTTPS to your operator. - -```csharp -namespace KubeOps.TestOperator -{ - public class Startup - { - public void ConfigureServices(IServiceCollection services) - { - services - .AddKubernetesOperator() -#if DEBUG - .AddWebhookLocaltunnel() -#endif - ; - services.AddTransient(); - } - - public void Configure(IApplicationBuilder app) - { - app.UseKubernetesOperator(); - } - } -} -``` - -> It is _strongly_ advices against using auto-webhooks -> with localtunnel in production. This feature -> is intended to improve the developer experience -> while coding operators. - -> Some IDEs (like Rider from JetBrains) do not correctly -> terminate debugged applications. Hence, the -> webhook registration remains in Kubernetes. If you remove -> webhooks from your operator, you need to remove the -> registration within Kubernetes as well. - -#### Using external proxy - -The operator will run on a specific http address, depending on your -configuration. -Now, use [ngrok](https://ngrok.com/) or -[localtunnel](https://localtunnel.github.io/www/) or something -similar to create a HTTPS tunnel to your local running operator. - -Now you can use the cli command of the sdk -`dotnet run -- webhooks register --base-url <>` to -register the webhooks under the tunnel's url. - -The result is your webhook being called by the kubernetes api. -It is suggested one uses `Docker Desktop` with kubernetes. - -### Validation webhook - -The general idea of this webhook type is to validate an entity -before it is definitely created / updated or deleted. - -Webhooks are registered in a **scoped** manner to the DI system. -They behave like asp.net api controller. - -The implementation of a validator is fairly simple: - -- Create a class somewhere in your project. -- Implement the @"KubeOps.Operator.Webhooks.IValidationWebhook`1" interface. -- Define the @"KubeOps.Operator.Webhooks.IAdmissionWebhook`2.Operations" - (from the interface) that the validator is interested in. -- Overwrite the corresponding methods. - -> The interface contains default implementations for _ALL_ methods. -> The default of the async methods are to call the sync ones. -> The default of the sync methods is to return a "not implemented" -> result. -> The async methods take precedence over the synchronous ones. - -The return value of the validation methods are -@"KubeOps.Operator.Webhooks.ValidationResult" -objects. A result contains a boolean flag if the entity / operation -is valid or not. It may contain additional warnings (if it is valid) -that are presented to the user if the kubernetes api supports it. -If the result is invalid, one may add a custom http status code -as well as a custom error message that is presented to the user. - -#### Example - -```c# -public class TestValidator : IValidationWebhook - { - public AdmissionOperations Operations => AdmissionOperations.Create | AdmissionOperations.Update; - - public ValidationResult Create(EntityClass newEntity, bool dryRun) => - CheckSpec(newEntity) - ? ValidationResult.Success("The username may not be foobar.") - : ValidationResult.Fail(StatusCodes.Status400BadRequest, @"Username is ""foobar""."); - - public ValidationResult Update(EntityClass _, EntityClass newEntity, bool dryRun) => - CheckSpec(newEntity) - ? ValidationResult.Success("The username may not be foobar.") - : ValidationResult.Fail(StatusCodes.Status400BadRequest, @"Username is ""foobar""."); - - private static bool CheckSpec(EntityClass entity) => entity.Spec.Username != "foobar"; - } -``` - -### Mutation webhook - -Mutators are similar to validators but instead of defining if an object is -valid or not, they are able to modify an object on the fly. The result -of a mutator may generate a JSON Patch (http://jsonpatch.com) that patches -the object that is later passed to the validators and to the Kubernetes -API. - -The implementation of a mutator is fairly simple: - -- Create a class somewhere in your project. -- Implement the "KubeOps.Operator.Webhooks.IMutationWebhook" interface. -- Define the "KubeOps.Operator.Webhooks.IAdmissionWebhook.Operations" - (from the interface) that the validator is interested in. -- Overwrite the corresponding methods. - -> The interface contains default implementations for _ALL_ methods. -> The default of the async methods are to call the sync ones. -> The default of the sync methods is to return a "not implemented" -> result. -> The async methods take precedence over the synchronous ones. - -The return value of the mutation methods do indicate if -there has been a change in the model or not. If there is no -change, return a result from "KubeOps.Operator.Webhooks.MutationResult.NoChanges" -and if there are changes, modify the object that is passed to the -method and return the changed object with -"KubeOps.Operator.Webhooks.MutationResult.Modified(System.Object)". -The system then calculates the diff and creates a JSON patch for -the object. - -## Operator utils - -There are two basic utilities that should be mentioned: - -- Health-checks -- Metrics - -### Healthchecks - -This is a basic feature of asp.net. The operator sdk makes use of -it and splits them up into `Liveness` and `Readiness` checks. - -With the appropriate methods, you can add an `IHealthCheck` interface -to either `/ready`, `/health` or both. - -The urls can be configured via "KubeOps.Operator.OperatorSettings". - -- "AddHealthCheck": - adds a healthcheck to ready and liveness -- "AddLivenessCheck": - adds a healthcheck to the liveness route only -- "AddReadinessCheck": - adds a healthcheck to the readiness route only - -### Metrics - -By default, the operator lists some interessting metrics on the -`/metrics` route. The url can be configured via @"KubeOps.Operator.OperatorSettings". - -There are many counters on how many elements have been reconciled, if the -controllers and queues are up and how many elements are in timed requeue state. - -Please have a look at the metrics if you run your operator locally or online -to see which metrics are available. - -Of course you can also have a look at the used metrics classes to see the -implementation: [Metrics Implementations](https://github.com/buehler/dotnet-operator-sdk/tree/master/src/KubeOps/Operator/DevOps). - -## Entity / Resource utils - -There are several method extensions that help with day to day resource -handling. Head over to their documentation to see that they do: - -- `KubeOps.Operator.Entities.Extensions.KubernetesObjectExtensions.MakeObjectReference` -- `KubeOps.Operator.Entities.Extensions.KubernetesObjectExtensions.MakeOwnerReference` -- `KubeOps.Operator.Entities.Extensions.KubernetesObjectExtensions.WithOwnerReference` - -## Commands - -For convenience, there are multiple commands added to the executable -of your operator (through the KubeOps package). - -Those are implemented with the [CommandLineUtils by NateMcMaster](https://github.com/natemcmaster/CommandLineUtils). - -you can see the help and overview when using -`dotnet run -- --help` in your project. As you can see, you can run -multiple commands. Some of them do install / uninstall your crds in -your currently selected kubernetes cluster or can generate code. - -> For the normal "dotnet run" command exists a `--namespaced` -> option that starts the operator in namespaced mode. This means -> that only the given namespace is watched for entities. - -### Available Commands - -Here is a brief overview over the available commands: - -> all commands assume either the compiled dll or you using -> `dotnet run -- ` as prepended command. - -- `""` (empty): runs the operator (normal `dotnet run`) -- `version`: prints the version information for the actual connected kubernetes cluster -- `install`: install the CRDs for the solution into the cluster -- `uninstall`: uninstall the CRDs for the solution from the cluster -- `generator`: entry command for generator commands (i.e. has subcommands), all commands - output their result to the stdout or the given output path - - `crd`: generate the CRDs - - `docker`: generate the dockerfile - - `installer`: generate the installer files (i.e. kustomization yaml) for the operator - - `operator`: generate the deployment for the operator - - `rbac`: generate the needed rbac roles / role bindings for the operator -- `webhook`: entry command for webhook related operations - - `install`: generate the server certificate and install the service / webhook registration - - `register`: register the currently implemented webhooks to the currently selected cluster - -### Code Generation - -When installing this package, you also reference the default Targets and Props -that come with the build engine. While building the following elements are generated: - -- Dockerfile (if not already present) -- CRDs for your custom entities -- RBAC roles and role bindings for your requested resources -- Deployment files for your operator -- Installation file for your operator (kustomize) - -The dockerfile will not be overwritten in case you have custom elements in there. -The installation files won't be overwritten as well if you have custom elements in there. - -To regenerate those two elements, just delete them and rebuild your code. - -For the customization on those build targets, have a look at the next section. - -## MS Build extensions - -This project extends the default build process of dotnet with some -code generation targets after the build. - -You'll find the configurations and targets here: - -- [KubeOps.targets](https://github.com/buehler/dotnet-operator-sdk/blob/master/src/KubeOps/Build/KubeOps.targets): defines the additional build targets - -They can be configured with the prop settings described below. -The props file just defines the defaults. - -### Prop Settings - -You can overwrite the default behaviour of the building parts with the following -variables that you can add in a `` in your `csproj` file: - -| Property | Description | Default Value | -| ---------------------- | -------------------------------------------------------------------------- | ------------------------------- | -| KubeOpsBasePath | Base path for all other elements | `$(MSBuildProjectDirectory)` | -| KubeOpsDockerfilePath | The path of the dockerfile | `$(KubeOpsBasePath)\Dockerfile` | -| KubeOpsDockerTag | Which dotnet sdk / run tag should be used | `latest` | -| KubeOpsConfigRoot | The base directory for generated elements | `$(KubeOpsBasePath)\config` | -| KubeOpsCrdDir | The directory for the generated crds | `$(KubeOpsConfigRoot)\crds` | -| KubeOpsCrdFormat | Output format for crds | `Yaml` | -| KubeOpsCrdUseOldCrds | Use V1Beta version of crd instead of V1
(for kubernetes version < 1.16) | `false` | -| KubeOpsRbacDir | Where to put the roles | `$(KubeOpsConfigRoot)\rbac` | -| KubeOpsRbacFormat | Output format for rbac | `Yaml` | -| KubeOpsOperatorDir | Where to put operator related elements
(e.g. Deployment) | `$(KubeOpsConfigRoot)\operator` | -| KubeOpsOperatorFormat | Output format for the operator | `Yaml` | -| KubeOpsInstallerDir | Where to put the installation files
(e.g. Namespace / Kustomization) | `$(KubeOpsConfigRoot)\install` | -| KubeOpsInstallerFormat | Output format for the installation files | `Yaml` | -| KubeOpsSkipDockerfile | Skip dockerfile during build | `""` | -| KubeOpsSkipCrds | Skip crd generation during build | `""` | -| KubeOpsSkipRbac | Skip rbac generation during build | `""` | -| KubeOpsSkipOperator | Skip operator generation during build | `""` | -| KubeOpsSkipInstaller | Skip installer generation during build | `""` | - -## Advanced Topics - -### Assembly Scanning - -By default, KubeOps scans the assembly containing the main entrypoint for -controller, finalizer, webhook and entity types, and automatically registers -all types that implement the correct interfaces for usage. - -If some of the above are stored in a different assembly, KubeOps must be -specifically instructed to scan that assembly `KubeOps.Operator.Builder.IOperatorBuilder.AddResourceAssembly` or else those types won't be loaded. - -```csharp -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddKubernetesOperator() - .AddResourceAssembly(typeof(CustomEntityController).Assembly) - } - - public void Configure(IApplicationBuilder app) - { - app.UseKubernetesOperator(); - } -} -``` - -### Manual Registration - -If desired, the default behavior of assembly scanning can be disabled so -specific components can be registered manually. (Using both methods in parallel -is supported, such as if you want to load all components from one assembly and -only some from another.) - -See `KubeOps.Operator.Builder.IOperatorBuilder` for details on the methods -utilized in this registration pattern. - -```csharp -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddKubernetesOperator(settings => - { - settings.EnableAssemblyScanning = false; - }) - .AddEntity() - .AddController() - .AddController() - .AddFinalizer() - .AddValidationWebhook() - .AddMutationWebhook(); - } - - public void Configure(IApplicationBuilder app) - { - app.UseKubernetesOperator(); - } -} -``` diff --git a/_old/tests/KubeOps.Templates.Test/DotnetExecutor.cs b/_old/tests/KubeOps.Templates.Test/DotnetExecutor.cs deleted file mode 100644 index 5e2844de..00000000 --- a/_old/tests/KubeOps.Templates.Test/DotnetExecutor.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Diagnostics; - -namespace KubeOps.Templates.Test; - -public abstract class DotnetExecutor -{ - protected static async Task ExecuteDotnetProcess(string arguments) - { - var process = new Process { StartInfo = new() { FileName = "dotnet", Arguments = arguments, }, }; - - process.Start(); - await process.WaitForExitAsync(); - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/KubeOps.Templates.Test.csproj b/_old/tests/KubeOps.Templates.Test/KubeOps.Templates.Test.csproj deleted file mode 100644 index 52164163..00000000 --- a/_old/tests/KubeOps.Templates.Test/KubeOps.Templates.Test.csproj +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/_old/tests/KubeOps.Templates.Test/TemplateExecutor.cs b/_old/tests/KubeOps.Templates.Test/TemplateExecutor.cs deleted file mode 100644 index 7a90c28a..00000000 --- a/_old/tests/KubeOps.Templates.Test/TemplateExecutor.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace KubeOps.Templates.Test; - -public class TemplateExecutor : DotnetExecutor, IDisposable -{ - private string? _outputPath; - - public async Task ExecuteCSharpTemplate(string template, string? name = "Template") - { - _outputPath ??= Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); - await ExecuteDotnetProcess( - $"""new {template} -lang "C#" -n {name} -o {_outputPath} """); - } - - public async Task ExecuteFSharpTemplate(string template, string? name = "Template") - { - _outputPath ??= Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); - await ExecuteDotnetProcess( - $"""new {template} -lang "F#" -n {name} -o {_outputPath} """); - } - - public bool FileExists(params string[] name) => - _outputPath != null && File.Exists(Path.Join(_outputPath, Path.Combine(name))); - - public bool FileContains(string content, params string[] name) - { - var file = File.ReadAllText(Path.Join(_outputPath, Path.Combine(name))); - return file.Contains(content); - } - - public void Dispose() - { - if (_outputPath != null) - { - Directory.Delete(_outputPath, true); - } - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/TemplateInstaller.cs b/_old/tests/KubeOps.Templates.Test/TemplateInstaller.cs deleted file mode 100644 index 2794641d..00000000 --- a/_old/tests/KubeOps.Templates.Test/TemplateInstaller.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace KubeOps.Templates.Test; - -public class TemplateInstaller : DotnetExecutor, IDisposable -{ - private const string Up = ".."; - - public TemplateInstaller() - { - ExecuteDotnetProcess($"new -i {TemplatesPath}").Wait(); - } - - private static string TemplatesPath => Path.GetFullPath( - Path.Join( - Directory.GetCurrentDirectory(), - Up, - Up, - Up, - Up, - Up, - "src", - "KubeOps.Templates")); - - public void Dispose() - { - ExecuteDotnetProcess($"new -u {TemplatesPath}").Wait(); - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/Templates/Empty.CSharp.Test.cs b/_old/tests/KubeOps.Templates.Test/Templates/Empty.CSharp.Test.cs deleted file mode 100644 index 3b6d9748..00000000 --- a/_old/tests/KubeOps.Templates.Test/Templates/Empty.CSharp.Test.cs +++ /dev/null @@ -1,48 +0,0 @@ -using FluentAssertions; - -using Xunit; - -namespace KubeOps.Templates.Test.Templates; - -[Collection("Template Tests")] -public class EmptyCSharpTest : IDisposable -{ - private readonly TemplateExecutor _executor = new(); - - public EmptyCSharpTest(TemplateInstaller _) - { - } - - [Fact] - public async Task Should_Create_Correct_Files() - { - await _executor.ExecuteCSharpTemplate("operator-empty"); - _executor.FileExists("Template.csproj").Should().BeTrue(); - _executor.FileExists("Program.cs").Should().BeTrue(); - _executor.FileExists("appsettings.Development.json").Should().BeTrue(); - _executor.FileExists("appsettings.json").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference() - { - await _executor.ExecuteCSharpTemplate("operator-empty"); - _executor.FileContains(""" - PackageReference Include="KubeOps" - """, "Template.csproj").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference_Into_Program_Code() - { - await _executor.ExecuteCSharpTemplate("operator-empty"); - _executor.FileContains("builder.Services.AddKubernetesOperator();", "Program.cs").Should().BeTrue(); - _executor.FileContains("app.UseKubernetesOperator();", "Program.cs").Should().BeTrue(); - _executor.FileContains("await app.RunOperatorAsync(args);", "Program.cs").Should().BeTrue(); - } - - public void Dispose() - { - _executor.Dispose(); - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/Templates/Empty.FSharp.Test.cs b/_old/tests/KubeOps.Templates.Test/Templates/Empty.FSharp.Test.cs deleted file mode 100644 index 7b415b4e..00000000 --- a/_old/tests/KubeOps.Templates.Test/Templates/Empty.FSharp.Test.cs +++ /dev/null @@ -1,57 +0,0 @@ -using FluentAssertions; - -using Xunit; - -namespace KubeOps.Templates.Test.Templates; - -[Collection("Template Tests")] -public class EmptyFSharpTest : IDisposable -{ - private readonly TemplateExecutor _executor = new(); - - public EmptyFSharpTest(TemplateInstaller _) - { - } - - [Fact] - public async Task Should_Create_Correct_Files() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileExists("Template.fsproj").Should().BeTrue(); - _executor.FileExists("Startup.fs").Should().BeTrue(); - _executor.FileExists("Program.fs").Should().BeTrue(); - _executor.FileExists("appsettings.Development.json").Should().BeTrue(); - _executor.FileExists("appsettings.json").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileContains(""" - PackageReference Include="KubeOps" - """, "Template.fsproj").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference_Into_Startup_Files() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileContains("services.AddKubernetesOperator() |> ignore", "Startup.fs").Should().BeTrue(); - _executor.FileContains("app.UseKubernetesOperator()", "Startup.fs").Should().BeTrue(); - } - - [Fact] - public async Task Should_Create_Correct_Program_Code() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileContains(".RunOperatorAsync args", "Program.fs") - .Should() - .BeTrue(); - } - - public void Dispose() - { - _executor.Dispose(); - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/Templates/Operator.CSharp.Test.cs b/_old/tests/KubeOps.Templates.Test/Templates/Operator.CSharp.Test.cs deleted file mode 100644 index 86182f43..00000000 --- a/_old/tests/KubeOps.Templates.Test/Templates/Operator.CSharp.Test.cs +++ /dev/null @@ -1,91 +0,0 @@ -using FluentAssertions; - -using Xunit; - -namespace KubeOps.Templates.Test.Templates; - -[Collection("Template Tests")] -public class OperatorCSharpTest : IDisposable -{ - private readonly TemplateExecutor _executor = new(); - - public OperatorCSharpTest(TemplateInstaller _) - { - } - - [Fact] - public async Task Should_Create_Correct_Files() - { - await _executor.ExecuteCSharpTemplate("operator"); - _executor.FileExists("Template.csproj").Should().BeTrue(); - _executor.FileExists("Program.cs").Should().BeTrue(); - _executor.FileExists("appsettings.Development.json").Should().BeTrue(); - _executor.FileExists("appsettings.json").Should().BeTrue(); - - _executor.FileExists("Controller", "DemoController.cs").Should().BeTrue(); - _executor.FileExists("Entities", "V1DemoEntity.cs").Should().BeTrue(); - _executor.FileExists("Finalizer", "DemoFinalizer.cs").Should().BeTrue(); - _executor.FileExists("Webhooks", "DemoValidator.cs").Should().BeTrue(); - _executor.FileExists("Webhooks", "DemoMutator.cs").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference() - { - await _executor.ExecuteCSharpTemplate("operator"); - _executor.FileContains(""" - PackageReference Include="KubeOps" - """, "Template.csproj").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference_Into_Program_Code() - { - await _executor.ExecuteCSharpTemplate("operator"); - _executor.FileContains("builder.Services.AddKubernetesOperator();", "Program.cs").Should().BeTrue(); - _executor.FileContains("app.UseKubernetesOperator();", "Program.cs").Should().BeTrue(); - _executor.FileContains("await app.RunOperatorAsync(args);", "Program.cs").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_Correct_Demo_Files() - { - await _executor.ExecuteCSharpTemplate("operator"); - - _executor.FileContains( - "public class V1DemoEntity : CustomKubernetesEntity", - "Entities", - "V1DemoEntity.cs") - .Should() - .BeTrue(); - _executor.FileContains( - "public class DemoController : IResourceController", - "Controller", - "DemoController.cs") - .Should() - .BeTrue(); - _executor.FileContains( - "public class DemoFinalizer : IResourceFinalizer", - "Finalizer", - "DemoFinalizer.cs") - .Should() - .BeTrue(); - _executor.FileContains( - "public class DemoValidator : IValidationWebhook", - "Webhooks", - "DemoValidator.cs") - .Should() - .BeTrue(); - _executor.FileContains( - "public class DemoMutator : IMutationWebhook", - "Webhooks", - "DemoMutator.cs") - .Should() - .BeTrue(); - } - - public void Dispose() - { - _executor.Dispose(); - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/Templates/Operator.FSharp.Test.cs b/_old/tests/KubeOps.Templates.Test/Templates/Operator.FSharp.Test.cs deleted file mode 100644 index fc64df01..00000000 --- a/_old/tests/KubeOps.Templates.Test/Templates/Operator.FSharp.Test.cs +++ /dev/null @@ -1,100 +0,0 @@ -using FluentAssertions; - -using Xunit; - -namespace KubeOps.Templates.Test.Templates; - -[Collection("Template Tests")] -public class OperatorFSharpTest : IDisposable -{ - private readonly TemplateExecutor _executor = new(); - - public OperatorFSharpTest(TemplateInstaller _) - { - } - - [Fact] - public async Task Should_Create_Correct_Files() - { - await _executor.ExecuteFSharpTemplate("operator"); - _executor.FileExists("Template.fsproj").Should().BeTrue(); - _executor.FileExists("Startup.fs").Should().BeTrue(); - _executor.FileExists("Program.fs").Should().BeTrue(); - _executor.FileExists("appsettings.Development.json").Should().BeTrue(); - _executor.FileExists("appsettings.json").Should().BeTrue(); - - _executor.FileExists("Controller", "DemoController.fs").Should().BeTrue(); - _executor.FileExists("Entities", "V1DemoEntity.fs").Should().BeTrue(); - _executor.FileExists("Finalizer", "DemoFinalizer.fs").Should().BeTrue(); - _executor.FileExists("Webhooks", "DemoValidator.fs").Should().BeTrue(); - _executor.FileExists("Webhooks", "DemoMutator.fs").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileContains(""" - PackageReference Include="KubeOps" - """, "Template.fsproj").Should().BeTrue(); - } - - [Fact] - public async Task Should_Add_KubeOps_Reference_Into_Startup_Files() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileContains("services.AddKubernetesOperator() |> ignore", "Startup.fs").Should().BeTrue(); - _executor.FileContains("app.UseKubernetesOperator()", "Startup.fs").Should().BeTrue(); - } - - [Fact] - public async Task Should_Create_Correct_Program_Code() - { - await _executor.ExecuteFSharpTemplate("operator-empty"); - _executor.FileContains(".RunOperatorAsync args", "Program.fs") - .Should() - .BeTrue(); - } - - [Fact] - public async Task Should_Add_Correct_Demo_Files() - { - await _executor.ExecuteFSharpTemplate("operator"); - - _executor.FileContains( - "inherit CustomKubernetesEntity()", - "Entities", - "V1DemoEntity.fs") - .Should() - .BeTrue(); - _executor.FileContains( - "interface IResourceController with", - "Controller", - "DemoController.fs") - .Should() - .BeTrue(); - _executor.FileContains( - "interface IResourceFinalizer with", - "Finalizer", - "DemoFinalizer.fs") - .Should() - .BeTrue(); - _executor.FileContains( - "interface IValidationWebhook with", - "Webhooks", - "DemoValidator.fs") - .Should() - .BeTrue(); - _executor.FileContains( - "interface IMutationWebhook with", - "Webhooks", - "DemoMutator.fs") - .Should() - .BeTrue(); - } - - public void Dispose() - { - _executor.Dispose(); - } -} \ No newline at end of file diff --git a/_old/tests/KubeOps.Templates.Test/Templates/TemplateTestCollection.cs b/_old/tests/KubeOps.Templates.Test/Templates/TemplateTestCollection.cs deleted file mode 100644 index 22ac0b59..00000000 --- a/_old/tests/KubeOps.Templates.Test/Templates/TemplateTestCollection.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Xunit; - -namespace KubeOps.Templates.Test.Templates; - -[CollectionDefinition("Template Tests")] -public class TemplateTestCollection : ICollectionFixture -{ -} \ No newline at end of file diff --git a/examples/ConversionWebhookOperator/Controller/V1TestEntityController.cs b/examples/ConversionWebhookOperator/Controller/V1TestEntityController.cs index c8380f17..7746041a 100644 --- a/examples/ConversionWebhookOperator/Controller/V1TestEntityController.cs +++ b/examples/ConversionWebhookOperator/Controller/V1TestEntityController.cs @@ -1,22 +1,22 @@ -using ConversionWebhookOperator.Entities; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Rbac; - -namespace ConversionWebhookOperator.Controller; - -[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -public class V1TestEntityController(ILogger logger) : IEntityController -{ - public Task ReconcileAsync(V1TestEntity entity) - { - logger.LogInformation("Reconciling entity {Entity}.", entity); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1TestEntity entity) - { - logger.LogInformation("Deleted entity {Entity}.", entity); - return Task.CompletedTask; - } -} +using ConversionWebhookOperator.Entities; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Rbac; + +namespace ConversionWebhookOperator.Controller; + +[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +public class V1TestEntityController(ILogger logger) : IEntityController +{ + public Task ReconcileAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + logger.LogInformation("Reconciling entity {Entity}.", entity); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + logger.LogInformation("Deleted entity {Entity}.", entity); + return Task.CompletedTask; + } +} diff --git a/examples/ConversionWebhookOperator/Entities/V1TestEntity.cs b/examples/ConversionWebhookOperator/Entities/V1TestEntity.cs index 90feac2c..a3c3d611 100644 --- a/examples/ConversionWebhookOperator/Entities/V1TestEntity.cs +++ b/examples/ConversionWebhookOperator/Entities/V1TestEntity.cs @@ -1,16 +1,16 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace ConversionWebhookOperator.Entities; - -[KubernetesEntity(Group = "conversionwebhook.dev", ApiVersion = "v1", Kind = "TestEntity")] -public partial class V1TestEntity : CustomKubernetesEntity -{ - public override string ToString() => $"Test Entity v1 ({Metadata.Name}): {Spec.Name}"; - - public class EntitySpec - { - public string Name { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace ConversionWebhookOperator.Entities; + +[KubernetesEntity(Group = "conversionwebhook.dev", ApiVersion = "v1", Kind = "TestEntity")] +public partial class V1TestEntity : CustomKubernetesEntity +{ + public override string ToString() => $"Test Entity v1 ({Metadata.Name}): {Spec.Name}"; + + public class EntitySpec + { + public string Name { get; set; } = string.Empty; + } +} diff --git a/examples/ConversionWebhookOperator/Entities/V2TestEntity.cs b/examples/ConversionWebhookOperator/Entities/V2TestEntity.cs index 477c842e..8525bea5 100644 --- a/examples/ConversionWebhookOperator/Entities/V2TestEntity.cs +++ b/examples/ConversionWebhookOperator/Entities/V2TestEntity.cs @@ -1,18 +1,18 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace ConversionWebhookOperator.Entities; - -[KubernetesEntity(Group = "conversionwebhook.dev", ApiVersion = "v2", Kind = "TestEntity")] -public partial class V2TestEntity : CustomKubernetesEntity -{ - public override string ToString() => $"Test Entity v2 ({Metadata.Name}): {Spec.Firstname} {Spec.Lastname}"; - - public class EntitySpec - { - public string Firstname { get; set; } = string.Empty; - - public string Lastname { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace ConversionWebhookOperator.Entities; + +[KubernetesEntity(Group = "conversionwebhook.dev", ApiVersion = "v2", Kind = "TestEntity")] +public partial class V2TestEntity : CustomKubernetesEntity +{ + public override string ToString() => $"Test Entity v2 ({Metadata.Name}): {Spec.Firstname} {Spec.Lastname}"; + + public class EntitySpec + { + public string Firstname { get; set; } = string.Empty; + + public string Lastname { get; set; } = string.Empty; + } +} diff --git a/examples/ConversionWebhookOperator/Entities/V3TestEntity.cs b/examples/ConversionWebhookOperator/Entities/V3TestEntity.cs index cfcd8407..b4fef937 100644 --- a/examples/ConversionWebhookOperator/Entities/V3TestEntity.cs +++ b/examples/ConversionWebhookOperator/Entities/V3TestEntity.cs @@ -1,20 +1,20 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace ConversionWebhookOperator.Entities; - -[KubernetesEntity(Group = "conversionwebhook.dev", ApiVersion = "v3", Kind = "TestEntity")] -public partial class V3TestEntity : CustomKubernetesEntity -{ - public override string ToString() => $"Test Entity v3 ({Metadata.Name}): {Spec.Firstname} {Spec.MiddleName} {Spec.Lastname}"; - - public class EntitySpec - { - public string Firstname { get; set; } = string.Empty; - - public string Lastname { get; set; } = string.Empty; - - public string? MiddleName { get; set; } - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace ConversionWebhookOperator.Entities; + +[KubernetesEntity(Group = "conversionwebhook.dev", ApiVersion = "v3", Kind = "TestEntity")] +public partial class V3TestEntity : CustomKubernetesEntity +{ + public override string ToString() => $"Test Entity v3 ({Metadata.Name}): {Spec.Firstname} {Spec.MiddleName} {Spec.Lastname}"; + + public class EntitySpec + { + public string Firstname { get; set; } = string.Empty; + + public string Lastname { get; set; } = string.Empty; + + public string? MiddleName { get; set; } + } +} diff --git a/examples/ConversionWebhookOperator/Program.cs b/examples/ConversionWebhookOperator/Program.cs index cf328b30..44a63cea 100644 --- a/examples/ConversionWebhookOperator/Program.cs +++ b/examples/ConversionWebhookOperator/Program.cs @@ -1,22 +1,22 @@ -using KubeOps.Operator; -using KubeOps.Operator.Web.Builder; - -var builder = WebApplication.CreateBuilder(args); -builder.Services - .AddKubernetesOperator() - .RegisterComponents() -#if DEBUG - .AddDevelopmentTunnel(5000) -#endif - ; - -builder.Services - .AddControllers(); - -var app = builder.Build(); - -app.UseRouting(); -app.UseDeveloperExceptionPage(); -app.MapControllers(); - -await app.RunAsync(); +using KubeOps.Operator; +using KubeOps.Operator.Web.Builder; + +var builder = WebApplication.CreateBuilder(args); +builder.Services + .AddKubernetesOperator() + .RegisterComponents() +#if DEBUG + .AddDevelopmentTunnel(5000) +#endif + ; + +builder.Services + .AddControllers(); + +var app = builder.Build(); + +app.UseRouting(); +app.UseDeveloperExceptionPage(); +app.MapControllers(); + +await app.RunAsync(); diff --git a/examples/ConversionWebhookOperator/Webhooks/TestConversionWebhook.cs b/examples/ConversionWebhookOperator/Webhooks/TestConversionWebhook.cs index 49b1bd65..086b7957 100644 --- a/examples/ConversionWebhookOperator/Webhooks/TestConversionWebhook.cs +++ b/examples/ConversionWebhookOperator/Webhooks/TestConversionWebhook.cs @@ -1,52 +1,52 @@ -using ConversionWebhookOperator.Entities; - -using KubeOps.Operator.Web.Webhooks.Conversion; - -namespace ConversionWebhookOperator.Webhooks; - -[ConversionWebhook(typeof(V3TestEntity))] -public class TestConversionWebhook : ConversionWebhook -{ - protected override IEnumerable> Converters => new IEntityConverter[] - { - new V1ToV3(), new V2ToV3(), - }; - - private class V1ToV3 : IEntityConverter - { - public V3TestEntity Convert(V1TestEntity from) - { - var nameSplit = from.Spec.Name.Split(' '); - var result = new V3TestEntity { Metadata = from.Metadata }; - result.Spec.Firstname = nameSplit[0]; - result.Spec.Lastname = string.Join(' ', nameSplit[1..]); - return result; - } - - public V1TestEntity Revert(V3TestEntity to) - { - var result = new V1TestEntity { Metadata = to.Metadata }; - result.Spec.Name = $"{to.Spec.Firstname} {to.Spec.Lastname}"; - return result; - } - } - - private class V2ToV3 : IEntityConverter - { - public V3TestEntity Convert(V2TestEntity from) - { - var result = new V3TestEntity { Metadata = from.Metadata }; - result.Spec.Firstname = from.Spec.Firstname; - result.Spec.Lastname = from.Spec.Lastname; - return result; - } - - public V2TestEntity Revert(V3TestEntity to) - { - var result = new V2TestEntity { Metadata = to.Metadata }; - result.Spec.Firstname = to.Spec.Firstname; - result.Spec.Lastname = to.Spec.Lastname; - return result; - } - } -} +using ConversionWebhookOperator.Entities; + +using KubeOps.Operator.Web.Webhooks.Conversion; + +namespace ConversionWebhookOperator.Webhooks; + +[ConversionWebhook(typeof(V3TestEntity))] +public class TestConversionWebhook : ConversionWebhook +{ + protected override IEnumerable> Converters => new IEntityConverter[] + { + new V1ToV3(), new V2ToV3(), + }; + + private class V1ToV3 : IEntityConverter + { + public V3TestEntity Convert(V1TestEntity from) + { + var nameSplit = from.Spec.Name.Split(' '); + var result = new V3TestEntity { Metadata = from.Metadata }; + result.Spec.Firstname = nameSplit[0]; + result.Spec.Lastname = string.Join(' ', nameSplit[1..]); + return result; + } + + public V1TestEntity Revert(V3TestEntity to) + { + var result = new V1TestEntity { Metadata = to.Metadata }; + result.Spec.Name = $"{to.Spec.Firstname} {to.Spec.Lastname}"; + return result; + } + } + + private class V2ToV3 : IEntityConverter + { + public V3TestEntity Convert(V2TestEntity from) + { + var result = new V3TestEntity { Metadata = from.Metadata }; + result.Spec.Firstname = from.Spec.Firstname; + result.Spec.Lastname = from.Spec.Lastname; + return result; + } + + public V2TestEntity Revert(V3TestEntity to) + { + var result = new V2TestEntity { Metadata = to.Metadata }; + result.Spec.Firstname = to.Spec.Firstname; + result.Spec.Lastname = to.Spec.Lastname; + return result; + } + } +} diff --git a/examples/Operator/Controller/V1TestEntityController.cs b/examples/Operator/Controller/V1TestEntityController.cs index c7fb311b..d2c28cbf 100644 --- a/examples/Operator/Controller/V1TestEntityController.cs +++ b/examples/Operator/Controller/V1TestEntityController.cs @@ -1,32 +1,32 @@ -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Events; -using KubeOps.Abstractions.Queue; -using KubeOps.Abstractions.Rbac; - -using Microsoft.Extensions.Logging; - -using Operator.Entities; - -namespace Operator.Controller; - -[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -public class V1TestEntityController(ILogger logger, - EntityRequeue requeue, - EventPublisher eventPublisher) - : IEntityController -{ - public async Task ReconcileAsync(V1TestEntity entity) - { - logger.LogInformation("Reconciling entity {Entity}.", entity); - - await eventPublisher(entity, "RECONCILED", "Entity was reconciled."); - - requeue(entity, TimeSpan.FromSeconds(5)); - } - - public Task DeletedAsync(V1TestEntity entity) - { - logger.LogInformation("Deleting entity {Entity}.", entity); - return Task.CompletedTask; - } -} +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Events; +using KubeOps.Abstractions.Queue; +using KubeOps.Abstractions.Rbac; + +using Microsoft.Extensions.Logging; + +using Operator.Entities; + +namespace Operator.Controller; + +[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +public class V1TestEntityController(ILogger logger, + EntityRequeue requeue, + EventPublisher eventPublisher) + : IEntityController +{ + public async Task ReconcileAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + logger.LogInformation("Reconciling entity {Entity}.", entity); + + await eventPublisher(entity, "RECONCILED", "Entity was reconciled."); + + requeue(entity, TimeSpan.FromSeconds(5)); + } + + public Task DeletedAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + logger.LogInformation("Deleting entity {Entity}.", entity); + return Task.CompletedTask; + } +} diff --git a/examples/Operator/Entities/V1TestEntity.cs b/examples/Operator/Entities/V1TestEntity.cs index 2ec912a7..b76650c1 100644 --- a/examples/Operator/Entities/V1TestEntity.cs +++ b/examples/Operator/Entities/V1TestEntity.cs @@ -1,18 +1,18 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace Operator.Entities; - -[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] -public partial class V1TestEntity : CustomKubernetesEntity -{ - public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username} ({Spec.Email})"; - - public class EntitySpec - { - public string Username { get; set; } = string.Empty; - - public string Email { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace Operator.Entities; + +[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] +public partial class V1TestEntity : CustomKubernetesEntity +{ + public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username} ({Spec.Email})"; + + public class EntitySpec + { + public string Username { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + } +} diff --git a/examples/Operator/Finalizer/FinalizerOne.cs b/examples/Operator/Finalizer/FinalizerOne.cs index f46a173f..319bbabf 100644 --- a/examples/Operator/Finalizer/FinalizerOne.cs +++ b/examples/Operator/Finalizer/FinalizerOne.cs @@ -1,13 +1,13 @@ -using KubeOps.Abstractions.Finalizer; - -using Operator.Entities; - -namespace Operator.Finalizer; - -public class FinalizerOne : IEntityFinalizer -{ - public Task FinalizeAsync(V1TestEntity entity) - { - return Task.CompletedTask; - } -} +using KubeOps.Abstractions.Finalizer; + +using Operator.Entities; + +namespace Operator.Finalizer; + +public class FinalizerOne : IEntityFinalizer +{ + public Task FinalizeAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/examples/Operator/Program.cs b/examples/Operator/Program.cs index 0bd007a9..ba2acd6a 100644 --- a/examples/Operator/Program.cs +++ b/examples/Operator/Program.cs @@ -1,15 +1,15 @@ -using KubeOps.Operator; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -var builder = Host.CreateApplicationBuilder(args); - -builder.Logging.SetMinimumLevel(LogLevel.Trace); - -builder.Services - .AddKubernetesOperator() - .RegisterComponents(); - -using var host = builder.Build(); -await host.RunAsync(); +using KubeOps.Operator; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Logging.SetMinimumLevel(LogLevel.Trace); + +builder.Services + .AddKubernetesOperator() + .RegisterComponents(); + +using var host = builder.Build(); +await host.RunAsync(); diff --git a/examples/WebhookOperator/Controller/V1TestEntityController.cs b/examples/WebhookOperator/Controller/V1TestEntityController.cs index 5fff076f..c511b7a7 100644 --- a/examples/WebhookOperator/Controller/V1TestEntityController.cs +++ b/examples/WebhookOperator/Controller/V1TestEntityController.cs @@ -1,22 +1,22 @@ -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Rbac; - -using WebhookOperator.Entities; - -namespace WebhookOperator.Controller; - -[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -public class V1TestEntityController(ILogger logger) : IEntityController -{ - public Task ReconcileAsync(V1TestEntity entity) - { - logger.LogInformation("Reconciling entity {Entity}.", entity); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1TestEntity entity) - { - logger.LogInformation("Deleted entity {Entity}.", entity); - return Task.CompletedTask; - } -} +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Rbac; + +using WebhookOperator.Entities; + +namespace WebhookOperator.Controller; + +[EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +public class V1TestEntityController(ILogger logger) : IEntityController +{ + public Task ReconcileAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + logger.LogInformation("Reconciling entity {Entity}.", entity); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1TestEntity entity, CancellationToken cancellationToken) + { + logger.LogInformation("Deleted entity {Entity}.", entity); + return Task.CompletedTask; + } +} diff --git a/examples/WebhookOperator/Entities/V1TestEntity.cs b/examples/WebhookOperator/Entities/V1TestEntity.cs index 7855d56b..8610df26 100644 --- a/examples/WebhookOperator/Entities/V1TestEntity.cs +++ b/examples/WebhookOperator/Entities/V1TestEntity.cs @@ -1,16 +1,16 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace WebhookOperator.Entities; - -[KubernetesEntity(Group = "webhook.dev", ApiVersion = "v1", Kind = "TestEntity")] -public partial class V1TestEntity : CustomKubernetesEntity -{ - public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; - - public class EntitySpec - { - public string Username { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace WebhookOperator.Entities; + +[KubernetesEntity(Group = "webhook.dev", ApiVersion = "v1", Kind = "TestEntity")] +public partial class V1TestEntity : CustomKubernetesEntity +{ + public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; + + public class EntitySpec + { + public string Username { get; set; } = string.Empty; + } +} diff --git a/examples/WebhookOperator/Program.cs b/examples/WebhookOperator/Program.cs index cf328b30..44a63cea 100644 --- a/examples/WebhookOperator/Program.cs +++ b/examples/WebhookOperator/Program.cs @@ -1,22 +1,22 @@ -using KubeOps.Operator; -using KubeOps.Operator.Web.Builder; - -var builder = WebApplication.CreateBuilder(args); -builder.Services - .AddKubernetesOperator() - .RegisterComponents() -#if DEBUG - .AddDevelopmentTunnel(5000) -#endif - ; - -builder.Services - .AddControllers(); - -var app = builder.Build(); - -app.UseRouting(); -app.UseDeveloperExceptionPage(); -app.MapControllers(); - -await app.RunAsync(); +using KubeOps.Operator; +using KubeOps.Operator.Web.Builder; + +var builder = WebApplication.CreateBuilder(args); +builder.Services + .AddKubernetesOperator() + .RegisterComponents() +#if DEBUG + .AddDevelopmentTunnel(5000) +#endif + ; + +builder.Services + .AddControllers(); + +var app = builder.Build(); + +app.UseRouting(); +app.UseDeveloperExceptionPage(); +app.MapControllers(); + +await app.RunAsync(); diff --git a/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs b/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs index e1e2034e..c017c486 100644 --- a/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs +++ b/examples/WebhookOperator/Webhooks/TestMutationWebhook.cs @@ -1,20 +1,20 @@ -using KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -using WebhookOperator.Entities; - -namespace WebhookOperator.Webhooks; - -[MutationWebhook(typeof(V1TestEntity))] -public class TestMutationWebhook : MutationWebhook -{ - public override MutationResult Create(V1TestEntity entity, bool dryRun) - { - if (entity.Spec.Username == "overwrite") - { - entity.Spec.Username = "random overwritten"; - return Modified(entity); - } - - return NoChanges(); - } -} +using KubeOps.Operator.Web.Webhooks.Admission.Mutation; + +using WebhookOperator.Entities; + +namespace WebhookOperator.Webhooks; + +[MutationWebhook(typeof(V1TestEntity))] +public class TestMutationWebhook : MutationWebhook +{ + public override MutationResult Create(V1TestEntity entity, bool dryRun) + { + if (entity.Spec.Username == "overwrite") + { + entity.Spec.Username = "random overwritten"; + return Modified(entity); + } + + return NoChanges(); + } +} diff --git a/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs b/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs index 0422c552..c1c1d53d 100644 --- a/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs +++ b/examples/WebhookOperator/Webhooks/TestValidationWebhook.cs @@ -1,29 +1,29 @@ -using KubeOps.Operator.Web.Webhooks.Admission.Validation; - -using WebhookOperator.Entities; - -namespace WebhookOperator.Webhooks; - -[ValidationWebhook(typeof(V1TestEntity))] -public class TestValidationWebhook : ValidationWebhook -{ - public override ValidationResult Create(V1TestEntity entity, bool dryRun) - { - if (entity.Spec.Username == "forbidden") - { - return Fail("name may not be 'forbidden'.", 422); - } - - return Success(); - } - - public override ValidationResult Update(V1TestEntity oldEntity, V1TestEntity newEntity, bool dryRun) - { - if (newEntity.Spec.Username == "forbidden") - { - return Fail("name may not be 'forbidden'."); - } - - return Success(); - } -} +using KubeOps.Operator.Web.Webhooks.Admission.Validation; + +using WebhookOperator.Entities; + +namespace WebhookOperator.Webhooks; + +[ValidationWebhook(typeof(V1TestEntity))] +public class TestValidationWebhook : ValidationWebhook +{ + public override ValidationResult Create(V1TestEntity entity, bool dryRun) + { + if (entity.Spec.Username == "forbidden") + { + return Fail("name may not be 'forbidden'.", 422); + } + + return Success(); + } + + public override ValidationResult Update(V1TestEntity oldEntity, V1TestEntity newEntity, bool dryRun) + { + if (newEntity.Spec.Username == "forbidden") + { + return Fail("name may not be 'forbidden'."); + } + + return Success(); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3b03b377..edd3521c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -41,10 +41,10 @@ Condition="$(MSBuildProjectExtension) == '.csproj'" /> - + diff --git a/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs b/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs index 2553ffd9..0088276c 100644 --- a/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs +++ b/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs @@ -1,49 +1,49 @@ -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Finalizer; - -using Microsoft.Extensions.DependencyInjection; - -namespace KubeOps.Abstractions.Builder; - -/// -/// KubeOps operator builder. -/// -public interface IOperatorBuilder -{ - /// - /// The original service collection. - /// - IServiceCollection Services { get; } - - /// - /// Add a controller implementation for a specific entity to the operator. - /// The metadata for the entity must be added as well. - /// - /// Implementation type of the controller. - /// Entity type. - /// The builder for chaining. - IOperatorBuilder AddController() - where TImplementation : class, IEntityController - where TEntity : IKubernetesObject; - - /// - /// Add a finalizer implementation for a specific entity. - /// This adds the implementation as a transient service and registers - /// the finalizer with the provided identifier. Then an - /// is registered to - /// provide a delegate for attaching the finalizer to an entity. - /// - /// - /// The identifier for the finalizer. - /// This string is added to the Kubernetes entity as a finalizer. - /// - /// Type of the finalizer implementation. - /// Type of the Kubernetes entity. - /// The builder for chaining. - IOperatorBuilder AddFinalizer(string identifier) - where TImplementation : class, IEntityFinalizer - where TEntity : IKubernetesObject; -} +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Finalizer; + +using Microsoft.Extensions.DependencyInjection; + +namespace KubeOps.Abstractions.Builder; + +/// +/// KubeOps operator builder. +/// +public interface IOperatorBuilder +{ + /// + /// The original service collection. + /// + IServiceCollection Services { get; } + + /// + /// Add a controller implementation for a specific entity to the operator. + /// The metadata for the entity must be added as well. + /// + /// Implementation type of the controller. + /// Entity type. + /// The builder for chaining. + IOperatorBuilder AddController() + where TImplementation : class, IEntityController + where TEntity : IKubernetesObject; + + /// + /// Add a finalizer implementation for a specific entity. + /// This adds the implementation as a transient service and registers + /// the finalizer with the provided identifier. Then an + /// is registered to + /// provide a delegate for attaching the finalizer to an entity. + /// + /// + /// The identifier for the finalizer. + /// This string is added to the Kubernetes entity as a finalizer. + /// + /// Type of the finalizer implementation. + /// Type of the Kubernetes entity. + /// The builder for chaining. + IOperatorBuilder AddFinalizer(string identifier) + where TImplementation : class, IEntityFinalizer + where TEntity : IKubernetesObject; +} diff --git a/src/KubeOps.Abstractions/Builder/OperatorSettings.cs b/src/KubeOps.Abstractions/Builder/OperatorSettings.cs index ac110659..2faed930 100644 --- a/src/KubeOps.Abstractions/Builder/OperatorSettings.cs +++ b/src/KubeOps.Abstractions/Builder/OperatorSettings.cs @@ -1,62 +1,62 @@ -using System.Text.RegularExpressions; - -namespace KubeOps.Abstractions.Builder; - -/// -/// Operator settings. -/// -public sealed class OperatorSettings -{ - private const string DefaultOperatorName = "KubernetesOperator"; - private const string NonCharReplacement = "-"; - - /// - /// The name of the operator that appears in logs and other elements. - /// Defaults to "kubernetesoperator" when not set. - /// - public string Name { get; set; } = - new Regex(@"(\W|_)", RegexOptions.CultureInvariant).Replace( - DefaultOperatorName, - NonCharReplacement) - .ToLowerInvariant(); - - /// - /// - /// Controls the namespace which is watched by the operator. - /// If this field is left `null`, all namespaces are watched for - /// CRD instances. - /// - /// - public string? Namespace { get; set; } - - /// - /// - /// Whether the leader elector should run. You should enable - /// this if you plan to run the operator redundantly. - /// - /// - /// If this is disabled and an operator runs in multiple instances - /// (in the same namespace), it can lead to a "split brain" problem. - /// - /// - /// Defaults to `false`. - /// - /// - public bool EnableLeaderElection { get; set; } = false; - - /// - /// Defines how long one lease is valid for any leader. - /// Defaults to 15 seconds. - /// - public TimeSpan LeaderElectionLeaseDuration { get; set; } = TimeSpan.FromSeconds(15); - - /// - /// When the leader elector tries to refresh the leadership lease. - /// - public TimeSpan LeaderElectionRenewDeadline { get; set; } = TimeSpan.FromSeconds(10); - - /// - /// The wait timeout if the lease cannot be acquired. - /// - public TimeSpan LeaderElectionRetryPeriod { get; set; } = TimeSpan.FromSeconds(2); -} +using System.Text.RegularExpressions; + +namespace KubeOps.Abstractions.Builder; + +/// +/// Operator settings. +/// +public sealed class OperatorSettings +{ + private const string DefaultOperatorName = "KubernetesOperator"; + private const string NonCharReplacement = "-"; + + /// + /// The name of the operator that appears in logs and other elements. + /// Defaults to "kubernetesoperator" when not set. + /// + public string Name { get; set; } = + new Regex(@"(\W|_)", RegexOptions.CultureInvariant).Replace( + DefaultOperatorName, + NonCharReplacement) + .ToLowerInvariant(); + + /// + /// + /// Controls the namespace which is watched by the operator. + /// If this field is left `null`, all namespaces are watched for + /// CRD instances. + /// + /// + public string? Namespace { get; set; } + + /// + /// + /// Whether the leader elector should run. You should enable + /// this if you plan to run the operator redundantly. + /// + /// + /// If this is disabled and an operator runs in multiple instances + /// (in the same namespace), it can lead to a "split brain" problem. + /// + /// + /// Defaults to `false`. + /// + /// + public bool EnableLeaderElection { get; set; } = false; + + /// + /// Defines how long one lease is valid for any leader. + /// Defaults to 15 seconds. + /// + public TimeSpan LeaderElectionLeaseDuration { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// When the leader elector tries to refresh the leadership lease. + /// + public TimeSpan LeaderElectionRenewDeadline { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// The wait timeout if the lease cannot be acquired. + /// + public TimeSpan LeaderElectionRetryPeriod { get; set; } = TimeSpan.FromSeconds(2); +} diff --git a/src/KubeOps.Abstractions/Controller/IEntityController{TEntity}.cs b/src/KubeOps.Abstractions/Controller/IEntityController{TEntity}.cs index 7d3c3892..87856087 100644 --- a/src/KubeOps.Abstractions/Controller/IEntityController{TEntity}.cs +++ b/src/KubeOps.Abstractions/Controller/IEntityController{TEntity}.cs @@ -1,56 +1,56 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Controller; - -/// -/// Generic entity controller. The controller manages the reconcile loop -/// for a given entity type. -/// -/// The type of the Kubernetes entity. -/// -/// Simple example controller that just logs the entity. -/// -/// public class V1TestEntityController : IEntityController<V1TestEntity> -/// { -/// private readonly ILogger<V1TestEntityController> _logger; -/// -/// public V1TestEntityController( -/// ILogger<V1TestEntityController> logger) -/// { -/// _logger = logger; -/// } -/// -/// public async Task ReconcileAsync(V1TestEntity entity) -/// { -/// _logger.LogInformation("Reconciling entity {Entity}.", entity); -/// } -/// -/// public async Task DeletedAsync(V1TestEntity entity) -/// { -/// _logger.LogInformation("Deleting entity {Entity}.", entity); -/// } -/// } -/// -/// -public interface IEntityController - where TEntity : IKubernetesObject -{ - /// - /// Called for `added` and `modified` events from the watcher. - /// - /// The entity that fired the reconcile event. - /// A task that completes when the reconciliation is done. - Task ReconcileAsync(TEntity entity) => - Task.CompletedTask; - - /// - /// Called for `delete` events for a given entity. - /// - /// The entity that fired the deleted event. - /// - /// A task that completes, when the reconciliation is done. - /// - Task DeletedAsync(TEntity entity) => - Task.CompletedTask; -} +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Controller; + +/// +/// Generic entity controller. The controller manages the reconcile loop +/// for a given entity type. +/// +/// The type of the Kubernetes entity. +/// +/// Simple example controller that just logs the entity. +/// +/// public class V1TestEntityController : IEntityController<V1TestEntity> +/// { +/// private readonly ILogger<V1TestEntityController> _logger; +/// +/// public V1TestEntityController( +/// ILogger<V1TestEntityController> logger) +/// { +/// _logger = logger; +/// } +/// +/// public async Task ReconcileAsync(V1TestEntity entity, CancellationToken token) +/// { +/// _logger.LogInformation("Reconciling entity {Entity}.", entity); +/// } +/// +/// public async Task DeletedAsync(V1TestEntity entity, CancellationToken token) +/// { +/// _logger.LogInformation("Deleting entity {Entity}.", entity); +/// } +/// } +/// +/// +public interface IEntityController + where TEntity : IKubernetesObject +{ + /// + /// Called for `added` and `modified` events from the watcher. + /// + /// The entity that fired the reconcile event. + /// The token to monitor for cancellation requests. + /// A task that completes when the reconciliation is done. + Task ReconcileAsync(TEntity entity, CancellationToken cancellationToken); + + /// + /// Called for `delete` events for a given entity. + /// + /// The entity that fired the deleted event. + /// The token to monitor for cancellation requests. + /// + /// A task that completes, when the reconciliation is done. + /// + Task DeletedAsync(TEntity entity, CancellationToken cancellationToken); +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/AdditionalPrinterColumnAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/AdditionalPrinterColumnAttribute.cs index b561fdcd..286565bd 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/AdditionalPrinterColumnAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/AdditionalPrinterColumnAttribute.cs @@ -1,30 +1,30 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a property as an additional printer column. -/// -[AttributeUsage(AttributeTargets.Property)] -public class AdditionalPrinterColumnAttribute(PrinterColumnPriority priority = default, string? name = null) - : Attribute -{ - /// - /// The name of the column. Defaults to the property-name. - /// - public string? Name => name; - - /// - /// The priority of the additional printer column. - /// As documented in - /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#priority - /// the following rules apply to priority: - /// - /// - /// Columns with priority `0` are shown in standard view - /// - /// - /// Columns with priority greater than `0` are shown only in wide view - /// - /// - /// - public PrinterColumnPriority Priority => priority; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a property as an additional printer column. +/// +[AttributeUsage(AttributeTargets.Property)] +public class AdditionalPrinterColumnAttribute(PrinterColumnPriority priority = default, string? name = null) + : Attribute +{ + /// + /// The name of the column. Defaults to the property-name. + /// + public string? Name => name; + + /// + /// The priority of the additional printer column. + /// As documented in + /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#priority + /// the following rules apply to priority: + /// + /// + /// Columns with priority `0` are shown in standard view + /// + /// + /// Columns with priority greater than `0` are shown only in wide view + /// + /// + /// + public PrinterColumnPriority Priority => priority; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/DescriptionAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/DescriptionAttribute.cs index 9db44fef..3095000c 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/DescriptionAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/DescriptionAttribute.cs @@ -1,14 +1,14 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a description for a property. This precedes the description found in a -/// XML documentation file. -/// -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)] -public class DescriptionAttribute(string description) : Attribute -{ - /// - /// The given description for the property. - /// - public string Description => description; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a description for a property. This precedes the description found in a +/// XML documentation file. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class)] +public class DescriptionAttribute(string description) : Attribute +{ + /// + /// The given description for the property. + /// + public string Description => description; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/EmbeddedResourceAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/EmbeddedResourceAttribute.cs index 60e6818e..b09ddecb 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/EmbeddedResourceAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/EmbeddedResourceAttribute.cs @@ -1,12 +1,12 @@ -using k8s.Models; - -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a property as an embedded resource. -/// This property can contain another Kubernetes object -/// (e.g. a or a ). -/// This implicitly sets the . -/// -[AttributeUsage(AttributeTargets.Property)] -public class EmbeddedResourceAttribute : Attribute; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a property as an embedded resource. +/// This property can contain another Kubernetes object +/// (e.g. a or a ). +/// This implicitly sets the . +/// +[AttributeUsage(AttributeTargets.Property)] +public class EmbeddedResourceAttribute : Attribute; diff --git a/src/KubeOps.Abstractions/Entities/Attributes/EntityScopeAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/EntityScopeAttribute.cs index 7468850e..08ecfb83 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/EntityScopeAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/EntityScopeAttribute.cs @@ -1,7 +1,7 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -[AttributeUsage(AttributeTargets.Class, Inherited = false)] -public class EntityScopeAttribute(EntityScope scope = default) : Attribute -{ - public EntityScope Scope => scope; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class EntityScopeAttribute(EntityScope scope = default) : Attribute +{ + public EntityScope Scope => scope; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/ExternalDocsAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/ExternalDocsAttribute.cs index 237b0f35..864c9488 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/ExternalDocsAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/ExternalDocsAttribute.cs @@ -1,18 +1,18 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines that the property has an external documentation. -/// -[AttributeUsage(AttributeTargets.Property)] -public class ExternalDocsAttribute(string url, string? description = null) : Attribute -{ - /// - /// Additional description. - /// - public string? Description => description; - - /// - /// Url where to find the documentation. - /// - public string Url => url; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines that the property has an external documentation. +/// +[AttributeUsage(AttributeTargets.Property)] +public class ExternalDocsAttribute(string url, string? description = null) : Attribute +{ + /// + /// Additional description. + /// + public string? Description => description; + + /// + /// Url where to find the documentation. + /// + public string Url => url; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/GenericAdditionalPrinterColumnAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/GenericAdditionalPrinterColumnAttribute.cs index fc39dabb..912bd164 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/GenericAdditionalPrinterColumnAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/GenericAdditionalPrinterColumnAttribute.cs @@ -1,118 +1,118 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a generic additional printer column. -/// With this, other elements (such as Metadata.Name) -/// can be referenced. In contrast to the , -/// all needed information must be provided. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class GenericAdditionalPrinterColumnAttribute : Attribute -{ - /// - /// Create a generic additional printer column. - /// - /// JsonPath as in . - /// Name as in . - /// Type as in . - public GenericAdditionalPrinterColumnAttribute(string jsonPath, string name, string type) - { - JsonPath = jsonPath; - Name = name; - Type = type; - } - - /// - /// The json path for the property inside the resource. - /// .spec.replicas - /// .metadata.namespace - /// .metadata.creationTimestamp - /// - public string JsonPath { get; } - - /// - /// The name of the column. - /// - public string Name { get; } - - /// - /// Description for the column. - /// - public string? Description { get; init; } - - /// - /// The type of the column. - /// As documented in - /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#type. - /// The type field can be any of the following (from OpenAPI v3 data types): - /// - /// - /// `integer` - non-floating-point number - /// - /// - /// `number` - floating point number - /// - /// - /// `string` - strings - /// - /// - /// `boolean`- `true` or `false` - /// - /// - /// `date` - rendered differentially as time since this timestamp - /// - /// - /// If the value inside a CustomResource does not match the type specified for the column, the value is omitted. - /// - public string Type { get; } - - /// - /// The format of the column. - /// As documented in - /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#format. - /// The format field can be any of the following: - /// - /// - /// `int32` - /// - /// - /// `int64` - /// - /// - /// `float` - /// - /// - /// `double` - /// - /// - /// `byte` - /// - /// - /// `date` - /// - /// - /// `date-time` - /// - /// - /// `password` - /// - /// - /// - public string? Format { get; init; } - - /// - /// The priority of the additional printer column. - /// As documented in - /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#priority - /// the following rules apply to priority: - /// - /// - /// Columns with priority `0` are shown in standard view - /// - /// - /// Columns with priority greater than `0` are shown only in wide view - /// - /// - /// - public PrinterColumnPriority Priority { get; init; } -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a generic additional printer column. +/// With this, other elements (such as Metadata.Name) +/// can be referenced. In contrast to the , +/// all needed information must be provided. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class GenericAdditionalPrinterColumnAttribute : Attribute +{ + /// + /// Create a generic additional printer column. + /// + /// JsonPath as in . + /// Name as in . + /// Type as in . + public GenericAdditionalPrinterColumnAttribute(string jsonPath, string name, string type) + { + JsonPath = jsonPath; + Name = name; + Type = type; + } + + /// + /// The json path for the property inside the resource. + /// .spec.replicas + /// .metadata.namespace + /// .metadata.creationTimestamp + /// + public string JsonPath { get; } + + /// + /// The name of the column. + /// + public string Name { get; } + + /// + /// Description for the column. + /// + public string? Description { get; init; } + + /// + /// The type of the column. + /// As documented in + /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#type. + /// The type field can be any of the following (from OpenAPI v3 data types): + /// + /// + /// `integer` - non-floating-point number + /// + /// + /// `number` - floating point number + /// + /// + /// `string` - strings + /// + /// + /// `boolean`- `true` or `false` + /// + /// + /// `date` - rendered differentially as time since this timestamp + /// + /// + /// If the value inside a CustomResource does not match the type specified for the column, the value is omitted. + /// + public string Type { get; } + + /// + /// The format of the column. + /// As documented in + /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#format. + /// The format field can be any of the following: + /// + /// + /// `int32` + /// + /// + /// `int64` + /// + /// + /// `float` + /// + /// + /// `double` + /// + /// + /// `byte` + /// + /// + /// `date` + /// + /// + /// `date-time` + /// + /// + /// `password` + /// + /// + /// + public string? Format { get; init; } + + /// + /// The priority of the additional printer column. + /// As documented in + /// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#priority + /// the following rules apply to priority: + /// + /// + /// Columns with priority `0` are shown in standard view + /// + /// + /// Columns with priority greater than `0` are shown only in wide view + /// + /// + /// + public PrinterColumnPriority Priority { get; init; } +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/IgnoreEntityAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/IgnoreEntityAttribute.cs index f55bf314..fc16b717 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/IgnoreEntityAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/IgnoreEntityAttribute.cs @@ -1,8 +1,8 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Attribute that states that the given entity or property should be -/// ignored during CRD generation. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)] -public class IgnoreAttribute : Attribute; +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Attribute that states that the given entity or property should be +/// ignored during CRD generation. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)] +public class IgnoreAttribute : Attribute; diff --git a/src/KubeOps.Abstractions/Entities/Attributes/ItemsAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/ItemsAttribute.cs index 5253044c..7faeb71d 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/ItemsAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/ItemsAttribute.cs @@ -1,26 +1,26 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Define minimum and maximum items count for an array property. -/// -[AttributeUsage(AttributeTargets.Property)] -public class ItemsAttribute(long minItems = -1, long maxItems = -1) : Attribute -{ - /// - /// Defines the minimal item count for the property. - /// - public long? MinItems => minItems switch - { - -1 => null, - _ => minItems, - }; - - /// - /// Defines the maximal item count for the property. - /// - public long? MaxItems => maxItems switch - { - -1 => null, - _ => maxItems, - }; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Define minimum and maximum items count for an array property. +/// +[AttributeUsage(AttributeTargets.Property)] +public class ItemsAttribute(long minItems = -1, long maxItems = -1) : Attribute +{ + /// + /// Defines the minimal item count for the property. + /// + public long? MinItems => minItems switch + { + -1 => null, + _ => minItems, + }; + + /// + /// Defines the maximal item count for the property. + /// + public long? MaxItems => maxItems switch + { + -1 => null, + _ => maxItems, + }; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/KubernetesEntityShortNamesAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/KubernetesEntityShortNamesAttribute.cs index 6ae22093..67b42040 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/KubernetesEntityShortNamesAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/KubernetesEntityShortNamesAttribute.cs @@ -1,13 +1,13 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Define "shortNames" for CRDs. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class KubernetesEntityShortNamesAttribute(params string[] shortNames) : Attribute -{ - /// - /// Array of shortnames that should be attached to CRDs. - /// - public string[] ShortNames => shortNames; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Define "shortNames" for CRDs. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class KubernetesEntityShortNamesAttribute(params string[] shortNames) : Attribute +{ + /// + /// Array of shortnames that should be attached to CRDs. + /// + public string[] ShortNames => shortNames; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/LengthAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/LengthAttribute.cs index a960302b..22611cf4 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/LengthAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/LengthAttribute.cs @@ -1,26 +1,26 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines length limits for properties. -/// -[AttributeUsage(AttributeTargets.Property)] -public class LengthAttribute(long minLength = -1, long maxLength = -1) : Attribute -{ - /// - /// Define the minimum length. - /// - public long? MinLength => minLength switch - { - -1 => null, - _ => minLength, - }; - - /// - /// Define the maximum length. - /// - public long? MaxLength => maxLength switch - { - -1 => null, - _ => maxLength, - }; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines length limits for properties. +/// +[AttributeUsage(AttributeTargets.Property)] +public class LengthAttribute(long minLength = -1, long maxLength = -1) : Attribute +{ + /// + /// Define the minimum length. + /// + public long? MinLength => minLength switch + { + -1 => null, + _ => minLength, + }; + + /// + /// Define the maximum length. + /// + public long? MaxLength => maxLength switch + { + -1 => null, + _ => maxLength, + }; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/MultipleOfAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/MultipleOfAttribute.cs index 66fe6f64..6bd8c32e 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/MultipleOfAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/MultipleOfAttribute.cs @@ -1,13 +1,13 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines the factor that a numeric value must adhere to. -/// -[AttributeUsage(AttributeTargets.Property)] -public class MultipleOfAttribute(double value) : Attribute -{ - /// - /// The property should be a multiple of this value. - /// - public double Value => value; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines the factor that a numeric value must adhere to. +/// +[AttributeUsage(AttributeTargets.Property)] +public class MultipleOfAttribute(double value) : Attribute +{ + /// + /// The property should be a multiple of this value. + /// + public double Value => value; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/PatternAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/PatternAttribute.cs index ad4fd054..ee0ea269 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/PatternAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/PatternAttribute.cs @@ -1,13 +1,13 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Define a regex validator for the property. -/// -[AttributeUsage(AttributeTargets.Property)] -public class PatternAttribute(string regexPattern) : Attribute -{ - /// - /// The regex pattern to be used. - /// - public string RegexPattern => regexPattern; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Define a regex validator for the property. +/// +[AttributeUsage(AttributeTargets.Property)] +public class PatternAttribute(string regexPattern) : Attribute +{ + /// + /// The regex pattern to be used. + /// + public string RegexPattern => regexPattern; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/PreserveUnknownFieldsAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/PreserveUnknownFieldsAttribute.cs index 8d092cea..d2181d87 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/PreserveUnknownFieldsAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/PreserveUnknownFieldsAttribute.cs @@ -1,8 +1,8 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines that a property should keep unknown fields -/// so that kubernetes does not purge additional structures. -/// -[AttributeUsage(AttributeTargets.Property)] -public class PreserveUnknownFieldsAttribute : Attribute; +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines that a property should keep unknown fields +/// so that kubernetes does not purge additional structures. +/// +[AttributeUsage(AttributeTargets.Property)] +public class PreserveUnknownFieldsAttribute : Attribute; diff --git a/src/KubeOps.Abstractions/Entities/Attributes/RangeMaximum.cs b/src/KubeOps.Abstractions/Entities/Attributes/RangeMaximum.cs index aed36e26..ba388b00 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/RangeMaximum.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/RangeMaximum.cs @@ -1,18 +1,18 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a range maximum for a numeric property. -/// -[AttributeUsage(AttributeTargets.Property)] -public class RangeMaximumAttribute(double maximum, bool exclusiveMaximum = false) : Attribute -{ - /// - /// Maximum value to be set. - /// - public double Maximum => maximum; - - /// - /// Defines if the maximum value is included or excluded. - /// - public bool ExclusiveMaximum => exclusiveMaximum; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a range maximum for a numeric property. +/// +[AttributeUsage(AttributeTargets.Property)] +public class RangeMaximumAttribute(double maximum, bool exclusiveMaximum = false) : Attribute +{ + /// + /// Maximum value to be set. + /// + public double Maximum => maximum; + + /// + /// Defines if the maximum value is included or excluded. + /// + public bool ExclusiveMaximum => exclusiveMaximum; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/RangeMinimum.cs b/src/KubeOps.Abstractions/Entities/Attributes/RangeMinimum.cs index 78bc5fb6..21168260 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/RangeMinimum.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/RangeMinimum.cs @@ -1,18 +1,18 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a range minimum for a numeric property. -/// -[AttributeUsage(AttributeTargets.Property)] -public class RangeMinimumAttribute(double minimum, bool exclusiveMinimum = false) : Attribute -{ - /// - /// Minimum value to be set. - /// - public double Minimum => minimum; - - /// - /// Defines if the minimum value is included or excluded. - /// - public bool ExclusiveMinimum => exclusiveMinimum; -} +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a range minimum for a numeric property. +/// +[AttributeUsage(AttributeTargets.Property)] +public class RangeMinimumAttribute(double minimum, bool exclusiveMinimum = false) : Attribute +{ + /// + /// Minimum value to be set. + /// + public double Minimum => minimum; + + /// + /// Defines if the minimum value is included or excluded. + /// + public bool ExclusiveMinimum => exclusiveMinimum; +} diff --git a/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs index 2ca6d6c5..9574fa74 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/RequiredAttribute.cs @@ -1,7 +1,7 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// Defines a property of a specification as required. -/// -[AttributeUsage(AttributeTargets.Property)] -public class RequiredAttribute : Attribute; +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// Defines a property of a specification as required. +/// +[AttributeUsage(AttributeTargets.Property)] +public class RequiredAttribute : Attribute; diff --git a/src/KubeOps.Abstractions/Entities/Attributes/StorageVersionAttribute.cs b/src/KubeOps.Abstractions/Entities/Attributes/StorageVersionAttribute.cs index db48b663..cff4ab19 100644 --- a/src/KubeOps.Abstractions/Entities/Attributes/StorageVersionAttribute.cs +++ b/src/KubeOps.Abstractions/Entities/Attributes/StorageVersionAttribute.cs @@ -1,11 +1,11 @@ -namespace KubeOps.Abstractions.Entities.Attributes; - -/// -/// This attribute marks an entity as the storage version of -/// an entity. Only one storage version must be set. -/// If none of the versions define this attribute, the "newest" -/// one is taken according to the kubernetes versioning rules. -/// GA > Beta > Alpha > non versions. -/// -[AttributeUsage(AttributeTargets.Class)] -public class StorageVersionAttribute : Attribute; +namespace KubeOps.Abstractions.Entities.Attributes; + +/// +/// This attribute marks an entity as the storage version of +/// an entity. Only one storage version must be set. +/// If none of the versions define this attribute, the "newest" +/// one is taken according to the kubernetes versioning rules. +/// GA > Beta > Alpha > non versions. +/// +[AttributeUsage(AttributeTargets.Class)] +public class StorageVersionAttribute : Attribute; diff --git a/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity.cs b/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity.cs index 7c453c75..90feafef 100644 --- a/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity.cs +++ b/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity.cs @@ -1,16 +1,16 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Entities; - -/// -/// Base class for custom Kubernetes entities. The interface -/// can be used on its own, but this class provides convenience initializers. -/// -public abstract class CustomKubernetesEntity : KubernetesObject, IKubernetesObject -{ - /// - /// The metadata of the kubernetes object. - /// - public V1ObjectMeta Metadata { get; set; } = new(); -} +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Base class for custom Kubernetes entities. The interface +/// can be used on its own, but this class provides convenience initializers. +/// +public abstract class CustomKubernetesEntity : KubernetesObject, IKubernetesObject +{ + /// + /// The metadata of the kubernetes object. + /// + public V1ObjectMeta Metadata { get; set; } = new(); +} diff --git a/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec,TStatus}.cs b/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec,TStatus}.cs index b6837686..36ee3d97 100644 --- a/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec,TStatus}.cs +++ b/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec,TStatus}.cs @@ -1,22 +1,22 @@ -using k8s; - -namespace KubeOps.Abstractions.Entities; - -/// -/// Defines a custom Kubernetes entity. -/// This entity contains a spec (like ) -/// and a status () which can be updated to reflect the state -/// of the entity. -/// -/// The type of the specified data. -/// The type of the status data. -public abstract class CustomKubernetesEntity : CustomKubernetesEntity, IStatus - where TSpec : new() - where TStatus : new() -{ - /// - /// Status object for the entity. - /// - // [JsonPropertyName("status")] - public TStatus Status { get; set; } = new(); -} +using k8s; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Defines a custom Kubernetes entity. +/// This entity contains a spec (like ) +/// and a status () which can be updated to reflect the state +/// of the entity. +/// +/// The type of the specified data. +/// The type of the status data. +public abstract class CustomKubernetesEntity : CustomKubernetesEntity, IStatus + where TSpec : new() + where TStatus : new() +{ + /// + /// Status object for the entity. + /// + // [JsonPropertyName("status")] + public TStatus Status { get; set; } = new(); +} diff --git a/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec}.cs b/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec}.cs index 5a0353d4..d9d13e83 100644 --- a/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec}.cs +++ b/src/KubeOps.Abstractions/Entities/CustomKubernetesEntity{TSpec}.cs @@ -1,17 +1,17 @@ -using k8s; - -namespace KubeOps.Abstractions.Entities; - -/// -/// Defines a custom kubernetes entity which can be used in finalizers and controllers. -/// This entity contains a , which means in contains specified data. -/// -/// The type of the specified data. -public abstract class CustomKubernetesEntity : CustomKubernetesEntity, ISpec - where TSpec : new() -{ - /// - /// Specification of the kubernetes object. - /// - public TSpec Spec { get; set; } = new(); -} +using k8s; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Defines a custom kubernetes entity which can be used in finalizers and controllers. +/// This entity contains a , which means in contains specified data. +/// +/// The type of the specified data. +public abstract class CustomKubernetesEntity : CustomKubernetesEntity, ISpec + where TSpec : new() +{ + /// + /// Specification of the kubernetes object. + /// + public TSpec Spec { get; set; } = new(); +} diff --git a/src/KubeOps.Abstractions/Entities/EntityList.cs b/src/KubeOps.Abstractions/Entities/EntityList.cs index 769f917e..5071e7eb 100644 --- a/src/KubeOps.Abstractions/Entities/EntityList.cs +++ b/src/KubeOps.Abstractions/Entities/EntityList.cs @@ -1,22 +1,22 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Entities; - -/// -/// Type for a list of entities. -/// -/// Type for the list entries. -public class EntityList : KubernetesObject - where T : IKubernetesObject -{ - /// - /// Official list metadata object of kubernetes. - /// - public V1ListMeta Metadata { get; set; } = new(); - - /// - /// The list of items. - /// - public IList Items { get; set; } = new List(); -} +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Type for a list of entities. +/// +/// Type for the list entries. +public class EntityList : KubernetesObject + where T : IKubernetesObject +{ + /// + /// Official list metadata object of kubernetes. + /// + public V1ListMeta Metadata { get; set; } = new(); + + /// + /// The list of items. + /// + public IList Items { get; set; } = new List(); +} diff --git a/src/KubeOps.Abstractions/Entities/EntityMetadata.cs b/src/KubeOps.Abstractions/Entities/EntityMetadata.cs index c72c2ecc..3b035caa 100644 --- a/src/KubeOps.Abstractions/Entities/EntityMetadata.cs +++ b/src/KubeOps.Abstractions/Entities/EntityMetadata.cs @@ -1,28 +1,28 @@ -namespace KubeOps.Abstractions.Entities; - -/// -/// Metadata for a given entity. -/// -/// The kind of the entity (e.g. deployment). -/// Version (e.g. v1 or v2-alpha). -/// The group in Kubernetes (e.g. "testing.dev"). -/// An optional plural name. Defaults to the singular name with an added "s". -public record EntityMetadata(string Kind, string Version, string? Group = null, string? Plural = null) -{ - /// - /// Kind of the entity when used in a list. - /// - public string ListKind => $"{Kind}List"; - - /// - /// Name of the singular entity. - /// - public string SingularName => Kind.ToLowerInvariant(); - - /// - /// Name of the plural entity. - /// - public string PluralName => (Plural ?? $"{Kind}s").ToLowerInvariant(); - - public string GroupWithVersion => $"{Group ?? string.Empty}/{Version}".TrimStart('/'); -} +namespace KubeOps.Abstractions.Entities; + +/// +/// Metadata for a given entity. +/// +/// The kind of the entity (e.g. deployment). +/// Version (e.g. v1 or v2-alpha). +/// The group in Kubernetes (e.g. "testing.dev"). +/// An optional plural name. Defaults to the singular name with an added "s". +public record EntityMetadata(string Kind, string Version, string? Group = null, string? Plural = null) +{ + /// + /// Kind of the entity when used in a list. + /// + public string ListKind => $"{Kind}List"; + + /// + /// Name of the singular entity. + /// + public string SingularName => Kind.ToLowerInvariant(); + + /// + /// Name of the plural entity. + /// + public string PluralName => (Plural ?? $"{Kind}s").ToLowerInvariant(); + + public string GroupWithVersion => $"{Group ?? string.Empty}/{Version}".TrimStart('/'); +} diff --git a/src/KubeOps.Abstractions/Entities/EntityScope.cs b/src/KubeOps.Abstractions/Entities/EntityScope.cs index b7be754d..ad627ca7 100644 --- a/src/KubeOps.Abstractions/Entities/EntityScope.cs +++ b/src/KubeOps.Abstractions/Entities/EntityScope.cs @@ -1,18 +1,18 @@ -namespace KubeOps.Abstractions.Entities; - -/// -/// Scope of the resource. Custom entities (resources) in Kubernetes -/// can either be namespaced or cluster-wide. -/// -public enum EntityScope -{ - /// - /// The resource is namespace. - /// - Namespaced, - - /// - /// The resource is cluster-wide. - /// - Cluster, -} +namespace KubeOps.Abstractions.Entities; + +/// +/// Scope of the resource. Custom entities (resources) in Kubernetes +/// can either be namespaced or cluster-wide. +/// +public enum EntityScope +{ + /// + /// The resource is namespace. + /// + Namespaced, + + /// + /// The resource is cluster-wide. + /// + Cluster, +} diff --git a/src/KubeOps.Abstractions/Entities/Extensions.cs b/src/KubeOps.Abstractions/Entities/Extensions.cs index 28aa46ac..cd69d1c8 100644 --- a/src/KubeOps.Abstractions/Entities/Extensions.cs +++ b/src/KubeOps.Abstractions/Entities/Extensions.cs @@ -1,89 +1,89 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Entities; - -/// -/// Method extensions for . -/// -public static class Extensions -{ - /// - /// Sets the resource version of the specified Kubernetes object to the specified value. - /// - /// The type of the Kubernetes object. - /// The Kubernetes object. - /// The resource version to set. - /// The Kubernetes object with the updated resource version. - public static TEntity WithResourceVersion( - this TEntity entity, - string resourceVersion) - where TEntity : IKubernetesObject - { - entity.EnsureMetadata().ResourceVersion = resourceVersion; - return entity; - } - - /// - /// Sets the resource version of the specified Kubernetes object to the resource version of another object. - /// - /// The type of the Kubernetes object. - /// The Kubernetes object. - /// The other Kubernetes object. - /// The Kubernetes object with the updated resource version. - public static TEntity WithResourceVersion( - this TEntity entity, - TEntity other) - where TEntity : IKubernetesObject - { - entity.EnsureMetadata().ResourceVersion = other.ResourceVersion(); - return entity; - } - - /// - /// Create a of a kubernetes object. - /// - /// The object that should be translated. - /// The created . - public static V1ObjectReference MakeObjectReference(this IKubernetesObject kubernetesObject) - => new() - { - ApiVersion = kubernetesObject.ApiVersion, - Kind = kubernetesObject.Kind, - Name = kubernetesObject.Metadata.Name, - NamespaceProperty = kubernetesObject.Metadata.NamespaceProperty, - ResourceVersion = kubernetesObject.Metadata.ResourceVersion, - Uid = kubernetesObject.Metadata.Uid, - }; - - /// - /// Ensures the object contains owner references and adds the owner to the list. - /// - /// The resource that is owned by another resource. - /// The owner to add. - /// The type of the entity. - /// The resource with the added owner reference. - public static TEntity WithOwnerReference( - this TEntity resource, - IKubernetesObject owner) - where TEntity : IKubernetesObject - { - resource.EnsureMetadata().EnsureOwnerReferences().Add(owner.MakeOwnerReference()); - return resource; - } - - /// - /// Create a out of a kubernetes object. - /// - /// The object that should be translated. - /// The created . - public static V1OwnerReference MakeOwnerReference(this IKubernetesObject kubernetesObject) - => new( - kubernetesObject.ApiVersion, - kubernetesObject.Kind, - kubernetesObject.Metadata.Name, - kubernetesObject.Metadata.Uid); - - private static IList EnsureOwnerReferences(this V1ObjectMeta meta) => - meta.OwnerReferences ??= new List(); -} +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +/// +/// Method extensions for . +/// +public static class Extensions +{ + /// + /// Sets the resource version of the specified Kubernetes object to the specified value. + /// + /// The type of the Kubernetes object. + /// The Kubernetes object. + /// The resource version to set. + /// The Kubernetes object with the updated resource version. + public static TEntity WithResourceVersion( + this TEntity entity, + string resourceVersion) + where TEntity : IKubernetesObject + { + entity.EnsureMetadata().ResourceVersion = resourceVersion; + return entity; + } + + /// + /// Sets the resource version of the specified Kubernetes object to the resource version of another object. + /// + /// The type of the Kubernetes object. + /// The Kubernetes object. + /// The other Kubernetes object. + /// The Kubernetes object with the updated resource version. + public static TEntity WithResourceVersion( + this TEntity entity, + TEntity other) + where TEntity : IKubernetesObject + { + entity.EnsureMetadata().ResourceVersion = other.ResourceVersion(); + return entity; + } + + /// + /// Create a of a kubernetes object. + /// + /// The object that should be translated. + /// The created . + public static V1ObjectReference MakeObjectReference(this IKubernetesObject kubernetesObject) + => new() + { + ApiVersion = kubernetesObject.ApiVersion, + Kind = kubernetesObject.Kind, + Name = kubernetesObject.Metadata.Name, + NamespaceProperty = kubernetesObject.Metadata.NamespaceProperty, + ResourceVersion = kubernetesObject.Metadata.ResourceVersion, + Uid = kubernetesObject.Metadata.Uid, + }; + + /// + /// Ensures the object contains owner references and adds the owner to the list. + /// + /// The resource that is owned by another resource. + /// The owner to add. + /// The type of the entity. + /// The resource with the added owner reference. + public static TEntity WithOwnerReference( + this TEntity resource, + IKubernetesObject owner) + where TEntity : IKubernetesObject + { + resource.EnsureMetadata().EnsureOwnerReferences().Add(owner.MakeOwnerReference()); + return resource; + } + + /// + /// Create a out of a kubernetes object. + /// + /// The object that should be translated. + /// The created . + public static V1OwnerReference MakeOwnerReference(this IKubernetesObject kubernetesObject) + => new( + kubernetesObject.ApiVersion, + kubernetesObject.Kind, + kubernetesObject.Metadata.Name, + kubernetesObject.Metadata.Uid); + + private static IList EnsureOwnerReferences(this V1ObjectMeta meta) => + meta.OwnerReferences ??= new List(); +} diff --git a/src/KubeOps.Abstractions/Entities/PrinterColumnPriority.cs b/src/KubeOps.Abstractions/Entities/PrinterColumnPriority.cs index ec1af51d..fb205292 100644 --- a/src/KubeOps.Abstractions/Entities/PrinterColumnPriority.cs +++ b/src/KubeOps.Abstractions/Entities/PrinterColumnPriority.cs @@ -1,17 +1,17 @@ -namespace KubeOps.Abstractions.Entities; - -/// -/// Specifies the priority of a column in an additional printer view. -/// -public enum PrinterColumnPriority -{ - /// - /// The column is displayed in the standard view. - /// - StandardView, - - /// - /// The column is displayed in the wide view. - /// - WideView, -} +namespace KubeOps.Abstractions.Entities; + +/// +/// Specifies the priority of a column in an additional printer view. +/// +public enum PrinterColumnPriority +{ + /// + /// The column is displayed in the standard view. + /// + StandardView, + + /// + /// The column is displayed in the wide view. + /// + WideView, +} diff --git a/src/KubeOps.Abstractions/Events/EventType.cs b/src/KubeOps.Abstractions/Events/EventType.cs index 68183ca0..4d4ce659 100644 --- a/src/KubeOps.Abstractions/Events/EventType.cs +++ b/src/KubeOps.Abstractions/Events/EventType.cs @@ -1,20 +1,20 @@ -using k8s.Models; - -namespace KubeOps.Abstractions.Events; - -/// -/// The type of a . -/// The event type will be stringified and used as . -/// -public enum EventType -{ - /// - /// A normal event, informative value. - /// - Normal, - - /// - /// A warning, something might went wrong. - /// - Warning, -} +using k8s.Models; + +namespace KubeOps.Abstractions.Events; + +/// +/// The type of a . +/// The event type will be stringified and used as . +/// +public enum EventType +{ + /// + /// A normal event, informative value. + /// + Normal, + + /// + /// A warning, something might went wrong. + /// + Warning, +} diff --git a/src/KubeOps.Abstractions/Events/IEventPublisherFactory.cs b/src/KubeOps.Abstractions/Events/IEventPublisherFactory.cs new file mode 100644 index 00000000..6ad7d930 --- /dev/null +++ b/src/KubeOps.Abstractions/Events/IEventPublisherFactory.cs @@ -0,0 +1,13 @@ +namespace KubeOps.Abstractions.Events; + +/// +/// Represents a type used to create s for clients and controllers. +/// +public interface IEventPublisherFactory +{ + /// + /// Creates a new event publisher. + /// + /// The . + EventPublisher Create(); +} diff --git a/src/KubeOps.Abstractions/Events/Publisher.cs b/src/KubeOps.Abstractions/Events/Publisher.cs index cef72725..769133c4 100644 --- a/src/KubeOps.Abstractions/Events/Publisher.cs +++ b/src/KubeOps.Abstractions/Events/Publisher.cs @@ -1,46 +1,51 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Events; - -/// -/// This injectable delegate publishes events on entities. Events are created in the same -/// namespace as the provided entity. However, if no namespace is provided (for example in -/// cluster wide entities), the "default" namespace is used. -/// -/// The delegate creates a if none does exist or updates the -/// count and last seen timestamp if the same event already fired. -/// -/// Events have a hex encoded name of a SHA512 hash. For the delegate to update -/// an event, the entity, reason, message, and type must be the same. -/// -/// The entity that is involved with the event. -/// The reason string. This should be a machine readable reason string. -/// A human readable string for the event. -/// The of the event (either normal or warning). -/// A task that finishes when the event is created or updated. -/// -/// Controller that fires a simple reconcile event on any entity it encounters. -/// Note that the publication of an event does not trigger another reconcile. -/// -/// public class V1TestEntityController : IEntityController<V1TestEntity> -/// { -/// private readonly EventPublisher _eventPublisher; -/// -/// public V1TestEntityController() -/// { -/// _eventPublisher = eventPublisher; -/// } -/// -/// public async Task ReconcileAsync(V1TestEntity entity) -/// { -/// await _eventPublisher(entity, "Reconciled", "Entity was reconciled."); -/// } -/// } -/// -/// -public delegate Task EventPublisher( - IKubernetesObject entity, - string reason, - string message, - EventType type = EventType.Normal); +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Events; + +/// +/// +/// Publish a new event for . Events are created in the same namespace. However, +/// if no namespace is provided (e.g. for cluster-wide entities), the "default" namespace is used. +/// +/// +/// This effectively creates a new or updates the and +/// of the existing . +/// +/// +/// +/// Events have a hex encoded name of a SHA512 hash. For the delegate to update an event, +/// , , and must be the same. +/// +/// The entity that is involved with the event. +/// The reason string. This should be a machine readable reason string. +/// A human readable string for the event. +/// The of the event. +/// The token to monitor for cancellation requests. +/// A task that finishes when the event is created or updated. +/// +/// Controller that fires a simple reconcile event on any entity it encounters. +/// Note that the publication of an event does not trigger another reconcile. +/// +/// public class V1TestEntityController : IEntityController<V1TestEntity> +/// { +/// private readonly EventPublisher _eventPublisher; +/// +/// public V1TestEntityController() +/// { +/// _eventPublisher = eventPublisher; +/// } +/// +/// public async Task ReconcileAsync(V1TestEntity entity, CancellationToken token) +/// { +/// await _eventPublisher(entity, "Reconciled", "Entity was reconciled.", cancellationToken: token); +/// } +/// } +/// +/// +public delegate Task EventPublisher( + IKubernetesObject entity, + string reason, + string message, + EventType type = EventType.Normal, + CancellationToken cancellationToken = default); diff --git a/src/KubeOps.Abstractions/Finalizer/EntityFinalizerAttacher.cs b/src/KubeOps.Abstractions/Finalizer/EntityFinalizerAttacher.cs index 742a0d17..fb910242 100644 --- a/src/KubeOps.Abstractions/Finalizer/EntityFinalizerAttacher.cs +++ b/src/KubeOps.Abstractions/Finalizer/EntityFinalizerAttacher.cs @@ -1,47 +1,50 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Finalizer; - -/// -/// -/// Injectable delegate for finalizers. This delegate is used to attach a finalizer -/// with its identifier to an entity. When injected, simply call the delegate with -/// the entity to attach the finalizer. -/// -/// -/// As with other (possibly) mutating calls, use the returned entity for further -/// modification and Kubernetes client interactions, since the resource version -/// is updated each time the entity is modified. -/// -/// -/// Note that the operator needs RBAC access to modify the list of -/// finalizers on the entity. -/// -/// -/// The type of the entity finalizer. -/// The type of the Kubernetes entity. -/// The instance of the entity, that the finalizer is attached if needed. -/// A that resolves when the finalizer was attached. -/// -/// Use the finalizer delegate to attach the "FinalizerOne" to the entity as soon -/// as the entity gets reconciled. -/// -/// [EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -/// public class V1TestEntityController : IEntityController<V1TestEntity> -/// { -/// private readonly EntityFinalizerAttacher<FinalizerOne, V1TestEntity> _finalizer1; -/// -/// public V1TestEntityController( -/// EntityFinalizerAttacher<FinalizerOne, V1TestEntity> finalizer1) => _finalizer1 = finalizer1; -/// -/// public async Task ReconcileAsync(V1TestEntity entity) -/// { -/// entity = await _finalizer1(entity); -/// } -/// } -/// -/// -public delegate Task EntityFinalizerAttacher(TEntity entity) - where TImplementation : IEntityFinalizer - where TEntity : IKubernetesObject; +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Finalizer; + +/// +/// +/// Injectable delegate for finalizers. This delegate is used to attach a finalizer +/// with its identifier to an entity. When injected, simply call the delegate with +/// the entity to attach the finalizer. +/// +/// +/// As with other (possibly) mutating calls, use the returned entity for further +/// modification and Kubernetes client interactions, since the resource version +/// is updated each time the entity is modified. +/// +/// +/// Note that the operator needs RBAC access to modify the list of +/// finalizers on the entity. +/// +/// +/// The type of the entity finalizer. +/// The type of the Kubernetes entity. +/// The instance of the entity, that the finalizer is attached if needed. +/// The token to monitor for cancellation requests. +/// A that resolves when the finalizer was attached. +/// +/// Use the finalizer delegate to attach the "FinalizerOne" to the entity as soon +/// as the entity gets reconciled. +/// +/// [EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +/// public class V1TestEntityController : IEntityController<V1TestEntity> +/// { +/// private readonly EntityFinalizerAttacher<FinalizerOne, V1TestEntity> _finalizer1; +/// +/// public V1TestEntityController( +/// EntityFinalizerAttacher<FinalizerOne, V1TestEntity> finalizer1) => _finalizer1 = finalizer1; +/// +/// public async Task ReconcileAsync(V1TestEntity entity, CancellationToken token) +/// { +/// entity = await _finalizer1(entity, token); +/// } +/// } +/// +/// +public delegate Task EntityFinalizerAttacher( + TEntity entity, + CancellationToken cancellationToken = default) + where TImplementation : IEntityFinalizer + where TEntity : IKubernetesObject; diff --git a/src/KubeOps.Abstractions/Finalizer/IEntityFinalizer{TEntity}.cs b/src/KubeOps.Abstractions/Finalizer/IEntityFinalizer{TEntity}.cs index 1bb9114c..d0af39fc 100644 --- a/src/KubeOps.Abstractions/Finalizer/IEntityFinalizer{TEntity}.cs +++ b/src/KubeOps.Abstractions/Finalizer/IEntityFinalizer{TEntity}.cs @@ -1,20 +1,20 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Finalizer; - -/// -/// Finalizer for an entity. -/// -/// The type of the entity. -public interface IEntityFinalizer - where TEntity : IKubernetesObject -{ - /// - /// Finalize an entity that is pending for deletion. - /// - /// The kubernetes entity that needs to be finalized. - /// A task that resolves when the operation is done. - Task FinalizeAsync(TEntity entity) => - Task.CompletedTask; -} +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Finalizer; + +/// +/// Finalizer for an entity. +/// +/// The type of the entity. +public interface IEntityFinalizer + where TEntity : IKubernetesObject +{ + /// + /// Finalize an entity that is pending for deletion. + /// + /// The kubernetes entity that needs to be finalized. + /// The token to monitor for cancellation requests. + /// A task that resolves when the operation is done. + Task FinalizeAsync(TEntity entity, CancellationToken cancellationToken); +} diff --git a/src/KubeOps.Abstractions/Finalizer/IEventFinalizerAttacherFactory.cs b/src/KubeOps.Abstractions/Finalizer/IEventFinalizerAttacherFactory.cs new file mode 100644 index 00000000..0f53adb7 --- /dev/null +++ b/src/KubeOps.Abstractions/Finalizer/IEventFinalizerAttacherFactory.cs @@ -0,0 +1,22 @@ +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Finalizer; + +/// +/// Represents a type used to create for controllers. +/// +public interface IEventFinalizerAttacherFactory +{ + /// + /// Creates a new , which attaches the finalizer of + /// type to . + /// + /// The finalizer identifier. + /// The finalizer. + /// The entity. + /// A delegate to attach the finalizer implementation to the entity. + EntityFinalizerAttacher Create(string identifier) + where TImplementation : class, IEntityFinalizer + where TEntity : IKubernetesObject; +} diff --git a/src/KubeOps.Abstractions/Kustomize/KustomizationConfig.cs b/src/KubeOps.Abstractions/Kustomize/KustomizationConfig.cs index a650edab..aee0a57f 100644 --- a/src/KubeOps.Abstractions/Kustomize/KustomizationConfig.cs +++ b/src/KubeOps.Abstractions/Kustomize/KustomizationConfig.cs @@ -1,55 +1,55 @@ -using k8s; - -namespace KubeOps.Abstractions.Kustomize; - -/// -/// (Partial) definition for a kustomization yaml. -/// -public class KustomizationConfig : KubernetesObject -{ - public KustomizationConfig() - { - ApiVersion = "kustomize.config.k8s.io/v1beta1"; - Kind = "Kustomization"; - } - - /// - /// Namespace that should be set. - /// - public string? Namespace { get; set; } - - /// - /// Name prefix that should be set. - /// - public string? NamePrefix { get; set; } - - /// - /// Common labels for the resources. - /// - public IDictionary? CommonLabels { get; set; } - - /// - /// Resource list. - /// - public IList? Resources { get; set; } - - /// - /// List of merge patches. - /// - public IList? PatchesStrategicMerge { get; set; } - - /// - /// List of . - /// - public IList? Images { get; set; } - - /// - /// List of . - /// - public IList? ConfigMapGenerator { get; set; } - - /// - /// List of . - /// - public IList? SecretGenerator { get; set; } -} +using k8s; + +namespace KubeOps.Abstractions.Kustomize; + +/// +/// (Partial) definition for a kustomization yaml. +/// +public class KustomizationConfig : KubernetesObject +{ + public KustomizationConfig() + { + ApiVersion = "kustomize.config.k8s.io/v1beta1"; + Kind = "Kustomization"; + } + + /// + /// Namespace that should be set. + /// + public string? Namespace { get; set; } + + /// + /// Name prefix that should be set. + /// + public string? NamePrefix { get; set; } + + /// + /// Common labels for the resources. + /// + public IDictionary? CommonLabels { get; set; } + + /// + /// Resource list. + /// + public IList? Resources { get; set; } + + /// + /// List of merge patches. + /// + public IList? PatchesStrategicMerge { get; set; } + + /// + /// List of . + /// + public IList? Images { get; set; } + + /// + /// List of . + /// + public IList? ConfigMapGenerator { get; set; } + + /// + /// List of . + /// + public IList? SecretGenerator { get; set; } +} diff --git a/src/KubeOps.Abstractions/Kustomize/KustomizationConfigMapGenerator.cs b/src/KubeOps.Abstractions/Kustomize/KustomizationConfigMapGenerator.cs index c2cfa022..16b1bd22 100644 --- a/src/KubeOps.Abstractions/Kustomize/KustomizationConfigMapGenerator.cs +++ b/src/KubeOps.Abstractions/Kustomize/KustomizationConfigMapGenerator.cs @@ -1,23 +1,23 @@ -namespace KubeOps.Abstractions.Kustomize; - -/// -/// Entity for config map generators in a kustomization.yaml file. -/// -public class KustomizationConfigMapGenerator -{ - /// - /// The name of the config map. - /// - public string Name { get; set; } = string.Empty; - - /// - /// List of files that should be added to the generated config map. - /// - public IList? Files { get; set; } - - /// - /// Config literals to add to the config map in the form of: - /// - NAME=value. - /// - public IList? Literals { get; set; } -} +namespace KubeOps.Abstractions.Kustomize; + +/// +/// Entity for config map generators in a kustomization.yaml file. +/// +public class KustomizationConfigMapGenerator +{ + /// + /// The name of the config map. + /// + public string Name { get; set; } = string.Empty; + + /// + /// List of files that should be added to the generated config map. + /// + public IList? Files { get; set; } + + /// + /// Config literals to add to the config map in the form of: + /// - NAME=value. + /// + public IList? Literals { get; set; } +} diff --git a/src/KubeOps.Abstractions/Kustomize/KustomizationImage.cs b/src/KubeOps.Abstractions/Kustomize/KustomizationImage.cs index ecef2b61..dc0e1009 100644 --- a/src/KubeOps.Abstractions/Kustomize/KustomizationImage.cs +++ b/src/KubeOps.Abstractions/Kustomize/KustomizationImage.cs @@ -1,22 +1,22 @@ -namespace KubeOps.Abstractions.Kustomize; - -/// -/// Definition for an "image" in a kustomization yaml. -/// -public class KustomizationImage -{ - /// - /// Name of the image. - /// - public string Name { get; set; } = string.Empty; - - /// - /// New name of the image. - /// - public string NewName { get; set; } = string.Empty; - - /// - /// New tag of the image. - /// - public string NewTag { get; set; } = string.Empty; -} +namespace KubeOps.Abstractions.Kustomize; + +/// +/// Definition for an "image" in a kustomization yaml. +/// +public class KustomizationImage +{ + /// + /// Name of the image. + /// + public string Name { get; set; } = string.Empty; + + /// + /// New name of the image. + /// + public string NewName { get; set; } = string.Empty; + + /// + /// New tag of the image. + /// + public string NewTag { get; set; } = string.Empty; +} diff --git a/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs b/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs index 856dce6e..e8cc080e 100644 --- a/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs +++ b/src/KubeOps.Abstractions/Kustomize/KustomizationSecretGenerator.cs @@ -1,23 +1,23 @@ -namespace KubeOps.Abstractions.Kustomize; - -/// -/// Entitiy for config map generators in a kustomization.yaml file. -/// -public class KustomizationSecretGenerator -{ - /// - /// The name of the config map. - /// - public string Name { get; set; } = string.Empty; - - /// - /// List of files that should be added to the generated config map. - /// - public IList? Files { get; set; } - - /// - /// Config literals to add to the config map in the form of: - /// - NAME=value. - /// - public IList? Literals { get; set; } -} +namespace KubeOps.Abstractions.Kustomize; + +/// +/// Entitiy for config map generators in a kustomization.yaml file. +/// +public class KustomizationSecretGenerator +{ + /// + /// The name of the config map. + /// + public string Name { get; set; } = string.Empty; + + /// + /// List of files that should be added to the generated config map. + /// + public IList? Files { get; set; } + + /// + /// Config literals to add to the config map in the form of: + /// - NAME=value. + /// + public IList? Literals { get; set; } +} diff --git a/src/KubeOps.Abstractions/Queue/EntityRequeue.cs b/src/KubeOps.Abstractions/Queue/EntityRequeue.cs index 036e4ae5..ce951440 100644 --- a/src/KubeOps.Abstractions/Queue/EntityRequeue.cs +++ b/src/KubeOps.Abstractions/Queue/EntityRequeue.cs @@ -1,47 +1,47 @@ -using k8s; -using k8s.Models; - -namespace KubeOps.Abstractions.Queue; - -/// -/// Injectable delegate for requeueing entities. -/// -/// Use this delegate when you need to pro-actively reconcile an entity after a -/// certain amount of time. This is useful if you want to check your entities -/// periodically. -/// -/// -/// After the timeout is reached, the entity is fetched -/// from the API and passed to the controller for reconciliation. -/// If the entity was deleted in the meantime, the controller will not be called. -/// -/// -/// If the entity gets modified while the timeout is running, the timer -/// is canceled and restarted, if another requeue is requested. -/// -/// -/// The type of the entity. -/// The instance of the entity that should be requeued. -/// The time to wait before another reconcile loop is fired. -/// -/// Use the requeue delegate to repeatedly reconcile an entity after 5 seconds. -/// -/// [EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -/// public class V1TestEntityController : IEntityController<V1TestEntity> -/// { -/// private readonly EntityRequeue<V1TestEntity> _requeue; -/// -/// public V1TestEntityController(EntityRequeue<V1TestEntity> requeue) -/// { -/// _requeue = requeue; -/// } -/// -/// public async Task ReconcileAsync(V1TestEntity entity) -/// { -/// _requeue(entity, TimeSpan.FromSeconds(5)); -/// } -/// } -/// -/// -public delegate void EntityRequeue(TEntity entity, TimeSpan requeueIn) - where TEntity : IKubernetesObject; +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Queue; + +/// +/// Injectable delegate for requeueing entities. +/// +/// Use this delegate when you need to pro-actively reconcile an entity after a +/// certain amount of time. This is useful, if you want to check your entities +/// periodically. +/// +/// +/// After the timeout is reached, the entity is fetched +/// from the API and passed to the controller for reconciliation. +/// If the entity was deleted in the meantime, the controller will not be called. +/// +/// +/// If the entity gets modified while the timeout is running, the timer +/// is canceled and restarted, if another requeue is requested. +/// +/// +/// The type of the entity. +/// The instance of the entity that should be requeued. +/// The time to wait before another reconcile loop is fired. +/// +/// Use the requeue delegate to repeatedly reconcile an entity after 5 seconds. +/// +/// [EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +/// public class V1TestEntityController : IEntityController<V1TestEntity> +/// { +/// private readonly EntityRequeue<V1TestEntity> _requeue; +/// +/// public V1TestEntityController(EntityRequeue<V1TestEntity> requeue) +/// { +/// _requeue = requeue; +/// } +/// +/// public async Task ReconcileAsync(V1TestEntity entity, CancellationToken token) +/// { +/// _requeue(entity, TimeSpan.FromSeconds(5)); +/// } +/// } +/// +/// +public delegate void EntityRequeue(TEntity entity, TimeSpan requeueIn) + where TEntity : IKubernetesObject; diff --git a/src/KubeOps.Abstractions/Queue/IEntityRequeueFactory.cs b/src/KubeOps.Abstractions/Queue/IEntityRequeueFactory.cs new file mode 100644 index 00000000..d32ef8aa --- /dev/null +++ b/src/KubeOps.Abstractions/Queue/IEntityRequeueFactory.cs @@ -0,0 +1,18 @@ +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Queue; + +/// +/// Represents a type used to create delegates of type for requeueing entities. +/// +public interface IEntityRequeueFactory +{ + /// + /// Creates a new for the given type. + /// + /// The entity type. + /// A . + EntityRequeue Create() + where TEntity : IKubernetesObject; +} diff --git a/src/KubeOps.Abstractions/Rbac/EntityRbacAttribute.cs b/src/KubeOps.Abstractions/Rbac/EntityRbacAttribute.cs index aaf86688..7c244a73 100644 --- a/src/KubeOps.Abstractions/Rbac/EntityRbacAttribute.cs +++ b/src/KubeOps.Abstractions/Rbac/EntityRbacAttribute.cs @@ -1,30 +1,30 @@ -namespace KubeOps.Abstractions.Rbac; - -/// -/// Generate rbac information for a type. -/// Attach this attribute to a controller with the type reference to -/// a custom entity to define rbac needs for this given type(s). -/// -/// -/// Allow the operator "ALL" access to the V1TestEntity. -/// -/// [EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] -/// -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class EntityRbacAttribute(params Type[] entities) : RbacAttribute -{ - /// - /// List of types that this rbac verbs are valid. - /// - public IEnumerable Entities => entities; - - /// - /// Flags ("list") of allowed verbs. - /// - /// Yaml example: - /// "verbs: ["get", "list", "watch"]". - /// - /// - public RbacVerb Verbs { get; init; } -} +namespace KubeOps.Abstractions.Rbac; + +/// +/// Generate rbac information for a type. +/// Attach this attribute to a controller with the type reference to +/// a custom entity to define rbac needs for this given type(s). +/// +/// +/// Allow the operator "ALL" access to the V1TestEntity. +/// +/// [EntityRbac(typeof(V1TestEntity), Verbs = RbacVerb.All)] +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class EntityRbacAttribute(params Type[] entities) : RbacAttribute +{ + /// + /// List of types that this rbac verbs are valid. + /// + public IEnumerable Entities => entities; + + /// + /// Flags ("list") of allowed verbs. + /// + /// Yaml example: + /// "verbs: ["get", "list", "watch"]". + /// + /// + public RbacVerb Verbs { get; init; } +} diff --git a/src/KubeOps.Abstractions/Rbac/GenericRbacAttribute.cs b/src/KubeOps.Abstractions/Rbac/GenericRbacAttribute.cs index 26e38928..9a7cbad2 100644 --- a/src/KubeOps.Abstractions/Rbac/GenericRbacAttribute.cs +++ b/src/KubeOps.Abstractions/Rbac/GenericRbacAttribute.cs @@ -1,45 +1,45 @@ -namespace KubeOps.Abstractions.Rbac; - -/// -/// -/// Generic attribute to define rbac needs for the operator. -/// This needs get generated into rbac - yaml style resources -/// for installation on a cluster. -/// -/// The attribute essentially defines the role definition of kubernetes. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class GenericRbacAttribute : RbacAttribute -{ - /// - /// List of groups. - /// - /// Yaml example: - /// "apiGroups: ...". - /// - /// - public string[] Groups { get; init; } = Array.Empty(); - - /// - /// List of resources. - /// - /// Yaml example: - /// "resources: ["pods"]". - /// - /// - public string[] Resources { get; init; } = Array.Empty(); - - /// - /// List of urls. - /// - public string[] Urls { get; init; } = Array.Empty(); - - /// - /// Flags ("list") of allowed verbs. - /// - /// Yaml example: - /// "verbs: ["get", "list", "watch"]". - /// - /// - public RbacVerb Verbs { get; init; } -} +namespace KubeOps.Abstractions.Rbac; + +/// +/// +/// Generic attribute to define rbac needs for the operator. +/// This needs get generated into rbac - yaml style resources +/// for installation on a cluster. +/// +/// The attribute essentially defines the role definition of kubernetes. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class GenericRbacAttribute : RbacAttribute +{ + /// + /// List of groups. + /// + /// Yaml example: + /// "apiGroups: ...". + /// + /// + public string[] Groups { get; init; } = Array.Empty(); + + /// + /// List of resources. + /// + /// Yaml example: + /// "resources: ["pods"]". + /// + /// + public string[] Resources { get; init; } = Array.Empty(); + + /// + /// List of urls. + /// + public string[] Urls { get; init; } = Array.Empty(); + + /// + /// Flags ("list") of allowed verbs. + /// + /// Yaml example: + /// "verbs: ["get", "list", "watch"]". + /// + /// + public RbacVerb Verbs { get; init; } +} diff --git a/src/KubeOps.Abstractions/Rbac/RbacAttribute.cs b/src/KubeOps.Abstractions/Rbac/RbacAttribute.cs index 007d7e32..34d16934 100644 --- a/src/KubeOps.Abstractions/Rbac/RbacAttribute.cs +++ b/src/KubeOps.Abstractions/Rbac/RbacAttribute.cs @@ -1,7 +1,7 @@ -namespace KubeOps.Abstractions.Rbac; - -/// -/// Abstract base class for all RBAC attributes. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public abstract class RbacAttribute : Attribute; +namespace KubeOps.Abstractions.Rbac; + +/// +/// Abstract base class for all RBAC attributes. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public abstract class RbacAttribute : Attribute; diff --git a/src/KubeOps.Abstractions/Rbac/RbacVerbs.cs b/src/KubeOps.Abstractions/Rbac/RbacVerbs.cs index 725d70b9..a51671b7 100644 --- a/src/KubeOps.Abstractions/Rbac/RbacVerbs.cs +++ b/src/KubeOps.Abstractions/Rbac/RbacVerbs.cs @@ -1,53 +1,53 @@ -namespace KubeOps.Abstractions.Rbac; - -/// -/// List of possible rbac verbs. -/// -[Flags] -public enum RbacVerb -{ - /// - /// No permissions on the resource. - /// - None = 0, - - /// - /// All possible permissions. - /// - All = 1 << 0, - - /// - /// Retrieve the resource from the api. - /// - Get = 1 << 1, - - /// - /// List resources on the api. - /// - List = 1 << 2, - - /// - /// Watch for events on resources. - /// - Watch = 1 << 3, - - /// - /// Create new instances of the resource. - /// - Create = 1 << 4, - - /// - /// Update existing resources. - /// - Update = 1 << 5, - - /// - /// Patch resources. - /// - Patch = 1 << 6, - - /// - /// Delete resources on the api. - /// - Delete = 1 << 7, -} +namespace KubeOps.Abstractions.Rbac; + +/// +/// List of possible rbac verbs. +/// +[Flags] +public enum RbacVerb +{ + /// + /// No permissions on the resource. + /// + None = 0, + + /// + /// All possible permissions. + /// + All = 1 << 0, + + /// + /// Retrieve the resource from the api. + /// + Get = 1 << 1, + + /// + /// List resources on the api. + /// + List = 1 << 2, + + /// + /// Watch for events on resources. + /// + Watch = 1 << 3, + + /// + /// Create new instances of the resource. + /// + Create = 1 << 4, + + /// + /// Update existing resources. + /// + Update = 1 << 5, + + /// + /// Patch resources. + /// + Patch = 1 << 6, + + /// + /// Delete resources on the api. + /// + Delete = 1 << 7, +} diff --git a/src/KubeOps.Cli/Arguments.cs b/src/KubeOps.Cli/Arguments.cs index 20991908..1de25a40 100644 --- a/src/KubeOps.Cli/Arguments.cs +++ b/src/KubeOps.Cli/Arguments.cs @@ -1,39 +1,39 @@ -using System.CommandLine; - -namespace KubeOps.Cli; - -internal static class Arguments -{ - public static readonly Argument SolutionOrProjectFile = new( - "sln/csproj file", - () => - { - var projectFile - = Directory.EnumerateFiles( - Directory.GetCurrentDirectory(), - "*.csproj") - .Select(f => new FileInfo(f)) - .FirstOrDefault(); - var slnFile - = Directory.EnumerateFiles( - Directory.GetCurrentDirectory(), - "*.sln") - .Select(f => new FileInfo(f)) - .FirstOrDefault(); - - return (projectFile, slnFile) switch - { - ({ } prj, _) => prj, - (_, { } sln) => sln, - _ => null, - }; - }, - "A solution or project file where entities are located. " + - "If omitted, the current directory is searched for a *.csproj or *.sln file. " + - "If an *.sln file is used, all projects in the solution (with the newest framework) will be searched for entities. " + - "This behaviour can be filtered by using the --project and --target-framework option."); - - public static readonly Argument OperatorName = new( - "name", - "Name of the operator."); -} +using System.CommandLine; + +namespace KubeOps.Cli; + +internal static class Arguments +{ + public static readonly Argument SolutionOrProjectFile = new( + "sln/csproj file", + () => + { + var projectFile + = Directory.EnumerateFiles( + Directory.GetCurrentDirectory(), + "*.csproj") + .Select(f => new FileInfo(f)) + .FirstOrDefault(); + var slnFile + = Directory.EnumerateFiles( + Directory.GetCurrentDirectory(), + "*.sln") + .Select(f => new FileInfo(f)) + .FirstOrDefault(); + + return (projectFile, slnFile) switch + { + ({ } prj, _) => prj, + (_, { } sln) => sln, + _ => null, + }; + }, + "A solution or project file where entities are located. " + + "If omitted, the current directory is searched for a *.csproj or *.sln file. " + + "If an *.sln file is used, all projects in the solution (with the newest framework) will be searched for entities. " + + "This behaviour can be filtered by using the --project and --target-framework option."); + + public static readonly Argument OperatorName = new( + "name", + "Name of the operator."); +} diff --git a/src/KubeOps.Cli/Certificates/CertificateGenerator.cs b/src/KubeOps.Cli/Certificates/CertificateGenerator.cs index cb52f2d3..5d4d04c7 100644 --- a/src/KubeOps.Cli/Certificates/CertificateGenerator.cs +++ b/src/KubeOps.Cli/Certificates/CertificateGenerator.cs @@ -1,129 +1,129 @@ -using Org.BouncyCastle.Asn1.X509; -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.Crypto.Generators; -using Org.BouncyCastle.Crypto.Operators; -using Org.BouncyCastle.Crypto.Prng; -using Org.BouncyCastle.Math; -using Org.BouncyCastle.Security; -using Org.BouncyCastle.Utilities; -using Org.BouncyCastle.X509; -using Org.BouncyCastle.X509.Extension; - -namespace KubeOps.Cli.Certificates; - -internal static class CertificateGenerator -{ - public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate() - { - var randomGenerator = new CryptoApiRandomGenerator(); - var random = new SecureRandom(randomGenerator); - - // The Certificate Generator - var certificateGenerator = new X509V3CertificateGenerator(); - - // Serial Number - var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); - certificateGenerator.SetSerialNumber(serialNumber); - - // Issuer and Subject Name - var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes"); - certificateGenerator.SetIssuerDN(name); - certificateGenerator.SetSubjectDN(name); - - // Valid For - var notBefore = DateTime.UtcNow.Date; - var notAfter = notBefore.AddYears(5); - certificateGenerator.SetNotBefore(notBefore); - certificateGenerator.SetNotAfter(notAfter); - - // Cert Extensions - certificateGenerator.AddExtension( - X509Extensions.BasicConstraints, - true, - new BasicConstraints(true)); - certificateGenerator.AddExtension( - X509Extensions.KeyUsage, - true, - new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment)); - - // Subject Public Key - const int keyStrength = 256; - var keyGenerator = new ECKeyPairGenerator("ECDSA"); - keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); - var key = keyGenerator.GenerateKeyPair(); - - certificateGenerator.SetPublicKey(key.Public); - - var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random); - var certificate = certificateGenerator.Generate(signatureFactory); - - return (certificate, key); - } - - public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate( - (X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace) - { - var randomGenerator = new CryptoApiRandomGenerator(); - var random = new SecureRandom(randomGenerator); - - // The Certificate Generator - var certificateGenerator = new X509V3CertificateGenerator(); - - // Serial Number - var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); - certificateGenerator.SetSerialNumber(serialNumber); - - // Issuer and Subject Name - certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN); - certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes")); - - // Valid For - var notBefore = DateTime.UtcNow.Date; - var notAfter = notBefore.AddYears(5); - certificateGenerator.SetNotBefore(notBefore); - certificateGenerator.SetNotAfter(notAfter); - - // Cert Extensions - certificateGenerator.AddExtension( - X509Extensions.BasicConstraints, - false, - new BasicConstraints(false)); - certificateGenerator.AddExtension( - X509Extensions.KeyUsage, - true, - new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature)); - certificateGenerator.AddExtension( - X509Extensions.ExtendedKeyUsage, - false, - new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth)); - certificateGenerator.AddExtension( - X509Extensions.SubjectKeyIdentifier, - false, - new SubjectKeyIdentifierStructure(ca.Key.Public)); - certificateGenerator.AddExtension( - X509Extensions.AuthorityKeyIdentifier, - false, - new AuthorityKeyIdentifierStructure(ca.Certificate)); - certificateGenerator.AddExtension( - X509Extensions.SubjectAlternativeName, - false, - new GeneralNames([ - new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"), - new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"), - new GeneralName(GeneralName.DnsName, "*.svc"), - ])); - - // Subject Public Key - const int keyStrength = 256; - var keyGenerator = new ECKeyPairGenerator("ECDSA"); - keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); - var key = keyGenerator.GenerateKeyPair(); - - certificateGenerator.SetPublicKey(key.Public); - - var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random); - var certificate = certificateGenerator.Generate(signatureFactory); - - return (certificate, key); - } -} +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Prng; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities; +using Org.BouncyCastle.X509; +using Org.BouncyCastle.X509.Extension; + +namespace KubeOps.Cli.Certificates; + +internal static class CertificateGenerator +{ + public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateCaCertificate() + { + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + + // The Certificate Generator + var certificateGenerator = new X509V3CertificateGenerator(); + + // Serial Number + var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); + certificateGenerator.SetSerialNumber(serialNumber); + + // Issuer and Subject Name + var name = new X509Name("CN=Operator Root CA, C=DEV, L=Kubernetes"); + certificateGenerator.SetIssuerDN(name); + certificateGenerator.SetSubjectDN(name); + + // Valid For + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(5); + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + + // Cert Extensions + certificateGenerator.AddExtension( + X509Extensions.BasicConstraints, + true, + new BasicConstraints(true)); + certificateGenerator.AddExtension( + X509Extensions.KeyUsage, + true, + new KeyUsage(KeyUsage.KeyCertSign | KeyUsage.CrlSign | KeyUsage.KeyEncipherment)); + + // Subject Public Key + const int keyStrength = 256; + var keyGenerator = new ECKeyPairGenerator("ECDSA"); + keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); + var key = keyGenerator.GenerateKeyPair(); + + certificateGenerator.SetPublicKey(key.Public); + + var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", key.Private, random); + var certificate = certificateGenerator.Generate(signatureFactory); + + return (certificate, key); + } + + public static (X509Certificate Certificate, AsymmetricCipherKeyPair Key) CreateServerCertificate( + (X509Certificate Certificate, AsymmetricCipherKeyPair Key) ca, string serverName, string serverNamespace) + { + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + + // The Certificate Generator + var certificateGenerator = new X509V3CertificateGenerator(); + + // Serial Number + var serialNumber = BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(long.MaxValue), random); + certificateGenerator.SetSerialNumber(serialNumber); + + // Issuer and Subject Name + certificateGenerator.SetIssuerDN(ca.Certificate.SubjectDN); + certificateGenerator.SetSubjectDN(new X509Name("CN=Operator Service, C=DEV, L=Kubernetes")); + + // Valid For + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(5); + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + + // Cert Extensions + certificateGenerator.AddExtension( + X509Extensions.BasicConstraints, + false, + new BasicConstraints(false)); + certificateGenerator.AddExtension( + X509Extensions.KeyUsage, + true, + new KeyUsage(KeyUsage.NonRepudiation | KeyUsage.KeyEncipherment | KeyUsage.DigitalSignature)); + certificateGenerator.AddExtension( + X509Extensions.ExtendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeID.id_kp_clientAuth, KeyPurposeID.id_kp_serverAuth)); + certificateGenerator.AddExtension( + X509Extensions.SubjectKeyIdentifier, + false, + new SubjectKeyIdentifierStructure(ca.Key.Public)); + certificateGenerator.AddExtension( + X509Extensions.AuthorityKeyIdentifier, + false, + new AuthorityKeyIdentifierStructure(ca.Certificate)); + certificateGenerator.AddExtension( + X509Extensions.SubjectAlternativeName, + false, + new GeneralNames([ + new GeneralName(GeneralName.DnsName, $"{serverName}.{serverNamespace}.svc"), + new GeneralName(GeneralName.DnsName, $"*.{serverNamespace}.svc"), + new GeneralName(GeneralName.DnsName, "*.svc"), + ])); + + // Subject Public Key + const int keyStrength = 256; + var keyGenerator = new ECKeyPairGenerator("ECDSA"); + keyGenerator.Init(new KeyGenerationParameters(random, keyStrength)); + var key = keyGenerator.GenerateKeyPair(); + + certificateGenerator.SetPublicKey(key.Public); + + var signatureFactory = new Asn1SignatureFactory("SHA512WITHECDSA", ca.Key.Private, random); + var certificate = certificateGenerator.Generate(signatureFactory); + + return (certificate, key); + } +} diff --git a/src/KubeOps.Cli/Certificates/Extensions.cs b/src/KubeOps.Cli/Certificates/Extensions.cs index a72bf103..dfe53b83 100644 --- a/src/KubeOps.Cli/Certificates/Extensions.cs +++ b/src/KubeOps.Cli/Certificates/Extensions.cs @@ -1,22 +1,22 @@ -using System.Text; - -using Org.BouncyCastle.Crypto; -using Org.BouncyCastle.OpenSsl; -using Org.BouncyCastle.X509; - -namespace KubeOps.Cli.Certificates; - -internal static class Extensions -{ - public static string ToPem(this X509Certificate cert) => ObjToPem(cert); - - public static string ToPem(this AsymmetricCipherKeyPair key) => ObjToPem(key); - - private static string ObjToPem(object obj) - { - var sb = new StringBuilder(); - using var writer = new PemWriter(new StringWriter(sb)); - writer.WriteObject(obj); - return sb.ToString(); - } -} +using System.Text; + +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.X509; + +namespace KubeOps.Cli.Certificates; + +internal static class Extensions +{ + public static string ToPem(this X509Certificate cert) => ObjToPem(cert); + + public static string ToPem(this AsymmetricCipherKeyPair key) => ObjToPem(key); + + private static string ObjToPem(object obj) + { + var sb = new StringBuilder(); + using var writer = new PemWriter(new StringWriter(sb)); + writer.WriteObject(obj); + return sb.ToString(); + } +} diff --git a/src/KubeOps.Cli/Commands/Generator/Generate.cs b/src/KubeOps.Cli/Commands/Generator/Generate.cs index 5287f2a8..ce81a081 100644 --- a/src/KubeOps.Cli/Commands/Generator/Generate.cs +++ b/src/KubeOps.Cli/Commands/Generator/Generate.cs @@ -1,23 +1,23 @@ -using System.CommandLine; -using System.CommandLine.Help; - -namespace KubeOps.Cli.Commands.Generator; - -internal static class Generate -{ - public static Command Command - { - get - { - var cmd = new Command("generate", "Generates elements related to an operator.") - { - OperatorGenerator.Command, - }; - cmd.AddAlias("gen"); - cmd.AddAlias("g"); - cmd.SetHandler(ctx => ctx.HelpBuilder.Write(cmd, Console.Out)); - - return cmd; - } - } -} +using System.CommandLine; +using System.CommandLine.Help; + +namespace KubeOps.Cli.Commands.Generator; + +internal static class Generate +{ + public static Command Command + { + get + { + var cmd = new Command("generate", "Generates elements related to an operator.") + { + OperatorGenerator.Command, + }; + cmd.AddAlias("gen"); + cmd.AddAlias("g"); + cmd.SetHandler(ctx => ctx.HelpBuilder.Write(cmd, Console.Out)); + + return cmd; + } + } +} diff --git a/src/KubeOps.Cli/Commands/Generator/OperatorGenerator.cs b/src/KubeOps.Cli/Commands/Generator/OperatorGenerator.cs index 8313afe5..7e9a86a6 100644 --- a/src/KubeOps.Cli/Commands/Generator/OperatorGenerator.cs +++ b/src/KubeOps.Cli/Commands/Generator/OperatorGenerator.cs @@ -1,178 +1,178 @@ -using System.CommandLine; -using System.CommandLine.Invocation; -using System.Text; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Kustomize; -using KubeOps.Cli.Generators; -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; - -using Spectre.Console; - -namespace KubeOps.Cli.Commands.Generator; - -internal static class OperatorGenerator -{ - public static Command Command - { - get - { - var cmd = - new Command( - "operator", - "Generates all required resources and configs for the operator to be built and run.") - { - Options.ClearOutputPath, - Options.OutputFormat, - Options.OutputPath, - Options.SolutionProjectRegex, - Options.TargetFramework, - Arguments.OperatorName, - Arguments.SolutionOrProjectFile, - }; - cmd.AddAlias("op"); - cmd.SetHandler(Handler); - - return cmd; - } - } - - private static async Task Handler(InvocationContext ctx) - { - var name = ctx.ParseResult.GetValueForArgument(Arguments.OperatorName); - var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); - var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath); - var format = ctx.ParseResult.GetValueForOption(Options.OutputFormat); - - var result = new ResultOutput(AnsiConsole.Console, format); - AnsiConsole.Console.WriteLine("Generate operator resources."); - - AnsiConsole.Console.MarkupLine("[green]Load Project/Solution file.[/]"); - var parser = file switch - { - { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(AnsiConsole.Console, file), - { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( - AnsiConsole.Console, - file, - ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), - ctx.ParseResult.GetValueForOption(Options.TargetFramework)), - { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), - _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), - }; - - var mutators = parser.GetMutatedEntities().ToList(); - var validators = parser.GetValidatedEntities().ToList(); - var hasWebhooks = mutators.Count > 0 || validators.Count > 0 || parser.GetConvertedEntities().Any(); - - AnsiConsole.Console.MarkupLine("[green]Generate RBAC rules.[/]"); - new RbacGenerator(parser, format).Generate(result); - - AnsiConsole.Console.MarkupLine("[green]Generate Dockerfile.[/]"); - new DockerfileGenerator(hasWebhooks).Generate(result); - - if (hasWebhooks) - { - AnsiConsole.Console.MarkupLine( - "[yellow]The operator contains webhooks of some sort, generating webhook operator specific resources.[/]"); - - AnsiConsole.Console.MarkupLine("[green]Generate CA and Server certificates.[/]"); - new CertificateGenerator(name, $"{name}-system").Generate(result); - - AnsiConsole.Console.MarkupLine("[green]Generate Deployment and Service.[/]"); - new WebhookDeploymentGenerator(format).Generate(result); - - var caBundle = - Encoding.ASCII.GetBytes( - Convert.ToBase64String(Encoding.ASCII.GetBytes(result["ca.pem"].ToString() ?? string.Empty))); - - AnsiConsole.Console.MarkupLine("[green]Generate Validation Webhooks.[/]"); - new ValidationWebhookGenerator(validators, caBundle, format).Generate(result); - - AnsiConsole.Console.MarkupLine("[green]Generate Mutation Webhooks.[/]"); - new MutationWebhookGenerator(mutators, caBundle, format).Generate(result); - - AnsiConsole.Console.MarkupLine("[green]Generate CRDs.[/]"); - new CrdGenerator(parser, caBundle, format).Generate(result); - } - else - { - AnsiConsole.Console.MarkupLine("[green]Generate Deployment.[/]"); - new DeploymentGenerator(format).Generate(result); - - AnsiConsole.Console.MarkupLine("[green]Generate CRDs.[/]"); - new CrdGenerator(parser, Array.Empty(), format).Generate(result); - } - - result.Add( - $"namespace.{format.GetFileExtension()}", - new V1Namespace(metadata: new(name: "system")).Initialize()); - - result.Add( - $"kustomization.{format.GetFileExtension()}", - new KustomizationConfig - { - NamePrefix = $"{name}-", - Namespace = $"{name}-system", - CommonLabels = new Dictionary { { "operator", name }, }, - Resources = result.DefaultFormatFiles.ToList(), - Images = - new List - { - new() { Name = "operator", NewName = "accessible-docker-image", NewTag = "latest", }, - }, - ConfigMapGenerator = hasWebhooks - ? new List - { - new() - { - Name = "webhook-config", - Literals = new List - { - "KESTREL__ENDPOINTS__HTTP__URL=http://0.0.0.0:5000", - "KESTREL__ENDPOINTS__HTTPS__URL=https://0.0.0.0:5001", - "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__PATH=/certs/svc.pem", - "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__KEYPATH=/certs/svc-key.pem", - }, - }, - } - : null, - SecretGenerator = hasWebhooks - ? new List - { - new() { Name = "webhook-ca", Files = new List { "ca.pem", "ca-key.pem", }, }, - new() { Name = "webhook-cert", Files = new List { "svc.pem", "svc-key.pem", }, }, - } - : null, - }); - - if (outPath is not null) - { - if (ctx.ParseResult.GetValueForOption(Options.ClearOutputPath)) - { - AnsiConsole.Console.MarkupLine("[yellow]Clear output path.[/]"); - try - { - Directory.Delete(outPath, true); - } - catch (DirectoryNotFoundException) - { - // the dir is not present, so we don't need to delete it. - } - catch (Exception e) - { - AnsiConsole.Console.MarkupLine($"[red]Could not clear output path: {e.Message}[/]"); - } - } - - AnsiConsole.Console.MarkupLine($"[green]Write output to {outPath}.[/]"); - await result.Write(outPath); - } - else - { - result.Write(); - } - } -} +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Kustomize; +using KubeOps.Cli.Generators; +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; + +using Spectre.Console; + +namespace KubeOps.Cli.Commands.Generator; + +internal static class OperatorGenerator +{ + public static Command Command + { + get + { + var cmd = + new Command( + "operator", + "Generates all required resources and configs for the operator to be built and run.") + { + Options.ClearOutputPath, + Options.OutputFormat, + Options.OutputPath, + Options.SolutionProjectRegex, + Options.TargetFramework, + Arguments.OperatorName, + Arguments.SolutionOrProjectFile, + }; + cmd.AddAlias("op"); + cmd.SetHandler(Handler); + + return cmd; + } + } + + private static async Task Handler(InvocationContext ctx) + { + var name = ctx.ParseResult.GetValueForArgument(Arguments.OperatorName); + var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); + var outPath = ctx.ParseResult.GetValueForOption(Options.OutputPath); + var format = ctx.ParseResult.GetValueForOption(Options.OutputFormat); + + var result = new ResultOutput(AnsiConsole.Console, format); + AnsiConsole.Console.WriteLine("Generate operator resources."); + + AnsiConsole.Console.MarkupLine("[green]Load Project/Solution file.[/]"); + var parser = file switch + { + { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(AnsiConsole.Console, file), + { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( + AnsiConsole.Console, + file, + ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), + ctx.ParseResult.GetValueForOption(Options.TargetFramework)), + { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), + _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), + }; + + var mutators = parser.GetMutatedEntities().ToList(); + var validators = parser.GetValidatedEntities().ToList(); + var hasWebhooks = mutators.Count > 0 || validators.Count > 0 || parser.GetConvertedEntities().Any(); + + AnsiConsole.Console.MarkupLine("[green]Generate RBAC rules.[/]"); + new RbacGenerator(parser, format).Generate(result); + + AnsiConsole.Console.MarkupLine("[green]Generate Dockerfile.[/]"); + new DockerfileGenerator(hasWebhooks).Generate(result); + + if (hasWebhooks) + { + AnsiConsole.Console.MarkupLine( + "[yellow]The operator contains webhooks of some sort, generating webhook operator specific resources.[/]"); + + AnsiConsole.Console.MarkupLine("[green]Generate CA and Server certificates.[/]"); + new CertificateGenerator(name, $"{name}-system").Generate(result); + + AnsiConsole.Console.MarkupLine("[green]Generate Deployment and Service.[/]"); + new WebhookDeploymentGenerator(format).Generate(result); + + var caBundle = + Encoding.ASCII.GetBytes( + Convert.ToBase64String(Encoding.ASCII.GetBytes(result["ca.pem"].ToString() ?? string.Empty))); + + AnsiConsole.Console.MarkupLine("[green]Generate Validation Webhooks.[/]"); + new ValidationWebhookGenerator(validators, caBundle, format).Generate(result); + + AnsiConsole.Console.MarkupLine("[green]Generate Mutation Webhooks.[/]"); + new MutationWebhookGenerator(mutators, caBundle, format).Generate(result); + + AnsiConsole.Console.MarkupLine("[green]Generate CRDs.[/]"); + new CrdGenerator(parser, caBundle, format).Generate(result); + } + else + { + AnsiConsole.Console.MarkupLine("[green]Generate Deployment.[/]"); + new DeploymentGenerator(format).Generate(result); + + AnsiConsole.Console.MarkupLine("[green]Generate CRDs.[/]"); + new CrdGenerator(parser, Array.Empty(), format).Generate(result); + } + + result.Add( + $"namespace.{format.GetFileExtension()}", + new V1Namespace(metadata: new(name: "system")).Initialize()); + + result.Add( + $"kustomization.{format.GetFileExtension()}", + new KustomizationConfig + { + NamePrefix = $"{name}-", + Namespace = $"{name}-system", + CommonLabels = new Dictionary { { "operator", name }, }, + Resources = result.DefaultFormatFiles.ToList(), + Images = + new List + { + new() { Name = "operator", NewName = "accessible-docker-image", NewTag = "latest", }, + }, + ConfigMapGenerator = hasWebhooks + ? new List + { + new() + { + Name = "webhook-config", + Literals = new List + { + "KESTREL__ENDPOINTS__HTTP__URL=http://0.0.0.0:5000", + "KESTREL__ENDPOINTS__HTTPS__URL=https://0.0.0.0:5001", + "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__PATH=/certs/svc.pem", + "KESTREL__ENDPOINTS__HTTPS__CERTIFICATE__KEYPATH=/certs/svc-key.pem", + }, + }, + } + : null, + SecretGenerator = hasWebhooks + ? new List + { + new() { Name = "webhook-ca", Files = new List { "ca.pem", "ca-key.pem", }, }, + new() { Name = "webhook-cert", Files = new List { "svc.pem", "svc-key.pem", }, }, + } + : null, + }); + + if (outPath is not null) + { + if (ctx.ParseResult.GetValueForOption(Options.ClearOutputPath)) + { + AnsiConsole.Console.MarkupLine("[yellow]Clear output path.[/]"); + try + { + Directory.Delete(outPath, true); + } + catch (DirectoryNotFoundException) + { + // the dir is not present, so we don't need to delete it. + } + catch (Exception e) + { + AnsiConsole.Console.MarkupLine($"[red]Could not clear output path: {e.Message}[/]"); + } + } + + AnsiConsole.Console.MarkupLine($"[green]Write output to {outPath}.[/]"); + await result.Write(outPath); + } + else + { + result.Write(); + } + } +} diff --git a/src/KubeOps.Cli/Commands/Management/Install.cs b/src/KubeOps.Cli/Commands/Management/Install.cs index ed16f0ba..91706bdf 100644 --- a/src/KubeOps.Cli/Commands/Management/Install.cs +++ b/src/KubeOps.Cli/Commands/Management/Install.cs @@ -1,112 +1,112 @@ -using System.CommandLine; -using System.CommandLine.Invocation; - -using k8s; -using k8s.Autorest; -using k8s.Models; - -using KubeOps.Cli.Transpilation; -using KubeOps.Transpiler; - -using Spectre.Console; - -namespace KubeOps.Cli.Commands.Management; - -internal static class Install -{ - public static Command Command - { - get - { - var cmd = - new Command("install", "Install CRDs into the cluster of the actually selected context.") - { - Options.Force, - Options.SolutionProjectRegex, - Options.TargetFramework, - Arguments.SolutionOrProjectFile, - }; - cmd.AddAlias("i"); - cmd.SetHandler(ctx => Handler( - AnsiConsole.Console, - new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), - ctx)); - - return cmd; - } - } - - internal static async Task Handler(IAnsiConsole console, IKubernetes client, InvocationContext ctx) - { - var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); - var force = ctx.ParseResult.GetValueForOption(Options.Force); - - var parser = file switch - { - { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(console, file), - { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( - console, - file, - ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), - ctx.ParseResult.GetValueForOption(Options.TargetFramework)), - { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), - _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), - }; - - console.WriteLine($"Install CRDs from {file.Name}."); - var crds = parser.Transpile(parser.GetEntities()).ToList(); - if (crds.Count == 0) - { - console.WriteLine("No CRDs found. Exiting."); - ctx.ExitCode = ExitCodes.Success; - return; - } - - console.WriteLine($"Found {crds.Count} CRDs."); - console.WriteLine($"""Starting install into cluster with url "{client.BaseUri}"."""); - - foreach (var crd in crds) - { - console.MarkupLineInterpolated( - $"""Install [cyan]"{crd.Spec.Group}/{crd.Spec.Names.Kind}"[/] into the cluster."""); - - try - { - switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( - fieldSelector: $"metadata.name={crd.Name()}")) - { - case { Items: [var existing] }: - console.MarkupLineInterpolated( - $"""[yellow]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" already exists.[/]"""); - if (!force && !console.Confirm("[yellow]Should the CRD be overwritten?[/]")) - { - ctx.ExitCode = ExitCodes.Aborted; - return; - } - - crd.Metadata.ResourceVersion = existing.ResourceVersion(); - await client.ApiextensionsV1.ReplaceCustomResourceDefinitionAsync(crd, crd.Name()); - break; - default: - await client.ApiextensionsV1.CreateCustomResourceDefinitionAsync(crd); - break; - } - - console.MarkupLineInterpolated( - $"""[green]Installed / Updated CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); - } - catch (HttpOperationException) - { - console.WriteLine( - $"""[red]There was a http (api) error while installing "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); - throw; - } - catch (Exception) - { - console.WriteLine( - $"""[red]There was an error while installing "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); - throw; - } - } - } -} +using System.CommandLine; +using System.CommandLine.Invocation; + +using k8s; +using k8s.Autorest; +using k8s.Models; + +using KubeOps.Cli.Transpilation; +using KubeOps.Transpiler; + +using Spectre.Console; + +namespace KubeOps.Cli.Commands.Management; + +internal static class Install +{ + public static Command Command + { + get + { + var cmd = + new Command("install", "Install CRDs into the cluster of the actually selected context.") + { + Options.Force, + Options.SolutionProjectRegex, + Options.TargetFramework, + Arguments.SolutionOrProjectFile, + }; + cmd.AddAlias("i"); + cmd.SetHandler(ctx => Handler( + AnsiConsole.Console, + new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), + ctx)); + + return cmd; + } + } + + internal static async Task Handler(IAnsiConsole console, IKubernetes client, InvocationContext ctx) + { + var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); + var force = ctx.ParseResult.GetValueForOption(Options.Force); + + var parser = file switch + { + { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(console, file), + { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( + console, + file, + ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), + ctx.ParseResult.GetValueForOption(Options.TargetFramework)), + { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), + _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), + }; + + console.WriteLine($"Install CRDs from {file.Name}."); + var crds = parser.Transpile(parser.GetEntities()).ToList(); + if (crds.Count == 0) + { + console.WriteLine("No CRDs found. Exiting."); + ctx.ExitCode = ExitCodes.Success; + return; + } + + console.WriteLine($"Found {crds.Count} CRDs."); + console.WriteLine($"""Starting install into cluster with url "{client.BaseUri}"."""); + + foreach (var crd in crds) + { + console.MarkupLineInterpolated( + $"""Install [cyan]"{crd.Spec.Group}/{crd.Spec.Names.Kind}"[/] into the cluster."""); + + try + { + switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( + fieldSelector: $"metadata.name={crd.Name()}")) + { + case { Items: [var existing] }: + console.MarkupLineInterpolated( + $"""[yellow]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" already exists.[/]"""); + if (!force && !console.Confirm("[yellow]Should the CRD be overwritten?[/]")) + { + ctx.ExitCode = ExitCodes.Aborted; + return; + } + + crd.Metadata.ResourceVersion = existing.ResourceVersion(); + await client.ApiextensionsV1.ReplaceCustomResourceDefinitionAsync(crd, crd.Name()); + break; + default: + await client.ApiextensionsV1.CreateCustomResourceDefinitionAsync(crd); + break; + } + + console.MarkupLineInterpolated( + $"""[green]Installed / Updated CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); + } + catch (HttpOperationException) + { + console.WriteLine( + $"""[red]There was a http (api) error while installing "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); + throw; + } + catch (Exception) + { + console.WriteLine( + $"""[red]There was an error while installing "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); + throw; + } + } + } +} diff --git a/src/KubeOps.Cli/Commands/Management/Uninstall.cs b/src/KubeOps.Cli/Commands/Management/Uninstall.cs index e09fbd39..8c249806 100644 --- a/src/KubeOps.Cli/Commands/Management/Uninstall.cs +++ b/src/KubeOps.Cli/Commands/Management/Uninstall.cs @@ -1,109 +1,109 @@ -using System.CommandLine; -using System.CommandLine.Invocation; - -using k8s; -using k8s.Autorest; -using k8s.Models; - -using KubeOps.Cli.Transpilation; -using KubeOps.Transpiler; - -using Spectre.Console; - -namespace KubeOps.Cli.Commands.Management; - -internal static class Uninstall -{ - public static Command Command - { - get - { - var cmd = - new Command("uninstall", "Uninstall CRDs from the cluster of the actually selected context.") - { - Options.Force, - Options.SolutionProjectRegex, - Options.TargetFramework, - Arguments.SolutionOrProjectFile, - }; - cmd.AddAlias("u"); - cmd.SetHandler(ctx => Handler( - AnsiConsole.Console, - new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), - ctx)); - - return cmd; - } - } - - internal static async Task Handler(IAnsiConsole console, IKubernetes client, InvocationContext ctx) - { - var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); - var force = ctx.ParseResult.GetValueForOption(Options.Force); - - var parser = file switch - { - { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(console, file), - { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( - console, - file, - ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), - ctx.ParseResult.GetValueForOption(Options.TargetFramework)), - { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), - _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), - }; - - console.WriteLine($"Uninstall CRDs from {file.Name}."); - var crds = parser.Transpile(parser.GetEntities()).ToList(); - if (crds.Count == 0) - { - console.WriteLine("No CRDs found. Exiting."); - ctx.ExitCode = ExitCodes.Success; - return; - } - - console.WriteLine($"Found {crds.Count} CRDs."); - if (!force && !console.Confirm("[red]Should the CRDs be uninstalled?[/]", false)) - { - ctx.ExitCode = ExitCodes.Aborted; - return; - } - - console.WriteLine($"""Starting uninstall from cluster with url "{client.BaseUri}"."""); - - foreach (var crd in crds) - { - console.MarkupLineInterpolated( - $"""Uninstall [cyan]"{crd.Spec.Group}/{crd.Spec.Names.Kind}"[/] from the cluster."""); - - try - { - switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( - fieldSelector: $"metadata.name={crd.Name()}")) - { - case { Items: [var existing] }: - await client.ApiextensionsV1.DeleteCustomResourceDefinitionAsync(existing.Name()); - console.MarkupLineInterpolated( - $"""[green]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" deleted.[/]"""); - break; - default: - console.MarkupLineInterpolated( - $"""[green]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" did not exist.[/]"""); - break; - } - } - catch (HttpOperationException) - { - console.WriteLine( - $"""[red]There was a http (api) error while uninstalling "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); - throw; - } - catch (Exception) - { - console.WriteLine( - $"""[red]There was an error while uninstalling "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); - throw; - } - } - } -} +using System.CommandLine; +using System.CommandLine.Invocation; + +using k8s; +using k8s.Autorest; +using k8s.Models; + +using KubeOps.Cli.Transpilation; +using KubeOps.Transpiler; + +using Spectre.Console; + +namespace KubeOps.Cli.Commands.Management; + +internal static class Uninstall +{ + public static Command Command + { + get + { + var cmd = + new Command("uninstall", "Uninstall CRDs from the cluster of the actually selected context.") + { + Options.Force, + Options.SolutionProjectRegex, + Options.TargetFramework, + Arguments.SolutionOrProjectFile, + }; + cmd.AddAlias("u"); + cmd.SetHandler(ctx => Handler( + AnsiConsole.Console, + new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), + ctx)); + + return cmd; + } + } + + internal static async Task Handler(IAnsiConsole console, IKubernetes client, InvocationContext ctx) + { + var file = ctx.ParseResult.GetValueForArgument(Arguments.SolutionOrProjectFile); + var force = ctx.ParseResult.GetValueForOption(Options.Force); + + var parser = file switch + { + { Extension: ".csproj", Exists: true } => await AssemblyLoader.ForProject(console, file), + { Extension: ".sln", Exists: true } => await AssemblyLoader.ForSolution( + console, + file, + ctx.ParseResult.GetValueForOption(Options.SolutionProjectRegex), + ctx.ParseResult.GetValueForOption(Options.TargetFramework)), + { Exists: false } => throw new FileNotFoundException($"The file {file.Name} does not exist."), + _ => throw new NotSupportedException("Only *.csproj and *.sln files are supported."), + }; + + console.WriteLine($"Uninstall CRDs from {file.Name}."); + var crds = parser.Transpile(parser.GetEntities()).ToList(); + if (crds.Count == 0) + { + console.WriteLine("No CRDs found. Exiting."); + ctx.ExitCode = ExitCodes.Success; + return; + } + + console.WriteLine($"Found {crds.Count} CRDs."); + if (!force && !console.Confirm("[red]Should the CRDs be uninstalled?[/]", false)) + { + ctx.ExitCode = ExitCodes.Aborted; + return; + } + + console.WriteLine($"""Starting uninstall from cluster with url "{client.BaseUri}"."""); + + foreach (var crd in crds) + { + console.MarkupLineInterpolated( + $"""Uninstall [cyan]"{crd.Spec.Group}/{crd.Spec.Names.Kind}"[/] from the cluster."""); + + try + { + switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( + fieldSelector: $"metadata.name={crd.Name()}")) + { + case { Items: [var existing] }: + await client.ApiextensionsV1.DeleteCustomResourceDefinitionAsync(existing.Name()); + console.MarkupLineInterpolated( + $"""[green]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" deleted.[/]"""); + break; + default: + console.MarkupLineInterpolated( + $"""[green]CRD "{crd.Spec.Group}/{crd.Spec.Names.Kind}" did not exist.[/]"""); + break; + } + } + catch (HttpOperationException) + { + console.WriteLine( + $"""[red]There was a http (api) error while uninstalling "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); + throw; + } + catch (Exception) + { + console.WriteLine( + $"""[red]There was an error while uninstalling "{crd.Spec.Group}/{crd.Spec.Names.Kind}".[/]"""); + throw; + } + } + } +} diff --git a/src/KubeOps.Cli/Commands/Utilities/Version.cs b/src/KubeOps.Cli/Commands/Utilities/Version.cs index 5fc70cb9..a22b5fb7 100644 --- a/src/KubeOps.Cli/Commands/Utilities/Version.cs +++ b/src/KubeOps.Cli/Commands/Utilities/Version.cs @@ -1,40 +1,40 @@ -using System.CommandLine; - -using k8s; - -using Spectre.Console; - -namespace KubeOps.Cli.Commands.Utilities; - -internal static class Version -{ - public static Command Command - { - get - { - var cmd = new Command( - "api-version", - "Prints the actual server version of the connected kubernetes cluster."); - cmd.AddAlias("av"); - cmd.SetHandler(() => - Handler(AnsiConsole.Console, new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()))); - - return cmd; - } - } - - internal static async Task Handler(IAnsiConsole console, IKubernetes client) - { - var version = await client.Version.GetCodeAsync(); - console.Write(new Table() - .Title("Kubernetes API Version") - .HideHeaders() - .AddColumns("Info", "Value") - .AddRow("Git-Version", version.GitVersion) - .AddRow("Major", version.Major) - .AddRow("Minor", version.Minor) - .AddRow("Platform", version.Platform)); - - return ExitCodes.Success; - } -} +using System.CommandLine; + +using k8s; + +using Spectre.Console; + +namespace KubeOps.Cli.Commands.Utilities; + +internal static class Version +{ + public static Command Command + { + get + { + var cmd = new Command( + "api-version", + "Prints the actual server version of the connected kubernetes cluster."); + cmd.AddAlias("av"); + cmd.SetHandler(() => + Handler(AnsiConsole.Console, new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()))); + + return cmd; + } + } + + internal static async Task Handler(IAnsiConsole console, IKubernetes client) + { + var version = await client.Version.GetCodeAsync(); + console.Write(new Table() + .Title("Kubernetes API Version") + .HideHeaders() + .AddColumns("Info", "Value") + .AddRow("Git-Version", version.GitVersion) + .AddRow("Major", version.Major) + .AddRow("Minor", version.Minor) + .AddRow("Platform", version.Platform)); + + return ExitCodes.Success; + } +} diff --git a/src/KubeOps.Cli/ExitCodes.cs b/src/KubeOps.Cli/ExitCodes.cs index 0a6c912c..f3caf944 100644 --- a/src/KubeOps.Cli/ExitCodes.cs +++ b/src/KubeOps.Cli/ExitCodes.cs @@ -1,9 +1,9 @@ -namespace KubeOps.Cli; - -internal static class ExitCodes -{ - public const int Success = 0; - public const int Error = 1; - public const int Aborted = 2; - public const int UsageError = 99; -} +namespace KubeOps.Cli; + +internal static class ExitCodes +{ + public const int Success = 0; + public const int Error = 1; + public const int Aborted = 2; + public const int UsageError = 99; +} diff --git a/src/KubeOps.Cli/Generators/CertificateGenerator.cs b/src/KubeOps.Cli/Generators/CertificateGenerator.cs index f0edc328..101a5c0e 100644 --- a/src/KubeOps.Cli/Generators/CertificateGenerator.cs +++ b/src/KubeOps.Cli/Generators/CertificateGenerator.cs @@ -1,23 +1,23 @@ -using KubeOps.Cli.Certificates; -using KubeOps.Cli.Output; - -namespace KubeOps.Cli.Generators; - -internal class CertificateGenerator(string serverName, string namespaceName) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - var (caCert, caKey) = Certificates.CertificateGenerator.CreateCaCertificate(); - - output.Add("ca.pem", caCert.ToPem(), OutputFormat.Plain); - output.Add("ca-key.pem", caKey.ToPem(), OutputFormat.Plain); - - var (srvCert, srvKey) = Certificates.CertificateGenerator.CreateServerCertificate( - (caCert, caKey), - serverName, - namespaceName); - - output.Add("svc.pem", srvCert.ToPem(), OutputFormat.Plain); - output.Add("svc-key.pem", srvKey.ToPem(), OutputFormat.Plain); - } -} +using KubeOps.Cli.Certificates; +using KubeOps.Cli.Output; + +namespace KubeOps.Cli.Generators; + +internal class CertificateGenerator(string serverName, string namespaceName) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + var (caCert, caKey) = Certificates.CertificateGenerator.CreateCaCertificate(); + + output.Add("ca.pem", caCert.ToPem(), OutputFormat.Plain); + output.Add("ca-key.pem", caKey.ToPem(), OutputFormat.Plain); + + var (srvCert, srvKey) = Certificates.CertificateGenerator.CreateServerCertificate( + (caCert, caKey), + serverName, + namespaceName); + + output.Add("svc.pem", srvCert.ToPem(), OutputFormat.Plain); + output.Add("svc-key.pem", srvKey.ToPem(), OutputFormat.Plain); + } +} diff --git a/src/KubeOps.Cli/Generators/CrdGenerator.cs b/src/KubeOps.Cli/Generators/CrdGenerator.cs index 1a9572f8..129a6a0f 100644 --- a/src/KubeOps.Cli/Generators/CrdGenerator.cs +++ b/src/KubeOps.Cli/Generators/CrdGenerator.cs @@ -1,46 +1,46 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; -using KubeOps.Transpiler; - -namespace KubeOps.Cli.Generators; - -internal class CrdGenerator(MetadataLoadContext parser, byte[] caBundle, - OutputFormat outputFormat) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - var crds = parser.Transpile(parser.GetEntities()).ToList(); - var conversionWebhooks = parser.GetConvertedEntities().ToList(); - - foreach (var crd in crds) - { - if (conversionWebhooks - .Find(wh => crd.Spec.Group == wh.Group && crd.Spec.Names.Kind == wh.Kind) is not null) - { - crd.Spec.Conversion = new V1CustomResourceConversion - { - Strategy = "Webhook", - Webhook = new V1WebhookConversion - { - ConversionReviewVersions = new[] { "v1" }, - ClientConfig = new Apiextensionsv1WebhookClientConfig - { - CaBundle = caBundle, - Service = new Apiextensionsv1ServiceReference - { - Path = $"/convert/{crd.Spec.Group}/{crd.Spec.Names.Plural}", - Name = "service", - }, - }, - }, - }; - } - - output.Add($"{crd.Metadata.Name.Replace('.', '_')}.{outputFormat.GetFileExtension()}", crd); - } - } -} +using System.Reflection; + +using k8s.Models; + +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; +using KubeOps.Transpiler; + +namespace KubeOps.Cli.Generators; + +internal class CrdGenerator(MetadataLoadContext parser, byte[] caBundle, + OutputFormat outputFormat) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + var crds = parser.Transpile(parser.GetEntities()).ToList(); + var conversionWebhooks = parser.GetConvertedEntities().ToList(); + + foreach (var crd in crds) + { + if (conversionWebhooks + .Find(wh => crd.Spec.Group == wh.Group && crd.Spec.Names.Kind == wh.Kind) is not null) + { + crd.Spec.Conversion = new V1CustomResourceConversion + { + Strategy = "Webhook", + Webhook = new V1WebhookConversion + { + ConversionReviewVersions = new[] { "v1" }, + ClientConfig = new Apiextensionsv1WebhookClientConfig + { + CaBundle = caBundle, + Service = new Apiextensionsv1ServiceReference + { + Path = $"/convert/{crd.Spec.Group}/{crd.Spec.Names.Plural}", + Name = "service", + }, + }, + }, + }; + } + + output.Add($"{crd.Metadata.Name.Replace('.', '_')}.{outputFormat.GetFileExtension()}", crd); + } + } +} diff --git a/src/KubeOps.Cli/Generators/DeploymentGenerator.cs b/src/KubeOps.Cli/Generators/DeploymentGenerator.cs index a976ee01..f8741960 100644 --- a/src/KubeOps.Cli/Generators/DeploymentGenerator.cs +++ b/src/KubeOps.Cli/Generators/DeploymentGenerator.cs @@ -1,69 +1,69 @@ -using k8s; -using k8s.Models; - -using KubeOps.Cli.Output; - -namespace KubeOps.Cli.Generators; - -internal class DeploymentGenerator(OutputFormat format) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - var deployment = new V1Deployment(metadata: new V1ObjectMeta( - labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }, - name: "operator")).Initialize(); - deployment.Spec = new V1DeploymentSpec - { - Replicas = 1, - RevisionHistoryLimit = 0, - Selector = new V1LabelSelector( - matchLabels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }), - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta( - labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }), - Spec = new V1PodSpec - { - TerminationGracePeriodSeconds = 10, - Containers = new List - { - new() - { - Image = "operator", - Name = "operator", - Env = new List - { - new() - { - Name = "POD_NAMESPACE", - ValueFrom = - new V1EnvVarSource - { - FieldRef = new V1ObjectFieldSelector - { - FieldPath = "metadata.namespace", - }, - }, - }, - }, - Resources = new V1ResourceRequirements - { - Requests = new Dictionary - { - { "cpu", new ResourceQuantity("100m") }, - { "memory", new ResourceQuantity("64Mi") }, - }, - Limits = new Dictionary - { - { "cpu", new ResourceQuantity("100m") }, - { "memory", new ResourceQuantity("128Mi") }, - }, - }, - }, - }, - }, - }, - }; - output.Add($"deployment.{format.GetFileExtension()}", deployment); - } -} +using k8s; +using k8s.Models; + +using KubeOps.Cli.Output; + +namespace KubeOps.Cli.Generators; + +internal class DeploymentGenerator(OutputFormat format) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + var deployment = new V1Deployment(metadata: new V1ObjectMeta( + labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }, + name: "operator")).Initialize(); + deployment.Spec = new V1DeploymentSpec + { + Replicas = 1, + RevisionHistoryLimit = 0, + Selector = new V1LabelSelector( + matchLabels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }), + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta( + labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }), + Spec = new V1PodSpec + { + TerminationGracePeriodSeconds = 10, + Containers = new List + { + new() + { + Image = "operator", + Name = "operator", + Env = new List + { + new() + { + Name = "POD_NAMESPACE", + ValueFrom = + new V1EnvVarSource + { + FieldRef = new V1ObjectFieldSelector + { + FieldPath = "metadata.namespace", + }, + }, + }, + }, + Resources = new V1ResourceRequirements + { + Requests = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("64Mi") }, + }, + Limits = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("128Mi") }, + }, + }, + }, + }, + }, + }, + }; + output.Add($"deployment.{format.GetFileExtension()}", deployment); + } +} diff --git a/src/KubeOps.Cli/Generators/DockerfileGenerator.cs b/src/KubeOps.Cli/Generators/DockerfileGenerator.cs index 728263d0..7bb7d113 100644 --- a/src/KubeOps.Cli/Generators/DockerfileGenerator.cs +++ b/src/KubeOps.Cli/Generators/DockerfileGenerator.cs @@ -1,33 +1,33 @@ -using KubeOps.Cli.Output; - -namespace KubeOps.Cli.Generators; - -internal class DockerfileGenerator(bool hasWebhooks) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - output.Add( - "Dockerfile", - $""" - FROM mcr.microsoft.com/dotnet/sdk:latest as build - WORKDIR /operator - - COPY ./ ./ - RUN dotnet publish -c Release /p:AssemblyName=operator -o out - - # The runner for the application - FROM mcr.microsoft.com/dotnet/{(hasWebhooks ? "aspnet" : "runtime")}:latest as final - - RUN addgroup k8s-operator && useradd -G k8s-operator operator-user - - WORKDIR /operator - COPY --from=build /operator/out/ ./ - RUN chown operator-user:k8s-operator -R . - - USER operator-user - - ENTRYPOINT [ "dotnet", "operator.dll" ] - """, - OutputFormat.Plain); - } -} +using KubeOps.Cli.Output; + +namespace KubeOps.Cli.Generators; + +internal class DockerfileGenerator(bool hasWebhooks) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + output.Add( + "Dockerfile", + $""" + FROM mcr.microsoft.com/dotnet/sdk:latest as build + WORKDIR /operator + + COPY ./ ./ + RUN dotnet publish -c Release /p:AssemblyName=operator -o out + + # The runner for the application + FROM mcr.microsoft.com/dotnet/{(hasWebhooks ? "aspnet" : "runtime")}:latest as final + + RUN addgroup k8s-operator && useradd -G k8s-operator operator-user + + WORKDIR /operator + COPY --from=build /operator/out/ ./ + RUN chown operator-user:k8s-operator -R . + + USER operator-user + + ENTRYPOINT [ "dotnet", "operator.dll" ] + """, + OutputFormat.Plain); + } +} diff --git a/src/KubeOps.Cli/Generators/IConfigGenerator.cs b/src/KubeOps.Cli/Generators/IConfigGenerator.cs index e25eeac5..46dd3df7 100644 --- a/src/KubeOps.Cli/Generators/IConfigGenerator.cs +++ b/src/KubeOps.Cli/Generators/IConfigGenerator.cs @@ -1,10 +1,10 @@ -using KubeOps.Cli.Output; - -using Spectre.Console; - -namespace KubeOps.Cli.Generators; - -internal interface IConfigGenerator -{ - void Generate(ResultOutput output); -} +using KubeOps.Cli.Output; + +using Spectre.Console; + +namespace KubeOps.Cli.Generators; + +internal interface IConfigGenerator +{ + void Generate(ResultOutput output); +} diff --git a/src/KubeOps.Cli/Generators/MutationWebhookGenerator.cs b/src/KubeOps.Cli/Generators/MutationWebhookGenerator.cs index 5e84d4fb..d69b0719 100644 --- a/src/KubeOps.Cli/Generators/MutationWebhookGenerator.cs +++ b/src/KubeOps.Cli/Generators/MutationWebhookGenerator.cs @@ -1,56 +1,56 @@ -using k8s; -using k8s.Models; - -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; - -namespace KubeOps.Cli.Generators; - -internal class MutationWebhookGenerator - (List webhooks, byte[] caBundle, OutputFormat format) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - if (webhooks.Count == 0) - { - return; - } - - var mutatorConfig = new V1MutatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "mutators"), - webhooks: new List()).Initialize(); - - foreach (var hook in webhooks) - { - mutatorConfig.Webhooks.Add(new V1MutatingWebhook - { - Name = $"mutate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = hook.GetOperations(), - Resources = new[] { hook.Metadata.PluralName }, - ApiGroups = new[] { hook.Metadata.Group }, - ApiVersions = new[] { hook.Metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - CaBundle = caBundle, - Service = new Admissionregistrationv1ServiceReference - { - Name = "operator", - Path = hook.WebhookPath, - }, - }, - }); - } - - output.Add( - $"mutators.{format.GetFileExtension()}", mutatorConfig); - } -} +using k8s; +using k8s.Models; + +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; + +namespace KubeOps.Cli.Generators; + +internal class MutationWebhookGenerator + (List webhooks, byte[] caBundle, OutputFormat format) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + if (webhooks.Count == 0) + { + return; + } + + var mutatorConfig = new V1MutatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "mutators"), + webhooks: new List()).Initialize(); + + foreach (var hook in webhooks) + { + mutatorConfig.Webhooks.Add(new V1MutatingWebhook + { + Name = $"mutate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = hook.GetOperations(), + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + CaBundle = caBundle, + Service = new Admissionregistrationv1ServiceReference + { + Name = "operator", + Path = hook.WebhookPath, + }, + }, + }); + } + + output.Add( + $"mutators.{format.GetFileExtension()}", mutatorConfig); + } +} diff --git a/src/KubeOps.Cli/Generators/RbacGenerator.cs b/src/KubeOps.Cli/Generators/RbacGenerator.cs index 0b73564d..30e74633 100644 --- a/src/KubeOps.Cli/Generators/RbacGenerator.cs +++ b/src/KubeOps.Cli/Generators/RbacGenerator.cs @@ -1,41 +1,41 @@ -using System.Reflection; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Rbac; -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; -using KubeOps.Transpiler; - -namespace KubeOps.Cli.Generators; - -internal class RbacGenerator(MetadataLoadContext parser, - OutputFormat outputFormat) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - var attributes = parser - .GetRbacAttributes() - .Concat(parser.GetContextType().GetCustomAttributesData()) - .ToList(); - - var role = new V1ClusterRole(rules: parser.Transpile(attributes).ToList()).Initialize(); - role.Metadata.Name = "operator-role"; - output.Add($"operator-role.{outputFormat.GetFileExtension()}", role); - - var roleBinding = new V1ClusterRoleBinding( - roleRef: new V1RoleRef(V1ClusterRole.KubeGroup, V1ClusterRole.KubeKind, "operator-role"), - subjects: new List - { - new(V1ServiceAccount.KubeKind, "default", namespaceProperty: "system"), - }) - .Initialize(); - roleBinding.Metadata.Name = "operator-role-binding"; - output.Add($"operator-role-binding.{outputFormat.GetFileExtension()}", roleBinding); - } - - [EntityRbac(typeof(Corev1Event), Verbs = RbacVerb.Get | RbacVerb.List | RbacVerb.Create | RbacVerb.Update)] - [EntityRbac(typeof(V1Lease), Verbs = RbacVerb.All)] - private sealed class DefaultRbacAttributes; -} +using System.Reflection; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Rbac; +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; +using KubeOps.Transpiler; + +namespace KubeOps.Cli.Generators; + +internal class RbacGenerator(MetadataLoadContext parser, + OutputFormat outputFormat) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + var attributes = parser + .GetRbacAttributes() + .Concat(parser.GetContextType().GetCustomAttributesData()) + .ToList(); + + var role = new V1ClusterRole(rules: parser.Transpile(attributes).ToList()).Initialize(); + role.Metadata.Name = "operator-role"; + output.Add($"operator-role.{outputFormat.GetFileExtension()}", role); + + var roleBinding = new V1ClusterRoleBinding( + roleRef: new V1RoleRef(V1ClusterRole.KubeGroup, V1ClusterRole.KubeKind, "operator-role"), + subjects: new List + { + new(V1ServiceAccount.KubeKind, "default", namespaceProperty: "system"), + }) + .Initialize(); + roleBinding.Metadata.Name = "operator-role-binding"; + output.Add($"operator-role-binding.{outputFormat.GetFileExtension()}", roleBinding); + } + + [EntityRbac(typeof(Corev1Event), Verbs = RbacVerb.Get | RbacVerb.List | RbacVerb.Create | RbacVerb.Update)] + [EntityRbac(typeof(V1Lease), Verbs = RbacVerb.All)] + private sealed class DefaultRbacAttributes; +} diff --git a/src/KubeOps.Cli/Generators/ValidationWebhookGenerator.cs b/src/KubeOps.Cli/Generators/ValidationWebhookGenerator.cs index a0febbef..6861d671 100644 --- a/src/KubeOps.Cli/Generators/ValidationWebhookGenerator.cs +++ b/src/KubeOps.Cli/Generators/ValidationWebhookGenerator.cs @@ -1,56 +1,56 @@ -using k8s; -using k8s.Models; - -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; - -namespace KubeOps.Cli.Generators; - -internal class ValidationWebhookGenerator - (List webhooks, byte[] caBundle, OutputFormat format) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - if (webhooks.Count == 0) - { - return; - } - - var validatorConfig = new V1ValidatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "validators"), - webhooks: new List()).Initialize(); - - foreach (var hook in webhooks) - { - validatorConfig.Webhooks.Add(new V1ValidatingWebhook - { - Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = hook.GetOperations(), - Resources = new[] { hook.Metadata.PluralName }, - ApiGroups = new[] { hook.Metadata.Group }, - ApiVersions = new[] { hook.Metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - CaBundle = caBundle, - Service = new Admissionregistrationv1ServiceReference - { - Name = "operator", - Path = hook.WebhookPath, - }, - }, - }); - } - - output.Add( - $"validators.{format.GetFileExtension()}", validatorConfig); - } -} +using k8s; +using k8s.Models; + +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; + +namespace KubeOps.Cli.Generators; + +internal class ValidationWebhookGenerator + (List webhooks, byte[] caBundle, OutputFormat format) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + if (webhooks.Count == 0) + { + return; + } + + var validatorConfig = new V1ValidatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "validators"), + webhooks: new List()).Initialize(); + + foreach (var hook in webhooks) + { + validatorConfig.Webhooks.Add(new V1ValidatingWebhook + { + Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = hook.GetOperations(), + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + CaBundle = caBundle, + Service = new Admissionregistrationv1ServiceReference + { + Name = "operator", + Path = hook.WebhookPath, + }, + }, + }); + } + + output.Add( + $"validators.{format.GetFileExtension()}", validatorConfig); + } +} diff --git a/src/KubeOps.Cli/Generators/WebhookDeploymentGenerator.cs b/src/KubeOps.Cli/Generators/WebhookDeploymentGenerator.cs index 00884a99..6f840cd6 100644 --- a/src/KubeOps.Cli/Generators/WebhookDeploymentGenerator.cs +++ b/src/KubeOps.Cli/Generators/WebhookDeploymentGenerator.cs @@ -1,102 +1,102 @@ -using System.Reflection; - -using k8s; -using k8s.Models; - -using KubeOps.Cli.Output; -using KubeOps.Cli.Transpilation; -using KubeOps.Transpiler; - -namespace KubeOps.Cli.Generators; - -internal class WebhookDeploymentGenerator(OutputFormat format) : IConfigGenerator -{ - public void Generate(ResultOutput output) - { - var deployment = new V1Deployment(metadata: new V1ObjectMeta( - labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }, - name: "operator")).Initialize(); - deployment.Spec = new V1DeploymentSpec - { - Replicas = 1, - RevisionHistoryLimit = 0, - Selector = new V1LabelSelector( - matchLabels: - new Dictionary { { "operator-deployment", "kubernetes-operator" } }), - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta( - labels: - new Dictionary { { "operator-deployment", "kubernetes-operator" }, }), - Spec = new V1PodSpec - { - TerminationGracePeriodSeconds = 10, - Volumes = new List - { - new() { Name = "certificates", Secret = new() { SecretName = "webhook-cert" }, }, - new() { Name = "ca-certificates", Secret = new() { SecretName = "webhook-ca" }, }, - }, - Containers = new List - { - new() - { - Image = "operator", - Name = "operator", - VolumeMounts = new List - { - new() { Name = "certificates", MountPath = "/certs", ReadOnlyProperty = true, }, - new() { Name = "ca-certificates", MountPath = "/ca", ReadOnlyProperty = true, }, - }, - Env = new List - { - new() - { - Name = "POD_NAMESPACE", - ValueFrom = - new V1EnvVarSource - { - FieldRef = new V1ObjectFieldSelector - { - FieldPath = "metadata.namespace", - }, - }, - }, - }, - EnvFrom = - new List - { - new() { ConfigMapRef = new() { Name = "webhook-config" } }, - }, - Ports = new List { new(5001, name: "https"), }, - Resources = new V1ResourceRequirements - { - Requests = new Dictionary - { - { "cpu", new ResourceQuantity("100m") }, - { "memory", new ResourceQuantity("64Mi") }, - }, - Limits = new Dictionary - { - { "cpu", new ResourceQuantity("100m") }, - { "memory", new ResourceQuantity("128Mi") }, - }, - }, - }, - }, - }, - }, - }; - output.Add($"deployment.{format.GetFileExtension()}", deployment); - - output.Add( - $"service.{format.GetFileExtension()}", - new V1Service( - metadata: new V1ObjectMeta(name: "operator"), - spec: new V1ServiceSpec - { - Ports = - new List { new() { Name = "https", TargetPort = "https", Port = 443, }, }, - Selector = new Dictionary { { "operator-deployment", "kubernetes-operator" }, }, - }).Initialize()); - } -} +using System.Reflection; + +using k8s; +using k8s.Models; + +using KubeOps.Cli.Output; +using KubeOps.Cli.Transpilation; +using KubeOps.Transpiler; + +namespace KubeOps.Cli.Generators; + +internal class WebhookDeploymentGenerator(OutputFormat format) : IConfigGenerator +{ + public void Generate(ResultOutput output) + { + var deployment = new V1Deployment(metadata: new V1ObjectMeta( + labels: new Dictionary { { "operator-deployment", "kubernetes-operator" } }, + name: "operator")).Initialize(); + deployment.Spec = new V1DeploymentSpec + { + Replicas = 1, + RevisionHistoryLimit = 0, + Selector = new V1LabelSelector( + matchLabels: + new Dictionary { { "operator-deployment", "kubernetes-operator" } }), + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta( + labels: + new Dictionary { { "operator-deployment", "kubernetes-operator" }, }), + Spec = new V1PodSpec + { + TerminationGracePeriodSeconds = 10, + Volumes = new List + { + new() { Name = "certificates", Secret = new() { SecretName = "webhook-cert" }, }, + new() { Name = "ca-certificates", Secret = new() { SecretName = "webhook-ca" }, }, + }, + Containers = new List + { + new() + { + Image = "operator", + Name = "operator", + VolumeMounts = new List + { + new() { Name = "certificates", MountPath = "/certs", ReadOnlyProperty = true, }, + new() { Name = "ca-certificates", MountPath = "/ca", ReadOnlyProperty = true, }, + }, + Env = new List + { + new() + { + Name = "POD_NAMESPACE", + ValueFrom = + new V1EnvVarSource + { + FieldRef = new V1ObjectFieldSelector + { + FieldPath = "metadata.namespace", + }, + }, + }, + }, + EnvFrom = + new List + { + new() { ConfigMapRef = new() { Name = "webhook-config" } }, + }, + Ports = new List { new(5001, name: "https"), }, + Resources = new V1ResourceRequirements + { + Requests = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("64Mi") }, + }, + Limits = new Dictionary + { + { "cpu", new ResourceQuantity("100m") }, + { "memory", new ResourceQuantity("128Mi") }, + }, + }, + }, + }, + }, + }, + }; + output.Add($"deployment.{format.GetFileExtension()}", deployment); + + output.Add( + $"service.{format.GetFileExtension()}", + new V1Service( + metadata: new V1ObjectMeta(name: "operator"), + spec: new V1ServiceSpec + { + Ports = + new List { new() { Name = "https", TargetPort = "https", Port = 443, }, }, + Selector = new Dictionary { { "operator-deployment", "kubernetes-operator" }, }, + }).Initialize()); + } +} diff --git a/src/KubeOps.Cli/Options.cs b/src/KubeOps.Cli/Options.cs index 81e0b6db..5bf3df69 100644 --- a/src/KubeOps.Cli/Options.cs +++ b/src/KubeOps.Cli/Options.cs @@ -1,43 +1,43 @@ -using System.CommandLine; -using System.Text.RegularExpressions; - -using KubeOps.Cli.Output; - -namespace KubeOps.Cli; - -internal static class Options -{ - public static readonly Option OutputFormat = new( - "--format", - () => Output.OutputFormat.Yaml, - "The format of the generated output."); - - public static readonly Option OutputPath = new( - "--out", - "The path the command will write the files to. If omitted, prints output to console."); - - public static readonly Option TargetFramework = new( - ["--target-framework", "--tfm"], - description: "Target framework of projects in the solution to search for entities. " + - "If omitted, the newest framework is used."); - - public static readonly Option SolutionProjectRegex = new( - "--project", - parseArgument: result => - { - var value = result.Tokens.Single().Value; - return new Regex(value); - }, - description: "Regex pattern to filter projects in the solution to search for entities. " + - "If omitted, all projects are searched."); - - public static readonly Option Force = new( - ["--force", "-f"], - () => false, - description: "Do not bother the user with questions and just do it."); - - public static readonly Option ClearOutputPath = new( - ["--clear-out"], - () => false, - description: "Clear the output path before generating resources."); -} +using System.CommandLine; +using System.Text.RegularExpressions; + +using KubeOps.Cli.Output; + +namespace KubeOps.Cli; + +internal static class Options +{ + public static readonly Option OutputFormat = new( + "--format", + () => Output.OutputFormat.Yaml, + "The format of the generated output."); + + public static readonly Option OutputPath = new( + "--out", + "The path the command will write the files to. If omitted, prints output to console."); + + public static readonly Option TargetFramework = new( + ["--target-framework", "--tfm"], + description: "Target framework of projects in the solution to search for entities. " + + "If omitted, the newest framework is used."); + + public static readonly Option SolutionProjectRegex = new( + "--project", + parseArgument: result => + { + var value = result.Tokens.Single().Value; + return new Regex(value); + }, + description: "Regex pattern to filter projects in the solution to search for entities. " + + "If omitted, all projects are searched."); + + public static readonly Option Force = new( + ["--force", "-f"], + () => false, + description: "Do not bother the user with questions and just do it."); + + public static readonly Option ClearOutputPath = new( + ["--clear-out"], + () => false, + description: "Clear the output path before generating resources."); +} diff --git a/src/KubeOps.Cli/Output/OutputFormat.cs b/src/KubeOps.Cli/Output/OutputFormat.cs index d8bd2ac0..c4567c58 100644 --- a/src/KubeOps.Cli/Output/OutputFormat.cs +++ b/src/KubeOps.Cli/Output/OutputFormat.cs @@ -1,29 +1,29 @@ -namespace KubeOps.Cli.Output; - -internal enum OutputFormat -{ - /// - /// Format the output in Kubernetes YAML style. - /// - Yaml, - - /// - /// Format the output in Kubernetes JSON style. - /// - Json, - - /// - /// Format the output in plain text style. - /// - Plain, -} - -internal static class OutputFormatExtensions -{ - public static string GetFileExtension(this OutputFormat format) => format switch - { - OutputFormat.Yaml => "yaml", - OutputFormat.Json => "json", - _ => string.Empty, - }; -} +namespace KubeOps.Cli.Output; + +internal enum OutputFormat +{ + /// + /// Format the output in Kubernetes YAML style. + /// + Yaml, + + /// + /// Format the output in Kubernetes JSON style. + /// + Json, + + /// + /// Format the output in plain text style. + /// + Plain, +} + +internal static class OutputFormatExtensions +{ + public static string GetFileExtension(this OutputFormat format) => format switch + { + OutputFormat.Yaml => "yaml", + OutputFormat.Json => "json", + _ => string.Empty, + }; +} diff --git a/src/KubeOps.Cli/Output/ResultOutput.cs b/src/KubeOps.Cli/Output/ResultOutput.cs index e234e710..09be5e00 100644 --- a/src/KubeOps.Cli/Output/ResultOutput.cs +++ b/src/KubeOps.Cli/Output/ResultOutput.cs @@ -1,61 +1,61 @@ -using System.Text; - -using k8s; - -using Spectre.Console; - -namespace KubeOps.Cli.Output; - -internal class ResultOutput(IAnsiConsole console, OutputFormat defaultFormat) -{ - private readonly Dictionary _files = new(); - - public IEnumerable Files => _files.Keys; - - public IEnumerable DefaultFormatFiles => - _files - .Where(f => f.Value.Item2 == defaultFormat) - .Select(f => f.Key); - - public object this[string filename] - { - get => _files[filename].Item1; - } - - public void Add(string filename, object content) => _files.Add(filename, (content, defaultFormat)); - - public void Add(string filename, object content, OutputFormat format) => _files.Add(filename, (content, format)); - - public async Task Write(string outputDirectory) - { - Directory.CreateDirectory(outputDirectory); - foreach (var (filename, content) in _files) - { - await using var file = File.Open( - Path.Join( - outputDirectory, - filename), - FileMode.Create); - await file.WriteAsync(Encoding.UTF8.GetBytes(Serialize(content))); - } - } - - public void Write() - { - console.Write(new Rule()); - foreach (var (filename, content) in _files) - { - console.MarkupLineInterpolated($"[bold]File:[/] [underline]{filename}[/]"); - console.WriteLine(Serialize(content)); - console.Write(new Rule()); - } - } - - private static string Serialize((object Object, OutputFormat Format) data) => data.Format switch - { - OutputFormat.Yaml => KubernetesYaml.Serialize(data.Object), - OutputFormat.Json => KubernetesJson.Serialize(data.Object), - OutputFormat.Plain => data.Object.ToString() ?? string.Empty, - _ => throw new ArgumentException("Unknown output format."), - }; -} +using System.Text; + +using k8s; + +using Spectre.Console; + +namespace KubeOps.Cli.Output; + +internal class ResultOutput(IAnsiConsole console, OutputFormat defaultFormat) +{ + private readonly Dictionary _files = new(); + + public IEnumerable Files => _files.Keys; + + public IEnumerable DefaultFormatFiles => + _files + .Where(f => f.Value.Item2 == defaultFormat) + .Select(f => f.Key); + + public object this[string filename] + { + get => _files[filename].Item1; + } + + public void Add(string filename, object content) => _files.Add(filename, (content, defaultFormat)); + + public void Add(string filename, object content, OutputFormat format) => _files.Add(filename, (content, format)); + + public async Task Write(string outputDirectory) + { + Directory.CreateDirectory(outputDirectory); + foreach (var (filename, content) in _files) + { + await using var file = File.Open( + Path.Join( + outputDirectory, + filename), + FileMode.Create); + await file.WriteAsync(Encoding.UTF8.GetBytes(Serialize(content))); + } + } + + public void Write() + { + console.Write(new Rule()); + foreach (var (filename, content) in _files) + { + console.MarkupLineInterpolated($"[bold]File:[/] [underline]{filename}[/]"); + console.WriteLine(Serialize(content)); + console.Write(new Rule()); + } + } + + private static string Serialize((object Object, OutputFormat Format) data) => data.Format switch + { + OutputFormat.Yaml => KubernetesYaml.Serialize(data.Object), + OutputFormat.Json => KubernetesJson.Serialize(data.Object), + OutputFormat.Plain => data.Object.ToString() ?? string.Empty, + _ => throw new ArgumentException("Unknown output format."), + }; +} diff --git a/src/KubeOps.Cli/Program.cs b/src/KubeOps.Cli/Program.cs index 45bb09f6..77c0865f 100644 --- a/src/KubeOps.Cli/Program.cs +++ b/src/KubeOps.Cli/Program.cs @@ -1,28 +1,28 @@ -using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.Parsing; - -using KubeOps.Cli; -using KubeOps.Cli.Commands.Generator; -using KubeOps.Cli.Commands.Management; - -using Spectre.Console; - -using Version = KubeOps.Cli.Commands.Utilities.Version; - -return await new CommandLineBuilder(new RootCommand( - "CLI for KubeOps. Commandline tool to help with management tasks such as generating or installing CRDs.") - { - Generate.Command, Version.Command, Install.Command, Uninstall.Command, - }) - .UseDefaults() - .UseParseErrorReporting(ExitCodes.UsageError) - .UseExceptionHandler((ex, ctx) => - { - AnsiConsole.MarkupLineInterpolated( - $"[red]An error occurred whiled executing {ctx.ParseResult.CommandResult.Command}[/]"); - AnsiConsole.MarkupLineInterpolated($"[red]{ex.Message}[/]"); - ctx.ExitCode = ExitCodes.Error; - }) - .Build() - .InvokeAsync(args); +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Parsing; + +using KubeOps.Cli; +using KubeOps.Cli.Commands.Generator; +using KubeOps.Cli.Commands.Management; + +using Spectre.Console; + +using Version = KubeOps.Cli.Commands.Utilities.Version; + +return await new CommandLineBuilder(new RootCommand( + "CLI for KubeOps. Commandline tool to help with management tasks such as generating or installing CRDs.") + { + Generate.Command, Version.Command, Install.Command, Uninstall.Command, + }) + .UseDefaults() + .UseParseErrorReporting(ExitCodes.UsageError) + .UseExceptionHandler((ex, ctx) => + { + AnsiConsole.MarkupLineInterpolated( + $"[red]An error occurred whiled executing {ctx.ParseResult.CommandResult.Command}[/]"); + AnsiConsole.MarkupLineInterpolated($"[red]{ex.Message}[/]"); + ctx.ExitCode = ExitCodes.Error; + }) + .Build() + .InvokeAsync(args); diff --git a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs index 04a3db67..c69d5adc 100644 --- a/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs +++ b/src/KubeOps.Cli/Transpilation/AssemblyLoader.cs @@ -1,206 +1,206 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; -using KubeOps.Abstractions.Rbac; -using KubeOps.Operator.Web.Webhooks.Admission.Mutation; -using KubeOps.Operator.Web.Webhooks.Admission.Validation; -using KubeOps.Operator.Web.Webhooks.Conversion; -using KubeOps.Transpiler; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; - -using Spectre.Console; - -namespace KubeOps.Cli.Transpilation; - -/// -/// AssemblyLoader. -/// -[SuppressMessage( - "Usage", - "CA2252:This API requires opting into preview features", - Justification = "It is the CLI that uses the libraries.")] -internal static partial class AssemblyLoader -{ - static AssemblyLoader() - { - MSBuildLocator.RegisterDefaults(); - } - - public static Task ForProject( - IAnsiConsole console, - FileInfo projectFile) - => console.Status().StartAsync($"Compiling {projectFile.Name}...", async _ => - { - console.MarkupLineInterpolated($"Compile project [aqua]{projectFile.FullName}[/]."); - using var workspace = MSBuildWorkspace.Create(); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - console.WriteLine("Load project."); - var project = await workspace.OpenProjectAsync(projectFile.FullName); - console.MarkupLine("[green]Project loaded.[/]"); - console.WriteLine("Load compilation context."); - var compilation = await project.GetCompilationAsync(); - console.MarkupLine("[green]Compilation context loaded.[/]"); - if (compilation is null) - { - throw new AggregateException("Compilation could not be found."); - } - - using var assemblyStream = new MemoryStream(); - console.WriteLine("Start compilation."); - switch (compilation.Emit(assemblyStream)) - { - case { Success: false, Diagnostics: var diag }: - throw new AggregateException( - $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); - } - - console.MarkupLine("[green]Compilation successful.[/]"); - console.WriteLine(); - var mlc = new MetadataLoadContext( - new PathAssemblyResolver(project.MetadataReferences.Select(m => m.Display ?? string.Empty) - .Concat(new[] { typeof(object).Assembly.Location }))); - mlc.LoadFromByteArray(assemblyStream.ToArray()); - - return mlc; - }); - - public static Task ForSolution( - IAnsiConsole console, - FileInfo slnFile, - Regex? projectFilter = null, - string? tfm = null) - => console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ => - { - projectFilter ??= DefaultRegex(); - tfm ??= "latest"; - - console.MarkupLineInterpolated($"Compile solution [aqua]{slnFile.FullName}[/]."); -#pragma warning disable RCS1097 - console.MarkupLineInterpolated($"[grey]With project filter:[/] {projectFilter.ToString()}"); -#pragma warning restore RCS1097 - console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {tfm}"); - - using var workspace = MSBuildWorkspace.Create(); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - console.WriteLine("Load solution."); - var solution = await workspace.OpenSolutionAsync(slnFile.FullName); - console.MarkupLine("[green]Solution loaded.[/]"); - - var assemblies = await Task.WhenAll(solution.Projects - .Select(p => - { - var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty); - var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; - return (name, tfm, project: p); - }) - .Where(p => projectFilter.IsMatch(p.name)) - .Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm) - .OrderByDescending(p => p.tfm, new TfmComparer()) - .GroupBy(p => p.name) - .Select(p => p.FirstOrDefault()) - .Where(p => p != default) - .Select(async p => - { - console.MarkupLineInterpolated( - $"Load compilation context for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); - var compilation = await p.project.GetCompilationAsync(); - console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.name}.[/]"); - if (compilation is null) - { - throw new AggregateException("Compilation could not be found."); - } - - using var assemblyStream = new MemoryStream(); - console.MarkupLineInterpolated( - $"Start compilation for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); - switch (compilation.Emit(assemblyStream)) - { - case { Success: false, Diagnostics: var diag }: - throw new AggregateException( - $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); - } - - console.MarkupLineInterpolated($"[green]Compilation successful for {p.name}.[/]"); - return (Assembly: assemblyStream.ToArray(), - Refs: p.project.MetadataReferences.Select(m => m.Display ?? string.Empty)); - })); - - console.WriteLine(); - var mlc = new MetadataLoadContext( - new PathAssemblyResolver(assemblies.SelectMany(a => a.Refs) - .Concat(new[] { typeof(object).Assembly.Location }).Distinct())); - foreach (var assembly in assemblies) - { - mlc.LoadFromByteArray(assembly.Assembly); - } - - return mlc; - }); - - public static IEnumerable GetEntities(this MetadataLoadContext context) => context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .Select(t => (t, attrs: CustomAttributeData.GetCustomAttributes(t))) - .Where(e => e.attrs.Any(a => a.AttributeType.Name == nameof(KubernetesEntityAttribute)) && - e.attrs.All(a => a.AttributeType.Name != nameof(IgnoreAttribute))) - .Select(e => e.t); - - public static IEnumerable GetRbacAttributes(this MetadataLoadContext context) - { - foreach (var type in context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .SelectMany(t => - t.GetCustomAttributesData())) - { - yield return type; - } - - foreach (var type in context.GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .SelectMany(t => - t.GetCustomAttributesData())) - { - yield return type; - } - } - - public static IEnumerable GetValidatedEntities(this MetadataLoadContext context) => context - .GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .Where(t => t.BaseType?.Name == typeof(ValidationWebhook<>).Name && - t.BaseType?.Namespace == typeof(ValidationWebhook<>).Namespace) - .Distinct() - .Select(t => new ValidationWebhook(t, context.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)); - - public static IEnumerable GetMutatedEntities(this MetadataLoadContext context) => context - .GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .Where(t => t.BaseType?.Name == typeof(MutationWebhook<>).Name && - t.BaseType?.Namespace == typeof(MutationWebhook<>).Namespace) - .Distinct() - .Select(t => new MutationWebhook(t, context.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)); - - public static IEnumerable GetConvertedEntities(this MetadataLoadContext context) => context - .GetAssemblies() - .SelectMany(a => a.DefinedTypes) - .Where(t => t.BaseType?.Name == typeof(ConversionWebhook<>).Name && - t.BaseType?.Namespace == typeof(ConversionWebhook<>).Namespace) - .Distinct() - .Select(t => context.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata); - -#if NET7_0_OR_GREATER - [GeneratedRegex(".*")] - private static partial Regex DefaultRegex(); -#else - private static Regex DefaultRegex() => new(".*"); -#endif -} +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Abstractions.Rbac; +using KubeOps.Operator.Web.Webhooks.Admission.Mutation; +using KubeOps.Operator.Web.Webhooks.Admission.Validation; +using KubeOps.Operator.Web.Webhooks.Conversion; +using KubeOps.Transpiler; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +using Spectre.Console; + +namespace KubeOps.Cli.Transpilation; + +/// +/// AssemblyLoader. +/// +[SuppressMessage( + "Usage", + "CA2252:This API requires opting into preview features", + Justification = "It is the CLI that uses the libraries.")] +internal static partial class AssemblyLoader +{ + static AssemblyLoader() + { + MSBuildLocator.RegisterDefaults(); + } + + public static Task ForProject( + IAnsiConsole console, + FileInfo projectFile) + => console.Status().StartAsync($"Compiling {projectFile.Name}...", async _ => + { + console.MarkupLineInterpolated($"Compile project [aqua]{projectFile.FullName}[/]."); + using var workspace = MSBuildWorkspace.Create(); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + console.WriteLine("Load project."); + var project = await workspace.OpenProjectAsync(projectFile.FullName); + console.MarkupLine("[green]Project loaded.[/]"); + console.WriteLine("Load compilation context."); + var compilation = await project.GetCompilationAsync(); + console.MarkupLine("[green]Compilation context loaded.[/]"); + if (compilation is null) + { + throw new AggregateException("Compilation could not be found."); + } + + using var assemblyStream = new MemoryStream(); + console.WriteLine("Start compilation."); + switch (compilation.Emit(assemblyStream)) + { + case { Success: false, Diagnostics: var diag }: + throw new AggregateException( + $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); + } + + console.MarkupLine("[green]Compilation successful.[/]"); + console.WriteLine(); + var mlc = new MetadataLoadContext( + new PathAssemblyResolver(project.MetadataReferences.Select(m => m.Display ?? string.Empty) + .Concat(new[] { typeof(object).Assembly.Location }))); + mlc.LoadFromByteArray(assemblyStream.ToArray()); + + return mlc; + }); + + public static Task ForSolution( + IAnsiConsole console, + FileInfo slnFile, + Regex? projectFilter = null, + string? tfm = null) + => console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ => + { + projectFilter ??= DefaultRegex(); + tfm ??= "latest"; + + console.MarkupLineInterpolated($"Compile solution [aqua]{slnFile.FullName}[/]."); +#pragma warning disable RCS1097 + console.MarkupLineInterpolated($"[grey]With project filter:[/] {projectFilter.ToString()}"); +#pragma warning restore RCS1097 + console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {tfm}"); + + using var workspace = MSBuildWorkspace.Create(); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + console.WriteLine("Load solution."); + var solution = await workspace.OpenSolutionAsync(slnFile.FullName); + console.MarkupLine("[green]Solution loaded.[/]"); + + var assemblies = await Task.WhenAll(solution.Projects + .Select(p => + { + var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty); + var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value; + return (name, tfm, project: p); + }) + .Where(p => projectFilter.IsMatch(p.name)) + .Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm) + .OrderByDescending(p => p.tfm, new TfmComparer()) + .GroupBy(p => p.name) + .Select(p => p.FirstOrDefault()) + .Where(p => p != default) + .Select(async p => + { + console.MarkupLineInterpolated( + $"Load compilation context for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); + var compilation = await p.project.GetCompilationAsync(); + console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.name}.[/]"); + if (compilation is null) + { + throw new AggregateException("Compilation could not be found."); + } + + using var assemblyStream = new MemoryStream(); + console.MarkupLineInterpolated( + $"Start compilation for [aqua]{p.name}[/]{(p.tfm.Length > 0 ? $" [grey]{p.tfm}[/]" : string.Empty)}."); + switch (compilation.Emit(assemblyStream)) + { + case { Success: false, Diagnostics: var diag }: + throw new AggregateException( + $"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}"); + } + + console.MarkupLineInterpolated($"[green]Compilation successful for {p.name}.[/]"); + return (Assembly: assemblyStream.ToArray(), + Refs: p.project.MetadataReferences.Select(m => m.Display ?? string.Empty)); + })); + + console.WriteLine(); + var mlc = new MetadataLoadContext( + new PathAssemblyResolver(assemblies.SelectMany(a => a.Refs) + .Concat(new[] { typeof(object).Assembly.Location }).Distinct())); + foreach (var assembly in assemblies) + { + mlc.LoadFromByteArray(assembly.Assembly); + } + + return mlc; + }); + + public static IEnumerable GetEntities(this MetadataLoadContext context) => context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .Select(t => (t, attrs: CustomAttributeData.GetCustomAttributes(t))) + .Where(e => e.attrs.Any(a => a.AttributeType.Name == nameof(KubernetesEntityAttribute)) && + e.attrs.All(a => a.AttributeType.Name != nameof(IgnoreAttribute))) + .Select(e => e.t); + + public static IEnumerable GetRbacAttributes(this MetadataLoadContext context) + { + foreach (var type in context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .SelectMany(t => + t.GetCustomAttributesData())) + { + yield return type; + } + + foreach (var type in context.GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .SelectMany(t => + t.GetCustomAttributesData())) + { + yield return type; + } + } + + public static IEnumerable GetValidatedEntities(this MetadataLoadContext context) => context + .GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .Where(t => t.BaseType?.Name == typeof(ValidationWebhook<>).Name && + t.BaseType?.Namespace == typeof(ValidationWebhook<>).Namespace) + .Distinct() + .Select(t => new ValidationWebhook(t, context.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)); + + public static IEnumerable GetMutatedEntities(this MetadataLoadContext context) => context + .GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .Where(t => t.BaseType?.Name == typeof(MutationWebhook<>).Name && + t.BaseType?.Namespace == typeof(MutationWebhook<>).Namespace) + .Distinct() + .Select(t => new MutationWebhook(t, context.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)); + + public static IEnumerable GetConvertedEntities(this MetadataLoadContext context) => context + .GetAssemblies() + .SelectMany(a => a.DefinedTypes) + .Where(t => t.BaseType?.Name == typeof(ConversionWebhook<>).Name && + t.BaseType?.Namespace == typeof(ConversionWebhook<>).Namespace) + .Distinct() + .Select(t => context.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata); + +#if NET7_0_OR_GREATER + [GeneratedRegex(".*")] + private static partial Regex DefaultRegex(); +#else + private static Regex DefaultRegex() => new(".*"); +#endif +} diff --git a/src/KubeOps.Cli/Transpilation/BaseWebhook.cs b/src/KubeOps.Cli/Transpilation/BaseWebhook.cs index 96dee456..10155f61 100644 --- a/src/KubeOps.Cli/Transpilation/BaseWebhook.cs +++ b/src/KubeOps.Cli/Transpilation/BaseWebhook.cs @@ -1,20 +1,20 @@ -using System.Reflection; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Cli.Transpilation; - -internal abstract record BaseWebhook(TypeInfo Webhook, EntityMetadata Metadata) -{ - private bool HasCreate => Webhook.DeclaredMembers.Any(m => m.Name.StartsWith("Create")); - - private bool HasUpdate => Webhook.DeclaredMembers.Any(m => m.Name.StartsWith("Update")); - - private bool HasDelete => Webhook.DeclaredMembers.Any(m => m.Name.StartsWith("Delete")); - - public abstract string WebhookPath { get; } - - public string[] GetOperations() => - new[] { HasCreate ? "CREATE" : null, HasUpdate ? "UPDATE" : null, HasDelete ? "DELETE" : null, } - .Where(o => o is not null).ToArray()!; -} +using System.Reflection; + +using KubeOps.Abstractions.Entities; + +namespace KubeOps.Cli.Transpilation; + +internal abstract record BaseWebhook(TypeInfo Webhook, EntityMetadata Metadata) +{ + private bool HasCreate => Webhook.DeclaredMembers.Any(m => m.Name.StartsWith("Create")); + + private bool HasUpdate => Webhook.DeclaredMembers.Any(m => m.Name.StartsWith("Update")); + + private bool HasDelete => Webhook.DeclaredMembers.Any(m => m.Name.StartsWith("Delete")); + + public abstract string WebhookPath { get; } + + public string[] GetOperations() => + new[] { HasCreate ? "CREATE" : null, HasUpdate ? "UPDATE" : null, HasDelete ? "DELETE" : null, } + .Where(o => o is not null).ToArray()!; +} diff --git a/src/KubeOps.Cli/Transpilation/MutationWebhook.cs b/src/KubeOps.Cli/Transpilation/MutationWebhook.cs index 00263d5b..e1868dfa 100644 --- a/src/KubeOps.Cli/Transpilation/MutationWebhook.cs +++ b/src/KubeOps.Cli/Transpilation/MutationWebhook.cs @@ -1,11 +1,11 @@ -using System.Reflection; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Cli.Transpilation; - -internal record MutationWebhook(TypeInfo Validator, EntityMetadata Metadata) : BaseWebhook(Validator, Metadata) -{ - public override string WebhookPath => - $"/mutate/{Validator.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant()}"; -} +using System.Reflection; + +using KubeOps.Abstractions.Entities; + +namespace KubeOps.Cli.Transpilation; + +internal record MutationWebhook(TypeInfo Validator, EntityMetadata Metadata) : BaseWebhook(Validator, Metadata) +{ + public override string WebhookPath => + $"/mutate/{Validator.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant()}"; +} diff --git a/src/KubeOps.Cli/Transpilation/TfmComparer.cs b/src/KubeOps.Cli/Transpilation/TfmComparer.cs index e297b3c4..1d9413a2 100644 --- a/src/KubeOps.Cli/Transpilation/TfmComparer.cs +++ b/src/KubeOps.Cli/Transpilation/TfmComparer.cs @@ -1,65 +1,65 @@ -using System.Text.RegularExpressions; - -namespace KubeOps.Cli.Transpilation; - -/// -/// Tfm Comparer. -/// -internal sealed partial class TfmComparer : IComparer -{ -#if NET7_0_OR_GREATER - [GeneratedRegex( - "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", - RegexOptions.Compiled)] - public static partial Regex TfmRegex(); -#else - public static Regex TfmRegex() => - new( - "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", - RegexOptions.Compiled); -#endif - - public int Compare(string? x, string? y) - { - if (x == null || y == null) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - - switch (TfmRegex().Match(x), TfmRegex().Match(y)) - { - case ({ Success: false }, _) or (_, { Success: false }): - return StringComparer.CurrentCulture.Compare(x, y); - case ({ } matchX, { } matchY): - var platformX = matchX.Groups["name"].Value; - var platformY = matchY.Groups["name"].Value; - if (platformX != platformY) - { - return (platformX, platformY) switch - { - ("netstandard", _) or (_, "net") => -1, - (_, "netstandard") or ("net", _) => 1, - _ => 0, - }; - } - - var majorX = matchX.Groups["major"].Value; - var majorY = matchY.Groups["major"].Value; - if (majorX != majorY) - { - return int.Parse(majorX) - int.Parse(majorY); - } - - var minorX = matchX.Groups["minor"].Value; - var minorY = matchY.Groups["minor"].Value; - if (minorX != minorY) - { - return int.Parse(minorX) - int.Parse(minorY); - } - - return 0; - default: - return 0; - } - } -} +using System.Text.RegularExpressions; + +namespace KubeOps.Cli.Transpilation; + +/// +/// Tfm Comparer. +/// +internal sealed partial class TfmComparer : IComparer +{ +#if NET7_0_OR_GREATER + [GeneratedRegex( + "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", + RegexOptions.Compiled)] + public static partial Regex TfmRegex(); +#else + public static Regex TfmRegex() => + new( + "[(]?(?(?(netcoreapp|net|netstandard){1})(?[0-9]+)[.](?[0-9]+))[)]?", + RegexOptions.Compiled); +#endif + + public int Compare(string? x, string? y) + { + if (x == null || y == null) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + + switch (TfmRegex().Match(x), TfmRegex().Match(y)) + { + case ({ Success: false }, _) or (_, { Success: false }): + return StringComparer.CurrentCulture.Compare(x, y); + case ({ } matchX, { } matchY): + var platformX = matchX.Groups["name"].Value; + var platformY = matchY.Groups["name"].Value; + if (platformX != platformY) + { + return (platformX, platformY) switch + { + ("netstandard", _) or (_, "net") => -1, + (_, "netstandard") or ("net", _) => 1, + _ => 0, + }; + } + + var majorX = matchX.Groups["major"].Value; + var majorY = matchY.Groups["major"].Value; + if (majorX != majorY) + { + return int.Parse(majorX) - int.Parse(majorY); + } + + var minorX = matchX.Groups["minor"].Value; + var minorY = matchY.Groups["minor"].Value; + if (minorX != minorY) + { + return int.Parse(minorX) - int.Parse(minorY); + } + + return 0; + default: + return 0; + } + } +} diff --git a/src/KubeOps.Cli/Transpilation/ValidationWebhook.cs b/src/KubeOps.Cli/Transpilation/ValidationWebhook.cs index 0678729f..1164e196 100644 --- a/src/KubeOps.Cli/Transpilation/ValidationWebhook.cs +++ b/src/KubeOps.Cli/Transpilation/ValidationWebhook.cs @@ -1,11 +1,11 @@ -using System.Reflection; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Cli.Transpilation; - -internal record ValidationWebhook(TypeInfo Validator, EntityMetadata Metadata) : BaseWebhook(Validator, Metadata) -{ - public override string WebhookPath => - $"/validate/{Validator.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant()}"; -} +using System.Reflection; + +using KubeOps.Abstractions.Entities; + +namespace KubeOps.Cli.Transpilation; + +internal record ValidationWebhook(TypeInfo Validator, EntityMetadata Metadata) : BaseWebhook(Validator, Metadata) +{ + public override string WebhookPath => + $"/validate/{Validator.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant()}"; +} diff --git a/src/KubeOps.Generator/Generators/ControllerRegistrationGenerator.cs b/src/KubeOps.Generator/Generators/ControllerRegistrationGenerator.cs index 69b93ba4..6792cde2 100644 --- a/src/KubeOps.Generator/Generators/ControllerRegistrationGenerator.cs +++ b/src/KubeOps.Generator/Generators/ControllerRegistrationGenerator.cs @@ -1,85 +1,85 @@ -using System.Text; - -using KubeOps.Generator.SyntaxReceiver; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace KubeOps.Generator.Generators; - -[Generator] -internal class ControllerRegistrationGenerator : ISourceGenerator -{ - private readonly EntityControllerSyntaxReceiver _ctrlReceiver = new(); - private readonly KubernetesEntitySyntaxReceiver _entityReceiver = new(); - - public void Initialize(GeneratorInitializationContext context) - { - context.RegisterForSyntaxNotifications(() => new CombinedSyntaxReceiver(_ctrlReceiver, _entityReceiver)); - } - - public void Execute(GeneratorExecutionContext context) - { - if (context.SyntaxContextReceiver is not CombinedSyntaxReceiver) - { - return; - } - - var declaration = CompilationUnit() - .WithUsings( - List( - new List { UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), })) - .WithMembers(SingletonList(ClassDeclaration("ControllerRegistrations") - .WithModifiers(TokenList( - Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .AddMembers(MethodDeclaration(IdentifierName("IOperatorBuilder"), "RegisterControllers") - .WithModifiers( - TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithParameterList(ParameterList( - SingletonSeparatedList( - Parameter( - Identifier("builder")) - .WithModifiers( - TokenList( - Token(SyntaxKind.ThisKeyword))) - .WithType( - IdentifierName("IOperatorBuilder"))))) - .WithBody(Block( - _ctrlReceiver.Controllers - .Where(c => _entityReceiver.Entities.Exists(e => - e.Class.Identifier.ToString() == c.EntityName)) - .Select(c => (c.Controller, Entity: _entityReceiver.Entities.First(e => - e.Class.Identifier.ToString() == c.EntityName).Class)) - .Select(e => ExpressionStatement( - InvocationExpression( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("builder"), - GenericName(Identifier("AddController")) - .WithTypeArgumentList( - TypeArgumentList( - SeparatedList(new[] - { - IdentifierName(context.Compilation - .GetSemanticModel(e.Controller.SyntaxTree) - .GetDeclaredSymbol(e.Controller)! - .ToDisplayString(SymbolDisplayFormat - .FullyQualifiedFormat)), - IdentifierName(context.Compilation - .GetSemanticModel(e.Entity.SyntaxTree) - .GetDeclaredSymbol(e.Entity)! - .ToDisplayString(SymbolDisplayFormat - .FullyQualifiedFormat)), - }))))))) - .Append(ReturnStatement(IdentifierName("builder")))))))) - .NormalizeWhitespace(); - - context.AddSource( - "ControllerRegistrations.g.cs", - SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); - } -} +using System.Text; + +using KubeOps.Generator.SyntaxReceiver; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KubeOps.Generator.Generators; + +[Generator] +internal class ControllerRegistrationGenerator : ISourceGenerator +{ + private readonly EntityControllerSyntaxReceiver _ctrlReceiver = new(); + private readonly KubernetesEntitySyntaxReceiver _entityReceiver = new(); + + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new CombinedSyntaxReceiver(_ctrlReceiver, _entityReceiver)); + } + + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxContextReceiver is not CombinedSyntaxReceiver) + { + return; + } + + var declaration = CompilationUnit() + .WithUsings( + List( + new List { UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), })) + .WithMembers(SingletonList(ClassDeclaration("ControllerRegistrations") + .WithModifiers(TokenList( + Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .AddMembers(MethodDeclaration(IdentifierName("IOperatorBuilder"), "RegisterControllers") + .WithModifiers( + TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithParameterList(ParameterList( + SingletonSeparatedList( + Parameter( + Identifier("builder")) + .WithModifiers( + TokenList( + Token(SyntaxKind.ThisKeyword))) + .WithType( + IdentifierName("IOperatorBuilder"))))) + .WithBody(Block( + _ctrlReceiver.Controllers + .Where(c => _entityReceiver.Entities.Exists(e => + e.Class.Identifier.ToString() == c.EntityName)) + .Select(c => (c.Controller, Entity: _entityReceiver.Entities.First(e => + e.Class.Identifier.ToString() == c.EntityName).Class)) + .Select(e => ExpressionStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("builder"), + GenericName(Identifier("AddController")) + .WithTypeArgumentList( + TypeArgumentList( + SeparatedList(new[] + { + IdentifierName(context.Compilation + .GetSemanticModel(e.Controller.SyntaxTree) + .GetDeclaredSymbol(e.Controller)! + .ToDisplayString(SymbolDisplayFormat + .FullyQualifiedFormat)), + IdentifierName(context.Compilation + .GetSemanticModel(e.Entity.SyntaxTree) + .GetDeclaredSymbol(e.Entity)! + .ToDisplayString(SymbolDisplayFormat + .FullyQualifiedFormat)), + }))))))) + .Append(ReturnStatement(IdentifierName("builder")))))))) + .NormalizeWhitespace(); + + context.AddSource( + "ControllerRegistrations.g.cs", + SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); + } +} diff --git a/src/KubeOps.Generator/Generators/EntityDefinitionGenerator.cs b/src/KubeOps.Generator/Generators/EntityDefinitionGenerator.cs index 853f6087..a643dca3 100644 --- a/src/KubeOps.Generator/Generators/EntityDefinitionGenerator.cs +++ b/src/KubeOps.Generator/Generators/EntityDefinitionGenerator.cs @@ -1,88 +1,88 @@ -using System.Text; - -using KubeOps.Generator.SyntaxReceiver; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace KubeOps.Generator.Generators; - -[Generator] -internal class EntityDefinitionGenerator : ISourceGenerator -{ - public void Initialize(GeneratorInitializationContext context) - { - context.RegisterForSyntaxNotifications(() => new KubernetesEntitySyntaxReceiver()); - } - - public void Execute(GeneratorExecutionContext context) - { - if (context.SyntaxContextReceiver is not KubernetesEntitySyntaxReceiver receiver) - { - return; - } - - var declaration = CompilationUnit() - .WithUsings( - List( - new List - { - UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), - UsingDirective(IdentifierName("KubeOps.Abstractions.Entities")), - })) - .WithMembers(SingletonList(ClassDeclaration("EntityDefinitions") - .WithModifiers(TokenList( - Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithMembers( - List(receiver.Entities.Select(e => FieldDeclaration( - VariableDeclaration( - IdentifierName("EntityMetadata")) - .WithVariables( - SingletonSeparatedList( - VariableDeclarator(e.Class.Identifier) - .WithInitializer( - EqualsValueClause( - ImplicitObjectCreationExpression() - .WithArgumentList( - ArgumentList( - SeparatedList(new List - { - Argument(LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(e.Kind))), - Argument(LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(e.Version))), - Argument(e.Group switch - { - null => LiteralExpression( - SyntaxKind.NullLiteralExpression), - _ => LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(e.Group)), - }), - Argument(e.Plural switch - { - null => LiteralExpression( - SyntaxKind.NullLiteralExpression), - _ => LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(e.Plural)), - }), - })))))))) - .WithModifiers( - TokenList( - Token(SyntaxKind.PublicKeyword), - Token(SyntaxKind.StaticKeyword), - Token(SyntaxKind.ReadOnlyKeyword)))))))) - .NormalizeWhitespace(); - - context.AddSource( - "EntityDefinitions.g.cs", - SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); - } -} +using System.Text; + +using KubeOps.Generator.SyntaxReceiver; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KubeOps.Generator.Generators; + +[Generator] +internal class EntityDefinitionGenerator : ISourceGenerator +{ + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new KubernetesEntitySyntaxReceiver()); + } + + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxContextReceiver is not KubernetesEntitySyntaxReceiver receiver) + { + return; + } + + var declaration = CompilationUnit() + .WithUsings( + List( + new List + { + UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), + UsingDirective(IdentifierName("KubeOps.Abstractions.Entities")), + })) + .WithMembers(SingletonList(ClassDeclaration("EntityDefinitions") + .WithModifiers(TokenList( + Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithMembers( + List(receiver.Entities.Select(e => FieldDeclaration( + VariableDeclaration( + IdentifierName("EntityMetadata")) + .WithVariables( + SingletonSeparatedList( + VariableDeclarator(e.Class.Identifier) + .WithInitializer( + EqualsValueClause( + ImplicitObjectCreationExpression() + .WithArgumentList( + ArgumentList( + SeparatedList(new List + { + Argument(LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(e.Kind))), + Argument(LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(e.Version))), + Argument(e.Group switch + { + null => LiteralExpression( + SyntaxKind.NullLiteralExpression), + _ => LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(e.Group)), + }), + Argument(e.Plural switch + { + null => LiteralExpression( + SyntaxKind.NullLiteralExpression), + _ => LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(e.Plural)), + }), + })))))))) + .WithModifiers( + TokenList( + Token(SyntaxKind.PublicKeyword), + Token(SyntaxKind.StaticKeyword), + Token(SyntaxKind.ReadOnlyKeyword)))))))) + .NormalizeWhitespace(); + + context.AddSource( + "EntityDefinitions.g.cs", + SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); + } +} diff --git a/src/KubeOps.Generator/Generators/EntityInitializerGenerator.cs b/src/KubeOps.Generator/Generators/EntityInitializerGenerator.cs index 524d0571..d1b1fbdf 100644 --- a/src/KubeOps.Generator/Generators/EntityInitializerGenerator.cs +++ b/src/KubeOps.Generator/Generators/EntityInitializerGenerator.cs @@ -1,137 +1,137 @@ -using System.Text; - -using KubeOps.Generator.SyntaxReceiver; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace KubeOps.Generator.Generators; - -[Generator] -internal class EntityInitializerGenerator : ISourceGenerator -{ - public void Initialize(GeneratorInitializationContext context) - { - context.RegisterForSyntaxNotifications(() => new KubernetesEntitySyntaxReceiver()); - } - - public void Execute(GeneratorExecutionContext context) - { - if (context.SyntaxContextReceiver is not KubernetesEntitySyntaxReceiver receiver) - { - return; - } - - // for each partial defined entity, create a partial class that - // introduces a default constructor that initializes the ApiVersion and Kind. - // But only, if there is no default constructor defined. - foreach (var entity in receiver.Entities - .Where(e => e.Class.Modifiers.Any(SyntaxKind.PartialKeyword)) - .Where(e => !e.Class.Members.Any(m => m is ConstructorDeclarationSyntax - { - ParameterList.Parameters.Count: 0, - }))) - { - var symbol = context.Compilation - .GetSemanticModel(entity.Class.SyntaxTree) - .GetDeclaredSymbol(entity.Class)!; - - var ns = new List(); - if (!symbol.ContainingNamespace.IsGlobalNamespace) - { - ns.Add(FileScopedNamespaceDeclaration(IdentifierName(symbol.ContainingNamespace.ToDisplayString()))); - } - - var partialEntityInitializer = CompilationUnit() - .AddMembers(ns.ToArray()) - .AddMembers(ClassDeclaration(entity.Class.Identifier) - .WithModifiers(entity.Class.Modifiers) - .AddMembers(ConstructorDeclaration(entity.Class.Identifier) - .WithModifiers( - TokenList( - Token(SyntaxKind.PublicKeyword))) - .WithBody( - Block( - ExpressionStatement( - AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - IdentifierName("ApiVersion"), - LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal($"{entity.Group}/{entity.Version}".TrimStart('/'))))), - ExpressionStatement( - AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - IdentifierName("Kind"), - LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(entity.Kind)))))))) - .NormalizeWhitespace(); - - context.AddSource( - $"{entity.Class.Identifier}.init.g.cs", - SourceText.From(partialEntityInitializer.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); - } - - // for each NON partial entity, generate a method extension that initializes the ApiVersion and Kind. - var staticInitializers = CompilationUnit() - .WithMembers(SingletonList(ClassDeclaration("EntityInitializer") - .WithModifiers(TokenList( - Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithMembers(List(receiver.Entities - .Where(e => !e.Class.Modifiers.Any(SyntaxKind.PartialKeyword) || e.Class.Members.Any(m => - m is ConstructorDeclarationSyntax - { - ParameterList.Parameters.Count: 0, - })) - .Select(e => (Entity: e, - ClassIdentifier: context.Compilation.GetSemanticModel(e.Class.SyntaxTree) - .GetDeclaredSymbol(e.Class)!.ToDisplayString(SymbolDisplayFormat - .FullyQualifiedFormat))) - .Select(e => - MethodDeclaration( - IdentifierName(e.ClassIdentifier), - "Initialize") - .WithModifiers( - TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithParameterList(ParameterList( - SingletonSeparatedList( - Parameter( - Identifier("entity")) - .WithModifiers( - TokenList( - Token(SyntaxKind.ThisKeyword))) - .WithType(IdentifierName(e.ClassIdentifier))))) - .WithBody(Block( - ExpressionStatement( - AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("entity"), - IdentifierName("ApiVersion")), - LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal($"{e.Entity.Group}/{e.Entity.Version}".TrimStart('/'))))), - ExpressionStatement( - AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("entity"), - IdentifierName("Kind")), - LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(e.Entity.Kind)))), - ReturnStatement(IdentifierName("entity"))))))))) - .NormalizeWhitespace(); - - context.AddSource( - "EntityInitializer.g.cs", - SourceText.From(staticInitializers.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); - } -} +using System.Text; + +using KubeOps.Generator.SyntaxReceiver; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KubeOps.Generator.Generators; + +[Generator] +internal class EntityInitializerGenerator : ISourceGenerator +{ + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new KubernetesEntitySyntaxReceiver()); + } + + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxContextReceiver is not KubernetesEntitySyntaxReceiver receiver) + { + return; + } + + // for each partial defined entity, create a partial class that + // introduces a default constructor that initializes the ApiVersion and Kind. + // But only, if there is no default constructor defined. + foreach (var entity in receiver.Entities + .Where(e => e.Class.Modifiers.Any(SyntaxKind.PartialKeyword)) + .Where(e => !e.Class.Members.Any(m => m is ConstructorDeclarationSyntax + { + ParameterList.Parameters.Count: 0, + }))) + { + var symbol = context.Compilation + .GetSemanticModel(entity.Class.SyntaxTree) + .GetDeclaredSymbol(entity.Class)!; + + var ns = new List(); + if (!symbol.ContainingNamespace.IsGlobalNamespace) + { + ns.Add(FileScopedNamespaceDeclaration(IdentifierName(symbol.ContainingNamespace.ToDisplayString()))); + } + + var partialEntityInitializer = CompilationUnit() + .AddMembers(ns.ToArray()) + .AddMembers(ClassDeclaration(entity.Class.Identifier) + .WithModifiers(entity.Class.Modifiers) + .AddMembers(ConstructorDeclaration(entity.Class.Identifier) + .WithModifiers( + TokenList( + Token(SyntaxKind.PublicKeyword))) + .WithBody( + Block( + ExpressionStatement( + AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + IdentifierName("ApiVersion"), + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal($"{entity.Group}/{entity.Version}".TrimStart('/'))))), + ExpressionStatement( + AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + IdentifierName("Kind"), + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(entity.Kind)))))))) + .NormalizeWhitespace(); + + context.AddSource( + $"{entity.Class.Identifier}.init.g.cs", + SourceText.From(partialEntityInitializer.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); + } + + // for each NON partial entity, generate a method extension that initializes the ApiVersion and Kind. + var staticInitializers = CompilationUnit() + .WithMembers(SingletonList(ClassDeclaration("EntityInitializer") + .WithModifiers(TokenList( + Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithMembers(List(receiver.Entities + .Where(e => !e.Class.Modifiers.Any(SyntaxKind.PartialKeyword) || e.Class.Members.Any(m => + m is ConstructorDeclarationSyntax + { + ParameterList.Parameters.Count: 0, + })) + .Select(e => (Entity: e, + ClassIdentifier: context.Compilation.GetSemanticModel(e.Class.SyntaxTree) + .GetDeclaredSymbol(e.Class)!.ToDisplayString(SymbolDisplayFormat + .FullyQualifiedFormat))) + .Select(e => + MethodDeclaration( + IdentifierName(e.ClassIdentifier), + "Initialize") + .WithModifiers( + TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithParameterList(ParameterList( + SingletonSeparatedList( + Parameter( + Identifier("entity")) + .WithModifiers( + TokenList( + Token(SyntaxKind.ThisKeyword))) + .WithType(IdentifierName(e.ClassIdentifier))))) + .WithBody(Block( + ExpressionStatement( + AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("entity"), + IdentifierName("ApiVersion")), + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal($"{e.Entity.Group}/{e.Entity.Version}".TrimStart('/'))))), + ExpressionStatement( + AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("entity"), + IdentifierName("Kind")), + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(e.Entity.Kind)))), + ReturnStatement(IdentifierName("entity"))))))))) + .NormalizeWhitespace(); + + context.AddSource( + "EntityInitializer.g.cs", + SourceText.From(staticInitializers.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); + } +} diff --git a/src/KubeOps.Generator/Generators/FinalizerRegistrationGenerator.cs b/src/KubeOps.Generator/Generators/FinalizerRegistrationGenerator.cs index aa206d44..8bef2d6b 100644 --- a/src/KubeOps.Generator/Generators/FinalizerRegistrationGenerator.cs +++ b/src/KubeOps.Generator/Generators/FinalizerRegistrationGenerator.cs @@ -1,117 +1,117 @@ -using System.Text; - -using KubeOps.Generator.SyntaxReceiver; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace KubeOps.Generator.Generators; - -[Generator] -internal class FinalizerRegistrationGenerator : ISourceGenerator -{ - private const byte MaxNameLength = 63; - - private readonly EntityFinalizerSyntaxReceiver _finalizerReceiver = new(); - private readonly KubernetesEntitySyntaxReceiver _entityReceiver = new(); - - public void Initialize(GeneratorInitializationContext context) - { - context.RegisterForSyntaxNotifications(() => new CombinedSyntaxReceiver(_finalizerReceiver, _entityReceiver)); - } - - public void Execute(GeneratorExecutionContext context) - { - if (context.SyntaxContextReceiver is not CombinedSyntaxReceiver) - { - return; - } - - var finalizers = _finalizerReceiver.Finalizer - .Where(c => _entityReceiver.Entities.Exists(e => - e.Class.Identifier.ToString() == c.EntityName)) - .Select(c => (c.Finalizer, Entity: _entityReceiver.Entities.First(e => - e.Class.Identifier.ToString() == c.EntityName))).ToList(); - - var declaration = CompilationUnit() - .WithUsings( - List( - new List { UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), })) - .WithMembers(SingletonList(ClassDeclaration("FinalizerRegistrations") - .WithModifiers(TokenList( - Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithMembers(List(finalizers.Select(f => - FieldDeclaration( - VariableDeclaration( - PredefinedType( - Token(SyntaxKind.StringKeyword))) - .WithVariables( - SingletonSeparatedList( - VariableDeclarator(Identifier($"{f.Finalizer.Identifier}Identifier")) - .WithInitializer( - EqualsValueClause( - LiteralExpression( - SyntaxKind.StringLiteralExpression, - Literal(FinalizerName(f)))))))) - .WithModifiers( - TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.ConstKeyword)))))) - .AddMembers(MethodDeclaration(IdentifierName("IOperatorBuilder"), "RegisterFinalizers") - .WithModifiers( - TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithParameterList(ParameterList( - SingletonSeparatedList( - Parameter( - Identifier("builder")) - .WithModifiers( - TokenList( - Token(SyntaxKind.ThisKeyword))) - .WithType( - IdentifierName("IOperatorBuilder"))))) - .WithBody(Block( - finalizers.Select(f => ExpressionStatement( - InvocationExpression( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("builder"), - GenericName(Identifier("AddFinalizer")) - .WithTypeArgumentList( - TypeArgumentList( - SeparatedList(new[] - { - IdentifierName(context.Compilation - .GetSemanticModel(f.Finalizer.SyntaxTree) - .GetDeclaredSymbol(f.Finalizer)! - .ToDisplayString(SymbolDisplayFormat - .FullyQualifiedFormat)), - IdentifierName(context.Compilation - .GetSemanticModel(f.Entity.Class.SyntaxTree) - .GetDeclaredSymbol(f.Entity.Class)! - .ToDisplayString(SymbolDisplayFormat - .FullyQualifiedFormat)), - }))))) - .WithArgumentList( - ArgumentList( - SingletonSeparatedList( - Argument( - IdentifierName($"{f.Finalizer.Identifier}Identifier"))))))) - .Append(ReturnStatement(IdentifierName("builder")))))))) - .NormalizeWhitespace(); - - context.AddSource( - "FinalizerRegistrations.g.cs", - SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); - } - - private static string FinalizerName((ClassDeclarationSyntax Finalizer, AttributedEntity Entity) finalizer) - { - var finalizerName = finalizer.Finalizer.Identifier.ToString().ToLowerInvariant(); - var name = - $"{finalizer.Entity.Group}/{finalizerName}{(finalizerName.EndsWith("finalizer") ? string.Empty : "finalizer")}" - .TrimStart('/'); - return name.Length > MaxNameLength ? name.Substring(0, MaxNameLength) : name; - } -} +using System.Text; + +using KubeOps.Generator.SyntaxReceiver; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KubeOps.Generator.Generators; + +[Generator] +internal class FinalizerRegistrationGenerator : ISourceGenerator +{ + private const byte MaxNameLength = 63; + + private readonly EntityFinalizerSyntaxReceiver _finalizerReceiver = new(); + private readonly KubernetesEntitySyntaxReceiver _entityReceiver = new(); + + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new CombinedSyntaxReceiver(_finalizerReceiver, _entityReceiver)); + } + + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxContextReceiver is not CombinedSyntaxReceiver) + { + return; + } + + var finalizers = _finalizerReceiver.Finalizer + .Where(c => _entityReceiver.Entities.Exists(e => + e.Class.Identifier.ToString() == c.EntityName)) + .Select(c => (c.Finalizer, Entity: _entityReceiver.Entities.First(e => + e.Class.Identifier.ToString() == c.EntityName))).ToList(); + + var declaration = CompilationUnit() + .WithUsings( + List( + new List { UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), })) + .WithMembers(SingletonList(ClassDeclaration("FinalizerRegistrations") + .WithModifiers(TokenList( + Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithMembers(List(finalizers.Select(f => + FieldDeclaration( + VariableDeclaration( + PredefinedType( + Token(SyntaxKind.StringKeyword))) + .WithVariables( + SingletonSeparatedList( + VariableDeclarator(Identifier($"{f.Finalizer.Identifier}Identifier")) + .WithInitializer( + EqualsValueClause( + LiteralExpression( + SyntaxKind.StringLiteralExpression, + Literal(FinalizerName(f)))))))) + .WithModifiers( + TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.ConstKeyword)))))) + .AddMembers(MethodDeclaration(IdentifierName("IOperatorBuilder"), "RegisterFinalizers") + .WithModifiers( + TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithParameterList(ParameterList( + SingletonSeparatedList( + Parameter( + Identifier("builder")) + .WithModifiers( + TokenList( + Token(SyntaxKind.ThisKeyword))) + .WithType( + IdentifierName("IOperatorBuilder"))))) + .WithBody(Block( + finalizers.Select(f => ExpressionStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("builder"), + GenericName(Identifier("AddFinalizer")) + .WithTypeArgumentList( + TypeArgumentList( + SeparatedList(new[] + { + IdentifierName(context.Compilation + .GetSemanticModel(f.Finalizer.SyntaxTree) + .GetDeclaredSymbol(f.Finalizer)! + .ToDisplayString(SymbolDisplayFormat + .FullyQualifiedFormat)), + IdentifierName(context.Compilation + .GetSemanticModel(f.Entity.Class.SyntaxTree) + .GetDeclaredSymbol(f.Entity.Class)! + .ToDisplayString(SymbolDisplayFormat + .FullyQualifiedFormat)), + }))))) + .WithArgumentList( + ArgumentList( + SingletonSeparatedList( + Argument( + IdentifierName($"{f.Finalizer.Identifier}Identifier"))))))) + .Append(ReturnStatement(IdentifierName("builder")))))))) + .NormalizeWhitespace(); + + context.AddSource( + "FinalizerRegistrations.g.cs", + SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); + } + + private static string FinalizerName((ClassDeclarationSyntax Finalizer, AttributedEntity Entity) finalizer) + { + var finalizerName = finalizer.Finalizer.Identifier.ToString().ToLowerInvariant(); + var name = + $"{finalizer.Entity.Group}/{finalizerName}{(finalizerName.EndsWith("finalizer") ? string.Empty : "finalizer")}" + .TrimStart('/'); + return name.Length > MaxNameLength ? name.Substring(0, MaxNameLength) : name; + } +} diff --git a/src/KubeOps.Generator/Generators/OperatorBuilderGenerator.cs b/src/KubeOps.Generator/Generators/OperatorBuilderGenerator.cs index bd3f80bf..4bea12db 100644 --- a/src/KubeOps.Generator/Generators/OperatorBuilderGenerator.cs +++ b/src/KubeOps.Generator/Generators/OperatorBuilderGenerator.cs @@ -1,60 +1,60 @@ -using System.Text; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; - -namespace KubeOps.Generator.Generators; - -[Generator] -internal class OperatorBuilderGenerator : ISourceGenerator -{ - public void Initialize(GeneratorInitializationContext context) - { - } - - public void Execute(GeneratorExecutionContext context) - { - var declaration = CompilationUnit() - .WithUsings( - List( - new List { UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), })) - .WithMembers(SingletonList(ClassDeclaration("OperatorBuilderExtensions") - .WithModifiers(TokenList( - Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .AddMembers(MethodDeclaration(IdentifierName("IOperatorBuilder"), "RegisterComponents") - .WithModifiers( - TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) - .WithParameterList(ParameterList( - SingletonSeparatedList( - Parameter( - Identifier("builder")) - .WithModifiers( - TokenList( - Token(SyntaxKind.ThisKeyword))) - .WithType( - IdentifierName("IOperatorBuilder"))))) - .WithBody(Block( - ExpressionStatement( - InvocationExpression( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("builder"), - IdentifierName("RegisterControllers")))), - ExpressionStatement( - InvocationExpression( - MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - IdentifierName("builder"), - IdentifierName("RegisterFinalizers")))), - ReturnStatement(IdentifierName("builder"))))))) - .NormalizeWhitespace(); - - context.AddSource( - "OperatorBuilder.g.cs", - SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); - } -} +using System.Text; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace KubeOps.Generator.Generators; + +[Generator] +internal class OperatorBuilderGenerator : ISourceGenerator +{ + public void Initialize(GeneratorInitializationContext context) + { + } + + public void Execute(GeneratorExecutionContext context) + { + var declaration = CompilationUnit() + .WithUsings( + List( + new List { UsingDirective(IdentifierName("KubeOps.Abstractions.Builder")), })) + .WithMembers(SingletonList(ClassDeclaration("OperatorBuilderExtensions") + .WithModifiers(TokenList( + Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .AddMembers(MethodDeclaration(IdentifierName("IOperatorBuilder"), "RegisterComponents") + .WithModifiers( + TokenList(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.StaticKeyword))) + .WithParameterList(ParameterList( + SingletonSeparatedList( + Parameter( + Identifier("builder")) + .WithModifiers( + TokenList( + Token(SyntaxKind.ThisKeyword))) + .WithType( + IdentifierName("IOperatorBuilder"))))) + .WithBody(Block( + ExpressionStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("builder"), + IdentifierName("RegisterControllers")))), + ExpressionStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("builder"), + IdentifierName("RegisterFinalizers")))), + ReturnStatement(IdentifierName("builder"))))))) + .NormalizeWhitespace(); + + context.AddSource( + "OperatorBuilder.g.cs", + SourceText.From(declaration.ToString(), Encoding.UTF8, SourceHashAlgorithm.Sha256)); + } +} diff --git a/src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs b/src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs index 0cf3a087..0cfc077a 100644 --- a/src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/AttributedEntity.cs @@ -1,10 +1,10 @@ -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KubeOps.Generator.SyntaxReceiver; - -internal record struct AttributedEntity( - ClassDeclarationSyntax Class, - string Kind, - string Version, - string? Group, - string? Plural); +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Generator.SyntaxReceiver; + +internal record struct AttributedEntity( + ClassDeclarationSyntax Class, + string Kind, + string Version, + string? Group, + string? Plural); diff --git a/src/KubeOps.Generator/SyntaxReceiver/CombinedSyntaxReceiver.cs b/src/KubeOps.Generator/SyntaxReceiver/CombinedSyntaxReceiver.cs index abee602a..074728ce 100644 --- a/src/KubeOps.Generator/SyntaxReceiver/CombinedSyntaxReceiver.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/CombinedSyntaxReceiver.cs @@ -1,14 +1,14 @@ -using Microsoft.CodeAnalysis; - -namespace KubeOps.Generator.SyntaxReceiver; - -internal class CombinedSyntaxReceiver(params ISyntaxContextReceiver[] receivers) : ISyntaxContextReceiver -{ - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - foreach (var syntaxContextReceiver in receivers) - { - syntaxContextReceiver.OnVisitSyntaxNode(context); - } - } -} +using Microsoft.CodeAnalysis; + +namespace KubeOps.Generator.SyntaxReceiver; + +internal class CombinedSyntaxReceiver(params ISyntaxContextReceiver[] receivers) : ISyntaxContextReceiver +{ + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + foreach (var syntaxContextReceiver in receivers) + { + syntaxContextReceiver.OnVisitSyntaxNode(context); + } + } +} diff --git a/src/KubeOps.Generator/SyntaxReceiver/EntityControllerSyntaxReceiver.cs b/src/KubeOps.Generator/SyntaxReceiver/EntityControllerSyntaxReceiver.cs index 5219ed0d..d40eb3f1 100644 --- a/src/KubeOps.Generator/SyntaxReceiver/EntityControllerSyntaxReceiver.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/EntityControllerSyntaxReceiver.cs @@ -1,22 +1,22 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KubeOps.Generator.SyntaxReceiver; - -internal class EntityControllerSyntaxReceiver : ISyntaxContextReceiver -{ - public List<(ClassDeclarationSyntax Controller, string EntityName)> Controllers { get; } = []; - - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - if (context.Node is not ClassDeclarationSyntax { BaseList.Types.Count: > 0 } cls || - cls.BaseList.Types.FirstOrDefault(t => t is - { Type: GenericNameSyntax { Identifier.Text: "IEntityController" } }) is not { } baseType) - { - return; - } - - var targetEntity = (baseType.Type as GenericNameSyntax)!.TypeArgumentList.Arguments.First(); - Controllers.Add((cls, targetEntity.ToString())); - } -} +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Generator.SyntaxReceiver; + +internal class EntityControllerSyntaxReceiver : ISyntaxContextReceiver +{ + public List<(ClassDeclarationSyntax Controller, string EntityName)> Controllers { get; } = []; + + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax { BaseList.Types.Count: > 0 } cls || + cls.BaseList.Types.FirstOrDefault(t => t is + { Type: GenericNameSyntax { Identifier.Text: "IEntityController" } }) is not { } baseType) + { + return; + } + + var targetEntity = (baseType.Type as GenericNameSyntax)!.TypeArgumentList.Arguments.First(); + Controllers.Add((cls, targetEntity.ToString())); + } +} diff --git a/src/KubeOps.Generator/SyntaxReceiver/EntityFinalizerSyntaxReceiver.cs b/src/KubeOps.Generator/SyntaxReceiver/EntityFinalizerSyntaxReceiver.cs index 305157d0..5f9614be 100644 --- a/src/KubeOps.Generator/SyntaxReceiver/EntityFinalizerSyntaxReceiver.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/EntityFinalizerSyntaxReceiver.cs @@ -1,22 +1,22 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KubeOps.Generator.SyntaxReceiver; - -internal class EntityFinalizerSyntaxReceiver : ISyntaxContextReceiver -{ - public List<(ClassDeclarationSyntax Finalizer, string EntityName)> Finalizer { get; } = []; - - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - if (context.Node is not ClassDeclarationSyntax { BaseList.Types.Count: > 0 } cls || - cls.BaseList.Types.FirstOrDefault(t => t is - { Type: GenericNameSyntax { Identifier.Text: "IEntityFinalizer" } }) is not { } baseType) - { - return; - } - - var targetEntity = (baseType.Type as GenericNameSyntax)!.TypeArgumentList.Arguments.First(); - Finalizer.Add((cls, targetEntity.ToString())); - } -} +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Generator.SyntaxReceiver; + +internal class EntityFinalizerSyntaxReceiver : ISyntaxContextReceiver +{ + public List<(ClassDeclarationSyntax Finalizer, string EntityName)> Finalizer { get; } = []; + + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax { BaseList.Types.Count: > 0 } cls || + cls.BaseList.Types.FirstOrDefault(t => t is + { Type: GenericNameSyntax { Identifier.Text: "IEntityFinalizer" } }) is not { } baseType) + { + return; + } + + var targetEntity = (baseType.Type as GenericNameSyntax)!.TypeArgumentList.Arguments.First(); + Finalizer.Add((cls, targetEntity.ToString())); + } +} diff --git a/src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs b/src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs index 1ed0040b..986b5909 100644 --- a/src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs +++ b/src/KubeOps.Generator/SyntaxReceiver/KubernetesEntitySyntaxReceiver.cs @@ -1,38 +1,38 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace KubeOps.Generator.SyntaxReceiver; - -internal class KubernetesEntitySyntaxReceiver : ISyntaxContextReceiver -{ - private const string KindName = "Kind"; - private const string GroupName = "Group"; - private const string PluralName = "Plural"; - private const string VersionName = "ApiVersion"; - private const string DefaultVersion = "v1"; - - public List Entities { get; } = []; - - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - if (context.Node is not ClassDeclarationSyntax { AttributeLists.Count: > 0 } cls || - cls.AttributeLists.SelectMany(a => a.Attributes) - .FirstOrDefault(a => a.Name.ToString() == "KubernetesEntity") is not { } attr) - { - return; - } - - Entities.Add(new( - cls, - GetArgumentValue(attr, KindName) ?? cls.Identifier.ToString(), - GetArgumentValue(attr, VersionName) ?? DefaultVersion, - GetArgumentValue(attr, GroupName), - GetArgumentValue(attr, PluralName))); - } - - private static string? GetArgumentValue(AttributeSyntax attr, string argName) => - attr.ArgumentList?.Arguments.FirstOrDefault(a => a.NameEquals?.Name.ToString() == argName) is - { Expression: LiteralExpressionSyntax { Token.ValueText: { } value } } - ? value - : null; -} +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace KubeOps.Generator.SyntaxReceiver; + +internal class KubernetesEntitySyntaxReceiver : ISyntaxContextReceiver +{ + private const string KindName = "Kind"; + private const string GroupName = "Group"; + private const string PluralName = "Plural"; + private const string VersionName = "ApiVersion"; + private const string DefaultVersion = "v1"; + + public List Entities { get; } = []; + + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax { AttributeLists.Count: > 0 } cls || + cls.AttributeLists.SelectMany(a => a.Attributes) + .FirstOrDefault(a => a.Name.ToString() == "KubernetesEntity") is not { } attr) + { + return; + } + + Entities.Add(new( + cls, + GetArgumentValue(attr, KindName) ?? cls.Identifier.ToString(), + GetArgumentValue(attr, VersionName) ?? DefaultVersion, + GetArgumentValue(attr, GroupName), + GetArgumentValue(attr, PluralName))); + } + + private static string? GetArgumentValue(AttributeSyntax attr, string argName) => + attr.ArgumentList?.Arguments.FirstOrDefault(a => a.NameEquals?.Name.ToString() == argName) is + { Expression: LiteralExpressionSyntax { Token.ValueText: { } value } } + ? value + : null; +} diff --git a/src/KubeOps.KubernetesClient/IKubernetesClient.cs b/src/KubeOps.KubernetesClient/IKubernetesClient.cs index f10013ea..24400d4b 100644 --- a/src/KubeOps.KubernetesClient/IKubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/IKubernetesClient.cs @@ -1,414 +1,486 @@ -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.KubernetesClient.LabelSelectors; - -namespace KubeOps.KubernetesClient; - -/// -/// Client for the Kubernetes API. Contains various methods to manage Kubernetes entities. -/// This client is generic and allows usage of all types as long as they are decorated -/// with the . -/// -public interface IKubernetesClient : IDisposable -{ - /// - /// Return the base URI of the currently used KubernetesClient. - /// - Uri BaseUri { get; } - - /// - /// Returns the name of the current namespace. - /// To determine the current namespace the following places (in the given order) are checked: - /// - /// - /// The created Kubernetes configuration (from file / in-cluster) - /// - /// - /// - /// The env variable given as the param to the function (default "POD_NAMESPACE") - /// which can be provided by the Kubernetes downward API - /// - /// - /// - /// - /// The fallback secret file if running on the cluster - /// (/var/run/secrets/Kubernetes.io/serviceaccount/namespace) - /// - /// - /// - /// `default` - /// - /// - /// - /// Customizable name of the env var to check for the namespace. - /// A string containing the current namespace (or a fallback of it). - Task GetCurrentNamespaceAsync(string downwardApiEnvName = "POD_NAMESPACE"); - - /// - string GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE"); - - /// - /// Fetch and return an entity from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The name of the entity (metadata.name). - /// - /// Optional namespace. If this is set, the entity must be a namespaced entity. - /// If it is omitted, the entity must be a cluster wide entity. - /// - /// The found entity of the given type, or null otherwise. - Task GetAsync(string name, string? @namespace = null) - where TEntity : IKubernetesObject; - - /// - TEntity? Get(string name, string? @namespace = null) - where TEntity : IKubernetesObject; - - /// - /// Fetch and return a list of entities from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// If the entities are namespaced, provide the name of the namespace. - /// A string, representing an optional label selector for filtering fetched objects. - /// A list of Kubernetes entities. - Task> ListAsync( - string? @namespace = null, - string? labelSelector = null) - where TEntity : IKubernetesObject; - - /// - /// Fetch and return a list of entities from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// - /// If only entities in a given namespace should be listed, provide the namespace here. - /// - /// A list of label-selectors to apply to the search. - /// A list of Kubernetes entities. - Task> ListAsync( - string? @namespace = null, - params LabelSelector[] labelSelectors) - where TEntity : IKubernetesObject - => ListAsync(@namespace, labelSelectors.ToExpression()); - - /// - IList List( - string? @namespace = null, - string? labelSelector = null) - where TEntity : IKubernetesObject; - - /// - IList List( - string? @namespace = null, - params LabelSelector[] labelSelectors) - where TEntity : IKubernetesObject - => List(@namespace, labelSelectors.ToExpression()); - - /// - /// Create or Update a entity. This first fetches the entity from the Kubernetes API - /// and if it does exist, updates the entity. Otherwise, the entity is created. - /// - /// The type of the Kubernetes entity. - /// The entity in question. - /// The saved instance of the entity. - async Task SaveAsync(TEntity entity) - where TEntity : IKubernetesObject - => - await GetAsync(entity.Name(), entity.Namespace()) switch - { - { } e => await UpdateAsync(entity.WithResourceVersion(e)), - _ => await CreateAsync(entity), - }; - - /// - /// Create or Update a list of entities. This first fetches each entity from the Kubernetes API - /// and if it does exist, updates the entity. Otherwise, the entity is created. - /// - /// The type of the Kubernetes entity. - /// The entity list. - /// The saved instances of the entities. - async Task> SaveAsync(IEnumerable entities) - where TEntity : IKubernetesObject => - await Task.WhenAll(entities.Select(SaveAsync)); - - /// - /// Create or Update a list of entities. This first fetches each entity from the Kubernetes API - /// and if it does exist, updates the entity. Otherwise, the entity is created. - /// - /// The type of the Kubernetes entity. - /// The entity list. - /// The saved instances of the entities. - async Task> SaveAsync(params TEntity[] entities) - where TEntity : IKubernetesObject => - await Task.WhenAll(entities.Select(SaveAsync)); - - /// - TEntity Save(TEntity entity) - where TEntity : IKubernetesObject - => - Get(entity.Name(), entity.Namespace()) switch - { - { } e => Update(entity.WithResourceVersion(e)), - _ => Create(entity), - }; - - /// - IEnumerable Save(IEnumerable entities) - where TEntity : IKubernetesObject - => - entities.Select(Save); - - /// - IEnumerable Save(params TEntity[] entities) - where TEntity : IKubernetesObject - => - entities.Select(Save); - - /// - /// Create the given entity on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entity instance. - /// The created instance of the entity. - Task CreateAsync(TEntity entity) - where TEntity : IKubernetesObject; - - /// - /// Create a list of entities on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entity list. - /// The created instances of the entities. - async Task> CreateAsync(IEnumerable entities) - where TEntity : IKubernetesObject - => await Task.WhenAll(entities.Select(CreateAsync)); - - /// - /// Create a list of entities on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entity list. - /// The created instances of the entities. - async Task> CreateAsync(params TEntity[] entities) - where TEntity : IKubernetesObject - => await Task.WhenAll(entities.Select(CreateAsync)); - - /// - TEntity Create(TEntity entity) - where TEntity : IKubernetesObject - => CreateAsync(entity).GetAwaiter().GetResult(); - - /// - IEnumerable Create(IEnumerable entities) - where TEntity : IKubernetesObject - => entities.Select(Create); - - /// - IEnumerable Create(params TEntity[] entities) - where TEntity : IKubernetesObject - => entities.Select(Create); - - /// - /// Update the given entity on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entity instance. - /// The updated instance of the entity. - Task UpdateAsync(TEntity entity) - where TEntity : IKubernetesObject; - - /// - /// Update a list of entities on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// An enumerable of entities. - /// The updated instances of the entities. - async Task> UpdateAsync(IEnumerable entities) - where TEntity : IKubernetesObject - => await Task.WhenAll(entities.Select(UpdateAsync)); - - /// - /// Update a list of entities on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// An enumerable of entities. - /// The updated instances of the entities. - async Task> UpdateAsync(params TEntity[] entities) - where TEntity : IKubernetesObject - => await Task.WhenAll(entities.Select(UpdateAsync)); - - /// - TEntity Update(TEntity entity) - where TEntity : IKubernetesObject - => UpdateAsync(entity).GetAwaiter().GetResult(); - - /// - IEnumerable Update(IEnumerable entities) - where TEntity : IKubernetesObject - => entities.Select(Update); - - /// - IEnumerable Update(params TEntity[] entities) - where TEntity : IKubernetesObject - => entities.Select(Update); - - /// - /// Update the status object of a given entity on the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entity that contains a status object. - /// The entity with the updated status. - Task UpdateStatusAsync(TEntity entity) - where TEntity : IKubernetesObject; - - /// - TEntity UpdateStatus(TEntity entity) - where TEntity : IKubernetesObject; - - /// - /// A task that completes when the call was made. - Task DeleteAsync(TEntity entity) - where TEntity : IKubernetesObject - => - DeleteAsync(entity.Name(), entity.Namespace()); - - /// - /// A task that completes when the call was made. - Task DeleteAsync(IEnumerable entities) - where TEntity : IKubernetesObject - => Task.WhenAll(entities.Select(DeleteAsync)); - - /// - /// A task that completes when the call was made. - Task DeleteAsync(params TEntity[] entities) - where TEntity : IKubernetesObject - => Task.WhenAll(entities.Select(DeleteAsync)); - - /// - /// A task that completes when the call was made. - Task DeleteAsync(string name, string? @namespace = null) - where TEntity : IKubernetesObject; - - /// - /// Delete a given entity from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entity in question. - void Delete(TEntity entity) - where TEntity : IKubernetesObject - => - Delete(entity.Name(), entity.Namespace()); - - /// - /// Delete a given list of entities from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entities in question. - void Delete(IEnumerable entities) - where TEntity : IKubernetesObject - { - foreach (var entity in entities) - { - Delete(entity); - } - } - - /// - /// Delete a given list of entities from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The entities in question. - void Delete(params TEntity[] entities) - where TEntity : IKubernetesObject - { - foreach (var entity in entities) - { - Delete(entity); - } - } - - /// - /// Delete a given entity by name from the Kubernetes API. - /// - /// The type of the Kubernetes entity. - /// The name of the entity. - /// The optional namespace of the entity. - void Delete(string name, string? @namespace = null) - where TEntity : IKubernetesObject - => DeleteAsync(name, @namespace).GetAwaiter().GetResult(); - - /// - /// Create a entity watcher on the Kubernetes API. - /// The entity watcher fires events for entity-events on - /// Kubernetes (events: . - /// - /// The type of the Kubernetes entity. - /// Action that is called when an event occurs. - /// Action that handles exceptions. - /// Action that handles closed connections. - /// - /// The namespace to watch for entities (if needed). - /// If the namespace is omitted, all entities on the cluster are watched. - /// - /// The timeout which the watcher has (after this timeout, the server will close the connection). - /// - /// When specified with a watch call, shows changes that occur after that particular version of a resource. - /// Defaults to changes from the beginning of history. - /// - /// Cancellation-Token. - /// A list of label-selectors to apply to the search. - /// A entity watcher for the given entity. - Watcher Watch( - Action onEvent, - Action? onError = null, - Action? onClose = null, - string? @namespace = null, - TimeSpan? timeout = null, - string? resourceVersion = null, - CancellationToken cancellationToken = default, - params LabelSelector[] labelSelectors) - where TEntity : IKubernetesObject - => Watch( - onEvent, - onError, - onClose, - @namespace, - timeout, - resourceVersion, - labelSelectors.ToExpression(), - cancellationToken); - - /// - /// Create a entity watcher on the Kubernetes API. - /// The entity watcher fires events for entity-events on - /// Kubernetes (events: . - /// - /// The type of the Kubernetes entity. - /// Action that is called when an event occurs. - /// Action that handles exceptions. - /// Action that handles closed connections. - /// - /// The namespace to watch for entities (if needed). - /// If the namespace is omitted, all entities on the cluster are watched. - /// - /// The timeout which the watcher has (after this timeout, the server will close the connection). - /// - /// When specified with a watch call, shows changes that occur after that particular version of a resource. - /// Defaults to changes from the beginning of history. - /// - /// A string, representing an optional label selector for filtering watched objects. - /// Cancellation-Token. - /// A entity watcher for the given entity. - Watcher Watch( - Action onEvent, - Action? onError = null, - Action? onClose = null, - string? @namespace = null, - TimeSpan? timeout = null, - string? resourceVersion = null, - string? labelSelector = null, - CancellationToken cancellationToken = default) - where TEntity : IKubernetesObject; -} +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.KubernetesClient.LabelSelectors; + +namespace KubeOps.KubernetesClient; + +/// +/// Client for the Kubernetes API. Contains various methods to manage Kubernetes entities. +/// This client is generic and allows usage of all types as long as they are decorated +/// with the . +/// +public interface IKubernetesClient : IDisposable +{ + /// + /// Return the base URI of the currently used KubernetesClient. + /// + Uri BaseUri { get; } + + /// + /// Returns the name of the current namespace. + /// To determine the current namespace the following places (in the given order) are checked: + /// + /// + /// The created Kubernetes configuration (from file / in-cluster) + /// + /// + /// + /// The env variable given as the param to the function (default "POD_NAMESPACE") + /// which can be provided by the Kubernetes downward API + /// + /// + /// + /// + /// The fallback secret file if running on the cluster + /// (/var/run/secrets/Kubernetes.io/serviceaccount/namespace) + /// + /// + /// + /// `default` + /// + /// + /// + /// Customizable name of the env var to check for the namespace. + /// The token to monitor for cancellation requests. + /// A string containing the current namespace (or a fallback of it). + Task GetCurrentNamespaceAsync( + string downwardApiEnvName = "POD_NAMESPACE", + CancellationToken cancellationToken = default); + + /// + string GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE"); + + /// + /// Fetch and return an entity from the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The name of the entity (metadata.name). + /// + /// Optional namespace. If this is set, the entity must be a namespaced entity. + /// If it is omitted, the entity must be a cluster wide entity. + /// + /// The token to monitor for cancellation requests. + /// The found entity of the given type, or null otherwise. + Task GetAsync( + string name, + string? @namespace = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + TEntity? Get(string name, string? @namespace = null) + where TEntity : IKubernetesObject; + + /// + /// Fetch and return a list of entities from the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// If the entities are namespaced, provide the name of the namespace. + /// A string, representing an optional label selector for filtering fetched objects. + /// The token to monitor for cancellation requests. + /// A list of Kubernetes entities. + Task> ListAsync( + string? @namespace = null, + string? labelSelector = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + /// Fetch and return a list of entities from the Kubernetes API. + /// + /// + /// This is invoking the API without any cancellation support. In order to pass a , + /// you need to use the overload. + /// + /// The type of the Kubernetes entity. + /// + /// If only entities in a given namespace should be listed, provide the namespace here. + /// + /// A list of label-selectors to apply to the search. + /// A list of Kubernetes entities. + Task> ListAsync( + string? @namespace = null, + params LabelSelector[] labelSelectors) + where TEntity : IKubernetesObject + { + using var cts = new CancellationTokenSource(); + return ListAsync(@namespace, labelSelectors.ToExpression(), cts.Token); + } + + /// + IList List( + string? @namespace = null, + string? labelSelector = null) + where TEntity : IKubernetesObject; + + /// + IList List( + string? @namespace = null, + params LabelSelector[] labelSelectors) + where TEntity : IKubernetesObject + => List(@namespace, labelSelectors.ToExpression()); + + /// + /// Create or Update a entity. This first fetches the entity from the Kubernetes API + /// and if it does exist, updates the entity. Otherwise, the entity is created. + /// + /// The type of the Kubernetes entity. + /// The entity in question. + /// The token to monitor for cancellation requests. + /// The saved instance of the entity. + async Task SaveAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + => + await GetAsync(entity.Name(), entity.Namespace(), cancellationToken) switch + { + { } e => await UpdateAsync(entity.WithResourceVersion(e), cancellationToken), + _ => await CreateAsync(entity, cancellationToken), + }; + + /// + /// Create or Update a list of entities. This first fetches each entity from the Kubernetes API + /// and if it does exist, updates the entity. Otherwise, the entity is created. + /// + /// The type of the Kubernetes entity. + /// The entity list. + /// The token to monitor for cancellation requests. + /// The saved instances of the entities. + async Task> SaveAsync( + IEnumerable entities, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject => + await Task.WhenAll(entities.Select(entity => SaveAsync(entity, cancellationToken))); + + /// + /// Create or Update a list of entities. This first fetches each entity from the Kubernetes API + /// and if it does exist, updates the entity. Otherwise, the entity is created. + /// + /// + /// This is invoking the API without any cancellation support. In order to pass a , + /// you need to use the overload. + /// + /// The type of the Kubernetes entity. + /// The entity list. + /// The saved instances of the entities. + Task> SaveAsync(params TEntity[] entities) + where TEntity : IKubernetesObject + { + using var cts = new CancellationTokenSource(); + return SaveAsync(entities, cts.Token); + } + + /// + TEntity Save(TEntity entity) + where TEntity : IKubernetesObject + => + Get(entity.Name(), entity.Namespace()) switch + { + { } e => Update(entity.WithResourceVersion(e)), + _ => Create(entity), + }; + + /// + IEnumerable Save(IEnumerable entities) + where TEntity : IKubernetesObject + => + entities.Select(Save); + + /// + IEnumerable Save(params TEntity[] entities) + where TEntity : IKubernetesObject + => + entities.Select(Save); + + /// + /// Create the given entity on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entity instance. + /// The token to monitor for cancellation requests. + /// The created instance of the entity. + Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + /// Create a list of entities on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entity list. + /// The token to monitor for cancellation requests. + /// The created instances of the entities. + async Task> CreateAsync( + IEnumerable entities, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + => await Task.WhenAll(entities.Select(entity => CreateAsync(entity, cancellationToken))); + + /// + /// Create a list of entities on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entity list. + /// + /// This is invoking the API without any cancellation support. In order to pass a , + /// you need to use the overload. + /// + /// The created instances of the entities. + async Task> CreateAsync(params TEntity[] entities) + where TEntity : IKubernetesObject + => await Task.WhenAll(entities.Select(entity => CreateAsync(entity))); + + /// + TEntity Create(TEntity entity) + where TEntity : IKubernetesObject + => CreateAsync(entity).GetAwaiter().GetResult(); + + /// + IEnumerable Create(IEnumerable entities) + where TEntity : IKubernetesObject + => entities.Select(Create); + + /// + IEnumerable Create(params TEntity[] entities) + where TEntity : IKubernetesObject + => entities.Select(Create); + + /// + /// Update the given entity on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entity instance. + /// The token to monitor for cancellation requests. + /// The updated instance of the entity. + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + /// Update a list of entities on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// An enumerable of entities. + /// The token to monitor for cancellation requests. + /// The updated instances of the entities. + async Task> UpdateAsync( + IEnumerable entities, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + => await Task.WhenAll(entities.Select(entity => UpdateAsync(entity, cancellationToken))); + + /// + /// Update a list of entities on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// An enumerable of entities. + /// + /// This is invoking the API without any cancellation support. In order to pass a , + /// you need to use the overload. + /// + /// The updated instances of the entities. + Task> UpdateAsync(params TEntity[] entities) + where TEntity : IKubernetesObject + { + using var cts = new CancellationTokenSource(); + return UpdateAsync(entities, cts.Token); + } + + /// + TEntity Update(TEntity entity) + where TEntity : IKubernetesObject + => UpdateAsync(entity).GetAwaiter().GetResult(); + + /// + IEnumerable Update(IEnumerable entities) + where TEntity : IKubernetesObject + => entities.Select(Update); + + /// + IEnumerable Update(params TEntity[] entities) + where TEntity : IKubernetesObject + => entities.Select(Update); + + /// + /// Update the status object of a given entity on the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entity that contains a status object. + /// The token to monitor for cancellation requests. + /// The entity with the updated status. + Task UpdateStatusAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + TEntity UpdateStatus(TEntity entity) + where TEntity : IKubernetesObject; + + /// + /// A task that completes when the call was made. + Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + => DeleteAsync(entity.Name(), entity.Namespace(), cancellationToken); + + /// + /// A task that completes when the call was made. + Task DeleteAsync(IEnumerable entities, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + => Task.WhenAll(entities.Select(entity => DeleteAsync(entity, cancellationToken))); + + /// + /// + /// This is invoking the API without any cancellation support. In order to pass a , + /// you need to use the overload. + /// + /// A task that completes when the call was made. + Task DeleteAsync(params TEntity[] entities) + where TEntity : IKubernetesObject + => DeleteAsync(entities, CancellationToken.None); + + /// + /// A task that completes when the call was made. + Task DeleteAsync(string name, string? @namespace = null, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + /// Delete a given entity from the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entity in question. + void Delete(TEntity entity) + where TEntity : IKubernetesObject + => + Delete(entity.Name(), entity.Namespace()); + + /// + /// Delete a given list of entities from the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entities in question. + void Delete(IEnumerable entities) + where TEntity : IKubernetesObject + { + foreach (var entity in entities) + { + Delete(entity); + } + } + + /// + /// Delete a given list of entities from the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The entities in question. + void Delete(params TEntity[] entities) + where TEntity : IKubernetesObject + { + foreach (var entity in entities) + { + Delete(entity); + } + } + + /// + /// Delete a given entity by name from the Kubernetes API. + /// + /// The type of the Kubernetes entity. + /// The name of the entity. + /// The optional namespace of the entity. + void Delete(string name, string? @namespace = null) + where TEntity : IKubernetesObject + => DeleteAsync(name, @namespace).GetAwaiter().GetResult(); + + /// + /// Create a entity watcher on the Kubernetes API. + /// The entity watcher fires events for entity-events on + /// Kubernetes (events: . + /// + /// The type of the Kubernetes entity. + /// Action that is called when an event occurs. + /// Action that handles exceptions. + /// Action that handles closed connections. + /// + /// The namespace to watch for entities (if needed). + /// If the namespace is omitted, all entities on the cluster are watched. + /// + /// The timeout which the watcher has (after this timeout, the server will close the connection). + /// + /// When specified with a watch call, shows changes that occur after that particular version of a resource. + /// Defaults to changes from the beginning of history. + /// + /// Cancellation-Token. + /// A list of label-selectors to apply to the search. + /// A entity watcher for the given entity. + Watcher Watch( + Action onEvent, + Action? onError = null, + Action? onClose = null, + string? @namespace = null, + TimeSpan? timeout = null, + string? resourceVersion = null, + CancellationToken cancellationToken = default, + params LabelSelector[] labelSelectors) + where TEntity : IKubernetesObject + => Watch( + onEvent, + onError, + onClose, + @namespace, + timeout, + resourceVersion, + labelSelectors.ToExpression(), + cancellationToken); + + /// + /// Create a entity watcher on the Kubernetes API. + /// The entity watcher fires events for entity-events on + /// Kubernetes (events: . + /// + /// The type of the Kubernetes entity. + /// Action that is called when an event occurs. + /// Action that handles exceptions. + /// Action that handles closed connections. + /// + /// The namespace to watch for entities (if needed). + /// If the namespace is omitted, all entities on the cluster are watched. + /// + /// The timeout which the watcher has (after this timeout, the server will close the connection). + /// + /// When specified with a watch call, shows changes that occur after that particular version of a resource. + /// Defaults to changes from the beginning of history. + /// + /// A string, representing an optional label selector for filtering watched objects. + /// Cancellation-Token. + /// A entity watcher for the given entity. + Watcher Watch( + Action onEvent, + Action? onError = null, + Action? onClose = null, + string? @namespace = null, + TimeSpan? timeout = null, + string? resourceVersion = null, + string? labelSelector = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; + + /// + /// Creates an asynchronous entity watcher on the Kubernetes API. + /// + /// + /// The namespace to watch for entities (if needed). + /// If the namespace is omitted, all entities on the cluster are watched. + /// + /// + /// When specified with a watch call, shows changes that occur after that particular version of a resource. + /// Defaults to changes from the beginning of history. + /// + /// A string, representing an optional label selector for filtering watched objects. + /// The token to monitor for cancellation requests. + /// The type of the Kubernetes entity. + /// An asynchronous enumerable that finishes once is cancelled. + IAsyncEnumerable<(WatchEventType Type, TEntity Entity)> WatchAsync( + string? @namespace = null, + string? resourceVersion = null, + string? labelSelector = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject; +} diff --git a/src/KubeOps.KubernetesClient/KubernetesClient.cs b/src/KubeOps.KubernetesClient/KubernetesClient.cs index caa4f27c..c170c13b 100644 --- a/src/KubeOps.KubernetesClient/KubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/KubernetesClient.cs @@ -1,376 +1,481 @@ -using System.Collections.Concurrent; -using System.Net; - -using k8s; -using k8s.Autorest; -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Transpiler; - -namespace KubeOps.KubernetesClient; - -/// -public class KubernetesClient : IKubernetesClient -{ - private const string DownwardApiNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; - private const string DefaultNamespace = "default"; - - private static readonly ConcurrentDictionary MetadataCache = new(); - - private readonly KubernetesClientConfiguration _clientConfig; - private readonly IKubernetes _client; - - /// - /// Create a new Kubernetes client for the given entity. - /// The client will use the default configuration. - /// - public KubernetesClient() - : this(KubernetesClientConfiguration.BuildDefaultConfig()) - { - } - - /// - /// Create a new Kubernetes client for the given entity with a custom client configuration. - /// - /// The config for the underlying Kubernetes client. - public KubernetesClient(KubernetesClientConfiguration clientConfig) - : this(clientConfig, new Kubernetes(clientConfig)) - { - } - - /// - /// Create a new Kubernetes client for the given entity with a custom client configuration and client. - /// - /// The config for the underlying Kubernetes client. - /// The underlying client. - public KubernetesClient(KubernetesClientConfiguration clientConfig, IKubernetes client) - { - _clientConfig = clientConfig; - _client = client; - } - - /// - public Uri BaseUri => _client.BaseUri; - - public static void ClearMetadataCache() => MetadataCache.Clear(); - - /// - public async Task GetCurrentNamespaceAsync(string downwardApiEnvName = "POD_NAMESPACE") - { - if (_clientConfig.Namespace is { } configValue) - { - return configValue; - } - - if (Environment.GetEnvironmentVariable(downwardApiEnvName) is { } envValue) - { - return envValue; - } - - if (File.Exists(DownwardApiNamespaceFile)) - { - var ns = await File.ReadAllTextAsync(DownwardApiNamespaceFile); - return ns.Trim(); - } - - return DefaultNamespace; - } - - /// - public string GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE") - { - if (_clientConfig.Namespace is { } configValue) - { - return configValue; - } - - if (Environment.GetEnvironmentVariable(downwardApiEnvName) is { } envValue) - { - return envValue; - } - - if (File.Exists(DownwardApiNamespaceFile)) - { - var ns = File.ReadAllText(DownwardApiNamespaceFile); - return ns.Trim(); - } - - return DefaultNamespace; - } - - /// - public async Task GetAsync(string name, string? @namespace = null) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - - try - { - return await (string.IsNullOrWhiteSpace(@namespace) - ? _client.CustomObjects.GetClusterCustomObjectAsync( - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - name) - : _client.CustomObjects.GetNamespacedCustomObjectAsync( - metadata.Group ?? string.Empty, - metadata.Version, - @namespace, - metadata.PluralName, - name)); - } - catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) - { - return default; - } - } - - /// - public TEntity? Get(string name, string? @namespace = null) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - - try - { - return string.IsNullOrWhiteSpace(@namespace) - ? _client.CustomObjects.GetClusterCustomObject( - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - name) - : _client.CustomObjects.GetNamespacedCustomObject( - metadata.Group ?? string.Empty, - metadata.Version, - @namespace, - metadata.PluralName, - name); - } - catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) - { - return default; - } - } - - /// - public async Task> ListAsync(string? @namespace = null, string? labelSelector = null) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - return (@namespace switch - { - null => await _client.CustomObjects.ListClusterCustomObjectAsync>( - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - labelSelector: labelSelector), - _ => await _client.CustomObjects.ListNamespacedCustomObjectAsync>( - metadata.Group ?? string.Empty, - metadata.Version, - @namespace, - metadata.PluralName, - labelSelector: labelSelector), - }).Items; - } - - /// - public IList List(string? @namespace = null, string? labelSelector = null) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - return (@namespace switch - { - null => _client.CustomObjects.ListClusterCustomObject>( - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - labelSelector: labelSelector), - _ => _client.CustomObjects.ListNamespacedCustomObject>( - metadata.Group ?? string.Empty, - metadata.Version, - @namespace, - metadata.PluralName, - labelSelector: labelSelector), - }).Items; - } - - /// - public async Task CreateAsync(TEntity entity) - where TEntity : IKubernetesObject - { - using var client = CreateGenericClient(); - return await (entity.Namespace() switch - { - { } ns => client.CreateNamespacedAsync(entity, ns), - null => client.CreateAsync(entity), - }); - } - - /// - public async Task UpdateAsync(TEntity entity) - where TEntity : IKubernetesObject - { - using var client = CreateGenericClient(); - return await (entity.Namespace() switch - { - { } ns => client.ReplaceNamespacedAsync(entity, ns, entity.Name()), - null => client.ReplaceAsync(entity, entity.Name()), - }); - } - - /// - public Task UpdateStatusAsync(TEntity entity) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - return entity.Namespace() switch - { - { } ns => _client.CustomObjects.ReplaceNamespacedCustomObjectStatusAsync( - entity, - metadata.Group ?? string.Empty, - metadata.Version, - ns, - metadata.PluralName, - entity.Name()), - _ => _client.CustomObjects.ReplaceClusterCustomObjectStatusAsync( - entity, - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - entity.Name()), - }; - } - - /// - public TEntity UpdateStatus(TEntity entity) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - return entity.Namespace() switch - { - { } ns => _client.CustomObjects.ReplaceNamespacedCustomObjectStatus( - entity, - metadata.Group ?? string.Empty, - metadata.Version, - ns, - metadata.PluralName, - entity.Name()), - _ => _client.CustomObjects.ReplaceClusterCustomObjectStatus( - entity, - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - entity.Name()), - }; - } - - /// - public async Task DeleteAsync(string name, string? @namespace = null) - where TEntity : IKubernetesObject - { - try - { - using var client = CreateGenericClient(); - switch (@namespace) - { - case not null: - await client.DeleteNamespacedAsync(@namespace, name); - break; - default: - await client.DeleteAsync(name); - break; - } - } - catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) - { - // The resource was not found. We can ignore this. - } - } - - /// - public Watcher Watch( - Action onEvent, - Action? onError = null, - Action? onClose = null, - string? @namespace = null, - TimeSpan? timeout = null, - string? resourceVersion = null, - string? labelSelector = null, - CancellationToken cancellationToken = default) - where TEntity : IKubernetesObject - { - var metadata = GetMetadata(); - return (@namespace switch - { - not null => _client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( - metadata.Group ?? string.Empty, - metadata.Version, - @namespace, - metadata.PluralName, - labelSelector: labelSelector, - resourceVersion: resourceVersion, - timeoutSeconds: timeout switch - { - null => null, - _ => (int?)timeout.Value.TotalSeconds, - }, - watch: true, - cancellationToken: cancellationToken), - _ => _client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( - metadata.Group ?? string.Empty, - metadata.Version, - metadata.PluralName, - labelSelector: labelSelector, - resourceVersion: resourceVersion, - timeoutSeconds: timeout switch - { - null => null, - _ => (int?)timeout.Value.TotalSeconds, - }, - watch: true, - cancellationToken: cancellationToken), - }).Watch(onEvent, onError, onClose); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - _client.Dispose(); - } - - private static EntityMetadata GetMetadata() - { - var type = typeof(TEntity); - return MetadataCache.GetOrAdd(type, t => Entities.ToEntityMetadata(t).Metadata); - } - - private GenericClient CreateGenericClient() - { - var metadata = GetMetadata(); - return metadata.Group switch - { - null => new GenericClient( - _client, - metadata.Version, - metadata.PluralName, - false), - _ => new GenericClient( - _client, - metadata.Group, - metadata.Version, - metadata.PluralName, - false), - }; - } -} +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Runtime.CompilerServices; + +using k8s; +using k8s.Autorest; +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Transpiler; + +namespace KubeOps.KubernetesClient; + +/// +public class KubernetesClient : IKubernetesClient +{ + private const string DownwardApiNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; + private const string DefaultNamespace = "default"; + + private static readonly ConcurrentDictionary MetadataCache = new(); + + private readonly KubernetesClientConfiguration _clientConfig; + private readonly IKubernetes _client; + private bool _disposed; + + /// + /// Create a new Kubernetes client for the given entity. + /// The client will use the default configuration. + /// + public KubernetesClient() + : this(KubernetesClientConfiguration.BuildDefaultConfig()) + { + } + + /// + /// Create a new Kubernetes client for the given entity with a custom client configuration. + /// + /// The config for the underlying Kubernetes client. + public KubernetesClient(KubernetesClientConfiguration clientConfig) + : this(clientConfig, new Kubernetes(clientConfig)) + { + } + + /// + /// Create a new Kubernetes client for the given entity with a custom client configuration and client. + /// + /// + /// is automatically disposed if this is also being disposed. + /// + /// The config for the underlying Kubernetes client. + /// The underlying client. + public KubernetesClient(KubernetesClientConfiguration clientConfig, IKubernetes client) + { + _clientConfig = clientConfig; + _client = client; + } + + /// + public Uri BaseUri => _client.BaseUri; + + /// + /// Clears the metadata cache. + /// + public static void ClearMetadataCache() => MetadataCache.Clear(); + + /// + public async Task GetCurrentNamespaceAsync( + string downwardApiEnvName = "POD_NAMESPACE", + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (_clientConfig.Namespace is { } configValue) + { + return configValue; + } + + if (Environment.GetEnvironmentVariable(downwardApiEnvName) is { } envValue) + { + return envValue; + } + + if (File.Exists(DownwardApiNamespaceFile)) + { + var ns = await File.ReadAllTextAsync(DownwardApiNamespaceFile, cancellationToken); + return ns.Trim(); + } + + return DefaultNamespace; + } + + /// + public string GetCurrentNamespace(string downwardApiEnvName = "POD_NAMESPACE") + { + ThrowIfDisposed(); + + if (_clientConfig.Namespace is { } configValue) + { + return configValue; + } + + if (Environment.GetEnvironmentVariable(downwardApiEnvName) is { } envValue) + { + return envValue; + } + + if (File.Exists(DownwardApiNamespaceFile)) + { + var ns = File.ReadAllText(DownwardApiNamespaceFile); + return ns.Trim(); + } + + return DefaultNamespace; + } + + /// + public async Task GetAsync( + string name, + string? @namespace = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + var metadata = GetMetadata(); + + try + { + return await (string.IsNullOrWhiteSpace(@namespace) + ? _client.CustomObjects.GetClusterCustomObjectAsync( + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + name, + cancellationToken: cancellationToken) + : _client.CustomObjects.GetNamespacedCustomObjectAsync( + metadata.Group ?? string.Empty, + metadata.Version, + @namespace, + metadata.PluralName, + name, + cancellationToken: cancellationToken)); + } + catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) + { + return default; + } + } + + /// + public TEntity? Get(string name, string? @namespace = null) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + var metadata = GetMetadata(); + + try + { + return string.IsNullOrWhiteSpace(@namespace) + ? _client.CustomObjects.GetClusterCustomObject( + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + name) + : _client.CustomObjects.GetNamespacedCustomObject( + metadata.Group ?? string.Empty, + metadata.Version, + @namespace, + metadata.PluralName, + name); + } + catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) + { + return default; + } + } + + /// + public async Task> ListAsync( + string? @namespace = null, + string? labelSelector = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + var metadata = GetMetadata(); + return (@namespace switch + { + null => await _client.CustomObjects.ListClusterCustomObjectAsync>( + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + labelSelector: labelSelector, + cancellationToken: cancellationToken), + _ => await _client.CustomObjects.ListNamespacedCustomObjectAsync>( + metadata.Group ?? string.Empty, + metadata.Version, + @namespace, + metadata.PluralName, + labelSelector: labelSelector, + cancellationToken: cancellationToken), + }).Items; + } + + /// + public IList List(string? @namespace = null, string? labelSelector = null) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + var metadata = GetMetadata(); + return (@namespace switch + { + null => _client.CustomObjects.ListClusterCustomObject>( + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + labelSelector: labelSelector), + _ => _client.CustomObjects.ListNamespacedCustomObject>( + metadata.Group ?? string.Empty, + metadata.Version, + @namespace, + metadata.PluralName, + labelSelector: labelSelector), + }).Items; + } + + /// + public async Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + using var client = CreateGenericClient(); + return await (entity.Namespace() switch + { + { } ns => client.CreateNamespacedAsync(entity, ns, cancellationToken), + null => client.CreateAsync(entity, cancellationToken), + }); + } + + /// + public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + using var client = CreateGenericClient(); + return await (entity.Namespace() switch + { + { } ns => client.ReplaceNamespacedAsync(entity, ns, entity.Name(), cancellationToken), + null => client.ReplaceAsync(entity, entity.Name(), cancellationToken), + }); + } + + /// + public Task UpdateStatusAsync(TEntity entity, CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + var metadata = GetMetadata(); + return entity.Namespace() switch + { + { } ns => _client.CustomObjects.ReplaceNamespacedCustomObjectStatusAsync( + entity, + metadata.Group ?? string.Empty, + metadata.Version, + ns, + metadata.PluralName, + entity.Name(), + cancellationToken: cancellationToken), + _ => _client.CustomObjects.ReplaceClusterCustomObjectStatusAsync( + entity, + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + entity.Name(), + cancellationToken: cancellationToken), + }; + } + + /// + public TEntity UpdateStatus(TEntity entity) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + var metadata = GetMetadata(); + return entity.Namespace() switch + { + { } ns => _client.CustomObjects.ReplaceNamespacedCustomObjectStatus( + entity, + metadata.Group ?? string.Empty, + metadata.Version, + ns, + metadata.PluralName, + entity.Name()), + _ => _client.CustomObjects.ReplaceClusterCustomObjectStatus( + entity, + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + entity.Name()), + }; + } + + /// + public async Task DeleteAsync( + string name, + string? @namespace = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + + try + { + using var client = CreateGenericClient(); + switch (@namespace) + { + case not null: + await client.DeleteNamespacedAsync(@namespace, name, cancellationToken); + break; + default: + await client.DeleteAsync(name, cancellationToken); + break; + } + } + catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound) + { + // The resource was not found. We can ignore this. + } + } + + /// + public Watcher Watch( + Action onEvent, + Action? onError = null, + Action? onClose = null, + string? @namespace = null, + TimeSpan? timeout = null, + string? resourceVersion = null, + string? labelSelector = null, + CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + var metadata = GetMetadata(); + return (@namespace switch + { + not null => _client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( + metadata.Group ?? string.Empty, + metadata.Version, + @namespace, + metadata.PluralName, + labelSelector: labelSelector, + resourceVersion: resourceVersion, + timeoutSeconds: timeout switch + { + null => null, + _ => (int?)timeout.Value.TotalSeconds, + }, + watch: true, + cancellationToken: cancellationToken), + _ => _client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + labelSelector: labelSelector, + resourceVersion: resourceVersion, + timeoutSeconds: timeout switch + { + null => null, + _ => (int?)timeout.Value.TotalSeconds, + }, + watch: true, + cancellationToken: cancellationToken), + }).Watch(onEvent, onError, onClose); + } + + public async IAsyncEnumerable<(WatchEventType Type, TEntity Entity)> WatchAsync( + string? @namespace = null, + string? resourceVersion = null, + string? labelSelector = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TEntity : IKubernetesObject + { + ThrowIfDisposed(); + var metadata = GetMetadata(); + var watcher = (@namespace switch + { + not null => _client.CustomObjects.ListNamespacedCustomObjectWithHttpMessagesAsync( + metadata.Group ?? string.Empty, + metadata.Version, + @namespace, + metadata.PluralName, + labelSelector: labelSelector, + resourceVersion: resourceVersion, + watch: true, + cancellationToken: cancellationToken), + _ => _client.CustomObjects.ListClusterCustomObjectWithHttpMessagesAsync( + metadata.Group ?? string.Empty, + metadata.Version, + metadata.PluralName, + labelSelector: labelSelector, + resourceVersion: resourceVersion, + watch: true, + cancellationToken: cancellationToken), + }).WatchAsync( + onError: ex => throw ex, + cancellationToken: cancellationToken); + + await foreach ((WatchEventType watchEventType, TEntity? entity) in watcher) + { + Debug.Assert(entity is not null, "Received null entity during watch"); + yield return (watchEventType, entity); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + ThrowIfDisposed(); + + // The property is intentionally set before the underlying _client is disposed. + // This ensures that even if the disposal of the client is not finished yet, that all calls to the client + // are instantly failing. + _disposed = true; + _client.Dispose(); + } + + private static EntityMetadata GetMetadata() + { + var type = typeof(TEntity); + return MetadataCache.GetOrAdd(type, t => Entities.ToEntityMetadata(t).Metadata); + } + + [DebuggerHidden] + private void ThrowIfDisposed() + { + if (!_disposed) + { + return; + } + + throw new ObjectDisposedException(nameof(KubernetesClient)); + } + + private GenericClient CreateGenericClient() + { + ThrowIfDisposed(); + var metadata = GetMetadata(); + return metadata.Group switch + { + null => new GenericClient( + _client, + metadata.Version, + metadata.PluralName, + false), + _ => new GenericClient( + _client, + metadata.Group, + metadata.Version, + metadata.PluralName, + false), + }; + } +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs index 1751df6b..0e313b2f 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs @@ -1,13 +1,13 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -/// -/// Label-selector that checks if a certain label contains -/// a specific value (out of a list of values). -/// Note that "label in (value)" is the same as "label == value". -/// -/// The label that needs to equal to one of the values. -/// The possible values. -public record EqualsSelector(string Label, params string[] Values) : LabelSelector -{ - protected override string ToExpression() => $"{Label} in ({string.Join(",", Values)})"; -} +namespace KubeOps.KubernetesClient.LabelSelectors; + +/// +/// Label-selector that checks if a certain label contains +/// a specific value (out of a list of values). +/// Note that "label in (value)" is the same as "label == value". +/// +/// The label that needs to equal to one of the values. +/// The possible values. +public record EqualsSelector(string Label, params string[] Values) : LabelSelector +{ + protected override string ToExpression() => $"{Label} in ({string.Join(",", Values)})"; +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs index dccbfb1c..ed40e73c 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs @@ -1,10 +1,10 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -/// -/// Selector that checks if a certain label exists. -/// -/// The label that needs to exist on the entity/resource. -public record ExistsSelector(string Label) : LabelSelector -{ - protected override string ToExpression() => $"{Label}"; -} +namespace KubeOps.KubernetesClient.LabelSelectors; + +/// +/// Selector that checks if a certain label exists. +/// +/// The label that needs to exist on the entity/resource. +public record ExistsSelector(string Label) : LabelSelector +{ + protected override string ToExpression() => $"{Label}"; +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs b/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs index 81477fd6..40f09f12 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs @@ -1,12 +1,12 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -public static class Extensions -{ - /// - /// Convert an enumerable list of s to a string. - /// - /// The list of selectors. - /// A comma-joined string with all selectors converted to their expressions. - public static string ToExpression(this IEnumerable selectors) => - string.Join(",", selectors.Select(x => (string)x)); -} +namespace KubeOps.KubernetesClient.LabelSelectors; + +public static class Extensions +{ + /// + /// Convert an enumerable list of s to a string. + /// + /// The list of selectors. + /// A comma-joined string with all selectors converted to their expressions. + public static string ToExpression(this IEnumerable selectors) => + string.Join(",", selectors.Select(x => (string)x)); +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs index 2a49926e..543b1c03 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs @@ -1,20 +1,20 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -/// -/// Different label selectors for querying the Kubernetes API. -/// -public abstract record LabelSelector -{ - /// - /// Cast the label selector to a string. - /// - /// The selector. - /// A string representation of the label selector. - public static implicit operator string(LabelSelector selector) => selector.ToExpression(); - - /// - /// Create an expression from the label selector. - /// - /// A string that represents the label selector. - protected abstract string ToExpression(); -} +namespace KubeOps.KubernetesClient.LabelSelectors; + +/// +/// Different label selectors for querying the Kubernetes API. +/// +public abstract record LabelSelector +{ + /// + /// Cast the label selector to a string. + /// + /// The selector. + /// A string representation of the label selector. + public static implicit operator string(LabelSelector selector) => selector.ToExpression(); + + /// + /// Create an expression from the label selector. + /// + /// A string that represents the label selector. + protected abstract string ToExpression(); +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs index cd461e36..32a68d09 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs @@ -1,13 +1,13 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -/// -/// Label-selector that checks if a certain label does not contain -/// a specific value (out of a list of values). -/// Note that "label notin (value)" is the same as "label != value". -/// -/// The label that must not equal to one of the values. -/// The possible values. -public record NotEqualsSelector(string Label, params string[] Values) : LabelSelector -{ - protected override string ToExpression() => $"{Label} notin ({string.Join(",", Values)})"; -} +namespace KubeOps.KubernetesClient.LabelSelectors; + +/// +/// Label-selector that checks if a certain label does not contain +/// a specific value (out of a list of values). +/// Note that "label notin (value)" is the same as "label != value". +/// +/// The label that must not equal to one of the values. +/// The possible values. +public record NotEqualsSelector(string Label, params string[] Values) : LabelSelector +{ + protected override string ToExpression() => $"{Label} notin ({string.Join(",", Values)})"; +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs index bebc6ef1..52db320a 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs +++ b/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs @@ -1,10 +1,10 @@ -namespace KubeOps.KubernetesClient.LabelSelectors; - -/// -/// Selector that checks if a certain label does not exist. -/// -/// The label that must not exist on the entity/resource. -public record NotExistsSelector(string Label) : LabelSelector -{ - protected override string ToExpression() => $"!{Label}"; -} +namespace KubeOps.KubernetesClient.LabelSelectors; + +/// +/// Selector that checks if a certain label does not exist. +/// +/// The label that must not exist on the entity/resource. +public record NotExistsSelector(string Label) : LabelSelector +{ + protected override string ToExpression() => $"!{Label}"; +} diff --git a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs index 91f0b22d..429dd216 100644 --- a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs +++ b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs @@ -1,54 +1,54 @@ -using System.Reflection; -using System.Runtime.Versioning; - -using KubeOps.Abstractions.Builder; -using KubeOps.Operator.Web.LocalTunnel; - -using Microsoft.Extensions.DependencyInjection; - -namespace KubeOps.Operator.Web.Builder; - -/// -/// Method extensions for the operator builder to register web specific services. -/// -public static class OperatorBuilderExtensions -{ - /// - /// Adds a hosted service to the system that creates a "Local Tunnel" - /// (http://localtunnel.github.io/www/) to the running application. - /// The tunnel points to the configured host/port configuration and then - /// registers itself as webhook target within Kubernetes. This - /// enables developers to easily create webhooks without the requirement - /// of registering ngrok / localtunnel urls themselves. - /// - /// The operator builder. - /// The desired port that the asp.net application will run on. - /// The desired hostname. - /// The builder for chaining. - /// - /// Attach the development tunnel to the operator if in debug mode. - /// - /// var builder = WebApplication.CreateBuilder(args); - /// builder.Services - /// .AddKubernetesOperator() - /// .RegisterComponents() - /// #if DEBUG - /// .AddDevelopmentTunnel(5000) - /// #endif - /// ; - /// - /// - [RequiresPreviewFeatures( - "Localtunnel is sometimes unstable, use with caution. " + - "This API is in preview and may be removed in future versions if no stable alternative is found.")] - public static IOperatorBuilder AddDevelopmentTunnel( - this IOperatorBuilder builder, - ushort port, - string hostname = "localhost") - { - builder.Services.AddHostedService(); - builder.Services.AddSingleton(new TunnelConfig(hostname, port)); - builder.Services.AddSingleton(new WebhookLoader(Assembly.GetEntryAssembly()!)); - return builder; - } -} +using System.Reflection; +using System.Runtime.Versioning; + +using KubeOps.Abstractions.Builder; +using KubeOps.Operator.Web.LocalTunnel; + +using Microsoft.Extensions.DependencyInjection; + +namespace KubeOps.Operator.Web.Builder; + +/// +/// Method extensions for the operator builder to register web specific services. +/// +public static class OperatorBuilderExtensions +{ + /// + /// Adds a hosted service to the system that creates a "Local Tunnel" + /// (http://localtunnel.github.io/www/) to the running application. + /// The tunnel points to the configured host/port configuration and then + /// registers itself as webhook target within Kubernetes. This + /// enables developers to easily create webhooks without the requirement + /// of registering ngrok / localtunnel urls themselves. + /// + /// The operator builder. + /// The desired port that the asp.net application will run on. + /// The desired hostname. + /// The builder for chaining. + /// + /// Attach the development tunnel to the operator if in debug mode. + /// + /// var builder = WebApplication.CreateBuilder(args); + /// builder.Services + /// .AddKubernetesOperator() + /// .RegisterComponents() + /// #if DEBUG + /// .AddDevelopmentTunnel(5000) + /// #endif + /// ; + /// + /// + [RequiresPreviewFeatures( + "Localtunnel is sometimes unstable, use with caution. " + + "This API is in preview and may be removed in future versions if no stable alternative is found.")] + public static IOperatorBuilder AddDevelopmentTunnel( + this IOperatorBuilder builder, + ushort port, + string hostname = "localhost") + { + builder.Services.AddHostedService(); + builder.Services.AddSingleton(new TunnelConfig(hostname, port)); + builder.Services.AddSingleton(new WebhookLoader(Assembly.GetEntryAssembly()!)); + return builder; + } +} diff --git a/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs index b377a2ff..4441952a 100644 --- a/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs +++ b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs @@ -1,151 +1,151 @@ -using k8s; -using k8s.Models; - -using KubeOps.KubernetesClient; -using KubeOps.Transpiler; - -using Localtunnel; -using Localtunnel.Endpoints.Http; -using Localtunnel.Handlers.Kestrel; -using Localtunnel.Processors; -using Localtunnel.Tunnels; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Web.LocalTunnel; - -internal class DevelopmentTunnelService(ILoggerFactory loggerFactory, IKubernetesClient client, TunnelConfig config, WebhookLoader loader) - : IHostedService -{ - private readonly LocaltunnelClient _tunnelClient = new(loggerFactory); - private Tunnel? _tunnel; - - public async Task StartAsync(CancellationToken cancellationToken) - { - _tunnel = await _tunnelClient.OpenAsync( - new KestrelTunnelConnectionHandler( - new HttpRequestProcessingPipelineBuilder() - .Append(new HttpHostHeaderRewritingRequestProcessor(config.Hostname)).Build(), - new HttpTunnelEndpointFactory(config.Hostname, config.Port)), - cancellationToken: cancellationToken); - await _tunnel.StartAsync(cancellationToken: cancellationToken); - await RegisterValidators(_tunnel.Information.Url); - await RegisterMutators(_tunnel.Information.Url); - await RegisterConverters(_tunnel.Information.Url); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _tunnel?.Dispose(); - return Task.CompletedTask; - } - - private async Task RegisterValidators(Uri uri) - { - var validationWebhooks = loader - .ValidationWebhooks - .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), - Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) - .Select(hook => new V1ValidatingWebhook - { - Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = new[] { "*" }, - Resources = new[] { hook.Metadata.PluralName }, - ApiGroups = new[] { hook.Metadata.Group }, - ApiVersions = new[] { hook.Metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - Url = $"{uri}validate/{hook.HookTypeName}", - }, - }); - - var validatorConfig = new V1ValidatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "dev-validators"), - webhooks: validationWebhooks.ToList()).Initialize(); - - if (validatorConfig.Webhooks.Any()) - { - await client.SaveAsync(validatorConfig); - } - } - - private async Task RegisterMutators(Uri uri) - { - var mutationWebhooks = loader - .MutationWebhooks - .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), - Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) - .Select(hook => new V1MutatingWebhook - { - Name = $"mutate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", - MatchPolicy = "Exact", - AdmissionReviewVersions = new[] { "v1" }, - SideEffects = "None", - Rules = new[] - { - new V1RuleWithOperations - { - Operations = new[] { "*" }, - Resources = new[] { hook.Metadata.PluralName }, - ApiGroups = new[] { hook.Metadata.Group }, - ApiVersions = new[] { hook.Metadata.Version }, - }, - }, - ClientConfig = new Admissionregistrationv1WebhookClientConfig - { - Url = $"{uri}mutate/{hook.HookTypeName}", - }, - }); - - var mutatorConfig = new V1MutatingWebhookConfiguration( - metadata: new V1ObjectMeta(name: "dev-mutators"), - webhooks: mutationWebhooks.ToList()).Initialize(); - - if (mutatorConfig.Webhooks.Any()) - { - await client.SaveAsync(mutatorConfig); - } - } - - private async Task RegisterConverters(Uri uri) - { - var conversionWebhooks = loader.ConversionWebhooks.ToList(); - if (conversionWebhooks.Count == 0) - { - return; - } - - foreach (var wh in conversionWebhooks) - { - var metadata = Entities.ToEntityMetadata(wh.BaseType!.GenericTypeArguments[0]).Metadata; - var crdName = $"{metadata.PluralName}.{metadata.Group}"; - - if (await client.GetAsync(crdName) is not { } crd) - { - continue; - } - - var whUrl = $"{uri}convert/{metadata.Group}/{metadata.PluralName}"; - crd.Spec.Conversion = new V1CustomResourceConversion("Webhook") - { - Webhook = new V1WebhookConversion - { - ConversionReviewVersions = new[] { "v1" }, - ClientConfig = new Apiextensionsv1WebhookClientConfig { Url = whUrl }, - }, - }; - - await client.UpdateAsync(crd); - } - } -} +using k8s; +using k8s.Models; + +using KubeOps.KubernetesClient; +using KubeOps.Transpiler; + +using Localtunnel; +using Localtunnel.Endpoints.Http; +using Localtunnel.Handlers.Kestrel; +using Localtunnel.Processors; +using Localtunnel.Tunnels; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Web.LocalTunnel; + +internal class DevelopmentTunnelService(ILoggerFactory loggerFactory, IKubernetesClient client, TunnelConfig config, WebhookLoader loader) + : IHostedService +{ + private readonly LocaltunnelClient _tunnelClient = new(loggerFactory); + private Tunnel? _tunnel; + + public async Task StartAsync(CancellationToken cancellationToken) + { + _tunnel = await _tunnelClient.OpenAsync( + new KestrelTunnelConnectionHandler( + new HttpRequestProcessingPipelineBuilder() + .Append(new HttpHostHeaderRewritingRequestProcessor(config.Hostname)).Build(), + new HttpTunnelEndpointFactory(config.Hostname, config.Port)), + cancellationToken: cancellationToken); + await _tunnel.StartAsync(cancellationToken: cancellationToken); + await RegisterValidators(_tunnel.Information.Url); + await RegisterMutators(_tunnel.Information.Url); + await RegisterConverters(_tunnel.Information.Url); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _tunnel?.Dispose(); + return Task.CompletedTask; + } + + private async Task RegisterValidators(Uri uri) + { + var validationWebhooks = loader + .ValidationWebhooks + .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), + Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) + .Select(hook => new V1ValidatingWebhook + { + Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "*" }, + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + Url = $"{uri}validate/{hook.HookTypeName}", + }, + }); + + var validatorConfig = new V1ValidatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "dev-validators"), + webhooks: validationWebhooks.ToList()).Initialize(); + + if (validatorConfig.Webhooks.Any()) + { + await client.SaveAsync(validatorConfig); + } + } + + private async Task RegisterMutators(Uri uri) + { + var mutationWebhooks = loader + .MutationWebhooks + .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), + Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) + .Select(hook => new V1MutatingWebhook + { + Name = $"mutate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "*" }, + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + Url = $"{uri}mutate/{hook.HookTypeName}", + }, + }); + + var mutatorConfig = new V1MutatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "dev-mutators"), + webhooks: mutationWebhooks.ToList()).Initialize(); + + if (mutatorConfig.Webhooks.Any()) + { + await client.SaveAsync(mutatorConfig); + } + } + + private async Task RegisterConverters(Uri uri) + { + var conversionWebhooks = loader.ConversionWebhooks.ToList(); + if (conversionWebhooks.Count == 0) + { + return; + } + + foreach (var wh in conversionWebhooks) + { + var metadata = Entities.ToEntityMetadata(wh.BaseType!.GenericTypeArguments[0]).Metadata; + var crdName = $"{metadata.PluralName}.{metadata.Group}"; + + if (await client.GetAsync(crdName) is not { } crd) + { + continue; + } + + var whUrl = $"{uri}convert/{metadata.Group}/{metadata.PluralName}"; + crd.Spec.Conversion = new V1CustomResourceConversion("Webhook") + { + Webhook = new V1WebhookConversion + { + ConversionReviewVersions = new[] { "v1" }, + ClientConfig = new Apiextensionsv1WebhookClientConfig { Url = whUrl }, + }, + }; + + await client.UpdateAsync(crd); + } + } +} diff --git a/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs b/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs index 666e5a9e..5d085589 100644 --- a/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs +++ b/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs @@ -1,3 +1,3 @@ -namespace KubeOps.Operator.Web.LocalTunnel; - -internal record TunnelConfig(string Hostname, ushort Port); +namespace KubeOps.Operator.Web.LocalTunnel; + +internal record TunnelConfig(string Hostname, ushort Port); diff --git a/src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs b/src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs index ba273761..2390c5ab 100644 --- a/src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs +++ b/src/KubeOps.Operator.Web/LocalTunnel/WebhookLoader.cs @@ -1,28 +1,28 @@ -using System.Reflection; -using System.Runtime.Versioning; - -using KubeOps.Operator.Web.Webhooks.Admission.Mutation; -using KubeOps.Operator.Web.Webhooks.Admission.Validation; -using KubeOps.Operator.Web.Webhooks.Conversion; - -namespace KubeOps.Operator.Web.LocalTunnel; - -internal record WebhookLoader(Assembly Entry) -{ - public IEnumerable ValidationWebhooks => Entry - .DefinedTypes - .Where(t => t.BaseType?.IsGenericType == true && - t.BaseType?.GetGenericTypeDefinition() == typeof(ValidationWebhook<>)); - - public IEnumerable MutationWebhooks => Entry - .DefinedTypes - .Where(t => t.BaseType?.IsGenericType == true && - t.BaseType?.GetGenericTypeDefinition() == typeof(MutationWebhook<>)); - - public IEnumerable ConversionWebhooks => Entry - .DefinedTypes - .Where(t => t.BaseType?.IsGenericType == true && -#pragma warning disable CA2252 // This is internal only. - t.BaseType?.GetGenericTypeDefinition() == typeof(ConversionWebhook<>)); -#pragma warning restore CA2252 -} +using System.Reflection; +using System.Runtime.Versioning; + +using KubeOps.Operator.Web.Webhooks.Admission.Mutation; +using KubeOps.Operator.Web.Webhooks.Admission.Validation; +using KubeOps.Operator.Web.Webhooks.Conversion; + +namespace KubeOps.Operator.Web.LocalTunnel; + +internal record WebhookLoader(Assembly Entry) +{ + public IEnumerable ValidationWebhooks => Entry + .DefinedTypes + .Where(t => t.BaseType?.IsGenericType == true && + t.BaseType?.GetGenericTypeDefinition() == typeof(ValidationWebhook<>)); + + public IEnumerable MutationWebhooks => Entry + .DefinedTypes + .Where(t => t.BaseType?.IsGenericType == true && + t.BaseType?.GetGenericTypeDefinition() == typeof(MutationWebhook<>)); + + public IEnumerable ConversionWebhooks => Entry + .DefinedTypes + .Where(t => t.BaseType?.IsGenericType == true && +#pragma warning disable CA2252 // This is internal only. + t.BaseType?.GetGenericTypeDefinition() == typeof(ConversionWebhook<>)); +#pragma warning restore CA2252 +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionRequest.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionRequest.cs index 2a0e7129..e43ae9c1 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionRequest.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionRequest.cs @@ -1,64 +1,64 @@ -using System.Text.Json.Serialization; - -using k8s; -using k8s.Models; - -#pragma warning disable CS0693 - -namespace KubeOps.Operator.Web.Webhooks.Admission; - -/// -/// Incoming admission request for a webhook. -/// -/// The type of the entity. -public sealed class AdmissionRequest : AdmissionReview - where TEntity : IKubernetesObject -{ - /// - /// Admission request data. - /// - [JsonPropertyName("request")] - public AdmissionRequestData Request { get; init; } = new(); - - /// - /// Data for an incoming admission request. - /// - /// The type of the entity. - public sealed class AdmissionRequestData - where TEntity : IKubernetesObject - { - /// - /// The unique ID of the admission request. - /// - [JsonPropertyName("uid")] - public string Uid { get; init; } = string.Empty; - - /// - /// The operation that is used. - /// Valid values are: "CREATE", "UPDATE", "DELETE". - /// "CONNECT" does exist, but is not supported by the operator-sdk. - /// - [JsonPropertyName("operation")] - public string Operation { get; init; } = string.Empty; - - /// - /// If set, the object that is passed to the webhook. - /// This is set in CREATE and UPDATE operations. - /// - [JsonPropertyName("object")] - public TEntity? Object { get; init; } - - /// - /// If set, the old object that is passed to the webhook. - /// This is set in UPDATE and DELETE operations. - /// - [JsonPropertyName("oldObject")] - public TEntity? OldObject { get; init; } - - /// - /// A flag to indicate if the API was called with the "dryRun" flag. - /// - [JsonPropertyName("dryRun")] - public bool DryRun { get; init; } - } -} +using System.Text.Json.Serialization; + +using k8s; +using k8s.Models; + +#pragma warning disable CS0693 + +namespace KubeOps.Operator.Web.Webhooks.Admission; + +/// +/// Incoming admission request for a webhook. +/// +/// The type of the entity. +public sealed class AdmissionRequest : AdmissionReview + where TEntity : IKubernetesObject +{ + /// + /// Admission request data. + /// + [JsonPropertyName("request")] + public AdmissionRequestData Request { get; init; } = new(); + + /// + /// Data for an incoming admission request. + /// + /// The type of the entity. + public sealed class AdmissionRequestData + where TEntity : IKubernetesObject + { + /// + /// The unique ID of the admission request. + /// + [JsonPropertyName("uid")] + public string Uid { get; init; } = string.Empty; + + /// + /// The operation that is used. + /// Valid values are: "CREATE", "UPDATE", "DELETE". + /// "CONNECT" does exist, but is not supported by the operator-sdk. + /// + [JsonPropertyName("operation")] + public string Operation { get; init; } = string.Empty; + + /// + /// If set, the object that is passed to the webhook. + /// This is set in CREATE and UPDATE operations. + /// + [JsonPropertyName("object")] + public TEntity? Object { get; init; } + + /// + /// If set, the old object that is passed to the webhook. + /// This is set in UPDATE and DELETE operations. + /// + [JsonPropertyName("oldObject")] + public TEntity? OldObject { get; init; } + + /// + /// A flag to indicate if the API was called with the "dryRun" flag. + /// + [JsonPropertyName("dryRun")] + public bool DryRun { get; init; } + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionResponse.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionResponse.cs index 1da0d425..c468c31c 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionResponse.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionResponse.cs @@ -1,30 +1,30 @@ -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks.Admission; - -internal sealed class AdmissionResponse : AdmissionReview -{ - [JsonPropertyName("response")] - public AdmissionResponseData Response { get; init; } = new(); - - internal sealed class AdmissionResponseData - { - [JsonPropertyName("uid")] - public string Uid { get; init; } = string.Empty; - - [JsonPropertyName("allowed")] - public bool Allowed { get; init; } - - [JsonPropertyName("status")] - public AdmissionStatus? Status { get; init; } - - [JsonPropertyName("warnings")] - public string[]? Warnings { get; init; } - - [JsonPropertyName("patch")] - public string? Patch { get; init; } - - [JsonPropertyName("patchType")] - public string? PatchType { get; init; } - } -} +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks.Admission; + +internal sealed class AdmissionResponse : AdmissionReview +{ + [JsonPropertyName("response")] + public AdmissionResponseData Response { get; init; } = new(); + + internal sealed class AdmissionResponseData + { + [JsonPropertyName("uid")] + public string Uid { get; init; } = string.Empty; + + [JsonPropertyName("allowed")] + public bool Allowed { get; init; } + + [JsonPropertyName("status")] + public AdmissionStatus? Status { get; init; } + + [JsonPropertyName("warnings")] + public string[]? Warnings { get; init; } + + [JsonPropertyName("patch")] + public string? Patch { get; init; } + + [JsonPropertyName("patchType")] + public string? PatchType { get; init; } + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionReview.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionReview.cs index fcf691fd..87184386 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionReview.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionReview.cs @@ -1,15 +1,15 @@ -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks.Admission; - -/// -/// Base class for admission review requests. -/// -public abstract class AdmissionReview -{ - [JsonPropertyName("apiVersion")] - public string ApiVersion => "admission.k8s.io/v1"; - - [JsonPropertyName("kind")] - public string Kind => "AdmissionReview"; -} +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks.Admission; + +/// +/// Base class for admission review requests. +/// +public abstract class AdmissionReview +{ + [JsonPropertyName("apiVersion")] + public string ApiVersion => "admission.k8s.io/v1"; + + [JsonPropertyName("kind")] + public string Kind => "AdmissionReview"; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionStatus.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionStatus.cs index ab3b1138..0b0a0611 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionStatus.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/AdmissionStatus.cs @@ -1,14 +1,14 @@ -using System.Text.Json.Serialization; - -using Microsoft.AspNetCore.Http; - -namespace KubeOps.Operator.Web.Webhooks.Admission; - -/// -/// The admission status for the response to the API. -/// -/// A message that is passed to the API. -/// A custom status code to provide more detailed information. -public record AdmissionStatus([property: JsonPropertyName("message")] - string Message, [property: JsonPropertyName("code")] - int? Code = StatusCodes.Status200OK); +using System.Text.Json.Serialization; + +using Microsoft.AspNetCore.Http; + +namespace KubeOps.Operator.Web.Webhooks.Admission; + +/// +/// The admission status for the response to the API. +/// +/// A message that is passed to the API. +/// A custom status code to provide more detailed information. +public record AdmissionStatus([property: JsonPropertyName("message")] + string Message, [property: JsonPropertyName("code")] + int? Code = StatusCodes.Status200OK); diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs index c570ba96..283066bb 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/JsonDiffer.cs @@ -1,27 +1,27 @@ -using System.Text; -using System.Text.Json.JsonDiffPatch; -using System.Text.Json.JsonDiffPatch.Diffs.Formatters; -using System.Text.Json.Nodes; - -using k8s; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -internal static class JsonDiffer -{ - public static string Base64Diff(this JsonNode from, object? to) - { - var formatter = new JsonPatchDeltaFormatter(); - - var toToken = GetNode(to); - var patch = from.Diff(toToken, formatter)!; - - return Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.ToString())); - } - - public static JsonNode? GetNode(object? o) - { - var json = KubernetesJson.Serialize(o); - return JsonNode.Parse(json); - } -} +using System.Text; +using System.Text.Json.JsonDiffPatch; +using System.Text.Json.JsonDiffPatch.Diffs.Formatters; +using System.Text.Json.Nodes; + +using k8s; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; + +internal static class JsonDiffer +{ + public static string Base64Diff(this JsonNode from, object? to) + { + var formatter = new JsonPatchDeltaFormatter(); + + var toToken = GetNode(to); + var patch = from.Diff(toToken, formatter)!; + + return Convert.ToBase64String(Encoding.UTF8.GetBytes(patch.ToString())); + } + + public static JsonNode? GetNode(object? o) + { + var json = KubernetesJson.Serialize(o); + return JsonNode.Parse(json); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs index df9373aa..d37f65fb 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationResult.cs @@ -1,77 +1,77 @@ -using System.Text.Json.Nodes; - -using k8s; -using k8s.Models; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -/// -/// The mutation result for the mutation (admission) request to a webhook. -/// -/// The modified entity if any changes are requested. -/// The type of the entity. -public record MutationResult(TEntity? ModifiedObject = default) : IActionResult - where TEntity : IKubernetesObject -{ - private const string JsonPatch = "JSONPatch"; - - internal string Uid { get; init; } = string.Empty; - - internal JsonNode? OriginalObject { get; init; } - - public bool Valid { get; init; } = true; - - /// - /// Provides additional information to the validation result. - /// The message is displayed to the user if the validation fails. - /// The status code can provide more information about the error. - /// - /// See "Extensible Admission Controller Response" - /// - /// - public AdmissionStatus? Status { get; init; } - - /// - /// Despite being "valid", the validation can add a list of warnings to the user. - /// If this is not yet supported by the cluster, the field is ignored. - /// Warnings may contain up to 256 characters but they should be limited to 120 characters. - /// If more than 4096 characters are submitted, additional messages are ignored. - /// - public IList Warnings { get; init; } = new List(); - - /// - public async Task ExecuteResultAsync(ActionContext context) - { - var response = context.HttpContext.Response; - if (string.IsNullOrWhiteSpace(Uid)) - { - response.StatusCode = StatusCodes.Status500InternalServerError; - await response.WriteAsync("No request UID was provided."); - return; - } - - if (ModifiedObject is not null && OriginalObject is null) - { - response.StatusCode = StatusCodes.Status500InternalServerError; - await response.WriteAsync("No original object was provided."); - return; - } - - await response.WriteAsJsonAsync( - new AdmissionResponse - { - Response = new() - { - Uid = Uid, - Allowed = Valid, - Status = Status, - Warnings = Warnings.ToArray(), - PatchType = ModifiedObject is null ? null : JsonPatch, - Patch = ModifiedObject is null ? null : OriginalObject!.Base64Diff(ModifiedObject), - }, - }); - } -} +using System.Text.Json.Nodes; + +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; + +/// +/// The mutation result for the mutation (admission) request to a webhook. +/// +/// The modified entity if any changes are requested. +/// The type of the entity. +public record MutationResult(TEntity? ModifiedObject = default) : IActionResult + where TEntity : IKubernetesObject +{ + private const string JsonPatch = "JSONPatch"; + + internal string Uid { get; init; } = string.Empty; + + internal JsonNode? OriginalObject { get; init; } + + public bool Valid { get; init; } = true; + + /// + /// Provides additional information to the validation result. + /// The message is displayed to the user if the validation fails. + /// The status code can provide more information about the error. + /// + /// See "Extensible Admission Controller Response" + /// + /// + public AdmissionStatus? Status { get; init; } + + /// + /// Despite being "valid", the validation can add a list of warnings to the user. + /// If this is not yet supported by the cluster, the field is ignored. + /// Warnings may contain up to 256 characters but they should be limited to 120 characters. + /// If more than 4096 characters are submitted, additional messages are ignored. + /// + public IList Warnings { get; init; } = new List(); + + /// + public async Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + if (string.IsNullOrWhiteSpace(Uid)) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No request UID was provided."); + return; + } + + if (ModifiedObject is not null && OriginalObject is null) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No original object was provided."); + return; + } + + await response.WriteAsJsonAsync( + new AdmissionResponse + { + Response = new() + { + Uid = Uid, + Allowed = Valid, + Status = Status, + Warnings = Warnings.ToArray(), + PatchType = ModifiedObject is null ? null : JsonPatch, + Patch = ModifiedObject is null ? null : OriginalObject!.Base64Diff(ModifiedObject), + }, + }); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhookAttribute.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhookAttribute.cs index 187c6cc6..cd30d151 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhookAttribute.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhookAttribute.cs @@ -1,11 +1,11 @@ -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -/// -/// Defines an MVC controller as "mutation webhook". The route is automatically set to -/// /mutate/[lower-case-name-of-the-type]. -/// This must be used in conjunction with the class. -/// -[AttributeUsage(AttributeTargets.Class)] -public class MutationWebhookAttribute(Type entityType) : RouteAttribute($"/mutate/{entityType.Name.ToLowerInvariant()}"); +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; + +/// +/// Defines an MVC controller as "mutation webhook". The route is automatically set to +/// /mutate/[lower-case-name-of-the-type]. +/// This must be used in conjunction with the class. +/// +[AttributeUsage(AttributeTargets.Class)] +public class MutationWebhookAttribute(Type entityType) : RouteAttribute($"/mutate/{entityType.Name.ToLowerInvariant()}"); diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs index 5bdb92f9..c17c08f8 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Mutation/MutationWebhook{TEntity}.cs @@ -1,167 +1,184 @@ -using k8s; -using k8s.Models; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -/// -/// The abstract base for any mutation webhook. To use them, attach controllers in the -/// main program and map controllers as well. A mutating webhook must be decorated -/// with the and the type must be provided. -/// There are async and sync methods for each operation. The async will take -/// precedence if both are implemented (i.e. overridden). -/// -/// The type of the entity that is mutated. -/// -/// Simple example of a webhook that sets all usernames to "hidden". -/// -/// [MutationWebhook(typeof(V1TestEntity))] -/// public class TestMutationWebhook : MutationWebhook<V1TestEntity> -/// { -/// public override MutationResult<V1TestEntity> Create(V1TestEntity entity, bool dryRun) -/// { -/// entity.Spec.Username = "hidden"; -/// return Modified(entity); -/// } -/// } -/// -/// -[ApiController] -public abstract class MutationWebhook : ControllerBase - where TEntity : IKubernetesObject -{ - private const string CreateOperation = "CREATE"; - private const string UpdateOperation = "UPDATE"; - private const string DeleteOperation = "DELETE"; - - /// - /// Mutation callback for entities that are created. - /// - /// The (soon to be) new entity. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task> CreateAsync(TEntity entity, bool dryRun) => - Task.FromResult(Create(entity, dryRun)); - - /// - [NonAction] - public virtual MutationResult Create(TEntity entity, bool dryRun) => NoChanges(); - - /// - /// Mutation callback for entities that are updated. - /// - /// The currently stored entity in the Kubernetes API. - /// The new entity that should be stored. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task> UpdateAsync(TEntity oldEntity, TEntity newEntity, bool dryRun) => - Task.FromResult(Update(oldEntity, newEntity, dryRun)); - - /// - [NonAction] - public virtual MutationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => NoChanges(); - - /// - /// Mutation callback for entities that are to be deleted. - /// - /// The (soon to be removed) entity. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task> DeleteAsync(TEntity entity, bool dryRun) => - Task.FromResult(Delete(entity, dryRun)); - - /// - [NonAction] - public virtual MutationResult Delete(TEntity entity, bool dryRun) => NoChanges(); - - /// - /// Public, non-virtual method that is called by the controller. - /// This method will call the correct method based on the operation. - /// - /// The incoming admission request for an entity. - /// The . - [HttpPost] - public async Task Mutate([FromBody] AdmissionRequest request) - { - var original = JsonDiffer.GetNode(request.Request.Operation switch - { - CreateOperation or UpdateOperation => request.Request.Object!, - _ => request.Request.OldObject!, - }); - - var result = request.Request.Operation switch - { - CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun), - UpdateOperation => await UpdateAsync( - request.Request.OldObject!, - request.Request.Object!, - request.Request.DryRun), - DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun), - _ => Fail( - $"Operation {request.Request.Operation} is not supported.", - StatusCodes.Status422UnprocessableEntity), - }; - - return result with { Uid = request.Request.Uid, OriginalObject = original }; - } - - /// - /// Create a with an optional list of warnings. - /// The mutation result indicates that no changes are required for the entity. - /// - /// A list of warnings that is presented to the user. - /// A . - [NonAction] - protected MutationResult NoChanges(params string[] warnings) - => new() { Warnings = warnings }; - - /// - /// Create a with an optional list of warnings. - /// The mutation result indicates that the entity needs to be patched before - /// it is definitely stored. This creates a JSON Patch between the original object - /// and the changed entity. If warnings are provided, they are presented to the user. - /// - /// The modified entity. - /// A list of warnings that is presented to the user. - /// A that indicates changes. - [NonAction] - protected MutationResult Modified(TEntity entity, params string[] warnings) - => new(entity) { Warnings = warnings }; - - /// - /// Create a that will fail the mutation. - /// The user will only see that the mutation failed. - /// - /// A . - [NonAction] - protected MutationResult Fail() - => new() { Valid = false }; - - /// - /// Create a that will fail the mutation. - /// The reason is presented to the user. - /// - /// A reason for the failure of the mutation. - /// A . - [NonAction] - protected MutationResult Fail(string reason) - => Fail() with { Status = new(reason) }; - - /// - /// Create a that will fail the mutation. - /// The reason is presented to the user with the custom status code. - /// The custom status code may provide further specific information about the - /// failure, but not all Kubernetes clusters support custom status codes. - /// - /// A reason for the failure of the mutation. - /// The custom status code. - /// A . - [NonAction] - protected MutationResult Fail(string reason, int statusCode) => - Fail() with { Status = new(reason, statusCode), }; -} +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Mutation; + +/// +/// The abstract base for any mutation webhook. To use them, attach controllers in the +/// main program and map controllers as well. A mutating webhook must be decorated +/// with the and the type must be provided. +/// There are async and sync methods for each operation. The async will take +/// precedence if both are implemented (i.e. overridden). +/// +/// The type of the entity that is mutated. +/// +/// Simple example of a webhook that sets all usernames to "hidden". +/// +/// [MutationWebhook(typeof(V1TestEntity))] +/// public class TestMutationWebhook : MutationWebhook<V1TestEntity> +/// { +/// public override MutationResult<V1TestEntity> Create(V1TestEntity entity, bool dryRun) +/// { +/// entity.Spec.Username = "hidden"; +/// return Modified(entity); +/// } +/// } +/// +/// +[ApiController] +public abstract class MutationWebhook : ControllerBase + where TEntity : IKubernetesObject +{ + private const string CreateOperation = "CREATE"; + private const string UpdateOperation = "UPDATE"; + private const string DeleteOperation = "DELETE"; + + /// + /// Mutation callback for entities that are created. + /// + /// The (soon to be) new entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// The token to monitor for cancellation requests. + /// A . + [NonAction] + public virtual Task> CreateAsync( + TEntity entity, + bool dryRun, + CancellationToken cancellationToken) => + Task.FromResult(Create(entity, dryRun)); + + /// + [NonAction] + public virtual MutationResult Create(TEntity entity, bool dryRun) => NoChanges(); + + /// + /// Mutation callback for entities that are updated. + /// + /// The currently stored entity in the Kubernetes API. + /// The new entity that should be stored. + /// Flag that indicates if the webhook was called in a dry-run. + /// The token to monitor for cancellation requests. + /// A . + [NonAction] + public virtual Task> UpdateAsync( + TEntity oldEntity, + TEntity newEntity, + bool dryRun, + CancellationToken cancellationToken) => + Task.FromResult(Update(oldEntity, newEntity, dryRun)); + + /// + [NonAction] + public virtual MutationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => NoChanges(); + + /// + /// Mutation callback for entities that are to be deleted. + /// + /// The (soon to be removed) entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// The token to monitor for cancellation requests. + /// A . + [NonAction] + public virtual Task> DeleteAsync( + TEntity entity, + bool dryRun, + CancellationToken cancellationToken) => + Task.FromResult(Delete(entity, dryRun)); + + /// + [NonAction] + public virtual MutationResult Delete(TEntity entity, bool dryRun) => NoChanges(); + + /// + /// Public, non-virtual method that is called by the controller. + /// This method will call the correct method based on the operation. + /// + /// The incoming admission request for an entity. + /// The incoming cancellation token that's cancelled if the request gets aborted. + /// The . + [HttpPost] + public async Task Mutate( + [FromBody] AdmissionRequest request, + CancellationToken cancellationToken) + { + var original = JsonDiffer.GetNode(request.Request.Operation switch + { + CreateOperation or UpdateOperation => request.Request.Object!, + _ => request.Request.OldObject!, + }); + + var result = request.Request.Operation switch + { + CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun, cancellationToken), + UpdateOperation => await UpdateAsync( + request.Request.OldObject!, + request.Request.Object!, + request.Request.DryRun, + cancellationToken), + DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun, cancellationToken), + _ => Fail( + $"Operation {request.Request.Operation} is not supported.", + StatusCodes.Status422UnprocessableEntity), + }; + + return result with { Uid = request.Request.Uid, OriginalObject = original }; + } + + /// + /// Create a with an optional list of warnings. + /// The mutation result indicates that no changes are required for the entity. + /// + /// A list of warnings that is presented to the user. + /// A . + [NonAction] + protected MutationResult NoChanges(params string[] warnings) + => new() { Warnings = warnings }; + + /// + /// Create a with an optional list of warnings. + /// The mutation result indicates that the entity needs to be patched before + /// it is definitely stored. This creates a JSON Patch between the original object + /// and the changed entity. If warnings are provided, they are presented to the user. + /// + /// The modified entity. + /// A list of warnings that is presented to the user. + /// A that indicates changes. + [NonAction] + protected MutationResult Modified(TEntity entity, params string[] warnings) + => new(entity) { Warnings = warnings }; + + /// + /// Create a that will fail the mutation. + /// The user will only see that the mutation failed. + /// + /// A . + [NonAction] + protected MutationResult Fail() + => new() { Valid = false }; + + /// + /// Create a that will fail the mutation. + /// The reason is presented to the user. + /// + /// A reason for the failure of the mutation. + /// A . + [NonAction] + protected MutationResult Fail(string reason) + => Fail() with { Status = new(reason) }; + + /// + /// Create a that will fail the mutation. + /// The reason is presented to the user with the custom status code. + /// The custom status code may provide further specific information about the + /// failure, but not all Kubernetes clusters support custom status codes. + /// + /// A reason for the failure of the mutation. + /// The custom status code. + /// A . + [NonAction] + protected MutationResult Fail(string reason, int statusCode) => + Fail() with { Status = new(reason, statusCode), }; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationResult.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationResult.cs index 919b99f9..65e8f738 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationResult.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationResult.cs @@ -1,55 +1,55 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Validation; - -/// -/// The validation result for the validation (admission) request to a webhook. -/// -/// Whether the validation / the entity is valid or not. -public record ValidationResult(bool Valid = true) : IActionResult -{ - internal string Uid { get; init; } = string.Empty; - - /// - /// Provides additional information to the validation result. - /// The message is displayed to the user if the validation fails. - /// The status code can provide more information about the error. - /// - /// See "Extensible Admission Controller Response" - /// - /// - public AdmissionStatus? Status { get; init; } - - /// - /// Despite being "valid", the validation can add a list of warnings to the user. - /// If this is not yet supported by the cluster, the field is ignored. - /// Warnings may contain up to 256 characters but they should be limited to 120 characters. - /// If more than 4096 characters are submitted, additional messages are ignored. - /// - public IList Warnings { get; init; } = new List(); - - /// - public async Task ExecuteResultAsync(ActionContext context) - { - var response = context.HttpContext.Response; - if (string.IsNullOrWhiteSpace(Uid)) - { - response.StatusCode = StatusCodes.Status500InternalServerError; - await response.WriteAsync("No request UID was provided."); - return; - } - - await response.WriteAsJsonAsync( - new AdmissionResponse - { - Response = new() - { - Uid = Uid, - Allowed = Valid, - Status = Status, - Warnings = Warnings.ToArray(), - }, - }); - } -} +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Validation; + +/// +/// The validation result for the validation (admission) request to a webhook. +/// +/// Whether the validation / the entity is valid or not. +public record ValidationResult(bool Valid = true) : IActionResult +{ + internal string Uid { get; init; } = string.Empty; + + /// + /// Provides additional information to the validation result. + /// The message is displayed to the user if the validation fails. + /// The status code can provide more information about the error. + /// + /// See "Extensible Admission Controller Response" + /// + /// + public AdmissionStatus? Status { get; init; } + + /// + /// Despite being "valid", the validation can add a list of warnings to the user. + /// If this is not yet supported by the cluster, the field is ignored. + /// Warnings may contain up to 256 characters but they should be limited to 120 characters. + /// If more than 4096 characters are submitted, additional messages are ignored. + /// + public IList Warnings { get; init; } = new List(); + + /// + public async Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + if (string.IsNullOrWhiteSpace(Uid)) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No request UID was provided."); + return; + } + + await response.WriteAsJsonAsync( + new AdmissionResponse + { + Response = new() + { + Uid = Uid, + Allowed = Valid, + Status = Status, + Warnings = Warnings.ToArray(), + }, + }); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhookAttribute.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhookAttribute.cs index 9f5de66c..cf8ecb7c 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhookAttribute.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhookAttribute.cs @@ -1,11 +1,11 @@ -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Validation; - -/// -/// Defines an MVC controller as "validation webhook". The route is automatically set to -/// /validate/[lower-case-name-of-the-type]. -/// This must be used in conjunction with the class. -/// -[AttributeUsage(AttributeTargets.Class)] -public class ValidationWebhookAttribute(Type entityType) : RouteAttribute($"/validate/{entityType.Name.ToLowerInvariant()}"); +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Validation; + +/// +/// Defines an MVC controller as "validation webhook". The route is automatically set to +/// /validate/[lower-case-name-of-the-type]. +/// This must be used in conjunction with the class. +/// +[AttributeUsage(AttributeTargets.Class)] +public class ValidationWebhookAttribute(Type entityType) : RouteAttribute($"/validate/{entityType.Name.ToLowerInvariant()}"); diff --git a/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhook{TEntity}.cs b/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhook{TEntity}.cs index 8dc5d3fd..5f1e0ca0 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhook{TEntity}.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Admission/Validation/ValidationWebhook{TEntity}.cs @@ -1,146 +1,161 @@ -using k8s; -using k8s.Models; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Admission.Validation; - -/// -/// The abstract base for any validation webhook. To use them, attach controllers in the -/// main program and map controllers as well. A validation webhook must be decorated -/// with the and the type must be provided. -/// There are async and sync methods for each operation. The async will take -/// precedence if both are implemented (i.e. overridden). -/// -/// The type of the entity that is validated. -/// -/// Simple example of a webhook that checks nothing but is called on every "CREATE" event. -/// -/// [ValidationWebhook(typeof(V1TestEntity))] -/// public class TestValidationWebhook : ValidationWebhook<V1TestEntity> -/// { -/// public override ValidationResult Create(V1TestEntity entity, bool dryRun) -/// { -/// return Success(); -/// } -/// } -/// -/// -[ApiController] -public abstract class ValidationWebhook : ControllerBase - where TEntity : IKubernetesObject -{ - private const string CreateOperation = "CREATE"; - private const string UpdateOperation = "UPDATE"; - private const string DeleteOperation = "DELETE"; - - /// - /// Validation callback for entities that are created. - /// - /// The (soon to be) new entity. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task CreateAsync(TEntity entity, bool dryRun) => - Task.FromResult(Create(entity, dryRun)); - - /// - [NonAction] - public virtual ValidationResult Create(TEntity entity, bool dryRun) => Success(); - - /// - /// Validation callback for entities that are updated. - /// - /// The currently stored entity in the Kubernetes API. - /// The new entity that should be stored. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task UpdateAsync(TEntity oldEntity, TEntity newEntity, bool dryRun) => - Task.FromResult(Update(oldEntity, newEntity, dryRun)); - - /// - [NonAction] - public virtual ValidationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => Success(); - - /// - /// Validation callback for entities that are to be deleted. - /// - /// The (soon to be removed) entity. - /// Flag that indicates if the webhook was called in a dry-run. - /// A . - [NonAction] - public virtual Task DeleteAsync(TEntity entity, bool dryRun) => - Task.FromResult(Delete(entity, dryRun)); - - /// - [NonAction] - public virtual ValidationResult Delete(TEntity entity, bool dryRun) => Success(); - - /// - /// Public, non-virtual method that is called by the controller. - /// This method will call the correct method based on the operation. - /// - /// The incoming admission request for an entity. - /// The . - [HttpPost] - public async Task Validate([FromBody] AdmissionRequest request) - { - var result = request.Request.Operation switch - { - CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun), - UpdateOperation => await UpdateAsync( - request.Request.OldObject!, - request.Request.Object!, - request.Request.DryRun), - DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun), - _ => Fail( - $"Operation {request.Request.Operation} is not supported.", - StatusCodes.Status422UnprocessableEntity), - }; - - return result with { Uid = request.Request.Uid }; - } - - /// - /// Create a with an optional list of warnings. - /// The validation will succeed, such that the operation will proceed. - /// - /// A list of warnings that is presented to the user. - /// A . - [NonAction] - protected ValidationResult Success(params string[] warnings) - => new() { Warnings = warnings }; - - /// - /// Create a that will fail the validation. - /// The user will only see that the validation failed. - /// - /// A . - [NonAction] - protected ValidationResult Fail() - => new(false); - - /// - /// Create a that will fail the validation. - /// The reason is presented to the user. - /// - /// A reason for the failure of the validation. - /// A . - [NonAction] - protected ValidationResult Fail(string reason) - => Fail() with { Status = new(reason) }; - - /// - /// Create a that will fail the validation. - /// The reason is presented to the user with the custom status code. - /// The custom status code may provide further specific information about the - /// failure, but not all Kubernetes clusters support custom status codes. - /// - /// A reason for the failure of the validation. - /// The custom status code. - /// A . - [NonAction] - protected ValidationResult Fail(string reason, int statusCode) => Fail() with { Status = new(reason, statusCode), }; -} +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Admission.Validation; + +/// +/// The abstract base for any validation webhook. To use them, attach controllers in the +/// main program and map controllers as well. A validation webhook must be decorated +/// with the and the type must be provided. +/// There are async and sync methods for each operation. The async will take +/// precedence if both are implemented (i.e. overridden). +/// +/// The type of the entity that is validated. +/// +/// Simple example of a webhook that checks nothing but is called on every "CREATE" event. +/// +/// [ValidationWebhook(typeof(V1TestEntity))] +/// public class TestValidationWebhook : ValidationWebhook<V1TestEntity> +/// { +/// public override ValidationResult Create(V1TestEntity entity, bool dryRun) +/// { +/// return Success(); +/// } +/// } +/// +/// +[ApiController] +public abstract class ValidationWebhook : ControllerBase + where TEntity : IKubernetesObject +{ + private const string CreateOperation = "CREATE"; + private const string UpdateOperation = "UPDATE"; + private const string DeleteOperation = "DELETE"; + + /// + /// Validation callback for entities that are created. + /// + /// The (soon to be) new entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// The token to monitor for cancellation requests. + /// A . + [NonAction] + public virtual Task CreateAsync( + TEntity entity, + bool dryRun, + CancellationToken cancellationToken) => + Task.FromResult(Create(entity, dryRun)); + + /// + [NonAction] + public virtual ValidationResult Create(TEntity entity, bool dryRun) => Success(); + + /// + /// Validation callback for entities that are updated. + /// + /// The currently stored entity in the Kubernetes API. + /// The new entity that should be stored. + /// Flag that indicates if the webhook was called in a dry-run. + /// The token to monitor for cancellation requests. + /// A . + [NonAction] + public virtual Task UpdateAsync( + TEntity oldEntity, + TEntity newEntity, + bool dryRun, + CancellationToken cancellationToken) => + Task.FromResult(Update(oldEntity, newEntity, dryRun)); + + /// + [NonAction] + public virtual ValidationResult Update(TEntity oldEntity, TEntity newEntity, bool dryRun) => Success(); + + /// + /// Validation callback for entities that are to be deleted. + /// + /// The (soon to be removed) entity. + /// Flag that indicates if the webhook was called in a dry-run. + /// The token to monitor for cancellation requests. + /// A . + [NonAction] + public virtual Task + DeleteAsync(TEntity entity, bool dryRun, CancellationToken cancellationToken) => + Task.FromResult(Delete(entity, dryRun)); + + /// + [NonAction] + public virtual ValidationResult Delete(TEntity entity, bool dryRun) => Success(); + + /// + /// Public, non-virtual method that is called by the controller. + /// This method will call the correct method based on the operation. + /// + /// The incoming admission request for an entity. + /// The incoming cancellation token that's cancelled if the request gets aborted. + /// The . + [HttpPost] + public async Task Validate( + [FromBody] AdmissionRequest request, + CancellationToken cancellationToken) + { + var result = request.Request.Operation switch + { + CreateOperation => await CreateAsync(request.Request.Object!, request.Request.DryRun, cancellationToken), + UpdateOperation => await UpdateAsync( + request.Request.OldObject!, + request.Request.Object!, + request.Request.DryRun, + cancellationToken), + DeleteOperation => await DeleteAsync(request.Request.OldObject!, request.Request.DryRun, cancellationToken), + _ => Fail( + $"Operation {request.Request.Operation} is not supported.", + StatusCodes.Status422UnprocessableEntity), + }; + + return result with { Uid = request.Request.Uid }; + } + + /// + /// Create a with an optional list of warnings. + /// The validation will succeed, such that the operation will proceed. + /// + /// A list of warnings that is presented to the user. + /// A . + [NonAction] + protected ValidationResult Success(params string[] warnings) + => new() { Warnings = warnings }; + + /// + /// Create a that will fail the validation. + /// The user will only see that the validation failed. + /// + /// A . + [NonAction] + protected ValidationResult Fail() + => new(false); + + /// + /// Create a that will fail the validation. + /// The reason is presented to the user. + /// + /// A reason for the failure of the validation. + /// A . + [NonAction] + protected ValidationResult Fail(string reason) + => Fail() with { Status = new(reason) }; + + /// + /// Create a that will fail the validation. + /// The reason is presented to the user with the custom status code. + /// The custom status code may provide further specific information about the + /// failure, but not all Kubernetes clusters support custom status codes. + /// + /// A reason for the failure of the validation. + /// The custom status code. + /// A . + [NonAction] + protected ValidationResult Fail(string reason, int statusCode) => Fail() with { Status = new(reason, statusCode), }; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionRequest.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionRequest.cs index 40793594..f239319e 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionRequest.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionRequest.cs @@ -1,47 +1,47 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.Versioning; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -using KubeOps.Operator.Web.Webhooks.Admission; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -/// -/// Incoming conversion request for a webhook. -/// -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -public sealed class ConversionRequest : ConversionReview -{ - /// - /// Conversion request data. - /// - [JsonPropertyName("request")] - public ConversionRequestData Request { get; init; } = new(); - - /// - /// Data object for the incoming conversion request. - /// - public sealed class ConversionRequestData - { - /// - /// The unique ID of the conversion request. - /// - [JsonPropertyName("uid")] - public string Uid { get; init; } = string.Empty; - - /// - /// Target group/api version of the conversion. - /// - [JsonPropertyName("desiredAPIVersion")] - public string DesiredApiVersion { get; init; } = string.Empty; - - /// - /// List of objects that need to be converted. - /// - [JsonPropertyName("objects")] - public JsonNode[] Objects { get; init; } = Array.Empty(); - } -} +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Versioning; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +using KubeOps.Operator.Web.Webhooks.Admission; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +/// +/// Incoming conversion request for a webhook. +/// +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +public sealed class ConversionRequest : ConversionReview +{ + /// + /// Conversion request data. + /// + [JsonPropertyName("request")] + public ConversionRequestData Request { get; init; } = new(); + + /// + /// Data object for the incoming conversion request. + /// + public sealed class ConversionRequestData + { + /// + /// The unique ID of the conversion request. + /// + [JsonPropertyName("uid")] + public string Uid { get; init; } = string.Empty; + + /// + /// Target group/api version of the conversion. + /// + [JsonPropertyName("desiredAPIVersion")] + public string DesiredApiVersion { get; init; } = string.Empty; + + /// + /// List of objects that need to be converted. + /// + [JsonPropertyName("objects")] + public JsonNode[] Objects { get; init; } = Array.Empty(); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionResponse.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionResponse.cs index eaca59ad..d2122f25 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionResponse.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionResponse.cs @@ -1,58 +1,58 @@ -using System.Runtime.Versioning; -using System.Text.Json.Serialization; - -using k8s; - -using KubeOps.Operator.Web.Webhooks.Admission; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -internal sealed class ConversionResponse : ConversionReview, IActionResult -{ - public ConversionResponse(string uid, string errorMessage) - { - Response.Uid = uid; - Response.Result = new(errorMessage); - } - - public ConversionResponse(string uid, IEnumerable result) - { - Response.Uid = uid; - Response.ConvertedObjects = result.ToArray(); - } - - [JsonPropertyName("response")] - public ConversionRequestData Response { get; init; } = new(); - - public async Task ExecuteResultAsync(ActionContext context) - { - var response = context.HttpContext.Response; - if (string.IsNullOrWhiteSpace(Response.Uid)) - { - response.StatusCode = StatusCodes.Status500InternalServerError; - await response.WriteAsync("No request UID was provided."); - return; - } - - response.ContentType = "application/json; charset=utf-8"; - await response.WriteAsync(KubernetesJson.Serialize(this)); - } - - internal sealed class ConversionRequestData - { - [JsonPropertyName("uid")] - public string Uid { get; set; } = string.Empty; - - [JsonPropertyName("result")] - public ConversionStatus Result { get; set; } = new(); - - [JsonPropertyName("convertedObjects")] - public object[] ConvertedObjects { get; set; } = Array.Empty(); - } -} +using System.Runtime.Versioning; +using System.Text.Json.Serialization; + +using k8s; + +using KubeOps.Operator.Web.Webhooks.Admission; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +internal sealed class ConversionResponse : ConversionReview, IActionResult +{ + public ConversionResponse(string uid, string errorMessage) + { + Response.Uid = uid; + Response.Result = new(errorMessage); + } + + public ConversionResponse(string uid, IEnumerable result) + { + Response.Uid = uid; + Response.ConvertedObjects = result.ToArray(); + } + + [JsonPropertyName("response")] + public ConversionRequestData Response { get; init; } = new(); + + public async Task ExecuteResultAsync(ActionContext context) + { + var response = context.HttpContext.Response; + if (string.IsNullOrWhiteSpace(Response.Uid)) + { + response.StatusCode = StatusCodes.Status500InternalServerError; + await response.WriteAsync("No request UID was provided."); + return; + } + + response.ContentType = "application/json; charset=utf-8"; + await response.WriteAsync(KubernetesJson.Serialize(this)); + } + + internal sealed class ConversionRequestData + { + [JsonPropertyName("uid")] + public string Uid { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public ConversionStatus Result { get; set; } = new(); + + [JsonPropertyName("convertedObjects")] + public object[] ConvertedObjects { get; set; } = Array.Empty(); + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionReview.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionReview.cs index a9f4ec83..d709f07f 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionReview.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionReview.cs @@ -1,19 +1,19 @@ -using System.Runtime.Versioning; -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -/// -/// Base class for conversion review requests. -/// -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -public abstract class ConversionReview -{ - [JsonPropertyName("apiVersion")] - public string ApiVersion => "apiextensions.k8s.io/v1"; - - [JsonPropertyName("kind")] - public string Kind => "ConversionReview"; -} +using System.Runtime.Versioning; +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +/// +/// Base class for conversion review requests. +/// +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +public abstract class ConversionReview +{ + [JsonPropertyName("apiVersion")] + public string ApiVersion => "apiextensions.k8s.io/v1"; + + [JsonPropertyName("kind")] + public string Kind => "ConversionReview"; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionStatus.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionStatus.cs index ae41b3ba..cc6d0195 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionStatus.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionStatus.cs @@ -1,19 +1,19 @@ -using System.Runtime.Versioning; -using System.Text.Json.Serialization; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -/// -/// Status object for the conversion. Reports the success / failure of the conversion -/// to the Kubernetes API. -/// -/// If set, reports the reason for the failure. Otherwise, the conversion is a success. -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -public record ConversionStatus([property: JsonPropertyName("message")] - string? Message = null) -{ - [JsonPropertyName("status")] - public string Status => string.IsNullOrWhiteSpace(Message) ? "Success" : "Failed"; -} +using System.Runtime.Versioning; +using System.Text.Json.Serialization; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +/// +/// Status object for the conversion. Reports the success / failure of the conversion +/// to the Kubernetes API. +/// +/// If set, reports the reason for the failure. Otherwise, the conversion is a success. +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +public record ConversionStatus([property: JsonPropertyName("message")] + string? Message = null) +{ + [JsonPropertyName("status")] + public string Status => string.IsNullOrWhiteSpace(Message) ? "Success" : "Failed"; +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs index 225bab68..03c6e430 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhook.cs @@ -1,72 +1,72 @@ -using System.Runtime.Versioning; -using System.Text.Json; - -using k8s; -using k8s.Models; - -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -/// -/// Base class for conversion webhooks. This class handles the conversion of -/// entities in their versions. Must be annotated with the . -/// -/// The target type (version) of the entity. -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -[ApiController] -public abstract class ConversionWebhook : ControllerBase - where TEntity : IKubernetesObject -{ - private JsonSerializerOptions _serializerOptions = null!; - - protected ConversionWebhook() - { - KubernetesJson.AddJsonOptions(c => _serializerOptions = c); - } - - /// - /// The list of converters that are available for this webhook. - /// - protected abstract IEnumerable> Converters { get; } - - private IEnumerable<(string To, string From, Func Converter, Type FromType)> AvailableConversions => - Converters - .SelectMany(c => new (string, string, Func, Type)[] - { - (c.ToGroupVersion, c.FromGroupVersion, o => c.Convert(o), c.FromType), - (c.FromGroupVersion, c.ToGroupVersion, o => c.Revert((TEntity)o), c.ToType), - }); - - [HttpPost] - public IActionResult Convert([FromBody] ConversionRequest request) - { - try - { - var toConverters = AvailableConversions - .Where(c => c.To == request.Request.DesiredApiVersion) - .ToList(); - var results = new List(); - foreach (var obj in request.Request.Objects) - { - if (obj["apiVersion"]?.GetValue() is not { } targetApiVersion || - toConverters.TrueForAll(c => c.From != targetApiVersion)) - { - continue; - } - - var (_, _, converter, type) = toConverters.Find(c => c.From == targetApiVersion); - - results.Add(converter(obj.Deserialize(type, _serializerOptions)!)); - } - - return new ConversionResponse(request.Request.Uid, results); - } - catch (Exception e) - { - return new ConversionResponse(request.Request.Uid, e.ToString()); - } - } -} +using System.Runtime.Versioning; +using System.Text.Json; + +using k8s; +using k8s.Models; + +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +/// +/// Base class for conversion webhooks. This class handles the conversion of +/// entities in their versions. Must be annotated with the . +/// +/// The target type (version) of the entity. +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +[ApiController] +public abstract class ConversionWebhook : ControllerBase + where TEntity : IKubernetesObject +{ + private JsonSerializerOptions _serializerOptions = null!; + + protected ConversionWebhook() + { + KubernetesJson.AddJsonOptions(c => _serializerOptions = c); + } + + /// + /// The list of converters that are available for this webhook. + /// + protected abstract IEnumerable> Converters { get; } + + private IEnumerable<(string To, string From, Func Converter, Type FromType)> AvailableConversions => + Converters + .SelectMany(c => new (string, string, Func, Type)[] + { + (c.ToGroupVersion, c.FromGroupVersion, o => c.Convert(o), c.FromType), + (c.FromGroupVersion, c.ToGroupVersion, o => c.Revert((TEntity)o), c.ToType), + }); + + [HttpPost] + public IActionResult Convert([FromBody] ConversionRequest request) + { + try + { + var toConverters = AvailableConversions + .Where(c => c.To == request.Request.DesiredApiVersion) + .ToList(); + var results = new List(); + foreach (var obj in request.Request.Objects) + { + if (obj["apiVersion"]?.GetValue() is not { } targetApiVersion || + toConverters.TrueForAll(c => c.From != targetApiVersion)) + { + continue; + } + + var (_, _, converter, type) = toConverters.Find(c => c.From == targetApiVersion); + + results.Add(converter(obj.Deserialize(type, _serializerOptions)!)); + } + + return new ConversionResponse(request.Request.Uid, results); + } + catch (Exception e) + { + return new ConversionResponse(request.Request.Uid, e.ToString()); + } + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhookAttribute.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhookAttribute.cs index ffe3d09e..951bc512 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhookAttribute.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/ConversionWebhookAttribute.cs @@ -1,35 +1,35 @@ -using System.Runtime.Versioning; - -using KubeOps.Transpiler; - -using Microsoft.AspNetCore.Mvc; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -/// -/// Defines (marks) an MVC controller as "conversion webhook". The route is automatically set to -/// /convert/[group]/[plural-name]. -/// This must be used in conjunction with the class. -/// -[AttributeUsage(AttributeTargets.Class)] -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -public class ConversionWebhookAttribute : RouteAttribute -{ - public ConversionWebhookAttribute(Type entityType) - : base(GetRouteTemplate(entityType)) - { - } - - public ConversionWebhookAttribute(string group, string pluralName) - : base($"/convert/{group}/{pluralName}") - { - } - - private static string GetRouteTemplate(Type entityType) - { - var meta = Entities.ToEntityMetadata(entityType).Metadata; - return $"/convert/{meta.Group}/{meta.PluralName}"; - } -} +using System.Runtime.Versioning; + +using KubeOps.Transpiler; + +using Microsoft.AspNetCore.Mvc; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +/// +/// Defines (marks) an MVC controller as "conversion webhook". The route is automatically set to +/// /convert/[group]/[plural-name]. +/// This must be used in conjunction with the class. +/// +[AttributeUsage(AttributeTargets.Class)] +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +public class ConversionWebhookAttribute : RouteAttribute +{ + public ConversionWebhookAttribute(Type entityType) + : base(GetRouteTemplate(entityType)) + { + } + + public ConversionWebhookAttribute(string group, string pluralName) + : base($"/convert/{group}/{pluralName}") + { + } + + private static string GetRouteTemplate(Type entityType) + { + var meta = Entities.ToEntityMetadata(entityType).Metadata; + return $"/convert/{meta.Group}/{meta.PluralName}"; + } +} diff --git a/src/KubeOps.Operator.Web/Webhooks/Conversion/IEntityConverter.cs b/src/KubeOps.Operator.Web/Webhooks/Conversion/IEntityConverter.cs index 94ca74ff..51d7d70e 100644 --- a/src/KubeOps.Operator.Web/Webhooks/Conversion/IEntityConverter.cs +++ b/src/KubeOps.Operator.Web/Webhooks/Conversion/IEntityConverter.cs @@ -1,86 +1,86 @@ -using System.Runtime.Versioning; - -using k8s; -using k8s.Models; - -using KubeOps.Transpiler; - -namespace KubeOps.Operator.Web.Webhooks.Conversion; - -/// -/// Entity converter that converts between two Kubernetes entity versions. -/// -/// Target type (version). -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -public interface IEntityConverter - where TTarget : IKubernetesObject -{ - /// - /// The type of the entity that is converted from. - /// - Type FromType { get; } - - /// - /// The type of the entity that is converted to. - /// - Type ToType => typeof(TTarget); - - /// - /// Group/APIVersion of the entity that is converted from. - /// - string FromGroupVersion { get; } - - /// - /// Group/APIVersion of the entity that is converted to. - /// - string ToGroupVersion => Entities.ToEntityMetadata().Metadata.GroupWithVersion; - - /// - /// Forward conversion of an object to the target entity version. - /// - /// The object that will be converted. - /// The converted version result. - TTarget Convert(object from); - - /// - /// Revert conversion of an object to the source entity version. - /// - /// The entity. - /// The base from where the entity was converted. - object Revert(TTarget to); -} - -/// -/// Specific entity converter that converts between two Kubernetes entity versions. -/// -/// Source entity. -/// Target entity. -[RequiresPreviewFeatures( - "Conversion webhooks API is not yet stable, the way that conversion " + - "webhooks are implemented may change in the future based on user feedback.")] -public interface IEntityConverter : IEntityConverter - where TFrom : IKubernetesObject - where TTo : IKubernetesObject -{ - /// - Type IEntityConverter.FromType => typeof(TFrom); - - /// - string IEntityConverter.FromGroupVersion => Entities.ToEntityMetadata().Metadata.GroupWithVersion; - - /// - TTo Convert(TFrom from); - - /// - new TFrom Revert(TTo from); - - /// - TTo IEntityConverter.Convert(object from) - => Convert((TFrom)from); - - /// - object IEntityConverter.Revert(TTo to) - => Revert(to); -} +using System.Runtime.Versioning; + +using k8s; +using k8s.Models; + +using KubeOps.Transpiler; + +namespace KubeOps.Operator.Web.Webhooks.Conversion; + +/// +/// Entity converter that converts between two Kubernetes entity versions. +/// +/// Target type (version). +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +public interface IEntityConverter + where TTarget : IKubernetesObject +{ + /// + /// The type of the entity that is converted from. + /// + Type FromType { get; } + + /// + /// The type of the entity that is converted to. + /// + Type ToType => typeof(TTarget); + + /// + /// Group/APIVersion of the entity that is converted from. + /// + string FromGroupVersion { get; } + + /// + /// Group/APIVersion of the entity that is converted to. + /// + string ToGroupVersion => Entities.ToEntityMetadata().Metadata.GroupWithVersion; + + /// + /// Forward conversion of an object to the target entity version. + /// + /// The object that will be converted. + /// The converted version result. + TTarget Convert(object from); + + /// + /// Revert conversion of an object to the source entity version. + /// + /// The entity. + /// The base from where the entity was converted. + object Revert(TTarget to); +} + +/// +/// Specific entity converter that converts between two Kubernetes entity versions. +/// +/// Source entity. +/// Target entity. +[RequiresPreviewFeatures( + "Conversion webhooks API is not yet stable, the way that conversion " + + "webhooks are implemented may change in the future based on user feedback.")] +public interface IEntityConverter : IEntityConverter + where TFrom : IKubernetesObject + where TTo : IKubernetesObject +{ + /// + Type IEntityConverter.FromType => typeof(TFrom); + + /// + string IEntityConverter.FromGroupVersion => Entities.ToEntityMetadata().Metadata.GroupWithVersion; + + /// + TTo Convert(TFrom from); + + /// + new TFrom Revert(TTo from); + + /// + TTo IEntityConverter.Convert(object from) + => Convert((TFrom)from); + + /// + object IEntityConverter.Revert(TTo to) + => Revert(to); +} diff --git a/src/KubeOps.Operator/Builder/OperatorBuilder.cs b/src/KubeOps.Operator/Builder/OperatorBuilder.cs index 58356418..35146ba9 100644 --- a/src/KubeOps.Operator/Builder/OperatorBuilder.cs +++ b/src/KubeOps.Operator/Builder/OperatorBuilder.cs @@ -1,220 +1,99 @@ -using System.Security.Cryptography; -using System.Text; - -using k8s; -using k8s.LeaderElection; -using k8s.LeaderElection.ResourceLock; -using k8s.Models; - -using KubeOps.Abstractions.Builder; -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Events; -using KubeOps.Abstractions.Finalizer; -using KubeOps.Abstractions.Queue; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Finalizer; -using KubeOps.Operator.Queue; -using KubeOps.Operator.Watcher; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Builder; - -internal class OperatorBuilder : IOperatorBuilder -{ - private readonly OperatorSettings _settings; - - public OperatorBuilder(IServiceCollection services, OperatorSettings settings) - { - _settings = settings; - Services = services; - AddOperatorBase(); - } - - public IServiceCollection Services { get; } - - public IOperatorBuilder AddController() - where TImplementation : class, IEntityController - where TEntity : IKubernetesObject - { - Services.AddScoped, TImplementation>(); - Services.AddSingleton(new TimedEntityQueue()); - Services.AddTransient(CreateEntityRequeue()); - - if (_settings.EnableLeaderElection) - { - Services.AddHostedService>(); - } - else - { - Services.AddHostedService>(); - } - - return this; - } - - public IOperatorBuilder AddFinalizer(string identifier) - where TImplementation : class, IEntityFinalizer - where TEntity : IKubernetesObject - { - Services.AddTransient(); - Services.AddSingleton(new FinalizerRegistration(identifier, typeof(TImplementation), typeof(TEntity))); - Services.AddTransient(CreateFinalizerAttacher(identifier)); - - return this; - } - - private static Func> CreateFinalizerAttacher< - TImplementation, TEntity>( - string identifier) - where TImplementation : class, IEntityFinalizer - where TEntity : IKubernetesObject - => services => async entity => - { - var logger = services.GetService>>(); - using var client = new KubernetesClient.KubernetesClient(); - - logger?.LogTrace( - """Try to add finalizer "{finalizer}" on entity "{kind}/{name}".""", - identifier, - entity.Kind, - entity.Name()); - - if (!entity.AddFinalizer(identifier)) - { - return entity; - } - - logger?.LogInformation( - """Added finalizer "{finalizer}" on entity "{kind}/{name}".""", - identifier, - entity.Kind, - entity.Name()); - return await client.UpdateAsync(entity); - }; - - private static Func> CreateEntityRequeue() - where TEntity : IKubernetesObject - => services => (entity, timeSpan) => - { - var logger = services.GetService>>(); - var queue = services.GetRequiredService>(); - - logger?.LogTrace( - """Requeue entity "{kind}/{name}" in {milliseconds}ms.""", - entity.Kind, - entity.Name(), - timeSpan.TotalMilliseconds); - - queue.Enqueue(entity, timeSpan); - }; - - private static Func CreateEventPublisher() - => services => - async (entity, reason, message, type) => - { - var logger = services.GetService>(); - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var settings = services.GetRequiredService(); - - var @namespace = entity.Namespace() ?? "default"; - logger?.LogTrace( - "Encoding event name with: {resourceName}.{resourceNamespace}.{reason}.{message}.{type}.", - entity.Name(), - @namespace, - reason, - message, - type); - - var eventName = $"{entity.Uid()}.{entity.Name()}.{@namespace}.{reason}.{message}.{type}"; - var encodedEventName = - Convert.ToHexString( - SHA512.HashData( - Encoding.UTF8.GetBytes(eventName))); - - logger?.LogTrace("""Search or create event with name "{name}".""", encodedEventName); - - var @event = await client.GetAsync(encodedEventName, @namespace) ?? - new Corev1Event - { - Metadata = new() - { - Name = encodedEventName, - NamespaceProperty = @namespace, - Annotations = - new Dictionary - { - { "originalName", eventName }, - { "nameHash", "sha512" }, - { "nameEncoding", "Hex String" }, - }, - }, - Type = type.ToString(), - Reason = reason, - Message = message, - ReportingComponent = settings.Name, - ReportingInstance = Environment.MachineName, - Source = new() { Component = settings.Name, }, - InvolvedObject = entity.MakeObjectReference(), - FirstTimestamp = DateTime.UtcNow, - LastTimestamp = DateTime.UtcNow, - Count = 0, - }.Initialize(); - - @event.Count++; - @event.LastTimestamp = DateTime.UtcNow; - logger?.LogTrace( - "Save event with new count {count} and last timestamp {timestamp}", - @event.Count, - @event.LastTimestamp); - - try - { - await client.SaveAsync(@event); - logger?.LogInformation( - """Created or updated event with name "{name}" to new count {count} on entity "{kind}/{name}".""", - eventName, - @event.Count, - entity.Kind, - entity.Name()); - } - catch (Exception e) - { - logger?.LogError( - e, - """Could not publish event with name "{name}" on entity "{kind}/{name}".""", - eventName, - entity.Kind, - entity.Name()); - } - }; - - private void AddOperatorBase() - { - Services.AddSingleton(_settings); - Services.AddTransient(_ => new KubernetesClient.KubernetesClient()); - Services.AddTransient(CreateEventPublisher()); - - if (_settings.EnableLeaderElection) - { - using var client = new KubernetesClient.KubernetesClient(); - - var elector = new LeaderElector( - new LeaderElectionConfig( - new LeaseLock( - new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()), - client.GetCurrentNamespace(), - $"{_settings.Name}-leader", - Environment.MachineName)) - { - LeaseDuration = _settings.LeaderElectionLeaseDuration, - RenewDeadline = _settings.LeaderElectionRenewDeadline, - RetryPeriod = _settings.LeaderElectionRetryPeriod, - }); - Services.AddSingleton(elector); - elector.RunAsync(); - } - } -} +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Events; +using KubeOps.Abstractions.Finalizer; +using KubeOps.Abstractions.Queue; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Events; +using KubeOps.Operator.Finalizer; +using KubeOps.Operator.LeaderElection; +using KubeOps.Operator.Queue; +using KubeOps.Operator.Watcher; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace KubeOps.Operator.Builder; + +internal class OperatorBuilder : IOperatorBuilder +{ + private readonly OperatorSettings _settings; + + public OperatorBuilder(IServiceCollection services, OperatorSettings settings) + { + _settings = settings; + Services = services; + AddOperatorBase(); + } + + public IServiceCollection Services { get; } + + public IOperatorBuilder AddController() + where TImplementation : class, IEntityController + where TEntity : IKubernetesObject + { + Services.AddHostedService>(); + Services.TryAddScoped, TImplementation>(); + Services.TryAddSingleton(new TimedEntityQueue()); + Services.TryAddTransient(); + Services.TryAddTransient>(services => + services.GetRequiredService().Create()); + + if (_settings.EnableLeaderElection) + { + Services.AddHostedService>(); + } + else + { + Services.AddHostedService>(); + } + + return this; + } + + public IOperatorBuilder AddFinalizer(string identifier) + where TImplementation : class, IEntityFinalizer + where TEntity : IKubernetesObject + { + Services.TryAddKeyedTransient, TImplementation>(identifier); + Services.TryAddTransient(); + Services.TryAddTransient>(services => + services.GetRequiredService() + .Create(identifier)); + + return this; + } + + private void AddOperatorBase() + { + Services.AddSingleton(_settings); + + // Add the default configuration and the client separately. This allows external users to override either + // just the config (e.g. for integration tests) or to replace the whole client, e.g. with a mock. + // We also add the k8s.IKubernetes as a singleton service, in order to allow to access internal services + // and also external users to make use of it's features that might not be implemented in the adapted client. + // + // Due to a memory leak in the Kubernetes client, it is important that the client is registered with + // with the same lifetime as the KubernetesClientConfiguration. This is tracked in kubernetes/csharp#1446. + // https://github.com/kubernetes-client/csharp/issues/1446 + // + // The missing ability to inject a custom HTTP client and therefore the possibility to use the .AddHttpClient() + // functionalities led us choosing Singleton as the lifetime. + Services.TryAddSingleton(_ => KubernetesClientConfiguration.BuildDefaultConfig()); + Services.TryAddSingleton(services => + new Kubernetes(services.GetRequiredService())); + Services.TryAddSingleton(); + + Services.TryAddTransient(); + Services.TryAddTransient( + services => services.GetRequiredService().Create()); + + if (_settings.EnableLeaderElection) + { + Services.AddLeaderElection(); + } + } +} diff --git a/src/KubeOps.Operator/Events/KubeOpsEventPublisherFactory.cs b/src/KubeOps.Operator/Events/KubeOpsEventPublisherFactory.cs new file mode 100644 index 00000000..20ef9278 --- /dev/null +++ b/src/KubeOps.Operator/Events/KubeOpsEventPublisherFactory.cs @@ -0,0 +1,107 @@ +using System.Security.Cryptography; +using System.Text; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Events; +using KubeOps.KubernetesClient; + +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Events; + +internal sealed class KubeOpsEventPublisherFactory( + IKubernetesClient client, + OperatorSettings settings, + ILogger logger) : IEventPublisherFactory +{ + public EventPublisher Create() => async (entity, reason, message, type, token) => + { + var @namespace = entity.Namespace() ?? "default"; + logger.LogTrace( + "Encoding event name with: {resourceName}.{resourceNamespace}.{reason}.{message}.{type}.", + entity.Name(), + @namespace, + reason, + message, + type); + + var eventName = $"{entity.Uid()}.{entity.Name()}.{@namespace}.{reason}.{message}.{type}"; + var encodedEventName = + Convert.ToHexString( + SHA512.HashData( + Encoding.UTF8.GetBytes(eventName))); + + logger.LogTrace("""Search or create event with name "{name}".""", encodedEventName); + + Corev1Event? @event; + try + { + @event = await client.GetAsync(encodedEventName, @namespace, token); + } + catch (Exception e) + { + logger.LogError( + e, + """Could not receive event with name "{name}" on entity "{kind}/{name}".""", + eventName, + entity.Kind, + entity.Name()); + return; + } + + @event ??= new Corev1Event + { + Metadata = new() + { + Name = encodedEventName, + NamespaceProperty = @namespace, + Annotations = + new Dictionary + { + { "originalName", eventName }, { "nameHash", "sha512" }, { "nameEncoding", "Hex String" }, + }, + }, + Type = type.ToString(), + Reason = reason, + Message = message, + ReportingComponent = settings.Name, + ReportingInstance = Environment.MachineName, + Source = new() { Component = settings.Name, }, + InvolvedObject = entity.MakeObjectReference(), + FirstTimestamp = DateTime.UtcNow, + LastTimestamp = DateTime.UtcNow, + Count = 0, + }.Initialize(); + + @event.Count++; + @event.LastTimestamp = DateTime.UtcNow; + logger.LogTrace( + "Save event with new count {count} and last timestamp {timestamp}", + @event.Count, + @event.LastTimestamp); + + try + { + await client.SaveAsync(@event, token); + logger.LogInformation( + """Created or updated event with name "{name}" to new count {count} on entity "{kind}/{name}".""", + eventName, + @event.Count, + entity.Kind, + entity.Name()); + } + catch (Exception e) + { + logger.LogError( + e, + """Could not publish event with name "{name}" on entity "{kind}/{name}".""", + eventName, + entity.Kind, + entity.Name()); + } + }; +} diff --git a/src/KubeOps.Operator/Finalizer/FinalizerRegistration.cs b/src/KubeOps.Operator/Finalizer/FinalizerRegistration.cs deleted file mode 100644 index baa49128..00000000 --- a/src/KubeOps.Operator/Finalizer/FinalizerRegistration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace KubeOps.Operator.Finalizer; - -internal record FinalizerRegistration(string Identifier, Type FinalizerType, Type EntityType); diff --git a/src/KubeOps.Operator/Finalizer/KubeOpsEventFinalizerAttacherFactory.cs b/src/KubeOps.Operator/Finalizer/KubeOpsEventFinalizerAttacherFactory.cs new file mode 100644 index 00000000..a79d070d --- /dev/null +++ b/src/KubeOps.Operator/Finalizer/KubeOpsEventFinalizerAttacherFactory.cs @@ -0,0 +1,40 @@ +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Finalizer; +using KubeOps.KubernetesClient; + +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Finalizer; + +internal sealed class KubeOpsEventFinalizerAttacherFactory(ILoggerFactory loggerFactory, IKubernetesClient client) + : IEventFinalizerAttacherFactory +{ + public EntityFinalizerAttacher Create(string identifier) + where TImplementation : class, IEntityFinalizer + where TEntity : IKubernetesObject + { + var logger = loggerFactory.CreateLogger>(); + return (entity, token) => + { + logger.LogTrace( + """Try to add finalizer "{finalizer}" on entity "{kind}/{name}".""", + identifier, + entity.Kind, + entity.Name()); + + if (!entity.AddFinalizer(identifier)) + { + return Task.FromResult(entity); + } + + logger.LogInformation( + """Added finalizer "{finalizer}" on entity "{kind}/{name}".""", + identifier, + entity.Kind, + entity.Name()); + return client.UpdateAsync(entity, token); + }; + } +} diff --git a/src/KubeOps.Operator/LeaderElection/ILeaderElectorFactory.cs b/src/KubeOps.Operator/LeaderElection/ILeaderElectorFactory.cs new file mode 100644 index 00000000..bb47b662 --- /dev/null +++ b/src/KubeOps.Operator/LeaderElection/ILeaderElectorFactory.cs @@ -0,0 +1,15 @@ +using k8s.LeaderElection; + +namespace KubeOps.Operator.LeaderElection; + +/// +/// Represents a type used to configure the election system and create instances of . +/// +public interface ILeaderElectorFactory +{ + /// + /// Creates a new instance. + /// + /// The . + LeaderElector CreateElector(); +} diff --git a/src/KubeOps.Operator/LeaderElection/KubernetesLeaderElectorFactory.cs b/src/KubeOps.Operator/LeaderElection/KubernetesLeaderElectorFactory.cs new file mode 100644 index 00000000..a0cf3520 --- /dev/null +++ b/src/KubeOps.Operator/LeaderElection/KubernetesLeaderElectorFactory.cs @@ -0,0 +1,26 @@ +using k8s; +using k8s.LeaderElection; +using k8s.LeaderElection.ResourceLock; + +using KubeOps.Abstractions.Builder; +using KubeOps.KubernetesClient; + +namespace KubeOps.Operator.LeaderElection; + +internal sealed class KubernetesLeaderElectorFactory( + IKubernetes kubernetes, + IKubernetesClient client, + OperatorSettings settings) + : ILeaderElectorFactory +{ + public LeaderElector CreateElector() => new(new LeaderElectionConfig(new LeaseLock( + kubernetes, + client.GetCurrentNamespace(), + $"{settings.Name}-leader", + Environment.MachineName)) + { + LeaseDuration = settings.LeaderElectionLeaseDuration, + RenewDeadline = settings.LeaderElectionRenewDeadline, + RetryPeriod = settings.LeaderElectionRetryPeriod, + }); +} diff --git a/src/KubeOps.Operator/LeaderElection/LeaderElectionBackgroundService.cs b/src/KubeOps.Operator/LeaderElection/LeaderElectionBackgroundService.cs new file mode 100644 index 00000000..66caf6f8 --- /dev/null +++ b/src/KubeOps.Operator/LeaderElection/LeaderElectionBackgroundService.cs @@ -0,0 +1,43 @@ +using k8s.LeaderElection; + +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.LeaderElection; + +/// +/// This background service connects to the API and continuously watches the leader election. +/// +/// The elector. +internal sealed class LeaderElectionBackgroundService(LeaderElector elector) : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + + public Task StartAsync(CancellationToken cancellationToken) + { + // The current implementation of IHostedService expects that StartAsync is "really" asynchronous. + // Blocking calls are not allowed, they would stop the rest of the startup flow. + // + // This is an open issue since 2019 and not expected to be closed soon. (https://github.com/dotnet/runtime/issues/36063) + // For reasons unknown at the time of writing this code, "await Task.Yield()" didn't work as expected, it caused + // a deadlock in 1/10 of the cases. + // + // Therefore, we use Task.Run() and put the work to queue. The passed cancellation token of the StartAsync + // method is not used, because it would only cancel the scheduling (which we definitely don't want to cancel). + // To make this intention explicit, CancellationToken.None gets passed. + _ = Task.Run(() => elector.RunAsync(_cts.Token), CancellationToken.None); + + return Task.CompletedTask; + } + + public void Dispose() => _cts.Dispose(); + +#if NET8_0_OR_GREATER + public Task StopAsync(CancellationToken cancellationToken) => _cts.CancelAsync(); +#else + public Task StopAsync(CancellationToken cancellationToken) + { + _cts.Cancel(); + return Task.CompletedTask; + } +#endif +} diff --git a/src/KubeOps.Operator/LeaderElection/ServiceCollectionExtensions.cs b/src/KubeOps.Operator/LeaderElection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..398b6e76 --- /dev/null +++ b/src/KubeOps.Operator/LeaderElection/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using k8s.LeaderElection; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace KubeOps.Operator.LeaderElection; + +internal static class ServiceCollectionExtensions +{ + /// + /// Adds support for leader election. + /// + /// The service collection. + public static void AddLeaderElection(this IServiceCollection services) + { + // In order to enable leader election, two things need to be done: + // First, we need to setup the LeaderElector, which is done by the factory. This is done in order to allow the + // injection of other services (like the k8s.IKubernetes) into the creation of the elector. + services.TryAddSingleton(); + services.TryAddSingleton(provider => + provider.GetRequiredService().CreateElector()); + + // The second thing to do is the addition of the LeaderElectionBackgroundService which is responsible for managing + // the leader election itself. + services.AddHostedService(); + } +} diff --git a/src/KubeOps.Operator/Queue/EntityRequeueBackgroundService.cs b/src/KubeOps.Operator/Queue/EntityRequeueBackgroundService.cs new file mode 100644 index 00000000..0eb8d7d2 --- /dev/null +++ b/src/KubeOps.Operator/Queue/EntityRequeueBackgroundService.cs @@ -0,0 +1,96 @@ +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.KubernetesClient; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Queue; + +internal sealed class EntityRequeueBackgroundService( + IKubernetesClient client, + TimedEntityQueue queue, + IServiceProvider provider, + ILogger> logger) : IHostedService, IDisposable + where TEntity : IKubernetesObject +{ + private readonly CancellationTokenSource _cts = new(); + + public Task StartAsync(CancellationToken cancellationToken) + { + // The current implementation of IHostedService expects that StartAsync is "really" asynchronous. + // Blocking calls are not allowed, they would stop the rest of the startup flow. + // + // This is an open issue since 2019 and not expected to be closed soon. (https://github.com/dotnet/runtime/issues/36063) + // For reasons unknown at the time of writing this code, "await Task.Yield()" didn't work as expected, it caused + // a deadlock in 1/10 of the cases. + // + // Therefore, we use Task.Run() and put the work to queue. The passed cancellation token of the StartAsync + // method is not used, because it would only cancel the scheduling (which we definitely don't want to cancel). + // To make this intention explicit, CancellationToken.None gets passed. + _ = Task.Run(() => WatchAsync(_cts.Token), CancellationToken.None); + + return Task.CompletedTask; + } + +#if NET8_0_OR_GREATER + public Task StopAsync(CancellationToken cancellationToken) => _cts.CancelAsync(); +#else + public Task StopAsync(CancellationToken cancellationToken) + { + _cts.Cancel(); + return Task.CompletedTask; + } +#endif + + public void Dispose() => _cts.Dispose(); + + private async Task WatchAsync(CancellationToken cancellationToken) + { + await foreach (var entity in queue) + { + try + { + await ReconcileSingleAsync(entity, cancellationToken); + } + catch (OperationCanceledException e) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError( + e, + """Queued reconciliation for the entity of type {ResourceType} for "{Kind}/{Name}" failed.""", + typeof(TEntity).Name, + entity.Kind, + entity.Name()); + } + catch (Exception e) + { + logger.LogError( + e, + """Queued reconciliation for the entity of type {ResourceType} for "{Kind}/{Name}" failed.""", + typeof(TEntity).Name, + entity.Kind, + entity.Name()); + } + } + } + + private async Task ReconcileSingleAsync(TEntity queued, CancellationToken cancellationToken) + { + logger.LogTrace("""Execute requested requeued reconciliation for "{name}".""", queued.Name()); + + if (await client.GetAsync(queued.Name(), queued.Namespace(), cancellationToken) is not + { } entity) + { + logger.LogWarning( + """Requeued entity "{name}" was not found. Skipping reconciliation.""", queued.Name()); + return; + } + + await using var scope = provider.CreateAsyncScope(); + var controller = scope.ServiceProvider.GetRequiredService>(); + await controller.ReconcileAsync(entity, cancellationToken); + } +} diff --git a/src/KubeOps.Operator/Queue/KubeOpsEntityRequeueFactory.cs b/src/KubeOps.Operator/Queue/KubeOpsEntityRequeueFactory.cs new file mode 100644 index 00000000..31000164 --- /dev/null +++ b/src/KubeOps.Operator/Queue/KubeOpsEntityRequeueFactory.cs @@ -0,0 +1,29 @@ +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Queue; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Queue; + +internal sealed class KubeOpsEntityRequeueFactory(IServiceProvider services) + : IEntityRequeueFactory +{ + public EntityRequeue Create() + where TEntity : IKubernetesObject => + (entity, timeSpan) => + { + var logger = services.GetService>>(); + var queue = services.GetRequiredService>(); + + logger?.LogTrace( + """Requeue entity "{kind}/{name}" in {milliseconds}ms.""", + entity.Kind, + entity.Name(), + timeSpan.TotalMilliseconds); + + queue.Enqueue(entity, timeSpan); + }; +} diff --git a/src/KubeOps.Operator/Queue/TimedEntityQueue.cs b/src/KubeOps.Operator/Queue/TimedEntityQueue.cs index d46f54b2..9c1bc384 100644 --- a/src/KubeOps.Operator/Queue/TimedEntityQueue.cs +++ b/src/KubeOps.Operator/Queue/TimedEntityQueue.cs @@ -1,63 +1,95 @@ -using System.Collections.Concurrent; - -using k8s; -using k8s.Models; - -using Timer = System.Timers.Timer; - -namespace KubeOps.Operator.Queue; - -internal class TimedEntityQueue - where TEntity : IKubernetesObject -{ - private readonly ConcurrentDictionary _queue = new(); - - public event EventHandler<(string Name, string? Namespace)>? RequeueRequested; - - internal int Count => _queue.Count; - - public void Clear() - { - foreach (var (_, _, timer) in _queue.Values) - { - timer.Stop(); - } - - _queue.Clear(); - } - - public void Enqueue(TEntity entity, TimeSpan requeueIn) - { - var (_, _, timer) = - _queue.AddOrUpdate( - entity.Uid(), - (entity.Name(), entity.Namespace(), new Timer(requeueIn.TotalMilliseconds)), - (_, e) => - { - e.Timer.Stop(); - e.Timer.Dispose(); - return (e.Name, e.Namespace, new Timer(requeueIn.TotalMilliseconds)); - }); - - timer.Elapsed += (_, _) => - { - if (!_queue.TryRemove(entity.Metadata.Uid, out var e)) - { - return; - } - - e.Timer.Stop(); - e.Timer.Dispose(); - RequeueRequested?.Invoke(this, (e.Name, e.Namespace)); - }; - timer.Start(); - } - - public void RemoveIfQueued(TEntity entity) - { - if (_queue.TryRemove(entity.Uid(), out var entry)) - { - entry.Timer.Stop(); - } - } -} +using System.Collections.Concurrent; + +using k8s; +using k8s.Models; + +namespace KubeOps.Operator.Queue; + +/// +/// Represents a queue that's used to inspect a Kubernetes entity again after a given time. +/// The given enumerable only contains items that should be considered for reconciliations. +/// +/// The type of the inner entity. +internal sealed class TimedEntityQueue : IDisposable + where TEntity : IKubernetesObject +{ + // A shared task factory for all the created tasks. + private readonly TaskFactory _scheduledEntries = new(TaskScheduler.Current); + + // Used for managing all the tasks that should add something to the queue. + private readonly ConcurrentDictionary> _management = new(); + + // The actual queue containing all the entries that have to be reconciled. + private readonly BlockingCollection _queue = new(new ConcurrentQueue()); + + internal int Count => _management.Count; + + /// + /// Enqueues the given to happen in . + /// If the item already exists, the existing entry is updated. + /// + /// The entity. + /// The time after , where the item is reevaluated again. + public void Enqueue(TEntity entity, TimeSpan requeueIn) + { + _management.AddOrUpdate( + entity.Name() ?? throw new InvalidOperationException("Cannot enqueue entities without name."), + key => + { + var entry = new TimedQueueEntry(entity, requeueIn); + _scheduledEntries.StartNew( + async () => + { + await entry.AddAfterDelay(_queue); + _management.TryRemove(key, out _); + }, + entry.Token); + return entry; + }, + (key, oldEntry) => + { + oldEntry.Cancel(); + var entry = new TimedQueueEntry(entity, requeueIn); + _scheduledEntries.StartNew( + async () => + { + await entry.AddAfterDelay(_queue); + _management.TryRemove(key, out _); + }, + entry.Token); + return entry; + }); + } + + public void Dispose() + { + _queue.Dispose(); + foreach (var entry in _management.Values) + { + entry.Dispose(); + } + } + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + await Task.Yield(); + foreach (var entry in _queue.GetConsumingEnumerable(cancellationToken)) + { + yield return entry; + } + } + + public void Remove(TEntity entity) + { + var name = entity.Name(); + if (name is null) + { + return; + } + + if (_management.Remove(name, out var task)) + { + task.Cancel(); + } + } +} diff --git a/src/KubeOps.Operator/Queue/TimedQueueEntry{TEntity}.cs b/src/KubeOps.Operator/Queue/TimedQueueEntry{TEntity}.cs new file mode 100644 index 00000000..81794634 --- /dev/null +++ b/src/KubeOps.Operator/Queue/TimedQueueEntry{TEntity}.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; + +namespace KubeOps.Operator.Queue; + +internal sealed record TimedQueueEntry : IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + private readonly TimeSpan _requeueIn; + private readonly TEntity _entity; + + public TimedQueueEntry(TEntity entity, TimeSpan requeueIn) + { + _requeueIn = requeueIn; + _entity = entity; + } + + /// + /// A that is triggered after calling . + /// + public CancellationToken Token => _cts.Token; + + public void Dispose() => _cts.Dispose(); + + /// + /// Cancels the execution of and disposes any associated resources. + /// + public void Cancel() + { + _cts.Cancel(); + Dispose(); + } + + /// + /// Adds the entity to after . + /// Can be canceled with . + /// + /// The collection to add the entry to. + /// A representing the asynchronous operation. + public async Task AddAfterDelay(BlockingCollection collection) + { + try + { + await Task.Delay(_requeueIn, _cts.Token); + if (_cts.IsCancellationRequested) + { + return; + } + + collection.TryAdd(_entity); + } + catch (TaskCanceledException) + { + // Ignore canceled tasks + } + catch (ObjectDisposedException) + { + // And also if the object is disposed. + } + } +} diff --git a/src/KubeOps.Operator/ServiceCollectionExtensions.cs b/src/KubeOps.Operator/ServiceCollectionExtensions.cs index c661e944..20456eb1 100644 --- a/src/KubeOps.Operator/ServiceCollectionExtensions.cs +++ b/src/KubeOps.Operator/ServiceCollectionExtensions.cs @@ -1,27 +1,27 @@ -using KubeOps.Abstractions.Builder; -using KubeOps.Operator.Builder; - -using Microsoft.Extensions.DependencyInjection; - -namespace KubeOps.Operator; - -/// -/// Method extensions for the . -/// -public static class ServiceCollectionExtensions -{ - /// - /// Add the Kubernetes operator to the dependency injection. - /// - /// . - /// An optional configure action for adjusting settings in the operator. - /// An for further configuration and chaining. - public static IOperatorBuilder AddKubernetesOperator( - this IServiceCollection services, - Action? configure = null) - { - var settings = new OperatorSettings(); - configure?.Invoke(settings); - return new OperatorBuilder(services, settings); - } -} +using KubeOps.Abstractions.Builder; +using KubeOps.Operator.Builder; + +using Microsoft.Extensions.DependencyInjection; + +namespace KubeOps.Operator; + +/// +/// Method extensions for the . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Add the Kubernetes operator to the dependency injection. + /// + /// . + /// An optional configure action for adjusting settings in the operator. + /// An for further configuration and chaining. + public static IOperatorBuilder AddKubernetesOperator( + this IServiceCollection services, + Action? configure = null) + { + var settings = new OperatorSettings(); + configure?.Invoke(settings); + return new OperatorBuilder(services, settings); + } +} diff --git a/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs b/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs index 88d4689b..9369705c 100644 --- a/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs +++ b/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs @@ -1,52 +1,59 @@ -using k8s; -using k8s.LeaderElection; -using k8s.Models; - -using KubeOps.Abstractions.Builder; -using KubeOps.Operator.Queue; - -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Watcher; - -internal class LeaderAwareResourceWatcher(ILogger> logger, - IServiceProvider provider, - TimedEntityQueue queue, - OperatorSettings settings, - LeaderElector elector) - : ResourceWatcher(logger, provider, queue, settings) - where TEntity : IKubernetesObject -{ - public override Task StartAsync(CancellationToken cancellationToken) - { - logger.LogDebug("Subscribe for leadership updates."); - elector.OnStartedLeading += StartedLeading; - elector.OnStoppedLeading += StoppedLeading; - if (elector.IsLeader()) - { - StartedLeading(); - } - - return Task.CompletedTask; - } - - public override Task StopAsync(CancellationToken cancellationToken) - { - logger.LogDebug("Unsubscribe from leadership updates."); - elector.OnStartedLeading -= StartedLeading; - elector.OnStoppedLeading -= StoppedLeading; - return Task.CompletedTask; - } - - private void StartedLeading() - { - logger.LogInformation("This instance started leading, starting watcher."); - base.StartAsync(default); - } - - private void StoppedLeading() - { - logger.LogInformation("This instance stopped leading, stopping watcher."); - base.StopAsync(default); - } -} +using k8s; +using k8s.LeaderElection; +using k8s.Models; + +using KubeOps.Abstractions.Builder; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Queue; + +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Watcher; + +internal sealed class LeaderAwareResourceWatcher( + ILogger> logger, + IServiceProvider provider, + TimedEntityQueue queue, + OperatorSettings settings, + IKubernetesClient client, + LeaderElector elector) + : ResourceWatcher(logger, provider, queue, settings, client) + where TEntity : IKubernetesObject +{ + private readonly CancellationTokenSource _cts = new(); + + public override Task StartAsync(CancellationToken cancellationToken) + { + logger.LogDebug("Subscribe for leadership updates."); + + elector.OnStartedLeading += StartedLeading; + elector.OnStoppedLeading += StoppedLeading; + + return elector.IsLeader() ? base.StartAsync(_cts.Token) : Task.CompletedTask; + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + logger.LogDebug("Unsubscribe from leadership updates."); + _cts.Cancel(); + _cts.Dispose(); + + elector.OnStartedLeading -= StartedLeading; + elector.OnStoppedLeading -= StoppedLeading; + return Task.CompletedTask; + } + + private void StartedLeading() + { + logger.LogInformation("This instance started leading, starting watcher."); + base.StartAsync(_cts.Token); + } + + private void StoppedLeading() + { + _cts.Cancel(); + + logger.LogInformation("This instance stopped leading, stopping watcher."); + base.StopAsync(_cts.Token).Wait(); + } +} diff --git a/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs b/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs index 2a517700..5484bba8 100644 --- a/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs +++ b/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs @@ -1,282 +1,259 @@ -using System.Collections.Concurrent; -using System.Runtime.Serialization; -using System.Text.Json; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Builder; -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Finalizer; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Finalizer; -using KubeOps.Operator.Queue; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Watcher; - -internal class ResourceWatcher : IHostedService - where TEntity : IKubernetesObject -{ - private readonly ILogger> _logger; - private readonly IServiceProvider _provider; - private readonly IKubernetesClient _client; - private readonly TimedEntityQueue _queue; - private readonly OperatorSettings _settings; - private readonly ConcurrentDictionary _entityCache = new(); - private readonly Lazy> _finalizers; - private bool _stopped; - private uint _watcherReconnectRetries; - - private Watcher? _watcher; - private string? _lastResourceVersion; - - public ResourceWatcher( - ILogger> logger, - IServiceProvider provider, - TimedEntityQueue queue, - OperatorSettings settings) - { - _logger = logger; - _provider = provider; - _client = provider.GetService() ?? new KubernetesClient.KubernetesClient(); - _queue = queue; - _settings = settings; - _finalizers = new(() => _provider.GetServices().ToList()); - } - - public virtual Task StartAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Starting resource watcher for {ResourceType}.", typeof(TEntity).Name); - _stopped = false; - _queue.RequeueRequested += OnEntityRequeue; - WatchResource(); - return Task.CompletedTask; - } - - public virtual Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Stopping resource watcher for {ResourceType}.", typeof(TEntity).Name); - _stopped = true; - StopWatching(); - _queue.RequeueRequested -= OnEntityRequeue; - _queue.Clear(); - return Task.CompletedTask; - } - - private void WatchResource() - { - if (_watcher != null) - { - if (!_watcher.Watching) - { - _watcher.Dispose(); - } - else - { - _logger.LogTrace("""Watcher for type "{type}" already running.""", typeof(TEntity)); - return; - } - } - - _logger.LogDebug("""Create watcher for entity of type "{type}".""", typeof(TEntity)); - _watcher = _client.Watch( - OnEvent, - OnError, - OnClosed, - @namespace: _settings.Namespace, - resourceVersion: _lastResourceVersion); - } - - private void StopWatching() - { - _watcher?.Dispose(); - _watcher = null; - } - - private async void OnEntityRequeue(object? sender, (string Name, string? Namespace) queued) - { - _logger.LogTrace( - """Execute requested requeued reconciliation for "{name}".""", - queued.Name); - - if (await _client.GetAsync(queued.Name, queued.Namespace) is not { } entity) - { - _logger.LogWarning( - """Requeued entity "{name}" was not found. Skip reconciliation.""", - queued.Name); - return; - } - - await ReconcileModification(entity); - } - - private async void OnError(Exception e) - { - switch (e) - { - case SerializationException when - e.InnerException is JsonException && - e.InnerException.Message.Contains("The input does not contain any JSON tokens"): - _logger.LogDebug( - """The watcher received an empty response for resource "{resource}".""", - typeof(TEntity)); - return; - - case HttpRequestException when - e.InnerException is EndOfStreamException && - e.InnerException.Message.Contains("Attempted to read past the end of the stream."): - _logger.LogDebug( - """The watcher received a known error from the watched resource "{resource}". This indicates that there are no instances of this resource.""", - typeof(TEntity)); - return; - } - - _logger.LogError(e, """There was an error while watching the resource "{resource}".""", typeof(TEntity)); - StopWatching(); - _watcherReconnectRetries++; - - var delay = TimeSpan - .FromSeconds(Math.Pow(2, Math.Clamp(_watcherReconnectRetries, 0, 5))) - .Add(TimeSpan.FromMilliseconds(new Random().Next(0, 1000))); - _logger.LogWarning( - "There were {retries} errors / retries in the watcher. Wait {seconds}s before next attempt to connect.", - _watcherReconnectRetries, - delay.TotalSeconds); - await Task.Delay(delay); - - WatchResource(); - } - - private void OnClosed() - { - _logger.LogDebug("The watcher was closed."); - if (!_stopped && _watcherReconnectRetries == 0) - { - WatchResource(); - } - } - - private async void OnEvent(WatchEventType type, TEntity entity) - { - _watcherReconnectRetries = 0; - _lastResourceVersion = entity.ResourceVersion(); - - _logger.LogTrace( - """Received watch event "{eventType}" for "{kind}/{name}", last observed resource version: {resourceVersion}.""", - type, - entity.Kind, - entity.Name(), - _lastResourceVersion); - - try - { - switch (type) - { - case WatchEventType.Added: - _entityCache.TryAdd(entity.Uid(), entity.Generation() ?? 0); - await ReconcileModification(entity); - break; - case WatchEventType.Modified: - switch (entity) - { - case { Metadata.DeletionTimestamp: null }: - _entityCache.TryGetValue(entity.Uid(), out var cachedGeneration); - - // Check if entity spec has changed through "Generation" value increment. Skip reconcile if not changed. - if (entity.Generation() <= cachedGeneration) - { - _logger.LogDebug( - """Entity "{kind}/{name}" modification did not modify generation. Skip event.""", - entity.Kind, - entity.Name()); - return; - } - - // update cached generation since generation now changed - _entityCache.TryUpdate(entity.Uid(), entity.Generation() ?? 1, cachedGeneration); - await ReconcileModification(entity); - break; - case { Metadata: { DeletionTimestamp: not null, Finalizers.Count: > 0 } }: - await ReconcileFinalizer(entity); - break; - } - - break; - case WatchEventType.Deleted: - await ReconcileDeletion(entity); - break; - default: - _logger.LogWarning( - """Received unsupported event "{eventType}" for "{kind}/{name}".""", - type, - entity.Kind, - entity.Name()); - break; - } - } - catch (Exception e) - { - _logger.LogError( - e, - "Reconciliation of {eventType} for {kind}/{name} failed.", - type, - entity.Kind, - entity.Name()); - } - } - - private async Task ReconcileModification(TEntity entity) - { - // Re-queue should requested in the controller reconcile method. Invalidate any existing queues. - _queue.RemoveIfQueued(entity); - await using var scope = _provider.CreateAsyncScope(); - var controller = scope.ServiceProvider.GetRequiredService>(); - await controller.ReconcileAsync(entity); - } - - private async Task ReconcileDeletion(TEntity entity) - { - _queue.RemoveIfQueued(entity); - _entityCache.TryRemove(entity.Uid(), out _); - await using var scope = _provider.CreateAsyncScope(); - var controller = scope.ServiceProvider.GetRequiredService>(); - await controller.DeletedAsync(entity); - } - - private async Task ReconcileFinalizer(TEntity entity) - { - _queue.RemoveIfQueued(entity); - var pendingFinalizer = entity.Finalizers(); - if (_finalizers.Value.Find(reg => - reg.EntityType == entity.GetType() && pendingFinalizer.Contains(reg.Identifier)) is not - { Identifier: var identifier, FinalizerType: var type }) - { - _logger.LogDebug( - """Entity "{kind}/{name}" is finalizing but this operator has no registered finalizers for it.""", - entity.Kind, - entity.Name()); - return; - } - - if (_provider.GetRequiredService(type) is not IEntityFinalizer finalizer) - { - _logger.LogError( - """Finalizer "{identifier}" was no IEntityFinalizer.""", - identifier); - return; - } - - await finalizer.FinalizeAsync(entity); - entity.RemoveFinalizer(identifier); - await _client.UpdateAsync(entity); - _logger.LogInformation( - """Entity "{kind}/{name}" finalized with "{finalizer}".""", - entity.Kind, - entity.Name(), - identifier); - } -} +using System.Collections.Concurrent; +using System.Net; +using System.Runtime.Serialization; +using System.Text.Json; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Finalizer; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Queue; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Watcher; + +internal class ResourceWatcher( + ILogger> logger, + IServiceProvider provider, + TimedEntityQueue requeue, + OperatorSettings settings, + IKubernetesClient client) + : IHostedService + where TEntity : IKubernetesObject +{ + private readonly ConcurrentDictionary _entityCache = new(); + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + private uint _watcherReconnectRetries; + private Task _eventWatcher = Task.CompletedTask; + + public virtual Task StartAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Starting resource watcher for {ResourceType}.", typeof(TEntity).Name); + + _eventWatcher = WatchClientEventsAsync(_cancellationTokenSource.Token); + + logger.LogInformation("Started resource watcher for {ResourceType}.", typeof(TEntity).Name); + return Task.CompletedTask; + } + + public virtual async Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Stopping resource watcher for {ResourceType}.", typeof(TEntity).Name); +#if NET8_0_OR_GREATER + await _cancellationTokenSource.CancelAsync(); +#else + _cancellationTokenSource.Cancel(); +#endif + await _eventWatcher.WaitAsync(cancellationToken); + _cancellationTokenSource.Dispose(); + logger.LogInformation("Stopped resource watcher for {ResourceType}.", typeof(TEntity).Name); + } + + private async Task WatchClientEventsAsync(CancellationToken stoppingToken) + { + try + { + while (!stoppingToken.IsCancellationRequested) + { + await foreach ((WatchEventType type, TEntity? entity) in client.WatchAsync( + settings.Namespace, + cancellationToken: stoppingToken)) + { +#pragma warning disable SA1312 + using var _ = logger.BeginScope(new +#pragma warning restore SA1312 + { + EventType = type, + + // ReSharper disable once RedundantAnonymousTypePropertyName + Kind = entity?.Kind, + Name = entity?.Name(), + ResourceVersion = entity?.ResourceVersion(), + }); + logger.LogInformation( + """Received watch event "{EventType}" for "{Kind}/{Name}", last observed resource version: {ResourceVersion}.""", + type, + entity?.Kind, + entity?.Name(), + entity?.ResourceVersion()); + try + { + await OnEventAsync(type, entity, stoppingToken); + } + catch (KubernetesException e) + { + if (e.Status.Code == (int)HttpStatusCode.Gone) + { + logger.LogDebug("Watch restarting due to 410 HTTP Gone"); + + break; + } + + LogReconciliationFailed(e); + } + catch (Exception e) + { + LogReconciliationFailed(e); + } + + void LogReconciliationFailed(Exception exception) + { + logger.LogError( + exception, + "Reconciliation of {EventType} for {Kind}/{Name} failed.", + type, + entity?.Kind, + entity.Name()); + } + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Don't throw if the cancellation was indeed requested. + } + catch (Exception e) + { + await OnWatchErrorAsync(e); + } + } + + private async Task OnEventAsync(WatchEventType type, TEntity? entity, CancellationToken cancellationToken) + { + switch (type) + { + case WatchEventType.Added: + _entityCache.TryAdd(entity.Uid(), entity.Generation() ?? 0); + await ReconcileModificationAsync(entity!, cancellationToken); + break; + case WatchEventType.Modified: + switch (entity) + { + case { Metadata.DeletionTimestamp: null }: + _entityCache.TryGetValue(entity.Uid(), out var cachedGeneration); + + // Check if entity spec has changed through "Generation" value increment. Skip reconcile if not changed. + if (entity.Generation() <= cachedGeneration) + { + logger.LogDebug( + """Entity "{kind}/{name}" modification did not modify generation. Skip event.""", + entity.Kind, + entity.Name()); + return; + } + + // update cached generation since generation now changed + _entityCache.TryUpdate(entity.Uid(), entity.Generation() ?? 1, cachedGeneration); + await ReconcileModificationAsync(entity, cancellationToken); + break; + case { Metadata: { DeletionTimestamp: not null, Finalizers.Count: > 0 } }: + await ReconcileFinalizersSequentialAsync(entity, cancellationToken); + break; + } + + break; + case WatchEventType.Deleted: + await ReconcileDeletionAsync(entity!, cancellationToken); + break; + default: + logger.LogWarning( + """Received unsupported event "{eventType}" for "{kind}/{name}".""", + type, + entity?.Kind, + entity.Name()); + break; + } + } + + private async Task OnWatchErrorAsync(Exception e) + { + switch (e) + { + case SerializationException when + e.InnerException is JsonException && + e.InnerException.Message.Contains("The input does not contain any JSON tokens"): + logger.LogDebug( + """The watcher received an empty response for resource "{resource}".""", + typeof(TEntity)); + return; + + case HttpRequestException when + e.InnerException is EndOfStreamException && + e.InnerException.Message.Contains("Attempted to read past the end of the stream."): + logger.LogDebug( + """The watcher received a known error from the watched resource "{resource}". This indicates that there are no instances of this resource.""", + typeof(TEntity)); + return; + } + + logger.LogError(e, """There was an error while watching the resource "{resource}".""", typeof(TEntity)); + _watcherReconnectRetries++; + + var delay = TimeSpan + .FromSeconds(Math.Pow(2, Math.Clamp(_watcherReconnectRetries, 0, 5))) + .Add(TimeSpan.FromMilliseconds(new Random().Next(0, 1000))); + logger.LogWarning( + "There were {retries} errors / retries in the watcher. Wait {seconds}s before next attempt to connect.", + _watcherReconnectRetries, + delay.TotalSeconds); + await Task.Delay(delay); + } + + private async Task ReconcileDeletionAsync(TEntity entity, CancellationToken cancellationToken) + { + requeue.Remove(entity); + _entityCache.TryRemove(entity.Uid(), out _); + + await using var scope = provider.CreateAsyncScope(); + var controller = scope.ServiceProvider.GetRequiredService>(); + await controller.DeletedAsync(entity, cancellationToken); + } + + private async Task ReconcileFinalizersSequentialAsync(TEntity entity, CancellationToken cancellationToken) + { + requeue.Remove(entity); + await using var scope = provider.CreateAsyncScope(); + + var identifier = entity.Finalizers().FirstOrDefault(); + if (identifier is null) + { + return; + } + + if (scope.ServiceProvider.GetKeyedService>(identifier) is not + { } finalizer) + { + logger.LogDebug( + """Entity "{kind}/{name}" is finalizing but this operator has no registered finalizers for the identifier {finalizerIdentifier}.""", + entity.Kind, + entity.Name(), + identifier); + return; + } + + await finalizer.FinalizeAsync(entity, cancellationToken); + entity.RemoveFinalizer(identifier); + await client.UpdateAsync(entity, cancellationToken); + logger.LogInformation( + """Entity "{kind}/{name}" finalized with "{finalizer}".""", + entity.Kind, + entity.Name(), + identifier); + } + + private async Task ReconcileModificationAsync(TEntity entity, CancellationToken cancellationToken) + { + // Re-queue should requested in the controller reconcile method. Invalidate any existing queues. + requeue.Remove(entity); + await using var scope = provider.CreateAsyncScope(); + var controller = scope.ServiceProvider.GetRequiredService>(); + await controller.ReconcileAsync(entity, cancellationToken); + } +} diff --git a/src/KubeOps.Transpiler/ContextCreator.cs b/src/KubeOps.Transpiler/ContextCreator.cs index 850023dd..d94f1cdb 100644 --- a/src/KubeOps.Transpiler/ContextCreator.cs +++ b/src/KubeOps.Transpiler/ContextCreator.cs @@ -1,36 +1,36 @@ -using System.Reflection; - -namespace KubeOps.Transpiler; - -/// -/// Helper to create s. -/// -public static class ContextCreator -{ - /// - /// Create a new with the given - /// and directly load an assembly into it. - /// - /// A list of paths. - /// The byte array that contains the assembly to load. - /// Optional core assembly name. - /// The configured . - public static MetadataLoadContext Create( - IEnumerable assemblyPaths, - byte[] assembly, - string? coreAssemblyName = null) - { - var mlc = Create(assemblyPaths, coreAssemblyName); - mlc.LoadFromByteArray(assembly); - return mlc; - } - - /// - /// Create a new with the given . - /// - /// A list of paths. - /// Optional core assembly name. - /// The configured . - public static MetadataLoadContext Create(IEnumerable assemblyPaths, string? coreAssemblyName = null) => - new(new PathAssemblyResolver(assemblyPaths), coreAssemblyName: coreAssemblyName); -} +using System.Reflection; + +namespace KubeOps.Transpiler; + +/// +/// Helper to create s. +/// +public static class ContextCreator +{ + /// + /// Create a new with the given + /// and directly load an assembly into it. + /// + /// A list of paths. + /// The byte array that contains the assembly to load. + /// Optional core assembly name. + /// The configured . + public static MetadataLoadContext Create( + IEnumerable assemblyPaths, + byte[] assembly, + string? coreAssemblyName = null) + { + var mlc = Create(assemblyPaths, coreAssemblyName); + mlc.LoadFromByteArray(assembly); + return mlc; + } + + /// + /// Create a new with the given . + /// + /// A list of paths. + /// Optional core assembly name. + /// The configured . + public static MetadataLoadContext Create(IEnumerable assemblyPaths, string? coreAssemblyName = null) => + new(new PathAssemblyResolver(assemblyPaths), coreAssemblyName: coreAssemblyName); +} diff --git a/src/KubeOps.Transpiler/Crds.cs b/src/KubeOps.Transpiler/Crds.cs index 8e5cf4d5..7e70998d 100644 --- a/src/KubeOps.Transpiler/Crds.cs +++ b/src/KubeOps.Transpiler/Crds.cs @@ -1,434 +1,434 @@ -using System.Collections; -using System.Collections.ObjectModel; -using System.Reflection; -using System.Text.Json.Serialization; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; -using KubeOps.Transpiler.Kubernetes; - -namespace KubeOps.Transpiler; - -/// -/// CRD transpiler for Kubernetes entities. -/// -public static class Crds -{ - private const string Integer = "integer"; - private const string Number = "number"; - private const string String = "string"; - private const string Boolean = "boolean"; - private const string Object = "object"; - private const string Array = "array"; - - private const string Int32 = "int32"; - private const string Int64 = "int64"; - private const string Float = "float"; - private const string Double = "double"; - private const string Decimal = "decimal"; - private const string DateTime = "date-time"; - - private static readonly string[] IgnoredToplevelProperties = ["metadata", "apiversion", "kind"]; - - /// - /// Transpile a single type to a CRD. - /// - /// The . - /// The type to convert. - /// The converted custom resource definition. - public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) - { - type = context.GetContextType(type); - var (meta, scope) = context.ToEntityMetadata(type); - var crd = new V1CustomResourceDefinition(new()).Initialize(); - - crd.Metadata.Name = $"{meta.PluralName}.{meta.Group}"; - crd.Spec.Group = meta.Group; - - crd.Spec.Names = - new V1CustomResourceDefinitionNames - { - Kind = meta.Kind, - ListKind = meta.ListKind, - Singular = meta.SingularName, - Plural = meta.PluralName, - }; - crd.Spec.Scope = scope; - if (type.GetCustomAttributeData()?.ConstructorArguments[0].Value is - ReadOnlyCollection shortNames) - { - crd.Spec.Names.ShortNames = shortNames.Select(a => a.Value?.ToString()).ToList(); - } - - var version = new V1CustomResourceDefinitionVersion(meta.Version, true, true); - if - (type.GetProperty("Status") != null - || type.GetProperty("status") != null) - { - version.Subresources = new V1CustomResourceSubresources(null, new object()); - } - - version.Schema = new V1CustomResourceValidation(new V1JSONSchemaProps - { - Type = Object, - Description = - type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), - Properties = type.GetProperties() - .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) - && p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) - .ToDictionary(t => t.Name, t => t.Schema), - }); - - version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch - { - { Count: > 0 } l => l, - _ => null, - }; - crd.Spec.Versions = new List { version }; - crd.Validate(); - - return crd; - } - - /// - /// Transpile a list of entities to CRDs and group them by version. - /// - /// The . - /// The types to convert. - /// The converted custom resource definitions. - public static IEnumerable Transpile( - this MetadataLoadContext context, - IEnumerable types) - => types - .Select(context.GetContextType) - .Where(type => type.Assembly != context.GetContextType().Assembly - && type.GetCustomAttributesData().Any() - && !type.GetCustomAttributesData().Any()) - .Select(type => (Props: context.Transpile(type), - IsStorage: type.GetCustomAttributesData().Any())) - .GroupBy(grp => grp.Props.Metadata.Name) - .Select( - group => - { - if (group.Count(def => def.IsStorage) > 1) - { - throw new ArgumentException("There are multiple stored versions on an entity."); - } - - var crd = group.First().Props; - crd.Spec.Versions = group - .SelectMany( - c => c.Props.Spec.Versions.Select( - v => - { - v.Served = true; - v.Storage = c.IsStorage; - return v; - })) - .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) - .ToList(); - - // when only one version exists, or when no StorageVersion attributes are found - // the first version in the list is the stored one. - if (crd.Spec.Versions.Count == 1 || !group.Any(def => def.IsStorage)) - { - crd.Spec.Versions[0].Storage = true; - } - - return crd; - }); - - private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContext context) - { - var name = prop.GetCustomAttributeData() switch - { - null => prop.Name, - { } attr => attr.GetCustomAttributeCtorArg(context, 0) ?? prop.Name, - }; - - return $"{name[..1].ToLowerInvariant()}{name[1..]}"; - } - - private static IEnumerable MapPrinterColumns( - this MetadataLoadContext context, - Type type) - { - var props = type.GetProperties().Select(p => (Prop: p, Path: string.Empty)).ToList(); - while (props.Count > 0) - { - var (prop, path) = props[0]; - props.RemoveAt(0); - - if (prop.PropertyType.IsClass) - { - props.AddRange(prop.PropertyType.GetProperties() - .Select(p => (Prop: p, Path: $"{path}.{prop.GetPropertyName(context)}"))); - } - - if (prop.GetCustomAttributeData() is not { } attr) - { - continue; - } - - var mapped = context.Map(prop); - yield return new V1CustomResourceColumnDefinition - { - Name = attr.GetCustomAttributeCtorArg(context, 1) ?? prop.GetPropertyName(context), - JsonPath = $"{path}.{prop.GetPropertyName(context)}", - Type = mapped.Type, - Description = mapped.Description, - Format = mapped.Format, - Priority = attr.GetCustomAttributeCtorArg(context, 0) switch - { - PrinterColumnPriority.StandardView => 0, - _ => 1, - }, - }; - } - - foreach (var attr in type.GetCustomAttributesData()) - { - yield return new V1CustomResourceColumnDefinition - { - Name = attr.GetCustomAttributeCtorArg(context, 1), - JsonPath = attr.GetCustomAttributeCtorArg(context, 0), - Type = attr.GetCustomAttributeCtorArg(context, 2), - Description = attr.GetCustomAttributeNamedArg(context, "Description"), - Format = attr.GetCustomAttributeNamedArg(context, "Format"), - Priority = attr.GetCustomAttributeNamedArg(context, "Priority") switch - { - PrinterColumnPriority.StandardView => 0, - _ => 1, - }, - }; - } - } - - private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyInfo prop) - { - var props = context.Map(prop.PropertyType); - - props.Description ??= prop.GetCustomAttributeData() - ?.GetCustomAttributeCtorArg(context, 0); - - props.Nullable = prop.IsNullable(); - - if (prop.GetCustomAttributeData() is { } extDocs) - { - props.ExternalDocs = new V1ExternalDocumentation( - extDocs.GetCustomAttributeCtorArg(context, 0), - extDocs.GetCustomAttributeCtorArg(context, 1)); - } - - if (prop.GetCustomAttributeData() is { } items) - { - props.MinItems = items.GetCustomAttributeCtorArg(context, 0); - props.MaxItems = items.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is { } length) - { - props.MinLength = length.GetCustomAttributeCtorArg(context, 0); - props.MaxLength = length.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is { } multi) - { - props.MultipleOf = multi.GetCustomAttributeCtorArg(context, 0); - } - - if (prop.GetCustomAttributeData() is { } pattern) - { - props.Pattern = pattern.GetCustomAttributeCtorArg(context, 0); - } - - if (prop.GetCustomAttributeData() is { } rangeMax) - { - props.Maximum = rangeMax.GetCustomAttributeCtorArg(context, 0); - props.ExclusiveMaximum = - rangeMax.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is { } rangeMin) - { - props.Minimum = rangeMin.GetCustomAttributeCtorArg(context, 0); - props.ExclusiveMinimum = - rangeMin.GetCustomAttributeCtorArg(context, 1); - } - - if (prop.GetCustomAttributeData() is not null) - { - props.XKubernetesPreserveUnknownFields = true; - } - - if (prop.GetCustomAttributeData() is not null) - { - props.XKubernetesEmbeddedResource = true; - props.XKubernetesPreserveUnknownFields = true; - props.Type = Object; - props.Properties = null; - } - - return props; - } - - private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type) - { - if (type.FullName == "System.String") - { - return new V1JSONSchemaProps { Type = String, Nullable = false }; - } - - if (type.Name == typeof(Nullable<>).Name && type.GenericTypeArguments.Length == 1) - { - var props = context.Map(type.GenericTypeArguments[0]); - props.Nullable = true; - return props; - } - - var interfaces = (type.IsInterface - ? type.GetInterfaces().Append(type) - : type.GetInterfaces()).ToList(); - - var interfaceNames = interfaces.Select(i => - i.IsGenericType - ? i.GetGenericTypeDefinition().FullName - : i.FullName).ToList(); - - if (interfaceNames.Contains(typeof(IDictionary<,>).FullName)) - { - var dictionaryImpl = interfaces - .First(i => i.IsGenericType - && i.GetGenericTypeDefinition().FullName == typeof(IDictionary<,>).FullName); - - var additionalProperties = context.Map(dictionaryImpl.GenericTypeArguments[1]); - return new V1JSONSchemaProps - { - Type = Object, - AdditionalProperties = additionalProperties, - Nullable = false, - }; - } - - if (interfaceNames.Contains(typeof(IDictionary).FullName)) - { - return new V1JSONSchemaProps { Type = Object, XKubernetesPreserveUnknownFields = true, Nullable = false, }; - } - - if (interfaceNames.Contains(typeof(IEnumerable<>).FullName)) - { - return context.MapEnumerationType(type, interfaces); - } - - return type.BaseType?.FullName switch - { - "System.Object" => context.MapObjectType(type), - "System.ValueType" => context.MapValueType(type), - "System.Enum" => new V1JSONSchemaProps - { - Type = String, - EnumProperty = Enum.GetNames(type).Cast().ToList(), - }, - _ => throw InvalidType(type), - }; - } - - private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context, Type type) - { - switch (type.FullName) - { - case "k8s.Models.V1ObjectMeta": - return new V1JSONSchemaProps { Type = Object, Nullable = false }; - case "k8s.Models.IntstrIntOrString": - return new V1JSONSchemaProps { XKubernetesIntOrString = true, Nullable = false }; - default: - if (context.GetContextType().IsAssignableFrom(type) && - type is { IsAbstract: false, IsInterface: false } && - type.Assembly == context.GetContextType().Assembly) - { - return new V1JSONSchemaProps - { - Type = Object, - Properties = null, - XKubernetesPreserveUnknownFields = true, - XKubernetesEmbeddedResource = true, - Nullable = false, - }; - } - - return new V1JSONSchemaProps - { - Type = Object, - Description = - type.GetCustomAttributeData() - ?.GetCustomAttributeCtorArg(context, 0), - Properties = type - .GetProperties() - .Where(p => p.GetCustomAttributeData() == null) - .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) - .ToDictionary(t => t.Name, t => t.Schema), - Required = type.GetProperties() - .Where(p => p.GetCustomAttributeData() != null - && p.GetCustomAttributeData() == null) - .Select(p => p.GetPropertyName(context)) - .ToList() switch - { - { Count: > 0 } p => p, - _ => null, - }, - }; - } - } - - private static V1JSONSchemaProps MapEnumerationType( - this MetadataLoadContext context, - Type type, - IEnumerable interfaces) - { - Type? enumerableType = interfaces - .FirstOrDefault(i => i.IsGenericType - && i.GetGenericTypeDefinition().FullName == typeof(IEnumerable<>).FullName - && i.GenericTypeArguments.Length == 1); - - if (enumerableType == null) - { - throw InvalidType(type); - } - - Type listType = enumerableType.GenericTypeArguments[0]; - if (listType.IsGenericType && listType.GetGenericTypeDefinition().FullName == typeof(KeyValuePair<,>).FullName) - { - var additionalProperties = context.Map(listType.GenericTypeArguments[1]); - return new V1JSONSchemaProps - { - Type = Object, - AdditionalProperties = additionalProperties, - Nullable = false, - }; - } - - var items = context.Map(listType); - return new V1JSONSchemaProps { Type = Array, Items = items, Nullable = false }; - } - - private static V1JSONSchemaProps MapValueType(this MetadataLoadContext _, Type type) => - type.FullName switch - { - "System.Int32" => new V1JSONSchemaProps { Type = Integer, Format = Int32, Nullable = false }, - "System.Int64" => new V1JSONSchemaProps { Type = Integer, Format = Int64, Nullable = false }, - "System.Single" => new V1JSONSchemaProps { Type = Number, Format = Float, Nullable = false }, - "System.Double" => new V1JSONSchemaProps { Type = Number, Format = Double, Nullable = false }, - "System.Decimal" => new V1JSONSchemaProps { Type = Number, Format = Decimal, Nullable = false }, - "System.Boolean" => new V1JSONSchemaProps { Type = Boolean, Nullable = false }, - "System.DateTime" => new V1JSONSchemaProps { Type = String, Format = DateTime, Nullable = false }, - "System.DateTimeOffset" => new V1JSONSchemaProps { Type = String, Format = DateTime, Nullable = false }, - _ => throw InvalidType(type), - }; - - private static ArgumentException InvalidType(Type type) => - new($"The given type {type.FullName} is not a valid Kubernetes entity."); -} +using System.Collections; +using System.Collections.ObjectModel; +using System.Reflection; +using System.Text.Json.Serialization; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; +using KubeOps.Transpiler.Kubernetes; + +namespace KubeOps.Transpiler; + +/// +/// CRD transpiler for Kubernetes entities. +/// +public static class Crds +{ + private const string Integer = "integer"; + private const string Number = "number"; + private const string String = "string"; + private const string Boolean = "boolean"; + private const string Object = "object"; + private const string Array = "array"; + + private const string Int32 = "int32"; + private const string Int64 = "int64"; + private const string Float = "float"; + private const string Double = "double"; + private const string Decimal = "decimal"; + private const string DateTime = "date-time"; + + private static readonly string[] IgnoredToplevelProperties = ["metadata", "apiversion", "kind"]; + + /// + /// Transpile a single type to a CRD. + /// + /// The . + /// The type to convert. + /// The converted custom resource definition. + public static V1CustomResourceDefinition Transpile(this MetadataLoadContext context, Type type) + { + type = context.GetContextType(type); + var (meta, scope) = context.ToEntityMetadata(type); + var crd = new V1CustomResourceDefinition(new()).Initialize(); + + crd.Metadata.Name = $"{meta.PluralName}.{meta.Group}"; + crd.Spec.Group = meta.Group; + + crd.Spec.Names = + new V1CustomResourceDefinitionNames + { + Kind = meta.Kind, + ListKind = meta.ListKind, + Singular = meta.SingularName, + Plural = meta.PluralName, + }; + crd.Spec.Scope = scope; + if (type.GetCustomAttributeData()?.ConstructorArguments[0].Value is + ReadOnlyCollection shortNames) + { + crd.Spec.Names.ShortNames = shortNames.Select(a => a.Value?.ToString()).ToList(); + } + + var version = new V1CustomResourceDefinitionVersion(meta.Version, true, true); + if + (type.GetProperty("Status") != null + || type.GetProperty("status") != null) + { + version.Subresources = new V1CustomResourceSubresources(null, new object()); + } + + version.Schema = new V1CustomResourceValidation(new V1JSONSchemaProps + { + Type = Object, + Description = + type.GetCustomAttributeData()?.GetCustomAttributeCtorArg(context, 0), + Properties = type.GetProperties() + .Where(p => !IgnoredToplevelProperties.Contains(p.Name.ToLowerInvariant()) + && p.GetCustomAttributeData() == null) + .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) + .ToDictionary(t => t.Name, t => t.Schema), + }); + + version.AdditionalPrinterColumns = context.MapPrinterColumns(type).ToList() switch + { + { Count: > 0 } l => l, + _ => null, + }; + crd.Spec.Versions = new List { version }; + crd.Validate(); + + return crd; + } + + /// + /// Transpile a list of entities to CRDs and group them by version. + /// + /// The . + /// The types to convert. + /// The converted custom resource definitions. + public static IEnumerable Transpile( + this MetadataLoadContext context, + IEnumerable types) + => types + .Select(context.GetContextType) + .Where(type => type.Assembly != context.GetContextType().Assembly + && type.GetCustomAttributesData().Any() + && !type.GetCustomAttributesData().Any()) + .Select(type => (Props: context.Transpile(type), + IsStorage: type.GetCustomAttributesData().Any())) + .GroupBy(grp => grp.Props.Metadata.Name) + .Select( + group => + { + if (group.Count(def => def.IsStorage) > 1) + { + throw new ArgumentException("There are multiple stored versions on an entity."); + } + + var crd = group.First().Props; + crd.Spec.Versions = group + .SelectMany( + c => c.Props.Spec.Versions.Select( + v => + { + v.Served = true; + v.Storage = c.IsStorage; + return v; + })) + .OrderByDescending(v => v.Name, new KubernetesVersionComparer()) + .ToList(); + + // when only one version exists, or when no StorageVersion attributes are found + // the first version in the list is the stored one. + if (crd.Spec.Versions.Count == 1 || !group.Any(def => def.IsStorage)) + { + crd.Spec.Versions[0].Storage = true; + } + + return crd; + }); + + private static string GetPropertyName(this PropertyInfo prop, MetadataLoadContext context) + { + var name = prop.GetCustomAttributeData() switch + { + null => prop.Name, + { } attr => attr.GetCustomAttributeCtorArg(context, 0) ?? prop.Name, + }; + + return $"{name[..1].ToLowerInvariant()}{name[1..]}"; + } + + private static IEnumerable MapPrinterColumns( + this MetadataLoadContext context, + Type type) + { + var props = type.GetProperties().Select(p => (Prop: p, Path: string.Empty)).ToList(); + while (props.Count > 0) + { + var (prop, path) = props[0]; + props.RemoveAt(0); + + if (prop.PropertyType.IsClass) + { + props.AddRange(prop.PropertyType.GetProperties() + .Select(p => (Prop: p, Path: $"{path}.{prop.GetPropertyName(context)}"))); + } + + if (prop.GetCustomAttributeData() is not { } attr) + { + continue; + } + + var mapped = context.Map(prop); + yield return new V1CustomResourceColumnDefinition + { + Name = attr.GetCustomAttributeCtorArg(context, 1) ?? prop.GetPropertyName(context), + JsonPath = $"{path}.{prop.GetPropertyName(context)}", + Type = mapped.Type, + Description = mapped.Description, + Format = mapped.Format, + Priority = attr.GetCustomAttributeCtorArg(context, 0) switch + { + PrinterColumnPriority.StandardView => 0, + _ => 1, + }, + }; + } + + foreach (var attr in type.GetCustomAttributesData()) + { + yield return new V1CustomResourceColumnDefinition + { + Name = attr.GetCustomAttributeCtorArg(context, 1), + JsonPath = attr.GetCustomAttributeCtorArg(context, 0), + Type = attr.GetCustomAttributeCtorArg(context, 2), + Description = attr.GetCustomAttributeNamedArg(context, "Description"), + Format = attr.GetCustomAttributeNamedArg(context, "Format"), + Priority = attr.GetCustomAttributeNamedArg(context, "Priority") switch + { + PrinterColumnPriority.StandardView => 0, + _ => 1, + }, + }; + } + } + + private static V1JSONSchemaProps Map(this MetadataLoadContext context, PropertyInfo prop) + { + var props = context.Map(prop.PropertyType); + + props.Description ??= prop.GetCustomAttributeData() + ?.GetCustomAttributeCtorArg(context, 0); + + props.Nullable = prop.IsNullable(); + + if (prop.GetCustomAttributeData() is { } extDocs) + { + props.ExternalDocs = new V1ExternalDocumentation( + extDocs.GetCustomAttributeCtorArg(context, 0), + extDocs.GetCustomAttributeCtorArg(context, 1)); + } + + if (prop.GetCustomAttributeData() is { } items) + { + props.MinItems = items.GetCustomAttributeCtorArg(context, 0); + props.MaxItems = items.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is { } length) + { + props.MinLength = length.GetCustomAttributeCtorArg(context, 0); + props.MaxLength = length.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is { } multi) + { + props.MultipleOf = multi.GetCustomAttributeCtorArg(context, 0); + } + + if (prop.GetCustomAttributeData() is { } pattern) + { + props.Pattern = pattern.GetCustomAttributeCtorArg(context, 0); + } + + if (prop.GetCustomAttributeData() is { } rangeMax) + { + props.Maximum = rangeMax.GetCustomAttributeCtorArg(context, 0); + props.ExclusiveMaximum = + rangeMax.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is { } rangeMin) + { + props.Minimum = rangeMin.GetCustomAttributeCtorArg(context, 0); + props.ExclusiveMinimum = + rangeMin.GetCustomAttributeCtorArg(context, 1); + } + + if (prop.GetCustomAttributeData() is not null) + { + props.XKubernetesPreserveUnknownFields = true; + } + + if (prop.GetCustomAttributeData() is not null) + { + props.XKubernetesEmbeddedResource = true; + props.XKubernetesPreserveUnknownFields = true; + props.Type = Object; + props.Properties = null; + } + + return props; + } + + private static V1JSONSchemaProps Map(this MetadataLoadContext context, Type type) + { + if (type.FullName == "System.String") + { + return new V1JSONSchemaProps { Type = String, Nullable = false }; + } + + if (type.Name == typeof(Nullable<>).Name && type.GenericTypeArguments.Length == 1) + { + var props = context.Map(type.GenericTypeArguments[0]); + props.Nullable = true; + return props; + } + + var interfaces = (type.IsInterface + ? type.GetInterfaces().Append(type) + : type.GetInterfaces()).ToList(); + + var interfaceNames = interfaces.Select(i => + i.IsGenericType + ? i.GetGenericTypeDefinition().FullName + : i.FullName).ToList(); + + if (interfaceNames.Contains(typeof(IDictionary<,>).FullName)) + { + var dictionaryImpl = interfaces + .First(i => i.IsGenericType + && i.GetGenericTypeDefinition().FullName == typeof(IDictionary<,>).FullName); + + var additionalProperties = context.Map(dictionaryImpl.GenericTypeArguments[1]); + return new V1JSONSchemaProps + { + Type = Object, + AdditionalProperties = additionalProperties, + Nullable = false, + }; + } + + if (interfaceNames.Contains(typeof(IDictionary).FullName)) + { + return new V1JSONSchemaProps { Type = Object, XKubernetesPreserveUnknownFields = true, Nullable = false, }; + } + + if (interfaceNames.Contains(typeof(IEnumerable<>).FullName)) + { + return context.MapEnumerationType(type, interfaces); + } + + return type.BaseType?.FullName switch + { + "System.Object" => context.MapObjectType(type), + "System.ValueType" => context.MapValueType(type), + "System.Enum" => new V1JSONSchemaProps + { + Type = String, + EnumProperty = Enum.GetNames(type).Cast().ToList(), + }, + _ => throw InvalidType(type), + }; + } + + private static V1JSONSchemaProps MapObjectType(this MetadataLoadContext context, Type type) + { + switch (type.FullName) + { + case "k8s.Models.V1ObjectMeta": + return new V1JSONSchemaProps { Type = Object, Nullable = false }; + case "k8s.Models.IntstrIntOrString": + return new V1JSONSchemaProps { XKubernetesIntOrString = true, Nullable = false }; + default: + if (context.GetContextType().IsAssignableFrom(type) && + type is { IsAbstract: false, IsInterface: false } && + type.Assembly == context.GetContextType().Assembly) + { + return new V1JSONSchemaProps + { + Type = Object, + Properties = null, + XKubernetesPreserveUnknownFields = true, + XKubernetesEmbeddedResource = true, + Nullable = false, + }; + } + + return new V1JSONSchemaProps + { + Type = Object, + Description = + type.GetCustomAttributeData() + ?.GetCustomAttributeCtorArg(context, 0), + Properties = type + .GetProperties() + .Where(p => p.GetCustomAttributeData() == null) + .Select(p => (Name: p.GetPropertyName(context), Schema: context.Map(p))) + .ToDictionary(t => t.Name, t => t.Schema), + Required = type.GetProperties() + .Where(p => p.GetCustomAttributeData() != null + && p.GetCustomAttributeData() == null) + .Select(p => p.GetPropertyName(context)) + .ToList() switch + { + { Count: > 0 } p => p, + _ => null, + }, + }; + } + } + + private static V1JSONSchemaProps MapEnumerationType( + this MetadataLoadContext context, + Type type, + IEnumerable interfaces) + { + Type? enumerableType = interfaces + .FirstOrDefault(i => i.IsGenericType + && i.GetGenericTypeDefinition().FullName == typeof(IEnumerable<>).FullName + && i.GenericTypeArguments.Length == 1); + + if (enumerableType == null) + { + throw InvalidType(type); + } + + Type listType = enumerableType.GenericTypeArguments[0]; + if (listType.IsGenericType && listType.GetGenericTypeDefinition().FullName == typeof(KeyValuePair<,>).FullName) + { + var additionalProperties = context.Map(listType.GenericTypeArguments[1]); + return new V1JSONSchemaProps + { + Type = Object, + AdditionalProperties = additionalProperties, + Nullable = false, + }; + } + + var items = context.Map(listType); + return new V1JSONSchemaProps { Type = Array, Items = items, Nullable = false }; + } + + private static V1JSONSchemaProps MapValueType(this MetadataLoadContext _, Type type) => + type.FullName switch + { + "System.Int32" => new V1JSONSchemaProps { Type = Integer, Format = Int32, Nullable = false }, + "System.Int64" => new V1JSONSchemaProps { Type = Integer, Format = Int64, Nullable = false }, + "System.Single" => new V1JSONSchemaProps { Type = Number, Format = Float, Nullable = false }, + "System.Double" => new V1JSONSchemaProps { Type = Number, Format = Double, Nullable = false }, + "System.Decimal" => new V1JSONSchemaProps { Type = Number, Format = Decimal, Nullable = false }, + "System.Boolean" => new V1JSONSchemaProps { Type = Boolean, Nullable = false }, + "System.DateTime" => new V1JSONSchemaProps { Type = String, Format = DateTime, Nullable = false }, + "System.DateTimeOffset" => new V1JSONSchemaProps { Type = String, Format = DateTime, Nullable = false }, + _ => throw InvalidType(type), + }; + + private static ArgumentException InvalidType(Type type) => + new($"The given type {type.FullName} is not a valid Kubernetes entity."); +} diff --git a/src/KubeOps.Transpiler/Entities.cs b/src/KubeOps.Transpiler/Entities.cs index 86d6089b..b4590492 100644 --- a/src/KubeOps.Transpiler/Entities.cs +++ b/src/KubeOps.Transpiler/Entities.cs @@ -1,106 +1,106 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; - -namespace KubeOps.Transpiler; - -/// -/// Transpiler for Kubernetes entities to create entity metadata. -/// -public static class Entities -{ - /// - /// Create a metadata / scope tuple out of a given entity type via externally loaded assembly. - /// - /// The context that loaded the types. - /// The type to convert. - /// A tuple that contains and a scope. - /// Thrown when the type contains no . - public static (EntityMetadata Metadata, string Scope) ToEntityMetadata( - this MetadataLoadContext context, - Type entityType) - => (context.GetContextType(entityType).GetCustomAttributeData(), - context.GetContextType(entityType).GetCustomAttributeData()) switch - { - (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), - ({ } attr, var scope) => (new( - Defaulted( - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Kind)), - entityType.Name), - Defaulted( - attr.GetCustomAttributeNamedArg( - context, - nameof(KubernetesEntityAttribute.ApiVersion)), - "v1"), - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Group)), - attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.PluralName))), - scope switch - { - null => Enum.GetName(EntityScope.Namespaced) ?? "Namespaced", - _ => Enum.GetName( - scope.GetCustomAttributeCtorArg(context, 0)) ?? "Namespaced", - }), - }; - - /// - /// Create a metadata / scope tuple out of a given entity type via reflection in the same loaded assembly. - /// - /// The type to convert. - /// A tuple that contains and a scope. - /// Thrown when the type contains no . - public static (EntityMetadata Metadata, string Scope) ToEntityMetadata(Type entityType) - => (entityType.GetCustomAttributeData(), - entityType.GetCustomAttributeData()) switch - { - (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), - ({ } attr, var scope) => (new( - Defaulted( - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Kind)), - entityType.Name), - Defaulted( - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.ApiVersion)), - "v1"), - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Group)), - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.PluralName))), - scope switch - { - null => Enum.GetName(EntityScope.Namespaced) ?? "Namespaced", - _ => Enum.GetName( - scope.GetCustomAttributeCtorArg(0)) ?? "Namespaced", - }), - }; - - /// - /// Create a metadata / scope tuple out of a given entity type via reflection in the same loaded assembly. - /// - /// The type to convert. - /// A tuple that contains and a scope. - /// Thrown when the type contains no . - public static (EntityMetadata Metadata, string Scope) ToEntityMetadata() - => (typeof(TEntity).GetCustomAttributeData(), - typeof(TEntity).GetCustomAttributeData()) switch - { - (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), - ({ } attr, var scope) => (new( - Defaulted( - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Kind)), - typeof(TEntity).Name), - Defaulted( - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.ApiVersion)), - "v1"), - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Group)), - attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.PluralName))), - scope switch - { - null => Enum.GetName(EntityScope.Namespaced) ?? "Namespaced", - _ => Enum.GetName( - scope.GetCustomAttributeCtorArg(0)) ?? "Namespaced", - }), - }; - - private static string Defaulted(string? value, string defaultValue) => - string.IsNullOrWhiteSpace(value) ? defaultValue : value; -} +using System.Reflection; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler; + +/// +/// Transpiler for Kubernetes entities to create entity metadata. +/// +public static class Entities +{ + /// + /// Create a metadata / scope tuple out of a given entity type via externally loaded assembly. + /// + /// The context that loaded the types. + /// The type to convert. + /// A tuple that contains and a scope. + /// Thrown when the type contains no . + public static (EntityMetadata Metadata, string Scope) ToEntityMetadata( + this MetadataLoadContext context, + Type entityType) + => (context.GetContextType(entityType).GetCustomAttributeData(), + context.GetContextType(entityType).GetCustomAttributeData()) switch + { + (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), + ({ } attr, var scope) => (new( + Defaulted( + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Kind)), + entityType.Name), + Defaulted( + attr.GetCustomAttributeNamedArg( + context, + nameof(KubernetesEntityAttribute.ApiVersion)), + "v1"), + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.Group)), + attr.GetCustomAttributeNamedArg(context, nameof(KubernetesEntityAttribute.PluralName))), + scope switch + { + null => Enum.GetName(EntityScope.Namespaced) ?? "Namespaced", + _ => Enum.GetName( + scope.GetCustomAttributeCtorArg(context, 0)) ?? "Namespaced", + }), + }; + + /// + /// Create a metadata / scope tuple out of a given entity type via reflection in the same loaded assembly. + /// + /// The type to convert. + /// A tuple that contains and a scope. + /// Thrown when the type contains no . + public static (EntityMetadata Metadata, string Scope) ToEntityMetadata(Type entityType) + => (entityType.GetCustomAttributeData(), + entityType.GetCustomAttributeData()) switch + { + (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), + ({ } attr, var scope) => (new( + Defaulted( + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Kind)), + entityType.Name), + Defaulted( + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.ApiVersion)), + "v1"), + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Group)), + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.PluralName))), + scope switch + { + null => Enum.GetName(EntityScope.Namespaced) ?? "Namespaced", + _ => Enum.GetName( + scope.GetCustomAttributeCtorArg(0)) ?? "Namespaced", + }), + }; + + /// + /// Create a metadata / scope tuple out of a given entity type via reflection in the same loaded assembly. + /// + /// The type to convert. + /// A tuple that contains and a scope. + /// Thrown when the type contains no . + public static (EntityMetadata Metadata, string Scope) ToEntityMetadata() + => (typeof(TEntity).GetCustomAttributeData(), + typeof(TEntity).GetCustomAttributeData()) switch + { + (null, _) => throw new ArgumentException("The given type is not a valid Kubernetes entity."), + ({ } attr, var scope) => (new( + Defaulted( + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Kind)), + typeof(TEntity).Name), + Defaulted( + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.ApiVersion)), + "v1"), + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.Group)), + attr.GetCustomAttributeNamedArg(nameof(KubernetesEntityAttribute.PluralName))), + scope switch + { + null => Enum.GetName(EntityScope.Namespaced) ?? "Namespaced", + _ => Enum.GetName( + scope.GetCustomAttributeCtorArg(0)) ?? "Namespaced", + }), + }; + + private static string Defaulted(string? value, string defaultValue) => + string.IsNullOrWhiteSpace(value) ? defaultValue : value; +} diff --git a/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs b/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs index 5246e01a..59a66e30 100644 --- a/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs +++ b/src/KubeOps.Transpiler/Kubernetes/KubernetesVersionComparer.cs @@ -1,73 +1,73 @@ -using System.Text.RegularExpressions; - -namespace KubeOps.Transpiler.Kubernetes; - -/// -/// Comparer for Kubernetes Versions. Uses the version priority according to -/// -/// the Kubernetes documentation -/// . -/// -public sealed partial class KubernetesVersionComparer : IComparer -{ -#if !NET7_0_OR_GREATER - private static readonly Regex KubernetesVersionRegex = - new("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled); -#endif - - private enum Stream - { - Alpha = 1, - Beta = 2, - Final = 3, - } - - public int Compare(string? x, string? y) - { - if (x == null || y == null) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - -#if NET7_0_OR_GREATER - var matchX = KubernetesVersionRegex().Match(x); -#else - var matchX = KubernetesVersionRegex.Match(x); -#endif - if (!matchX.Success) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - -#if NET7_0_OR_GREATER - var matchY = KubernetesVersionRegex().Match(y); -#else - var matchY = KubernetesVersionRegex.Match(y); -#endif - if (!matchY.Success) - { - return StringComparer.CurrentCulture.Compare(x, y); - } - - var versionX = ExtractVersion(matchX); - var versionY = ExtractVersion(matchY); - return versionX.CompareTo(versionY); - } - -#if NET7_0_OR_GREATER - [GeneratedRegex("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled)] - private static partial Regex KubernetesVersionRegex(); -#endif - - private Version ExtractVersion(Match match) - { - var major = int.Parse(match.Groups["major"].Value); - if (!Enum.TryParse(match.Groups["stream"].Value, true, out var stream)) - { - stream = Stream.Final; - } - - _ = int.TryParse(match.Groups["minor"].Value, out var minor); - return new Version(major, (int)stream, minor); - } -} +using System.Text.RegularExpressions; + +namespace KubeOps.Transpiler.Kubernetes; + +/// +/// Comparer for Kubernetes Versions. Uses the version priority according to +/// +/// the Kubernetes documentation +/// . +/// +public sealed partial class KubernetesVersionComparer : IComparer +{ +#if !NET7_0_OR_GREATER + private static readonly Regex KubernetesVersionRegex = + new("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled); +#endif + + private enum Stream + { + Alpha = 1, + Beta = 2, + Final = 3, + } + + public int Compare(string? x, string? y) + { + if (x == null || y == null) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + +#if NET7_0_OR_GREATER + var matchX = KubernetesVersionRegex().Match(x); +#else + var matchX = KubernetesVersionRegex.Match(x); +#endif + if (!matchX.Success) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + +#if NET7_0_OR_GREATER + var matchY = KubernetesVersionRegex().Match(y); +#else + var matchY = KubernetesVersionRegex.Match(y); +#endif + if (!matchY.Success) + { + return StringComparer.CurrentCulture.Compare(x, y); + } + + var versionX = ExtractVersion(matchX); + var versionY = ExtractVersion(matchY); + return versionX.CompareTo(versionY); + } + +#if NET7_0_OR_GREATER + [GeneratedRegex("^v(?[0-9]+)((?alpha|beta)(?[0-9]+))?$", RegexOptions.Compiled)] + private static partial Regex KubernetesVersionRegex(); +#endif + + private Version ExtractVersion(Match match) + { + var major = int.Parse(match.Groups["major"].Value); + if (!Enum.TryParse(match.Groups["stream"].Value, true, out var stream)) + { + stream = Stream.Final; + } + + _ = int.TryParse(match.Groups["minor"].Value, out var minor); + return new Version(major, (int)stream, minor); + } +} diff --git a/src/KubeOps.Transpiler/Rbac.cs b/src/KubeOps.Transpiler/Rbac.cs index cbbfcdf6..ed0047b9 100644 --- a/src/KubeOps.Transpiler/Rbac.cs +++ b/src/KubeOps.Transpiler/Rbac.cs @@ -1,92 +1,92 @@ -using System.Reflection; - -using k8s.Models; - -using KubeOps.Abstractions.Rbac; - -namespace KubeOps.Transpiler; - -/// -/// Transpiler for Kubernetes RBAC attributes to create s. -/// -public static class Rbac -{ - /// - /// Convert a list of s to a list of s. - /// The rules are grouped by entity type and verbs. - /// - /// The that was used to load the attributes. - /// List of s. - /// A converted, grouped list of s. - public static IEnumerable Transpile( - this MetadataLoadContext context, - IEnumerable attributes) - { - var list = attributes.ToList(); - - var generic = list - .Where(a => a.AttributeType == context.GetContextType()) - .Select(a => new V1PolicyRule - { - ApiGroups = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Groups)), - Resources = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Resources)), - NonResourceURLs = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Urls)), - Verbs = ConvertToStrings( - a.GetCustomAttributeNamedArg(context, nameof(GenericRbacAttribute.Verbs))), - }); - - var entities = list - .Where(a => a.AttributeType == context.GetContextType()) - .SelectMany(attribute => - attribute.GetCustomAttributeCtorArrayArg(0).Select(type => - (EntityType: type, - Verbs: attribute.GetCustomAttributeNamedArg( - context, - nameof(GenericRbacAttribute.Verbs))))) - .GroupBy(e => e.EntityType) - .Select( - group => ( - Crd: context.ToEntityMetadata(group.Key), - Verbs: group.Aggregate(RbacVerb.None, (accumulator, element) => accumulator | element.Verbs))) - .GroupBy(group => (group.Crd.Metadata.Group, group.Verbs)) - .Select( - group => new V1PolicyRule - { - ApiGroups = [group.Key.Group], - Resources = group.Select(crd => crd.Crd.Metadata.PluralName).Distinct().ToList(), - Verbs = ConvertToStrings(group.Key.Verbs), - }); - - var entityStatus = list - .Where(a => a.AttributeType == context.GetContextType()) - .SelectMany(attribute => - attribute.GetCustomAttributeCtorArrayArg(0).Select(type => - (EntityType: type, - Verbs: attribute.GetCustomAttributeNamedArg( - context, - nameof(GenericRbacAttribute.Verbs))))) - .Where(e => e.EntityType.GetProperty("Status") != null) - .GroupBy(e => e.EntityType) - .Select(group => context.ToEntityMetadata(group.Key)) - .Select( - crd => new V1PolicyRule - { - ApiGroups = [crd.Metadata.Group], - Resources = [$"{crd.Metadata.PluralName}/status"], - Verbs = ConvertToStrings(RbacVerb.Get | RbacVerb.Patch | RbacVerb.Update), - }); - - return generic.Concat(entities).Concat(entityStatus); - } - - private static string[] ConvertToStrings(RbacVerb verbs) => verbs switch - { - RbacVerb.None => Array.Empty(), - _ when verbs.HasFlag(RbacVerb.All) => ["*"], - _ => - Enum.GetValues() - .Where(v => verbs.HasFlag(v) && v != RbacVerb.All && v != RbacVerb.None) - .Select(v => v.ToString().ToLowerInvariant()) - .ToArray(), - }; -} +using System.Reflection; + +using k8s.Models; + +using KubeOps.Abstractions.Rbac; + +namespace KubeOps.Transpiler; + +/// +/// Transpiler for Kubernetes RBAC attributes to create s. +/// +public static class Rbac +{ + /// + /// Convert a list of s to a list of s. + /// The rules are grouped by entity type and verbs. + /// + /// The that was used to load the attributes. + /// List of s. + /// A converted, grouped list of s. + public static IEnumerable Transpile( + this MetadataLoadContext context, + IEnumerable attributes) + { + var list = attributes.ToList(); + + var generic = list + .Where(a => a.AttributeType == context.GetContextType()) + .Select(a => new V1PolicyRule + { + ApiGroups = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Groups)), + Resources = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Resources)), + NonResourceURLs = a.GetCustomAttributeNamedArrayArg(nameof(GenericRbacAttribute.Urls)), + Verbs = ConvertToStrings( + a.GetCustomAttributeNamedArg(context, nameof(GenericRbacAttribute.Verbs))), + }); + + var entities = list + .Where(a => a.AttributeType == context.GetContextType()) + .SelectMany(attribute => + attribute.GetCustomAttributeCtorArrayArg(0).Select(type => + (EntityType: type, + Verbs: attribute.GetCustomAttributeNamedArg( + context, + nameof(GenericRbacAttribute.Verbs))))) + .GroupBy(e => e.EntityType) + .Select( + group => ( + Crd: context.ToEntityMetadata(group.Key), + Verbs: group.Aggregate(RbacVerb.None, (accumulator, element) => accumulator | element.Verbs))) + .GroupBy(group => (group.Crd.Metadata.Group, group.Verbs)) + .Select( + group => new V1PolicyRule + { + ApiGroups = [group.Key.Group], + Resources = group.Select(crd => crd.Crd.Metadata.PluralName).Distinct().ToList(), + Verbs = ConvertToStrings(group.Key.Verbs), + }); + + var entityStatus = list + .Where(a => a.AttributeType == context.GetContextType()) + .SelectMany(attribute => + attribute.GetCustomAttributeCtorArrayArg(0).Select(type => + (EntityType: type, + Verbs: attribute.GetCustomAttributeNamedArg( + context, + nameof(GenericRbacAttribute.Verbs))))) + .Where(e => e.EntityType.GetProperty("Status") != null) + .GroupBy(e => e.EntityType) + .Select(group => context.ToEntityMetadata(group.Key)) + .Select( + crd => new V1PolicyRule + { + ApiGroups = [crd.Metadata.Group], + Resources = [$"{crd.Metadata.PluralName}/status"], + Verbs = ConvertToStrings(RbacVerb.Get | RbacVerb.Patch | RbacVerb.Update), + }); + + return generic.Concat(entities).Concat(entityStatus); + } + + private static string[] ConvertToStrings(RbacVerb verbs) => verbs switch + { + RbacVerb.None => Array.Empty(), + _ when verbs.HasFlag(RbacVerb.All) => ["*"], + _ => + Enum.GetValues() + .Where(v => verbs.HasFlag(v) && v != RbacVerb.All && v != RbacVerb.None) + .Select(v => v.ToString().ToLowerInvariant()) + .ToArray(), + }; +} diff --git a/src/KubeOps.Transpiler/Utilities.cs b/src/KubeOps.Transpiler/Utilities.cs index 4b95bd4c..67c97ee2 100644 --- a/src/KubeOps.Transpiler/Utilities.cs +++ b/src/KubeOps.Transpiler/Utilities.cs @@ -1,195 +1,195 @@ -using System.Collections.ObjectModel; -using System.Reflection; - -namespace KubeOps.Transpiler; - -/// -/// Utilities for loading attributes and information. -/// -public static class Utilities -{ - /// - /// Load a custom attribute from a read-only-reflected type. - /// - /// The type. - /// The type of the attribute to load. - /// The custom attribute data if an attribute is found. - public static CustomAttributeData? GetCustomAttributeData(this Type type) - where TAttribute : Attribute - => CustomAttributeData - .GetCustomAttributes(type) - .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); - - /// - /// Load a custom attribute from a read-only-reflected property. - /// - /// The property. - /// The type of the attribute to load. - /// The custom attribute data if an attribute is found. - public static CustomAttributeData? GetCustomAttributeData(this PropertyInfo prop) - where TAttribute : Attribute - => CustomAttributeData - .GetCustomAttributes(prop) - .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); - - /// - /// Load an enumerable of custom attributes from a read-only-reflected type. - /// - /// The type. - /// The type of the attribute to load. - /// The custom attribute data list if any were found. - public static IEnumerable GetCustomAttributesData(this Type type) - where TAttribute : Attribute - => CustomAttributeData - .GetCustomAttributes(type) - .Where(a => a.AttributeType.Name == typeof(TAttribute).Name); - - /// - /// Load a specific named argument from a custom attribute. - /// Named arguments are in the property-notation: - /// [KubernetesEntity(Kind = "foobar")]. - /// - /// The attribute in question. - /// The metadata load context that loaded everything. - /// The name of the argument. - /// What target type the argument has. - /// The argument value if found. - /// Thrown if the data did not match the target type. - public static T? - GetCustomAttributeNamedArg(this CustomAttributeData attr, MetadataLoadContext ctx, string name) => - attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.ArgumentType == ctx.GetContextType() - ? (T)attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value! - : default; - - /// - /// Load a specific named argument from a custom attribute. - /// Without the metadata load context, this method is only usable for types in the same loaded assembly. - /// Named arguments are in the property-notation: - /// [KubernetesEntity(Kind = "foobar")]. - /// - /// The attribute in question. - /// The name of the argument. - /// What target type the argument has. - /// The argument value if found. - /// Thrown if the data did not match the target type. - public static T? - GetCustomAttributeNamedArg(this CustomAttributeData attr, string name) => - attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.ArgumentType == typeof(T) - ? (T)attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value! - : default; - - /// - /// Load a specific named argument array from a custom attribute. - /// Named arguments are in the property-notation: - /// [Test(Foo = new[]{"bar", "baz"})]. - /// - /// The attribute in question. - /// The name of the argument. - /// What target type the arguments have. - /// The list of arguments if found. - /// Thrown if the data did not match the target type. - public static IList GetCustomAttributeNamedArrayArg(this CustomAttributeData attr, string name) => - attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value is - ReadOnlyCollection value - ? value.Select(v => (T)v.Value!).ToList() - : []; - - /// - /// Load a specific constructor argument from a custom attribute. - /// Constructor arguments are in the "new" format: - /// [KubernetesEntity("foobar")]. - /// - /// The attribute in question. - /// The metadata load context that loaded everything. - /// Index of the value in the constructor notation. - /// What target type the argument has. - /// The argument value if found. - /// Thrown if the data did not match the target type. - public static T? GetCustomAttributeCtorArg(this CustomAttributeData attr, MetadataLoadContext ctx, int index) => - attr.ConstructorArguments.Count >= index + 1 && - attr.ConstructorArguments[index].ArgumentType == ctx.GetContextType() - ? (T)attr.ConstructorArguments[index].Value! - : default; - - /// - /// Load a specific constructor argument from a custom attribute. - /// Without the metadata load context, this method is only usable for types in the same loaded assembly. - /// Constructor arguments are in the "new" format: - /// [KubernetesEntity("foobar")]. - /// - /// The attribute in question. - /// Index of the value in the constructor notation. - /// What target type the argument has. - /// The argument value if found. - /// Thrown if the data did not match the target type. - public static T? GetCustomAttributeCtorArg(this CustomAttributeData attr, int index) => - attr.ConstructorArguments.Count >= index + 1 && - attr.ConstructorArguments[index].ArgumentType == typeof(T) - ? (T)attr.ConstructorArguments[index].Value! - : default; - - /// - /// Load a specific constructor argument array from a custom attribute. - /// Constructor arguments are in the "new" format: - /// [KubernetesEntity(new[]{"foobar", "barbaz"})]. - /// - /// The attribute in question. - /// Index of the value in the constructor notation. - /// What target type the arguments have. - /// The list of arguments if found. - /// Thrown if the data did not match the target type. - public static IList GetCustomAttributeCtorArrayArg( - this CustomAttributeData attr, - int index) => - attr.ConstructorArguments.Count >= index + 1 && - attr.ConstructorArguments[index].Value is - ReadOnlyCollection value - ? value.Select(v => (T)v.Value!).ToList() - : []; - - /// - /// Load a type from a metadata load context. - /// - /// The context. - /// The type. - /// The loaded reflected type. - public static Type GetContextType(this MetadataLoadContext context) - => context.GetContextType(typeof(T)); - - /// - /// Load a type from a metadata load context. - /// - /// The context. - /// The type. - /// The loaded reflected type. - public static Type GetContextType(this MetadataLoadContext context, Type type) - { - foreach (var assembly in context.GetAssemblies()) - { - if (assembly.GetType(type.FullName!) is { } t) - { - return t; - } - } - - var newAssembly = context.LoadFromAssemblyPath(type.Assembly.Location); - return newAssembly.GetType(type.FullName!)!; - } - - /// - /// Check if a type is nullable. - /// - /// The type. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this Type type) - => type.FullName?.Contains("Nullable") == true; - - /// - /// Check if a property is nullable. - /// - /// The property. - /// True if the type is nullable (i.e. contains "nullable" in its name). - public static bool IsNullable(this PropertyInfo prop) - => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || - prop.PropertyType.FullName?.Contains("Nullable") == true; -} +using System.Collections.ObjectModel; +using System.Reflection; + +namespace KubeOps.Transpiler; + +/// +/// Utilities for loading attributes and information. +/// +public static class Utilities +{ + /// + /// Load a custom attribute from a read-only-reflected type. + /// + /// The type. + /// The type of the attribute to load. + /// The custom attribute data if an attribute is found. + public static CustomAttributeData? GetCustomAttributeData(this Type type) + where TAttribute : Attribute + => CustomAttributeData + .GetCustomAttributes(type) + .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); + + /// + /// Load a custom attribute from a read-only-reflected property. + /// + /// The property. + /// The type of the attribute to load. + /// The custom attribute data if an attribute is found. + public static CustomAttributeData? GetCustomAttributeData(this PropertyInfo prop) + where TAttribute : Attribute + => CustomAttributeData + .GetCustomAttributes(prop) + .FirstOrDefault(a => a.AttributeType.Name == typeof(TAttribute).Name); + + /// + /// Load an enumerable of custom attributes from a read-only-reflected type. + /// + /// The type. + /// The type of the attribute to load. + /// The custom attribute data list if any were found. + public static IEnumerable GetCustomAttributesData(this Type type) + where TAttribute : Attribute + => CustomAttributeData + .GetCustomAttributes(type) + .Where(a => a.AttributeType.Name == typeof(TAttribute).Name); + + /// + /// Load a specific named argument from a custom attribute. + /// Named arguments are in the property-notation: + /// [KubernetesEntity(Kind = "foobar")]. + /// + /// The attribute in question. + /// The metadata load context that loaded everything. + /// The name of the argument. + /// What target type the argument has. + /// The argument value if found. + /// Thrown if the data did not match the target type. + public static T? + GetCustomAttributeNamedArg(this CustomAttributeData attr, MetadataLoadContext ctx, string name) => + attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.ArgumentType == ctx.GetContextType() + ? (T)attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value! + : default; + + /// + /// Load a specific named argument from a custom attribute. + /// Without the metadata load context, this method is only usable for types in the same loaded assembly. + /// Named arguments are in the property-notation: + /// [KubernetesEntity(Kind = "foobar")]. + /// + /// The attribute in question. + /// The name of the argument. + /// What target type the argument has. + /// The argument value if found. + /// Thrown if the data did not match the target type. + public static T? + GetCustomAttributeNamedArg(this CustomAttributeData attr, string name) => + attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.ArgumentType == typeof(T) + ? (T)attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value! + : default; + + /// + /// Load a specific named argument array from a custom attribute. + /// Named arguments are in the property-notation: + /// [Test(Foo = new[]{"bar", "baz"})]. + /// + /// The attribute in question. + /// The name of the argument. + /// What target type the arguments have. + /// The list of arguments if found. + /// Thrown if the data did not match the target type. + public static IList GetCustomAttributeNamedArrayArg(this CustomAttributeData attr, string name) => + attr.NamedArguments.FirstOrDefault(a => a.MemberName == name).TypedValue.Value is + ReadOnlyCollection value + ? value.Select(v => (T)v.Value!).ToList() + : []; + + /// + /// Load a specific constructor argument from a custom attribute. + /// Constructor arguments are in the "new" format: + /// [KubernetesEntity("foobar")]. + /// + /// The attribute in question. + /// The metadata load context that loaded everything. + /// Index of the value in the constructor notation. + /// What target type the argument has. + /// The argument value if found. + /// Thrown if the data did not match the target type. + public static T? GetCustomAttributeCtorArg(this CustomAttributeData attr, MetadataLoadContext ctx, int index) => + attr.ConstructorArguments.Count >= index + 1 && + attr.ConstructorArguments[index].ArgumentType == ctx.GetContextType() + ? (T)attr.ConstructorArguments[index].Value! + : default; + + /// + /// Load a specific constructor argument from a custom attribute. + /// Without the metadata load context, this method is only usable for types in the same loaded assembly. + /// Constructor arguments are in the "new" format: + /// [KubernetesEntity("foobar")]. + /// + /// The attribute in question. + /// Index of the value in the constructor notation. + /// What target type the argument has. + /// The argument value if found. + /// Thrown if the data did not match the target type. + public static T? GetCustomAttributeCtorArg(this CustomAttributeData attr, int index) => + attr.ConstructorArguments.Count >= index + 1 && + attr.ConstructorArguments[index].ArgumentType == typeof(T) + ? (T)attr.ConstructorArguments[index].Value! + : default; + + /// + /// Load a specific constructor argument array from a custom attribute. + /// Constructor arguments are in the "new" format: + /// [KubernetesEntity(new[]{"foobar", "barbaz"})]. + /// + /// The attribute in question. + /// Index of the value in the constructor notation. + /// What target type the arguments have. + /// The list of arguments if found. + /// Thrown if the data did not match the target type. + public static IList GetCustomAttributeCtorArrayArg( + this CustomAttributeData attr, + int index) => + attr.ConstructorArguments.Count >= index + 1 && + attr.ConstructorArguments[index].Value is + ReadOnlyCollection value + ? value.Select(v => (T)v.Value!).ToList() + : []; + + /// + /// Load a type from a metadata load context. + /// + /// The context. + /// The type. + /// The loaded reflected type. + public static Type GetContextType(this MetadataLoadContext context) + => context.GetContextType(typeof(T)); + + /// + /// Load a type from a metadata load context. + /// + /// The context. + /// The type. + /// The loaded reflected type. + public static Type GetContextType(this MetadataLoadContext context, Type type) + { + foreach (var assembly in context.GetAssemblies()) + { + if (assembly.GetType(type.FullName!) is { } t) + { + return t; + } + } + + var newAssembly = context.LoadFromAssemblyPath(type.Assembly.Location); + return newAssembly.GetType(type.FullName!)!; + } + + /// + /// Check if a type is nullable. + /// + /// The type. + /// True if the type is nullable (i.e. contains "nullable" in its name). + public static bool IsNullable(this Type type) + => type.FullName?.Contains("Nullable") == true; + + /// + /// Check if a property is nullable. + /// + /// The property. + /// True if the type is nullable (i.e. contains "nullable" in its name). + public static bool IsNullable(this PropertyInfo prop) + => new NullabilityInfoContext().Create(prop).ReadState == NullabilityState.Nullable || + prop.PropertyType.FullName?.Contains("Nullable") == true; +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index b3edea99..a8088b23 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -11,12 +11,12 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/KubeOps.Cli.Test/GlobalUsings.cs b/test/KubeOps.Cli.Test/GlobalUsings.cs index e1065597..c802f448 100644 --- a/test/KubeOps.Cli.Test/GlobalUsings.cs +++ b/test/KubeOps.Cli.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.Cli.Test/Management/Install.Integration.Test.cs b/test/KubeOps.Cli.Test/Management/Install.Integration.Test.cs index fb47a763..16fb030e 100644 --- a/test/KubeOps.Cli.Test/Management/Install.Integration.Test.cs +++ b/test/KubeOps.Cli.Test/Management/Install.Integration.Test.cs @@ -1,30 +1,30 @@ -using System.CommandLine; -using System.CommandLine.Invocation; - -using k8s; - -using KubeOps.Cli.Commands.Management; - -using Spectre.Console.Testing; - -namespace KubeOps.Cli.Test.Management; - -public class InstallIntegrationTest -{ - private static readonly string ProjectPath = - Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "examples", "Operator", - "Operator.csproj"); - - [Fact(Skip = - "For some reason, the MetadataReferences are not loaded when the assembly parser is used from a test project.")] - public async Task Should_Install_Crds_In_Cluster() - { - var console = new TestConsole(); - var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); - var cmd = Install.Command; - var ctx = new InvocationContext( - cmd.Parse(ProjectPath, "-f")); - - await Install.Handler(console, client, ctx); - } -} +using System.CommandLine; +using System.CommandLine.Invocation; + +using k8s; + +using KubeOps.Cli.Commands.Management; + +using Spectre.Console.Testing; + +namespace KubeOps.Cli.Test.Management; + +public class InstallIntegrationTest +{ + private static readonly string ProjectPath = + Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..", "examples", "Operator", + "Operator.csproj"); + + [Fact(Skip = + "For some reason, the MetadataReferences are not loaded when the assembly parser is used from a test project.")] + public async Task Should_Install_Crds_In_Cluster() + { + var console = new TestConsole(); + var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); + var cmd = Install.Command; + var ctx = new InvocationContext( + cmd.Parse(ProjectPath, "-f")); + + await Install.Handler(console, client, ctx); + } +} diff --git a/test/KubeOps.Generator.Test/ControllerRegistrationGenerator.Test.cs b/test/KubeOps.Generator.Test/ControllerRegistrationGenerator.Test.cs index d66f778c..63ec20cc 100644 --- a/test/KubeOps.Generator.Test/ControllerRegistrationGenerator.Test.cs +++ b/test/KubeOps.Generator.Test/ControllerRegistrationGenerator.Test.cs @@ -1,57 +1,57 @@ -using FluentAssertions; - -using KubeOps.Generator.Generators; - -using Microsoft.CodeAnalysis.CSharp; - -namespace KubeOps.Generator.Test; - -public class ControllerRegistrationGeneratorTest -{ - [Theory] - [InlineData("", """ - using KubeOps.Abstractions.Builder; - - public static class ControllerRegistrations - { - public static IOperatorBuilder RegisterControllers(this IOperatorBuilder builder) - { - return builder; - } - } - """)] - [InlineData(""" - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class V1TestEntity : IKubernetesObject - { - } - - public class V1TestEntityController : IEntityController - { - } - """, """ - using KubeOps.Abstractions.Builder; - - public static class ControllerRegistrations - { - public static IOperatorBuilder RegisterControllers(this IOperatorBuilder builder) - { - builder.AddController(); - return builder; - } - } - """)] - public void Should_Generate_Correct_Code(string input, string expectedResult) - { - var inputCompilation = input.CreateCompilation(); - expectedResult = expectedResult.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new ControllerRegistrationGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("ControllerRegistrations.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } -} +using FluentAssertions; + +using KubeOps.Generator.Generators; + +using Microsoft.CodeAnalysis.CSharp; + +namespace KubeOps.Generator.Test; + +public class ControllerRegistrationGeneratorTest +{ + [Theory] + [InlineData("", """ + using KubeOps.Abstractions.Builder; + + public static class ControllerRegistrations + { + public static IOperatorBuilder RegisterControllers(this IOperatorBuilder builder) + { + return builder; + } + } + """)] + [InlineData(""" + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class V1TestEntity : IKubernetesObject + { + } + + public class V1TestEntityController : IEntityController + { + } + """, """ + using KubeOps.Abstractions.Builder; + + public static class ControllerRegistrations + { + public static IOperatorBuilder RegisterControllers(this IOperatorBuilder builder) + { + builder.AddController(); + return builder; + } + } + """)] + public void Should_Generate_Correct_Code(string input, string expectedResult) + { + var inputCompilation = input.CreateCompilation(); + expectedResult = expectedResult.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new ControllerRegistrationGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("ControllerRegistrations.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } +} diff --git a/test/KubeOps.Generator.Test/EntityDefinitionGenerator.Test.cs b/test/KubeOps.Generator.Test/EntityDefinitionGenerator.Test.cs index cfe99d6c..d9fa67b2 100644 --- a/test/KubeOps.Generator.Test/EntityDefinitionGenerator.Test.cs +++ b/test/KubeOps.Generator.Test/EntityDefinitionGenerator.Test.cs @@ -1,67 +1,67 @@ -using FluentAssertions; - -using KubeOps.Generator.Generators; - -using Microsoft.CodeAnalysis.CSharp; - -namespace KubeOps.Generator.Test; - -public class EntityDefinitionGeneratorTest -{ - [Theory] - [InlineData("", """ - using KubeOps.Abstractions.Builder; - using KubeOps.Abstractions.Entities; - - public static class EntityDefinitions - { - } - """)] - [InlineData(""" - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class V1TestEntity : IKubernetesObject - { - } - """, """ - using KubeOps.Abstractions.Builder; - using KubeOps.Abstractions.Entities; - - public static class EntityDefinitions - { - public static readonly EntityMetadata V1TestEntity = new("TestEntity", "v1", "testing.dev", null); - } - """)] - [InlineData(""" - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class V1TestEntity : IKubernetesObject - { - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "AnotherEntity")] - public class V1AnotherEntity : IKubernetesObject - { - } - """, """ - using KubeOps.Abstractions.Builder; - using KubeOps.Abstractions.Entities; - - public static class EntityDefinitions - { - public static readonly EntityMetadata V1TestEntity = new("TestEntity", "v1", "testing.dev", null); - public static readonly EntityMetadata V1AnotherEntity = new("AnotherEntity", "v1", "testing.dev", null); - } - """)] - public void Should_Generate_Correct_Code(string input, string expectedResult) - { - var inputCompilation = input.CreateCompilation(); - expectedResult = expectedResult.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityDefinitionGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("EntityDefinitions.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } -} +using FluentAssertions; + +using KubeOps.Generator.Generators; + +using Microsoft.CodeAnalysis.CSharp; + +namespace KubeOps.Generator.Test; + +public class EntityDefinitionGeneratorTest +{ + [Theory] + [InlineData("", """ + using KubeOps.Abstractions.Builder; + using KubeOps.Abstractions.Entities; + + public static class EntityDefinitions + { + } + """)] + [InlineData(""" + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class V1TestEntity : IKubernetesObject + { + } + """, """ + using KubeOps.Abstractions.Builder; + using KubeOps.Abstractions.Entities; + + public static class EntityDefinitions + { + public static readonly EntityMetadata V1TestEntity = new("TestEntity", "v1", "testing.dev", null); + } + """)] + [InlineData(""" + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class V1TestEntity : IKubernetesObject + { + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "AnotherEntity")] + public class V1AnotherEntity : IKubernetesObject + { + } + """, """ + using KubeOps.Abstractions.Builder; + using KubeOps.Abstractions.Entities; + + public static class EntityDefinitions + { + public static readonly EntityMetadata V1TestEntity = new("TestEntity", "v1", "testing.dev", null); + public static readonly EntityMetadata V1AnotherEntity = new("AnotherEntity", "v1", "testing.dev", null); + } + """)] + public void Should_Generate_Correct_Code(string input, string expectedResult) + { + var inputCompilation = input.CreateCompilation(); + expectedResult = expectedResult.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityDefinitionGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("EntityDefinitions.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } +} diff --git a/test/KubeOps.Generator.Test/EntityInitializerGenerator.Test.cs b/test/KubeOps.Generator.Test/EntityInitializerGenerator.Test.cs index 7691de1f..b719e951 100644 --- a/test/KubeOps.Generator.Test/EntityInitializerGenerator.Test.cs +++ b/test/KubeOps.Generator.Test/EntityInitializerGenerator.Test.cs @@ -1,287 +1,287 @@ -using FluentAssertions; - -using KubeOps.Generator.Generators; - -using Microsoft.CodeAnalysis.CSharp; - -namespace KubeOps.Generator.Test; - -public class EntityInitializerGeneratorTest -{ - [Fact] - public void Should_Generate_Empty_Initializer_Without_Input() - { - var inputCompilation = string.Empty.CreateCompilation(); - var expectedResult = """ - public static class EntityInitializer - { - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Static_Initializer_For_Non_Partial_Entities() - { - var inputCompilation = """ - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class V1TestEntity : IKubernetesObject - { - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v2", Kind = "TestEntity")] - public class V2TestEntity : IKubernetesObject - { - } - """.CreateCompilation(); - var expectedResult = """ - public static class EntityInitializer - { - public static global::V1TestEntity Initialize(this global::V1TestEntity entity) - { - entity.ApiVersion = "testing.dev/v1"; - entity.Kind = "TestEntity"; - return entity; - } - - public static global::V2TestEntity Initialize(this global::V2TestEntity entity) - { - entity.ApiVersion = "testing.dev/v2"; - entity.Kind = "TestEntity"; - return entity; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - output.SyntaxTrees.Any(s => s.FilePath.Contains("V1TestEntity")).Should().BeFalse(); - output.SyntaxTrees.Any(s => s.FilePath.Contains("V2TestEntity")).Should().BeFalse(); - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Correct_Initializer_Entities_Without_Groups() - { - var inputCompilation = """ - [KubernetesEntity(ApiVersion = "v1", Kind = "ConfigMap")] - public class V1ConfigMap : IKubernetesObject - { - } - """.CreateCompilation(); - var expectedResult = """ - public static class EntityInitializer - { - public static global::V1ConfigMap Initialize(this global::V1ConfigMap entity) - { - entity.ApiVersion = "v1"; - entity.Kind = "ConfigMap"; - return entity; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - output.SyntaxTrees.Any(s => s.FilePath.Contains("V1ConfigMap")).Should().BeFalse(); - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Static_Initializer_For_Partial_Entity_With_Default_Ctor() - { - var inputCompilation = """ - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public partial class V1TestEntity : IKubernetesObject - { - public V1TestEntity(){} - } - """.CreateCompilation(); - var expectedResult = """ - public static class EntityInitializer - { - public static global::V1TestEntity Initialize(this global::V1TestEntity entity) - { - entity.ApiVersion = "testing.dev/v1"; - entity.Kind = "TestEntity"; - return entity; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - output.SyntaxTrees.Any(s => s.FilePath.Contains("V1TestEntity")).Should().BeFalse(); - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Not_Generate_Static_Initializer_For_Partial_Entity() - { - var inputCompilation = """ - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public partial class V1TestEntity : IKubernetesObject - { - } - """.CreateCompilation(); - var expectedResult = """ - public static class EntityInitializer - { - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - output.SyntaxTrees.Any(s => s.FilePath.Contains("V1TestEntity")).Should().BeTrue(); - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Default_Ctor_For_FileNamespaced_Partial_Entity() - { - var inputCompilation = """ - namespace Foo.Bar; - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public partial class V1TestEntity : IKubernetesObject - { - } - """.CreateCompilation(); - var expectedResult = """ - namespace Foo.Bar; - public partial class V1TestEntity - { - public V1TestEntity() - { - ApiVersion = "testing.dev/v1"; - Kind = "TestEntity"; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Default_Ctor_For_ScopeNamespaced_Partial_Entity() - { - var inputCompilation = """ - namespace Foo.Bar - { - namespace Baz - { - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public partial class V1TestEntity : IKubernetesObject - { - } - } - } - """.CreateCompilation(); - var expectedResult = """ - namespace Foo.Bar.Baz; - public partial class V1TestEntity - { - public V1TestEntity() - { - ApiVersion = "testing.dev/v1"; - Kind = "TestEntity"; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Default_Ctor_For_Global_Partial_Entity() - { - var inputCompilation = """ - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public partial class V1TestEntity : IKubernetesObject - { - } - """.CreateCompilation(); - var expectedResult = """ - public partial class V1TestEntity - { - public V1TestEntity() - { - ApiVersion = "testing.dev/v1"; - Kind = "TestEntity"; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } - - [Fact] - public void Should_Generate_Default_Ctor_For_Partial_Entity_With_Ctor() - { - var inputCompilation = """ - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public partial class V1TestEntity : IKubernetesObject - { - public V1TestEntity(string name){} - } - """.CreateCompilation(); - var expectedResult = """ - public partial class V1TestEntity - { - public V1TestEntity() - { - ApiVersion = "testing.dev/v1"; - Kind = "TestEntity"; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } -} +using FluentAssertions; + +using KubeOps.Generator.Generators; + +using Microsoft.CodeAnalysis.CSharp; + +namespace KubeOps.Generator.Test; + +public class EntityInitializerGeneratorTest +{ + [Fact] + public void Should_Generate_Empty_Initializer_Without_Input() + { + var inputCompilation = string.Empty.CreateCompilation(); + var expectedResult = """ + public static class EntityInitializer + { + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Static_Initializer_For_Non_Partial_Entities() + { + var inputCompilation = """ + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class V1TestEntity : IKubernetesObject + { + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v2", Kind = "TestEntity")] + public class V2TestEntity : IKubernetesObject + { + } + """.CreateCompilation(); + var expectedResult = """ + public static class EntityInitializer + { + public static global::V1TestEntity Initialize(this global::V1TestEntity entity) + { + entity.ApiVersion = "testing.dev/v1"; + entity.Kind = "TestEntity"; + return entity; + } + + public static global::V2TestEntity Initialize(this global::V2TestEntity entity) + { + entity.ApiVersion = "testing.dev/v2"; + entity.Kind = "TestEntity"; + return entity; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + output.SyntaxTrees.Any(s => s.FilePath.Contains("V1TestEntity")).Should().BeFalse(); + output.SyntaxTrees.Any(s => s.FilePath.Contains("V2TestEntity")).Should().BeFalse(); + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Correct_Initializer_Entities_Without_Groups() + { + var inputCompilation = """ + [KubernetesEntity(ApiVersion = "v1", Kind = "ConfigMap")] + public class V1ConfigMap : IKubernetesObject + { + } + """.CreateCompilation(); + var expectedResult = """ + public static class EntityInitializer + { + public static global::V1ConfigMap Initialize(this global::V1ConfigMap entity) + { + entity.ApiVersion = "v1"; + entity.Kind = "ConfigMap"; + return entity; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + output.SyntaxTrees.Any(s => s.FilePath.Contains("V1ConfigMap")).Should().BeFalse(); + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Static_Initializer_For_Partial_Entity_With_Default_Ctor() + { + var inputCompilation = """ + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public partial class V1TestEntity : IKubernetesObject + { + public V1TestEntity(){} + } + """.CreateCompilation(); + var expectedResult = """ + public static class EntityInitializer + { + public static global::V1TestEntity Initialize(this global::V1TestEntity entity) + { + entity.ApiVersion = "testing.dev/v1"; + entity.Kind = "TestEntity"; + return entity; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + output.SyntaxTrees.Any(s => s.FilePath.Contains("V1TestEntity")).Should().BeFalse(); + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Not_Generate_Static_Initializer_For_Partial_Entity() + { + var inputCompilation = """ + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public partial class V1TestEntity : IKubernetesObject + { + } + """.CreateCompilation(); + var expectedResult = """ + public static class EntityInitializer + { + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + output.SyntaxTrees.Any(s => s.FilePath.Contains("V1TestEntity")).Should().BeTrue(); + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("EntityInitializer.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Default_Ctor_For_FileNamespaced_Partial_Entity() + { + var inputCompilation = """ + namespace Foo.Bar; + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public partial class V1TestEntity : IKubernetesObject + { + } + """.CreateCompilation(); + var expectedResult = """ + namespace Foo.Bar; + public partial class V1TestEntity + { + public V1TestEntity() + { + ApiVersion = "testing.dev/v1"; + Kind = "TestEntity"; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Default_Ctor_For_ScopeNamespaced_Partial_Entity() + { + var inputCompilation = """ + namespace Foo.Bar + { + namespace Baz + { + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public partial class V1TestEntity : IKubernetesObject + { + } + } + } + """.CreateCompilation(); + var expectedResult = """ + namespace Foo.Bar.Baz; + public partial class V1TestEntity + { + public V1TestEntity() + { + ApiVersion = "testing.dev/v1"; + Kind = "TestEntity"; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Default_Ctor_For_Global_Partial_Entity() + { + var inputCompilation = """ + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public partial class V1TestEntity : IKubernetesObject + { + } + """.CreateCompilation(); + var expectedResult = """ + public partial class V1TestEntity + { + public V1TestEntity() + { + ApiVersion = "testing.dev/v1"; + Kind = "TestEntity"; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } + + [Fact] + public void Should_Generate_Default_Ctor_For_Partial_Entity_With_Ctor() + { + var inputCompilation = """ + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public partial class V1TestEntity : IKubernetesObject + { + public V1TestEntity(string name){} + } + """.CreateCompilation(); + var expectedResult = """ + public partial class V1TestEntity + { + public V1TestEntity() + { + ApiVersion = "testing.dev/v1"; + Kind = "TestEntity"; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new EntityInitializerGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("V1TestEntity.init.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } +} diff --git a/test/KubeOps.Generator.Test/FinalizerRegistrationGenerator.Test.cs b/test/KubeOps.Generator.Test/FinalizerRegistrationGenerator.Test.cs index 784ad6f9..da88e296 100644 --- a/test/KubeOps.Generator.Test/FinalizerRegistrationGenerator.Test.cs +++ b/test/KubeOps.Generator.Test/FinalizerRegistrationGenerator.Test.cs @@ -1,86 +1,86 @@ -using FluentAssertions; - -using KubeOps.Generator.Generators; - -using Microsoft.CodeAnalysis.CSharp; - -namespace KubeOps.Generator.Test; - -public class FinalizerRegistrationGeneratorTest -{ - [Theory] - [InlineData("", """ - using KubeOps.Abstractions.Builder; - - public static class FinalizerRegistrations - { - public static IOperatorBuilder RegisterFinalizers(this IOperatorBuilder builder) - { - return builder; - } - } - """)] - [InlineData(""" - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class V1TestEntity : IKubernetesObject - { - } - - public class V1TestEntityFinalizer : IEntityFinalizer - { - } - """, """ - using KubeOps.Abstractions.Builder; - - public static class FinalizerRegistrations - { - public const string V1TestEntityFinalizerIdentifier = "testing.dev/v1testentityfinalizer"; - public static IOperatorBuilder RegisterFinalizers(this IOperatorBuilder builder) - { - builder.AddFinalizer(V1TestEntityFinalizerIdentifier); - return builder; - } - } - """)] - [InlineData(""" - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class V1TestEntity : IKubernetesObject - { - } - - public class V1TestEntityCleanupDeployment : IEntityFinalizer - { - } - - public class V1TestEntityCleanupOtherResourcesSuchThatThisFinalizerHasAVeryLongName : IEntityFinalizer - { - } - """, """ - using KubeOps.Abstractions.Builder; - - public static class FinalizerRegistrations - { - public const string V1TestEntityCleanupDeploymentIdentifier = "testing.dev/v1testentitycleanupdeploymentfinalizer"; - public const string V1TestEntityCleanupOtherResourcesSuchThatThisFinalizerHasAVeryLongNameIdentifier = "testing.dev/v1testentitycleanupotherresourcessuchthatthisfinali"; - public static IOperatorBuilder RegisterFinalizers(this IOperatorBuilder builder) - { - builder.AddFinalizer(V1TestEntityCleanupDeploymentIdentifier); - builder.AddFinalizer(V1TestEntityCleanupOtherResourcesSuchThatThisFinalizerHasAVeryLongNameIdentifier); - return builder; - } - } - """)] - public void Should_Generate_Correct_Code(string input, string expectedResult) - { - var inputCompilation = input.CreateCompilation(); - expectedResult = expectedResult.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new FinalizerRegistrationGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("FinalizerRegistrations.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } -} +using FluentAssertions; + +using KubeOps.Generator.Generators; + +using Microsoft.CodeAnalysis.CSharp; + +namespace KubeOps.Generator.Test; + +public class FinalizerRegistrationGeneratorTest +{ + [Theory] + [InlineData("", """ + using KubeOps.Abstractions.Builder; + + public static class FinalizerRegistrations + { + public static IOperatorBuilder RegisterFinalizers(this IOperatorBuilder builder) + { + return builder; + } + } + """)] + [InlineData(""" + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class V1TestEntity : IKubernetesObject + { + } + + public class V1TestEntityFinalizer : IEntityFinalizer + { + } + """, """ + using KubeOps.Abstractions.Builder; + + public static class FinalizerRegistrations + { + public const string V1TestEntityFinalizerIdentifier = "testing.dev/v1testentityfinalizer"; + public static IOperatorBuilder RegisterFinalizers(this IOperatorBuilder builder) + { + builder.AddFinalizer(V1TestEntityFinalizerIdentifier); + return builder; + } + } + """)] + [InlineData(""" + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class V1TestEntity : IKubernetesObject + { + } + + public class V1TestEntityCleanupDeployment : IEntityFinalizer + { + } + + public class V1TestEntityCleanupOtherResourcesSuchThatThisFinalizerHasAVeryLongName : IEntityFinalizer + { + } + """, """ + using KubeOps.Abstractions.Builder; + + public static class FinalizerRegistrations + { + public const string V1TestEntityCleanupDeploymentIdentifier = "testing.dev/v1testentitycleanupdeploymentfinalizer"; + public const string V1TestEntityCleanupOtherResourcesSuchThatThisFinalizerHasAVeryLongNameIdentifier = "testing.dev/v1testentitycleanupotherresourcessuchthatthisfinali"; + public static IOperatorBuilder RegisterFinalizers(this IOperatorBuilder builder) + { + builder.AddFinalizer(V1TestEntityCleanupDeploymentIdentifier); + builder.AddFinalizer(V1TestEntityCleanupOtherResourcesSuchThatThisFinalizerHasAVeryLongNameIdentifier); + return builder; + } + } + """)] + public void Should_Generate_Correct_Code(string input, string expectedResult) + { + var inputCompilation = input.CreateCompilation(); + expectedResult = expectedResult.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new FinalizerRegistrationGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("FinalizerRegistrations.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } +} diff --git a/test/KubeOps.Generator.Test/GlobalUsings.cs b/test/KubeOps.Generator.Test/GlobalUsings.cs index e1065597..c802f448 100644 --- a/test/KubeOps.Generator.Test/GlobalUsings.cs +++ b/test/KubeOps.Generator.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.Generator.Test/OperatorBuilderGenerator.Test.cs b/test/KubeOps.Generator.Test/OperatorBuilderGenerator.Test.cs index 536bbc20..5da30164 100644 --- a/test/KubeOps.Generator.Test/OperatorBuilderGenerator.Test.cs +++ b/test/KubeOps.Generator.Test/OperatorBuilderGenerator.Test.cs @@ -1,38 +1,38 @@ -using FluentAssertions; - -using KubeOps.Generator.Generators; - -using Microsoft.CodeAnalysis.CSharp; - -namespace KubeOps.Generator.Test; - -public class OperatorBuilderGeneratorTest -{ - [Fact] - public void Should_Generate_Correct_Code() - { - var inputCompilation = string.Empty.CreateCompilation(); - var expectedResult = - """ - using KubeOps.Abstractions.Builder; - - public static class OperatorBuilderExtensions - { - public static IOperatorBuilder RegisterComponents(this IOperatorBuilder builder) - { - builder.RegisterControllers(); - builder.RegisterFinalizers(); - return builder; - } - } - """.ReplaceLineEndings(); - - var driver = CSharpGeneratorDriver.Create(new OperatorBuilderGenerator()); - driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); - - var result = output.SyntaxTrees - .First(s => s.FilePath.Contains("OperatorBuilder.g.cs")) - .ToString().ReplaceLineEndings(); - result.Should().Be(expectedResult); - } -} +using FluentAssertions; + +using KubeOps.Generator.Generators; + +using Microsoft.CodeAnalysis.CSharp; + +namespace KubeOps.Generator.Test; + +public class OperatorBuilderGeneratorTest +{ + [Fact] + public void Should_Generate_Correct_Code() + { + var inputCompilation = string.Empty.CreateCompilation(); + var expectedResult = + """ + using KubeOps.Abstractions.Builder; + + public static class OperatorBuilderExtensions + { + public static IOperatorBuilder RegisterComponents(this IOperatorBuilder builder) + { + builder.RegisterControllers(); + builder.RegisterFinalizers(); + return builder; + } + } + """.ReplaceLineEndings(); + + var driver = CSharpGeneratorDriver.Create(new OperatorBuilderGenerator()); + driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var output, out var diag); + + var result = output.SyntaxTrees + .First(s => s.FilePath.Contains("OperatorBuilder.g.cs")) + .ToString().ReplaceLineEndings(); + result.Should().Be(expectedResult); + } +} diff --git a/test/KubeOps.Generator.Test/TestHelperExtensions.cs b/test/KubeOps.Generator.Test/TestHelperExtensions.cs index 47795bd8..c7e46b31 100644 --- a/test/KubeOps.Generator.Test/TestHelperExtensions.cs +++ b/test/KubeOps.Generator.Test/TestHelperExtensions.cs @@ -1,20 +1,20 @@ -using System.Reflection; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; - -namespace KubeOps.Generator.Test; - -internal static class TestHelperExtensions -{ - public static Compilation CreateCompilation(this string source) - { - var compilation = CSharpCompilation.Create( - "compilation", - new[] { CSharpSyntaxTree.ParseText(source) }, - new[] { MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - return compilation; - } -} +using System.Reflection; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace KubeOps.Generator.Test; + +internal static class TestHelperExtensions +{ + public static Compilation CreateCompilation(this string source) + { + var compilation = CSharpCompilation.Create( + "compilation", + new[] { CSharpSyntaxTree.ParseText(source) }, + new[] { MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + return compilation; + } +} diff --git a/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs b/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs index e1065597..c802f448 100644 --- a/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs +++ b/test/KubeOps.KubernetesClient.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.KubernetesClient.Test/IntegrationTestCollection.cs b/test/KubeOps.KubernetesClient.Test/IntegrationTestCollection.cs index 8488c4b8..e9b5ffb2 100644 --- a/test/KubeOps.KubernetesClient.Test/IntegrationTestCollection.cs +++ b/test/KubeOps.KubernetesClient.Test/IntegrationTestCollection.cs @@ -1,10 +1,10 @@ -namespace KubeOps.KubernetesClient.Test; - -[CollectionDefinition(Name, DisableParallelization = true)] -public class IntegrationTestCollection -{ - public const string Name = "Integration Tests"; -} - -[Collection(IntegrationTestCollection.Name)] -public abstract class IntegrationTestBase; +namespace KubeOps.KubernetesClient.Test; + +[CollectionDefinition(Name, DisableParallelization = true)] +public class IntegrationTestCollection +{ + public const string Name = "Integration Tests"; +} + +[Collection(IntegrationTestCollection.Name)] +public abstract class IntegrationTestBase; diff --git a/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs b/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs index 38431761..83830983 100644 --- a/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs +++ b/test/KubeOps.KubernetesClient.Test/KubernetesClient.Test.cs @@ -1,172 +1,172 @@ -using FluentAssertions; - -using k8s.Models; - -namespace KubeOps.KubernetesClient.Test; - -public class KubernetesClientTest : IntegrationTestBase, IDisposable -{ - private readonly IKubernetesClient _client = - new KubernetesClient(); - - private readonly IList _objects = new List(); - - [Fact] - public void Should_Return_Namespace() - { - var ns = _client.GetCurrentNamespace(); - ns.Should().Be("default"); - } - - [Fact] - public void Should_Create_Some_Object() - { - var config = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - - _objects.Add(config); - - config.Metadata.Should().NotBeNull(); - config.Metadata.ResourceVersion.Should().NotBeNullOrWhiteSpace(); - } - - [Fact] - public void Should_Get_Some_Object() - { - var config = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - - _objects.Add(config); - _objects.Add(_client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - })); - _objects.Add(_client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - })); - - var fetched = _client.Get(config.Name(), config.Namespace()); - fetched!.Name().Should().Be(config.Name()); - } - - [Fact] - public void Should_Update_Some_Object() - { - var config = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new V1ObjectMeta(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - var r1 = config.Metadata.ResourceVersion; - _objects.Add(config); - - config.Data.Add("test", "value"); - config = _client.Update(config); - var r2 = config.Metadata.ResourceVersion; - - r1.Should().NotBe(r2); - } - - [Fact] - public void Should_List_Some_Objects() - { - var config1 = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - var config2 = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - - _objects.Add(config1); - _objects.Add(config2); - - var configs = _client.List("default"); - - // there are _at least_ 2 config maps (the two that were created) - configs.Count.Should().BeGreaterOrEqualTo(2); - } - - [Fact] - public void Should_Delete_Some_Object() - { - var config1 = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - var config2 = _client.Create( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - _objects.Add(config1); - - var configs = _client.List("default"); - configs.Count.Should().BeGreaterOrEqualTo(2); - - _client.Delete(config2); - - configs = _client.List("default"); - configs.Count.Should().BeGreaterOrEqualTo(1); - } - - [Fact] - public void Should_Not_Throw_On_Not_Found_Delete() - { - var config = new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }; - _client.Delete(config); - } - - public void Dispose() - { - _client.Delete(_objects); - } - - private static string RandomName() => "cm-" + Guid.NewGuid().ToString().ToLower(); -} +using FluentAssertions; + +using k8s.Models; + +namespace KubeOps.KubernetesClient.Test; + +public class KubernetesClientTest : IntegrationTestBase, IDisposable +{ + private readonly IKubernetesClient _client = + new KubernetesClient(); + + private readonly IList _objects = new List(); + + [Fact] + public void Should_Return_Namespace() + { + var ns = _client.GetCurrentNamespace(); + ns.Should().Be("default"); + } + + [Fact] + public void Should_Create_Some_Object() + { + var config = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config); + + config.Metadata.Should().NotBeNull(); + config.Metadata.ResourceVersion.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Should_Get_Some_Object() + { + var config = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config); + _objects.Add(_client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + })); + _objects.Add(_client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + })); + + var fetched = _client.Get(config.Name(), config.Namespace()); + fetched!.Name().Should().Be(config.Name()); + } + + [Fact] + public void Should_Update_Some_Object() + { + var config = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new V1ObjectMeta(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var r1 = config.Metadata.ResourceVersion; + _objects.Add(config); + + config.Data.Add("test", "value"); + config = _client.Update(config); + var r2 = config.Metadata.ResourceVersion; + + r1.Should().NotBe(r2); + } + + [Fact] + public void Should_List_Some_Objects() + { + var config1 = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var config2 = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config1); + _objects.Add(config2); + + var configs = _client.List("default"); + + // there are _at least_ 2 config maps (the two that were created) + configs.Count.Should().BeGreaterOrEqualTo(2); + } + + [Fact] + public void Should_Delete_Some_Object() + { + var config1 = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var config2 = _client.Create( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + _objects.Add(config1); + + var configs = _client.List("default"); + configs.Count.Should().BeGreaterOrEqualTo(2); + + _client.Delete(config2); + + configs = _client.List("default"); + configs.Count.Should().BeGreaterOrEqualTo(1); + } + + [Fact] + public void Should_Not_Throw_On_Not_Found_Delete() + { + var config = new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }; + _client.Delete(config); + } + + public void Dispose() + { + _client.Delete(_objects); + } + + private static string RandomName() => "cm-" + Guid.NewGuid().ToString().ToLower(); +} diff --git a/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs b/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs index 4a184222..7e2cc8fd 100644 --- a/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs +++ b/test/KubeOps.KubernetesClient.Test/KubernetesClientAsync.Test.cs @@ -1,172 +1,172 @@ -using FluentAssertions; - -using k8s.Models; - -namespace KubeOps.KubernetesClient.Test; - -public class KubernetesClientAsyncTest : IntegrationTestBase, IDisposable -{ - private readonly IKubernetesClient _client = - new KubernetesClient(); - - private readonly IList _objects = new List(); - - [Fact] - public async Task Should_Return_Namespace() - { - var ns = await _client.GetCurrentNamespaceAsync(); - ns.Should().Be("default"); - } - - [Fact] - public async Task Should_Create_Some_Object() - { - var config = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - - _objects.Add(config); - - config.Metadata.Should().NotBeNull(); - config.Metadata.ResourceVersion.Should().NotBeNullOrWhiteSpace(); - } - - [Fact] - public async Task Should_Get_Some_Object() - { - var config = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - - _objects.Add(config); - _objects.Add(await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - })); - _objects.Add(await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - })); - - var fetched = await _client.GetAsync(config.Name(), config.Namespace()); - fetched!.Name().Should().Be(config.Name()); - } - - [Fact] - public async Task Should_Update_Some_Object() - { - var config = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new V1ObjectMeta(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - var r1 = config.Metadata.ResourceVersion; - _objects.Add(config); - - config.Data.Add("test", "value"); - config = await _client.UpdateAsync(config); - var r2 = config.Metadata.ResourceVersion; - - r1.Should().NotBe(r2); - } - - [Fact] - public async Task Should_List_Some_Objects() - { - var config1 = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - var config2 = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - - _objects.Add(config1); - _objects.Add(config2); - - var configs = await _client.ListAsync("default"); - - // there are _at least_ 2 config maps (the two that were created) - configs.Count.Should().BeGreaterOrEqualTo(2); - } - - [Fact] - public async Task Should_Delete_Some_Object() - { - var config1 = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - var config2 = await _client.CreateAsync( - new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }); - _objects.Add(config1); - - var configs = await _client.ListAsync("default"); - configs.Count.Should().BeGreaterOrEqualTo(2); - - await _client.DeleteAsync(config2); - - configs = await _client.ListAsync("default"); - configs.Count.Should().BeGreaterOrEqualTo(1); - } - - [Fact] - public async Task Should_Not_Throw_On_Not_Found_Delete() - { - var config = new V1ConfigMap - { - Kind = V1ConfigMap.KubeKind, - ApiVersion = V1ConfigMap.KubeApiVersion, - Metadata = new(name: RandomName(), namespaceProperty: "default"), - Data = new Dictionary { { "Hello", "World" } }, - }; - await _client.DeleteAsync(config); - } - - public void Dispose() - { - _client.Delete(_objects); - } - - private static string RandomName() => "cm-" + Guid.NewGuid().ToString().ToLower(); -} +using FluentAssertions; + +using k8s.Models; + +namespace KubeOps.KubernetesClient.Test; + +public class KubernetesClientAsyncTest : IntegrationTestBase, IDisposable +{ + private readonly IKubernetesClient _client = + new KubernetesClient(); + + private readonly IList _objects = new List(); + + [Fact] + public async Task Should_Return_Namespace() + { + var ns = await _client.GetCurrentNamespaceAsync(); + ns.Should().Be("default"); + } + + [Fact] + public async Task Should_Create_Some_Object() + { + var config = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config); + + config.Metadata.Should().NotBeNull(); + config.Metadata.ResourceVersion.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Should_Get_Some_Object() + { + var config = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config); + _objects.Add(await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + })); + _objects.Add(await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + })); + + var fetched = await _client.GetAsync(config.Name(), config.Namespace()); + fetched!.Name().Should().Be(config.Name()); + } + + [Fact] + public async Task Should_Update_Some_Object() + { + var config = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new V1ObjectMeta(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var r1 = config.Metadata.ResourceVersion; + _objects.Add(config); + + config.Data.Add("test", "value"); + config = await _client.UpdateAsync(config); + var r2 = config.Metadata.ResourceVersion; + + r1.Should().NotBe(r2); + } + + [Fact] + public async Task Should_List_Some_Objects() + { + var config1 = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var config2 = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + + _objects.Add(config1); + _objects.Add(config2); + + var configs = await _client.ListAsync("default"); + + // there are _at least_ 2 config maps (the two that were created) + configs.Count.Should().BeGreaterOrEqualTo(2); + } + + [Fact] + public async Task Should_Delete_Some_Object() + { + var config1 = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + var config2 = await _client.CreateAsync( + new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }); + _objects.Add(config1); + + var configs = await _client.ListAsync("default"); + configs.Count.Should().BeGreaterOrEqualTo(2); + + await _client.DeleteAsync(config2); + + configs = await _client.ListAsync("default"); + configs.Count.Should().BeGreaterOrEqualTo(1); + } + + [Fact] + public async Task Should_Not_Throw_On_Not_Found_Delete() + { + var config = new V1ConfigMap + { + Kind = V1ConfigMap.KubeKind, + ApiVersion = V1ConfigMap.KubeApiVersion, + Metadata = new(name: RandomName(), namespaceProperty: "default"), + Data = new Dictionary { { "Hello", "World" } }, + }; + await _client.DeleteAsync(config); + } + + public void Dispose() + { + _client.Delete(_objects); + } + + private static string RandomName() => "cm-" + Guid.NewGuid().ToString().ToLower(); +} diff --git a/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs b/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs index 9ed96657..20a5c28e 100644 --- a/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs +++ b/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs @@ -1,19 +1,19 @@ -using KubeOps.KubernetesClient.LabelSelectors; - -namespace KubeOps.KubernetesClient.Test; - -public class LabelSelectorTest : IntegrationTestBase -{ - [Fact] - public void Sould_Return_Correct_Expression() - { - var labelSelectors = new LabelSelector[] { - new EqualsSelector("app", Enumerable.Range(0,3).Select(x=>$"app-{x}").ToArray()), - new NotEqualsSelector("srv", Enumerable.Range(0,2).Select(x=>$"service-{x}").ToArray()) - }; - - string expected = "app in (app-0,app-1,app-2),srv notin (service-0,service-1)"; - var actual = labelSelectors.ToExpression(); - Assert.Equal(expected, actual); - } -} +using KubeOps.KubernetesClient.LabelSelectors; + +namespace KubeOps.KubernetesClient.Test; + +public class LabelSelectorTest : IntegrationTestBase +{ + [Fact] + public void Sould_Return_Correct_Expression() + { + var labelSelectors = new LabelSelector[] { + new EqualsSelector("app", Enumerable.Range(0,3).Select(x=>$"app-{x}").ToArray()), + new NotEqualsSelector("srv", Enumerable.Range(0,2).Select(x=>$"service-{x}").ToArray()) + }; + + string expected = "app in (app-0,app-1,app-2),srv notin (service-0,service-1)"; + var actual = labelSelectors.ToExpression(); + Assert.Equal(expected, actual); + } +} diff --git a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs index c51b715e..fff14f6d 100644 --- a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs +++ b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs @@ -1,99 +1,108 @@ -using FluentAssertions; - -using KubeOps.Abstractions.Builder; -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Events; -using KubeOps.Abstractions.Finalizer; -using KubeOps.Abstractions.Queue; -using KubeOps.Operator.Builder; -using KubeOps.Operator.Finalizer; -using KubeOps.Operator.Queue; -using KubeOps.Operator.Test.TestEntities; -using KubeOps.Operator.Watcher; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Builder; - -public class OperatorBuilderTest -{ - private readonly IOperatorBuilder _builder = new OperatorBuilder(new ServiceCollection(), new()); - - [Fact] - public void Should_Add_Default_Resources() - { - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(OperatorSettings) && - s.Lifetime == ServiceLifetime.Singleton); - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(EventPublisher) && - s.Lifetime == ServiceLifetime.Transient); - } - - [Fact] - public void Should_Add_Controller_Resources() - { - _builder.AddController(); - - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(IEntityController) && - s.ImplementationType == typeof(TestController) && - s.Lifetime == ServiceLifetime.Scoped); - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(IHostedService) && - s.ImplementationType == typeof(ResourceWatcher) && - s.Lifetime == ServiceLifetime.Singleton); - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(TimedEntityQueue) && - s.Lifetime == ServiceLifetime.Singleton); - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(EntityRequeue) && - s.Lifetime == ServiceLifetime.Transient); - } - - [Fact] - public void Should_Add_Finalizer_Resources() - { - _builder.AddFinalizer(string.Empty); - - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(TestFinalizer) && - s.Lifetime == ServiceLifetime.Transient); - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(FinalizerRegistration) && - s.Lifetime == ServiceLifetime.Singleton); - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(EntityFinalizerAttacher) && - s.Lifetime == ServiceLifetime.Transient); - } - - [Fact] - public void Should_Add_Leader_Elector() - { - var builder = new OperatorBuilder(new ServiceCollection(), new() { EnableLeaderElection = true }); - builder.Services.Should().Contain(s => - s.ServiceType == typeof(k8s.LeaderElection.LeaderElector) && - s.Lifetime == ServiceLifetime.Singleton); - } - - [Fact] - public void Should_Add_LeaderAwareResourceWatcher() - { - var builder = new OperatorBuilder(new ServiceCollection(), new() { EnableLeaderElection = true }); - builder.AddController(); - - builder.Services.Should().Contain(s => - s.ServiceType == typeof(IHostedService) && - s.ImplementationType == typeof(LeaderAwareResourceWatcher) && - s.Lifetime == ServiceLifetime.Singleton); - builder.Services.Should().NotContain(s => - s.ServiceType == typeof(IHostedService) && - s.ImplementationType == typeof(ResourceWatcher) && - s.Lifetime == ServiceLifetime.Singleton); - } - - private class TestController : IEntityController; - - private class TestFinalizer : IEntityFinalizer; -} +using FluentAssertions; + +using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Events; +using KubeOps.Abstractions.Finalizer; +using KubeOps.Abstractions.Queue; +using KubeOps.Operator.Builder; +using KubeOps.Operator.Finalizer; +using KubeOps.Operator.Queue; +using KubeOps.Operator.Test.TestEntities; +using KubeOps.Operator.Watcher; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Builder; + +public class OperatorBuilderTest +{ + private readonly IOperatorBuilder _builder = new OperatorBuilder(new ServiceCollection(), new()); + + [Fact] + public void Should_Add_Default_Resources() + { + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(OperatorSettings) && + s.Lifetime == ServiceLifetime.Singleton); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(EventPublisher) && + s.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void Should_Add_Controller_Resources() + { + _builder.AddController(); + + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IEntityController) && + s.ImplementationType == typeof(TestController) && + s.Lifetime == ServiceLifetime.Scoped); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IHostedService) && + s.ImplementationType == typeof(ResourceWatcher) && + s.Lifetime == ServiceLifetime.Singleton); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(TimedEntityQueue) && + s.Lifetime == ServiceLifetime.Singleton); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(EntityRequeue) && + s.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void Should_Add_Finalizer_Resources() + { + _builder.AddFinalizer(string.Empty); + + _builder.Services.Should().Contain(s => + s.IsKeyedService && + s.KeyedImplementationType == typeof(TestFinalizer) && + s.Lifetime == ServiceLifetime.Transient); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(EntityFinalizerAttacher) && + s.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void Should_Add_Leader_Elector() + { + var builder = new OperatorBuilder(new ServiceCollection(), new() { EnableLeaderElection = true }); + builder.Services.Should().Contain(s => + s.ServiceType == typeof(k8s.LeaderElection.LeaderElector) && + s.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void Should_Add_LeaderAwareResourceWatcher() + { + var builder = new OperatorBuilder(new ServiceCollection(), new() { EnableLeaderElection = true }); + builder.AddController(); + + builder.Services.Should().Contain(s => + s.ServiceType == typeof(IHostedService) && + s.ImplementationType == typeof(LeaderAwareResourceWatcher) && + s.Lifetime == ServiceLifetime.Singleton); + builder.Services.Should().NotContain(s => + s.ServiceType == typeof(IHostedService) && + s.ImplementationType == typeof(ResourceWatcher) && + s.Lifetime == ServiceLifetime.Singleton); + } + + private class TestController : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => + Task.CompletedTask; + } + + private class TestFinalizer : IEntityFinalizer + { + public Task FinalizeAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => + Task.CompletedTask; + } +} diff --git a/test/KubeOps.Operator.Test/Controller/CancelEntityRequeue.Integration.Test.cs b/test/KubeOps.Operator.Test/Controller/CancelEntityRequeue.Integration.Test.cs index 48a61c3f..75b75f5d 100644 --- a/test/KubeOps.Operator.Test/Controller/CancelEntityRequeue.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/Controller/CancelEntityRequeue.Integration.Test.cs @@ -1,87 +1,92 @@ -using FluentAssertions; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Queue; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Queue; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Controller; - -public class CancelEntityRequeueIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Cancel_Requeue_If_New_Event_Fires() - { - // This test fires the reconcile, which in turn requeues the entity. - // then immediately fires a new event, which should cancel the requeue. - - _mock.TargetInvocationCount = 2; - var e = await _client.CreateAsync( - new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - e.Spec.Username = "changed"; - await _client.UpdateAsync(e); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(2); - Services.GetRequiredService>().Count.Should().Be(0); - } - - [Fact] - public async Task Should_Not_Affect_Queues_If_Only_Status_Updated() - { - _mock.TargetInvocationCount = 1; - var result = await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - result.Status.Status = "changed"; - await _client.UpdateStatusAsync(result); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(1); - Services.GetRequiredService>().Count.Should().Be(1); - - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController(); - } - - private class TestController(InvocationCounter svc, - EntityRequeue requeue) - : IEntityController - { - public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - if (svc.Invocations.Count < 2) - { - requeue(entity, TimeSpan.FromMilliseconds(1000)); - } - - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Queue; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Queue; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Controller; + +public class CancelEntityRequeueIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Cancel_Requeue_If_New_Event_Fires() + { + // This test fires the reconcile, which in turn requeues the entity. + // then immediately fires a new event, which should cancel the requeue. + + _mock.TargetInvocationCount = 2; + var e = await _client.CreateAsync( + new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + e.Spec.Username = "changed"; + await _client.UpdateAsync(e); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(2); + Services.GetRequiredService>().Count.Should().Be(0); + } + + [Fact] + public async Task Should_Not_Affect_Queues_If_Only_Status_Updated() + { + _mock.TargetInvocationCount = 1; + var result = await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + result.Status.Status = "changed"; + await _client.UpdateStatusAsync(result); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(1); + Services.GetRequiredService>().Count.Should().Be(1); + + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController(); + } + + private class TestController(InvocationCounter svc, + EntityRequeue requeue) + : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + if (svc.Invocations.Count < 2) + { + requeue(entity, TimeSpan.FromMilliseconds(1000)); + } + + return Task.CompletedTask; + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/Controller/DeletedEntityRequeue.Integration.Test.cs b/test/KubeOps.Operator.Test/Controller/DeletedEntityRequeue.Integration.Test.cs index 5e180080..e6635c2d 100644 --- a/test/KubeOps.Operator.Test/Controller/DeletedEntityRequeue.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/Controller/DeletedEntityRequeue.Integration.Test.cs @@ -1,71 +1,71 @@ -using FluentAssertions; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Queue; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Queue; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Controller; - -public class DeletedEntityRequeueIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Cancel_Requeue_If_Entity_Is_Deleted() - { - _mock.TargetInvocationCount = 2; - var e = await _client.CreateAsync( - new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _client.DeleteAsync(e); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(2); - Services.GetRequiredService>().Count.Should().Be(0); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController(); - } - - private class TestController(InvocationCounter svc, - EntityRequeue requeue) - : IEntityController - { - public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - requeue(entity, TimeSpan.FromMilliseconds(1000)); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Queue; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Queue; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Controller; + +public class DeletedEntityRequeueIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Cancel_Requeue_If_Entity_Is_Deleted() + { + _mock.TargetInvocationCount = 2; + var e = await _client.CreateAsync( + new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _client.DeleteAsync(e); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(2); + Services.GetRequiredService>().Count.Should().Be(0); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController(); + } + + private class TestController(InvocationCounter svc, + EntityRequeue requeue) + : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + requeue(entity, TimeSpan.FromMilliseconds(1000)); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/Controller/EntityController.Integration.Test.cs b/test/KubeOps.Operator.Test/Controller/EntityController.Integration.Test.cs index 93f70905..f753af12 100644 --- a/test/KubeOps.Operator.Test/Controller/EntityController.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/Controller/EntityController.Integration.Test.cs @@ -1,131 +1,131 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Controller; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Controller; - -public class EntityControllerIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Call_Reconcile_On_New_Entity() - { - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(1); - var (method, entity) = _mock.Invocations[0]; - method.Should().Be("ReconcileAsync"); - entity.Should().BeOfType(); - entity.Name().Should().Be("test-entity"); - entity.Spec.Username.Should().Be("username"); - } - - [Fact] - public async Task Should_Call_Reconcile_On_Modification_Of_Entity() - { - _mock.TargetInvocationCount = 2; - - var result = - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - result.Spec.Username = "changed"; - await _client.UpdateAsync(result); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(2); - - Check(0, "username"); - Check(1, "changed"); - return; - - void Check(int idx, string username) - { - var (method, entity) = _mock.Invocations[idx]; - method.Should().Be("ReconcileAsync"); - entity.Should().BeOfType(); - entity.Spec.Username.Should().Be(username); - } - } - - [Fact] - public async Task Should_Not_Call_Reconcile_When_Only_Entity_Status_Changed() - { - _mock.TargetInvocationCount = 1; - - var result = - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - result.Status.Status = "changed"; - // Update or UpdateStatus do not call Reconcile - await _client.UpdateAsync(result); - await _client.UpdateStatusAsync(result); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(1); - - (string method, V1OperatorIntegrationTestEntity entity) = _mock.Invocations.Single(); - method.Should().Be("ReconcileAsync"); - entity.Should().BeOfType(); - entity.Spec.Username.Should().Be("username"); - } - - [Fact] - public async Task Should_Call_Delete_For_Deleted_Entity() - { - _mock.TargetInvocationCount = 2; - - var result = - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _client.DeleteAsync(result); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(2); - _mock.Invocations[0].Method.Should().Be("ReconcileAsync"); - _mock.Invocations[1].Method.Should().Be("DeletedAsync"); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController(); - } - - private class TestController(InvocationCounter svc) : IEntityController - { - public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Controller; + +public class EntityControllerIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Call_Reconcile_On_New_Entity() + { + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(1); + var (method, entity) = _mock.Invocations[0]; + method.Should().Be("ReconcileAsync"); + entity.Should().BeOfType(); + entity.Name().Should().Be("test-entity"); + entity.Spec.Username.Should().Be("username"); + } + + [Fact] + public async Task Should_Call_Reconcile_On_Modification_Of_Entity() + { + _mock.TargetInvocationCount = 2; + + var result = + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + result.Spec.Username = "changed"; + await _client.UpdateAsync(result); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(2); + + Check(0, "username"); + Check(1, "changed"); + return; + + void Check(int idx, string username) + { + var (method, entity) = _mock.Invocations[idx]; + method.Should().Be("ReconcileAsync"); + entity.Should().BeOfType(); + entity.Spec.Username.Should().Be(username); + } + } + + [Fact] + public async Task Should_Not_Call_Reconcile_When_Only_Entity_Status_Changed() + { + _mock.TargetInvocationCount = 1; + + var result = + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + result.Status.Status = "changed"; + // Update or UpdateStatus do not call Reconcile + await _client.UpdateAsync(result); + await _client.UpdateStatusAsync(result); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(1); + + (string method, V1OperatorIntegrationTestEntity entity) = _mock.Invocations.Single(); + method.Should().Be("ReconcileAsync"); + entity.Should().BeOfType(); + entity.Spec.Username.Should().Be("username"); + } + + [Fact] + public async Task Should_Call_Delete_For_Deleted_Entity() + { + _mock.TargetInvocationCount = 2; + + var result = + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _client.DeleteAsync(result); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(2); + _mock.Invocations[0].Method.Should().Be("ReconcileAsync"); + _mock.Invocations[1].Method.Should().Be("DeletedAsync"); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController(); + } + + private class TestController(InvocationCounter svc) : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/Controller/EntityRequeue.Integration.Test.cs b/test/KubeOps.Operator.Test/Controller/EntityRequeue.Integration.Test.cs index a601f238..1266dc9c 100644 --- a/test/KubeOps.Operator.Test/Controller/EntityRequeue.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/Controller/EntityRequeue.Integration.Test.cs @@ -1,98 +1,101 @@ -using FluentAssertions; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Queue; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Controller; - -public class EntityRequeueIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Not_Queue_If_Not_Requested() - { - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(1); - } - - [Fact] - public async Task Should_Requeue_Entity_And_Reconcile() - { - _mock.TargetInvocationCount = 5; - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(5); - } - - [Fact] - public async Task Should_Separately_And_Reliably_Requeue_And_Reconcile_Multiple_Entities_In_Parallel() - { - _mock.TargetInvocationCount = 100; - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity1", "username", _ns.Namespace)); - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity2", "username", _ns.Namespace)); - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity3", "username", _ns.Namespace)); - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity4", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - // Expecting invocations, but since in parallel, there is a possibility to for target hit while other are in flight. - _mock.Invocations.Count.Should().BeGreaterOrEqualTo(100).And.BeLessThan(105); - var invocationsGroupedById = _mock.Invocations.GroupBy(item => item.Entity.Metadata.Uid).ToList(); - invocationsGroupedById.Count.Should().Be(4); - var invocationDistributions = invocationsGroupedById - .Select(g => (double)g.Count() / _mock.Invocations.Count * 100) - .ToList(); - invocationDistributions - .All(p => p is >= 15 and <= 35) // Check that invocations are reasonably distributed - .Should() - .BeTrue($"each entity invocation proportion should be within the specified range of total invocations, " + - $"but instead the distributions were: '{string.Join(", ", invocationDistributions)}'"); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController(); - } - - private class TestController(InvocationCounter svc, - EntityRequeue requeue) - : IEntityController - { - public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - if (svc.Invocations.Count <= svc.TargetInvocationCount) - { - requeue(entity, TimeSpan.FromMilliseconds(1)); - } - - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Queue; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Controller; + +public class EntityRequeueIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Not_Queue_If_Not_Requested() + { + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(1); + } + + [Fact] + public async Task Should_Requeue_Entity_And_Reconcile() + { + _mock.TargetInvocationCount = 5; + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(5); + } + + [Fact] + public async Task Should_Separately_And_Reliably_Requeue_And_Reconcile_Multiple_Entities_In_Parallel() + { + _mock.TargetInvocationCount = 100; + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity1", "username", _ns.Namespace)); + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity2", "username", _ns.Namespace)); + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity3", "username", _ns.Namespace)); + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity4", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + // Expecting invocations, but since in parallel, there is a possibility to for target hit while other are in flight. + _mock.Invocations.Count.Should().BeGreaterOrEqualTo(100).And.BeLessThan(105); + var invocationsGroupedById = _mock.Invocations.GroupBy(item => item.Entity.Metadata.Uid).ToList(); + invocationsGroupedById.Count.Should().Be(4); + var invocationDistributions = invocationsGroupedById + .Select(g => (double)g.Count() / _mock.Invocations.Count * 100) + .ToList(); + invocationDistributions + .All(p => p is >= 15 and <= 35) // Check that invocations are reasonably distributed + .Should() + .BeTrue($"each entity invocation proportion should be within the specified range of total invocations, " + + $"but instead the distributions were: '{string.Join(", ", invocationDistributions)}'"); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController(); + } + + private class TestController(InvocationCounter svc, + EntityRequeue requeue) + : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + if (svc.Invocations.Count <= svc.TargetInvocationCount) + { + requeue(entity, TimeSpan.FromMilliseconds(1)); + } + + return Task.CompletedTask; + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => + Task.CompletedTask; + } +} diff --git a/test/KubeOps.Operator.Test/Events/EventPublisher.Integration.Test.cs b/test/KubeOps.Operator.Test/Events/EventPublisher.Integration.Test.cs index e0a2a435..26bc1eb4 100644 --- a/test/KubeOps.Operator.Test/Events/EventPublisher.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/Events/EventPublisher.Integration.Test.cs @@ -1,100 +1,105 @@ -using System.Security.Cryptography; -using System.Text; - -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Events; -using KubeOps.Abstractions.Queue; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Events; - -public class EventPublisherIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Create_New_Event() - { - var entity = - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("single-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - var eventName = $"{entity.Uid()}.single-entity.{_ns.Namespace}.REASON.message.Normal"; - var encodedEventName = - Convert.ToHexString( - SHA512.HashData( - Encoding.UTF8.GetBytes(eventName))); - - var e = await _client.GetAsync(encodedEventName, _ns.Namespace); - e!.Count.Should().Be(1); - e.Metadata.Annotations.Should().Contain(a => a.Key == "originalName" && a.Value == eventName); - } - - [Fact] - public async Task Should_Increase_Count_On_Existing_Event() - { - _mock.TargetInvocationCount = 5; - var entity = - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - var eventName = $"{entity.Uid()}.test-entity.{_ns.Namespace}.REASON.message.Normal"; - var encodedEventName = - Convert.ToHexString( - SHA512.HashData( - Encoding.UTF8.GetBytes(eventName))); - - var e = await _client.GetAsync(encodedEventName, _ns.Namespace); - e!.Count.Should().Be(5); - e.Metadata.Annotations.Should().Contain(a => a.Key == "originalName" && a.Value == eventName); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController(); - } - - private class TestController( - InvocationCounter svc, - EntityRequeue requeue, - EventPublisher eventPublisher) - : IEntityController - { - public async Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - await eventPublisher(entity, "REASON", "message"); - svc.Invocation(entity); - - if (svc.Invocations.Count < svc.TargetInvocationCount) - { - requeue(entity, TimeSpan.FromMilliseconds(10)); - } - } - } -} +using System.Security.Cryptography; +using System.Text; + +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Events; +using KubeOps.Abstractions.Queue; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Events; + +public class EventPublisherIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Create_New_Event() + { + var entity = + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("single-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + var eventName = $"{entity.Uid()}.single-entity.{_ns.Namespace}.REASON.message.Normal"; + var encodedEventName = + Convert.ToHexString( + SHA512.HashData( + Encoding.UTF8.GetBytes(eventName))); + + var e = await _client.GetAsync(encodedEventName, _ns.Namespace); + e!.Count.Should().Be(1); + e.Metadata.Annotations.Should().Contain(a => a.Key == "originalName" && a.Value == eventName); + } + + [Fact] + public async Task Should_Increase_Count_On_Existing_Event() + { + _mock.TargetInvocationCount = 5; + var entity = + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + var eventName = $"{entity.Uid()}.test-entity.{_ns.Namespace}.REASON.message.Normal"; + var encodedEventName = + Convert.ToHexString( + SHA512.HashData( + Encoding.UTF8.GetBytes(eventName))); + + var e = await _client.GetAsync(encodedEventName, _ns.Namespace); + e!.Count.Should().Be(5); + e.Metadata.Annotations.Should().Contain(a => a.Key == "originalName" && a.Value == eventName); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController(); + } + + private class TestController( + InvocationCounter svc, + EntityRequeue requeue, + EventPublisher eventPublisher) + : IEntityController + { + public async Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + await eventPublisher(entity, "REASON", "message", cancellationToken: cancellationToken); + svc.Invocation(entity); + + if (svc.Invocations.Count < svc.TargetInvocationCount) + { + requeue(entity, TimeSpan.FromMilliseconds(10)); + } + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/Finalizer/EntityFinalizer.Integration.Test.cs b/test/KubeOps.Operator.Test/Finalizer/EntityFinalizer.Integration.Test.cs index cc691982..bcf1a687 100644 --- a/test/KubeOps.Operator.Test/Finalizer/EntityFinalizer.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/Finalizer/EntityFinalizer.Integration.Test.cs @@ -1,249 +1,250 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Controller; -using KubeOps.Abstractions.Finalizer; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.Finalizer; - -public class EntityFinalizerIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Not_Call_Controller_When_Attaching_Finalizer() - { - var watcherCounter = new InvocationCounter { TargetInvocationCount = 2 }; - using var watcher = - _client.Watch((_, e) => watcherCounter.Invocation(e), - @namespace: _ns.Namespace); - - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first", "first", _ns.Namespace)); - await _mock.WaitForInvocations; - await watcherCounter.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(1); - // the resource watcher will be called twice (since the finalizer is added). - watcherCounter.Invocations.Count.Should().Be(2); - var (method, entity) = _mock.Invocations[0]; - method.Should().Be("ReconcileAsync"); - entity.Name().Should().Be("first"); - } - - [Fact] - public async Task Should_Not_Call_Controller_When_Attaching_Finalizers() - { - var watcherCounter = new InvocationCounter { TargetInvocationCount = 3 }; - using var watcher = - _client.Watch((_, e) => watcherCounter.Invocation(e), - @namespace: _ns.Namespace); - - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first-second", "first-second", _ns.Namespace)); - await _mock.WaitForInvocations; - await watcherCounter.WaitForInvocations; - - _mock.Invocations.Count.Should().Be(1); - // the resource watcher will be called trice (since the finalizers are added). - watcherCounter.Invocations.Count.Should().Be(3); - var (method, entity) = _mock.Invocations[0]; - method.Should().Be("ReconcileAsync"); - entity.Name().Should().Be("first-second"); - } - - [Fact] - public async Task Should_Attach_Finalizer_On_Entity() - { - var watcherCounter = new InvocationCounter { TargetInvocationCount = 2 }; - using var watcher = - _client.Watch((_, e) => watcherCounter.Invocation(e), - @namespace: _ns.Namespace); - - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first", "first", _ns.Namespace)); - await _mock.WaitForInvocations; - await watcherCounter.WaitForInvocations; - - var result = await _client.GetAsync("first", _ns.Namespace); - result!.Metadata.Finalizers.Should().Contain("first"); - } - - [Fact] - public async Task Should_Attach_Multiple_Finalizer_On_Entity() - { - var watcherCounter = new InvocationCounter { TargetInvocationCount = 3 }; - using var watcher = - _client.Watch((_, e) => watcherCounter.Invocation(e), - @namespace: _ns.Namespace); - - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first-second", "first-second", _ns.Namespace)); - await _mock.WaitForInvocations; - await watcherCounter.WaitForInvocations; - - var result = await _client.GetAsync("first-second", _ns.Namespace); - result!.Metadata.Finalizers.Should().Contain("first"); - result.Metadata.Finalizers.Should().Contain("second"); - } - - [Fact] - public async Task Should_Finalize_And_Delete_An_Entity() - { - var attachCounter = new InvocationCounter { TargetInvocationCount = 2 }; - using (_client.Watch((_, e) => attachCounter.Invocation(e), - @namespace: _ns.Namespace)) - { - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first", "first", _ns.Namespace)); - - // 1 invocation for create - await _mock.WaitForInvocations; - // 2 invocations: create, add finalizer - await attachCounter.WaitForInvocations; - } - - var oldInvocs = _mock.Invocations.ToList(); - - // reset to catch 1 invocation: deleted - _mock.Clear(); - - // 3 invocations: 1 when the watcher is created and the entity already exists, 1 for finalize, 1 for delete. - var finalizeCounter = new InvocationCounter { TargetInvocationCount = 3 }; - using (_client.Watch((_, e) => finalizeCounter.Invocation(e), - @namespace: _ns.Namespace)) - { - await _client.DeleteAsync("first", _ns.Namespace); - // 2 invocations: call to delete, update after finalize - await finalizeCounter.WaitForInvocations; - // 1 invocation for delete - await _mock.WaitForInvocations; - } - - oldInvocs.Should().Contain(i => i.Method == "ReconcileAsync"); - _mock.Invocations.Should().Contain(i => i.Method == "DeletedAsync"); - _mock.Invocations.Should().Contain(i => i.Method == "FinalizeAsync"); - } - - [Fact] - public async Task Should_Finalize_And_Delete_An_Entity_With_Multiple_Finalizer() - { - var attachCounter = new InvocationCounter { TargetInvocationCount = 3 }; - using (_client.Watch((_, e) => attachCounter.Invocation(e), - @namespace: _ns.Namespace)) - { - await _client.CreateAsync( - new V1OperatorIntegrationTestEntity("first-second", "first-second", _ns.Namespace)); - - // 1 invocation for create - await _mock.WaitForInvocations; - // 3 invocations: create, add finalizers - await attachCounter.WaitForInvocations; - } - - var oldInvocs = _mock.Invocations.ToList(); - - // reset to catch 3 invocation: deleted, and 2 finalized - _mock.Clear(); - _mock.TargetInvocationCount = 3; - - // 4 invocations: 1 when the watcher is created and the entity already exists, 2 for finalize, 1 for delete. - var finalizeCounter = new InvocationCounter { TargetInvocationCount = 4 }; - using (_client.Watch((_, e) => finalizeCounter.Invocation(e), - @namespace: _ns.Namespace)) - { - await _client.DeleteAsync("first-second", _ns.Namespace); - // 2 invocations: call to delete, update after finalize - await finalizeCounter.WaitForInvocations; - // 1 invocation for delete - await _mock.WaitForInvocations; - } - - oldInvocs.Should().Contain(i => i.Method == "ReconcileAsync"); - _mock.Invocations.Should().Contain(i => i.Method == "DeletedAsync"); - _mock.Invocations.Should().Contain(i => i.Method == "FinalizeAsync"); - _mock.Invocations.Count(i => i.Method == "FinalizeAsync").Should().Be(2); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - var entities = await _client.ListAsync(_ns.Namespace); - foreach (var e in entities) - { - if (e.Metadata.Finalizers is null) - { - continue; - } - - e.Metadata.Finalizers.Clear(); - await _client.UpdateAsync(e); - } - - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController() - .AddFinalizer("first") - .AddFinalizer("second"); - } - - private class TestController(InvocationCounter svc, - EntityFinalizerAttacher first, - EntityFinalizerAttacher second) - : IEntityController - { - public async Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - if (entity.Name().Contains("first")) - { - entity = await first(entity); - } - - if (entity.Name().Contains("second")) - { - await second(entity); - } - } - - public Task DeletedAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } - - private class FirstFinalizer(InvocationCounter svc) : IEntityFinalizer - { - public Task FinalizeAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } - - private class SecondFinalizer(InvocationCounter svc) : IEntityFinalizer - { - public Task FinalizeAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.Abstractions.Finalizer; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.Finalizer; + +public class EntityFinalizerIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Not_Call_Controller_When_Attaching_Finalizer() + { + var watcherCounter = new InvocationCounter { TargetInvocationCount = 2 }; + using var watcher = + _client.Watch((_, e) => watcherCounter.Invocation(e), + @namespace: _ns.Namespace); + + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first", "first", _ns.Namespace)); + await _mock.WaitForInvocations; + await watcherCounter.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(1); + // the resource watcher will be called twice (since the finalizer is added). + watcherCounter.Invocations.Count.Should().Be(2); + var (method, entity) = _mock.Invocations[0]; + method.Should().Be("ReconcileAsync"); + entity.Name().Should().Be("first"); + } + + [Fact] + public async Task Should_Not_Call_Controller_When_Attaching_Finalizers() + { + var watcherCounter = new InvocationCounter { TargetInvocationCount = 3 }; + using var watcher = + _client.Watch((_, e) => watcherCounter.Invocation(e), + @namespace: _ns.Namespace); + + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first-second", "first-second", _ns.Namespace)); + await _mock.WaitForInvocations; + await watcherCounter.WaitForInvocations; + + _mock.Invocations.Count.Should().Be(1); + // the resource watcher will be called trice (since the finalizers are added). + watcherCounter.Invocations.Count.Should().Be(3); + var (method, entity) = _mock.Invocations[0]; + method.Should().Be("ReconcileAsync"); + entity.Name().Should().Be("first-second"); + } + + [Fact] + public async Task Should_Attach_Finalizer_On_Entity() + { + var watcherCounter = new InvocationCounter { TargetInvocationCount = 2 }; + using var watcher = + _client.Watch((_, e) => watcherCounter.Invocation(e), + @namespace: _ns.Namespace); + + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first", "first", _ns.Namespace)); + await _mock.WaitForInvocations; + await watcherCounter.WaitForInvocations; + + var result = await _client.GetAsync("first", _ns.Namespace); + result!.Metadata.Finalizers.Should().Contain("first"); + } + + [Fact] + public async Task Should_Attach_Multiple_Finalizer_On_Entity() + { + var watcherCounter = new InvocationCounter { TargetInvocationCount = 3 }; + using var watcher = + _client.Watch((_, e) => watcherCounter.Invocation(e), + @namespace: _ns.Namespace); + + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first-second", "first-second", _ns.Namespace)); + await _mock.WaitForInvocations; + await watcherCounter.WaitForInvocations; + + var result = await _client.GetAsync("first-second", _ns.Namespace); + result!.Metadata.Finalizers.Should().Contain("first"); + result.Metadata.Finalizers.Should().Contain("second"); + } + + [Fact] + public async Task Should_Finalize_And_Delete_An_Entity() + { + var attachCounter = new InvocationCounter { TargetInvocationCount = 2 }; + using (_client.Watch((_, e) => attachCounter.Invocation(e), + @namespace: _ns.Namespace)) + { + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("first", "first", _ns.Namespace)); + + // 1 invocation for create + await _mock.WaitForInvocations; + // 2 invocations: create, add finalizer + await attachCounter.WaitForInvocations; + } + + var oldInvocs = _mock.Invocations.ToList(); + + // reset to catch 2 invocations: finalized and deleted + _mock.Clear(); + _mock.TargetInvocationCount = 2; + + // 3 invocations: 1 when the watcher is created and the entity already exists, 1 for finalize, 1 for delete. + var finalizeCounter = new InvocationCounter { TargetInvocationCount = 3 }; + using (_client.Watch((_, e) => finalizeCounter.Invocation(e), + @namespace: _ns.Namespace)) + { + await _client.DeleteAsync("first", _ns.Namespace); + // 2 invocations: call to delete, update after finalize + await finalizeCounter.WaitForInvocations; + // 2 invocations for reconciliation and deletion + await _mock.WaitForInvocations; + } + + oldInvocs.Should().Contain(i => i.Method == "ReconcileAsync"); + _mock.Invocations.Should().Contain(i => i.Method == "DeletedAsync"); + _mock.Invocations.Should().Contain(i => i.Method == "FinalizeAsync"); + } + + [Fact] + public async Task Should_Finalize_And_Delete_An_Entity_With_Multiple_Finalizer() + { + var attachCounter = new InvocationCounter { TargetInvocationCount = 3 }; + using (_client.Watch((_, e) => attachCounter.Invocation(e), + @namespace: _ns.Namespace)) + { + await _client.CreateAsync( + new V1OperatorIntegrationTestEntity("first-second", "first-second", _ns.Namespace)); + + // 1 invocation for create + await _mock.WaitForInvocations; + // 3 invocations: create, add finalizers + await attachCounter.WaitForInvocations; + } + + var oldInvocs = _mock.Invocations.ToList(); + + // reset to catch 3 invocation: deleted, and 2 finalized + _mock.Clear(); + _mock.TargetInvocationCount = 3; + + // 4 invocations: 1 when the watcher is created and the entity already exists, 2 for finalize, 1 for delete. + var finalizeCounter = new InvocationCounter { TargetInvocationCount = 4 }; + using (_client.Watch((_, e) => finalizeCounter.Invocation(e), + @namespace: _ns.Namespace)) + { + await _client.DeleteAsync("first-second", _ns.Namespace); + // 2 invocations: call to delete, update after finalize + await finalizeCounter.WaitForInvocations; + // 1 invocation for delete + await _mock.WaitForInvocations; + } + + oldInvocs.Should().Contain(i => i.Method == "ReconcileAsync"); + _mock.Invocations.Should().Contain(i => i.Method == "DeletedAsync"); + _mock.Invocations.Should().Contain(i => i.Method == "FinalizeAsync"); + _mock.Invocations.Count(i => i.Method == "FinalizeAsync").Should().Be(2); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + var entities = await _client.ListAsync(_ns.Namespace); + foreach (var e in entities) + { + if (e.Metadata.Finalizers is null) + { + continue; + } + + e.Metadata.Finalizers.Clear(); + await _client.UpdateAsync(e); + } + + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController() + .AddFinalizer("first") + .AddFinalizer("second"); + } + + private class TestController(InvocationCounter svc, + EntityFinalizerAttacher first, + EntityFinalizerAttacher second) + : IEntityController + { + public async Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + if (entity.Name().Contains("first")) + { + entity = await first(entity); + } + + if (entity.Name().Contains("second")) + { + await second(entity); + } + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } + + private class FirstFinalizer(InvocationCounter svc) : IEntityFinalizer + { + public Task FinalizeAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } + + private class SecondFinalizer(InvocationCounter svc) : IEntityFinalizer + { + public Task FinalizeAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/GlobalUsings.cs b/test/KubeOps.Operator.Test/GlobalUsings.cs index e1065597..c802f448 100644 --- a/test/KubeOps.Operator.Test/GlobalUsings.cs +++ b/test/KubeOps.Operator.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.Operator.Test/IntegrationTestCollection.cs b/test/KubeOps.Operator.Test/IntegrationTestCollection.cs index b7fd3d5d..cf8f0557 100644 --- a/test/KubeOps.Operator.Test/IntegrationTestCollection.cs +++ b/test/KubeOps.Operator.Test/IntegrationTestCollection.cs @@ -1,108 +1,108 @@ -using k8s; -using k8s.Models; - -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; -using KubeOps.Transpiler; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Test; - -[CollectionDefinition(Name, DisableParallelization = true)] -public class IntegrationTestCollection : ICollectionFixture -{ - public const string Name = "Integration Tests"; -} - -[Collection(IntegrationTestCollection.Name)] -public abstract class IntegrationTestBase : IAsyncLifetime -{ - private IHost? _host; - - protected IServiceProvider Services => _host?.Services ?? throw new InvalidOperationException(); - - public virtual async Task InitializeAsync() - { - var builder = Host.CreateApplicationBuilder(); -#if DEBUG - builder.Logging.AddSystemdConsole(); - builder.Logging.SetMinimumLevel(LogLevel.Trace); -#else - builder.Logging.SetMinimumLevel(LogLevel.None); -#endif - ConfigureHost(builder); - _host = builder.Build(); - await _host.StartAsync(); - } - - public virtual async Task DisposeAsync() - { - if (_host is null) - { - return; - } - - await _host.StopAsync(); - } - - protected abstract void ConfigureHost(HostApplicationBuilder builder); -} - -public sealed class TestNamespaceProvider : IAsyncLifetime -{ - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private V1Namespace _namespace = null!; - - public string Namespace { get; } = Guid.NewGuid().ToString().ToLower(); - - public async Task InitializeAsync() - { - _namespace = - await _client.CreateAsync(new V1Namespace(metadata: new V1ObjectMeta(name: Namespace)).Initialize()); - } - - public async Task DisposeAsync() - { - await _client.DeleteAsync(_namespace); - _client.Dispose(); - } -} - -public sealed class CrdInstaller : IAsyncLifetime -{ - private List _crds = []; - - public async Task InitializeAsync() - { - await using var p = new MlcProvider(); - await p.InitializeAsync(); - _crds = p.Mlc.Transpile(new[] { typeof(V1OperatorIntegrationTestEntity) }).ToList(); - - using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); - foreach (var crd in _crds) - { - switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( - fieldSelector: $"metadata.name={crd.Name()}")) - { - case { Items: [var existing] }: - crd.Metadata.ResourceVersion = existing.ResourceVersion(); - await client.ApiextensionsV1.ReplaceCustomResourceDefinitionAsync(crd, crd.Name()); - break; - default: - await client.ApiextensionsV1.CreateCustomResourceDefinitionAsync(crd); - break; - } - } - } - - public async Task DisposeAsync() - { - using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); - foreach (var crd in _crds) - { - await client.ApiextensionsV1.DeleteCustomResourceDefinitionAsync(crd.Name()); - } - } -} +using k8s; +using k8s.Models; + +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; +using KubeOps.Transpiler; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Test; + +[CollectionDefinition(Name, DisableParallelization = true)] +public class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "Integration Tests"; +} + +[Collection(IntegrationTestCollection.Name)] +public abstract class IntegrationTestBase : IAsyncLifetime +{ + private IHost? _host; + + protected IServiceProvider Services => _host?.Services ?? throw new InvalidOperationException(); + + public virtual async Task InitializeAsync() + { + var builder = Host.CreateApplicationBuilder(); +#if DEBUG + builder.Logging.AddSystemdConsole(); + builder.Logging.SetMinimumLevel(LogLevel.Trace); +#else + builder.Logging.SetMinimumLevel(LogLevel.None); +#endif + ConfigureHost(builder); + _host = builder.Build(); + await _host.StartAsync(); + } + + public virtual async Task DisposeAsync() + { + if (_host is null) + { + return; + } + + await _host.StopAsync(); + } + + protected abstract void ConfigureHost(HostApplicationBuilder builder); +} + +public sealed class TestNamespaceProvider : IAsyncLifetime +{ + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private V1Namespace _namespace = null!; + + public string Namespace { get; } = Guid.NewGuid().ToString().ToLower(); + + public async Task InitializeAsync() + { + _namespace = + await _client.CreateAsync(new V1Namespace(metadata: new V1ObjectMeta(name: Namespace)).Initialize()); + } + + public async Task DisposeAsync() + { + await _client.DeleteAsync(_namespace); + _client.Dispose(); + } +} + +public sealed class CrdInstaller : IAsyncLifetime +{ + private List _crds = []; + + public async Task InitializeAsync() + { + await using var p = new MlcProvider(); + await p.InitializeAsync(); + _crds = p.Mlc.Transpile(new[] { typeof(V1OperatorIntegrationTestEntity) }).ToList(); + + using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); + foreach (var crd in _crds) + { + switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( + fieldSelector: $"metadata.name={crd.Name()}")) + { + case { Items: [var existing] }: + crd.Metadata.ResourceVersion = existing.ResourceVersion(); + await client.ApiextensionsV1.ReplaceCustomResourceDefinitionAsync(crd, crd.Name()); + break; + default: + await client.ApiextensionsV1.CreateCustomResourceDefinitionAsync(crd); + break; + } + } + } + + public async Task DisposeAsync() + { + using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); + foreach (var crd in _crds) + { + await client.ApiextensionsV1.DeleteCustomResourceDefinitionAsync(crd.Name()); + } + } +} diff --git a/test/KubeOps.Operator.Test/InvocationCounter.cs b/test/KubeOps.Operator.Test/InvocationCounter.cs index 321169ef..c68bcf60 100644 --- a/test/KubeOps.Operator.Test/InvocationCounter.cs +++ b/test/KubeOps.Operator.Test/InvocationCounter.cs @@ -1,33 +1,39 @@ -using System.Runtime.CompilerServices; - -using k8s; -using k8s.Models; - -namespace KubeOps.Operator.Test; - -public class InvocationCounter - where TEntity : IKubernetesObject -{ - private TaskCompletionSource _task = new(); - public readonly List<(string Method, TEntity Entity)> Invocations = []; - - public Task WaitForInvocations => _task.Task; - - public int TargetInvocationCount { get; set; } = 1; - - public void Invocation(TEntity entity, [CallerMemberName] string name = "Invocation") - { - Invocations.Add((name, entity)); - if (Invocations.Count >= TargetInvocationCount) - { - _task.TrySetResult(); - } - } - - public void Clear() - { - Invocations.Clear(); - _task = new TaskCompletionSource(); - TargetInvocationCount = 1; - } -} +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; + +using k8s; +using k8s.Models; + +namespace KubeOps.Operator.Test; + +public class InvocationCounter + where TEntity : IKubernetesObject +{ + private TaskCompletionSource _task = new(); + private readonly ConcurrentQueue<(string Method, TEntity Entity)> _invocations = new(); + public IReadOnlyList<(string Method, TEntity Entity)> Invocations => _invocations.ToList(); + +#if DEBUG + public Task WaitForInvocations => _task.Task; +#else + public Task WaitForInvocations => _task.Task.WaitAsync(TimeSpan.FromSeconds(30)); +#endif + + public int TargetInvocationCount { get; set; } = 1; + + public void Invocation(TEntity entity, [CallerMemberName] string name = "Invocation") + { + _invocations.Enqueue((name, entity)); + if (Invocations.Count >= TargetInvocationCount) + { + _task.TrySetResult(); + } + } + + public void Clear() + { + _invocations.Clear(); + _task = new TaskCompletionSource(); + TargetInvocationCount = 1; + } +} diff --git a/test/KubeOps.Operator.Test/LeaderElector/LeaderAwareness.Integration.Test.cs b/test/KubeOps.Operator.Test/LeaderElector/LeaderAwareness.Integration.Test.cs index 5cb9abf2..85c1fafa 100644 --- a/test/KubeOps.Operator.Test/LeaderElector/LeaderAwareness.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/LeaderElector/LeaderAwareness.Integration.Test.cs @@ -1,66 +1,66 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Controller; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test.LeaderElector; - -public class LeaderAwarenessIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - - [Fact] - public async Task Should_Create_V1Lease_And_Start_Watcher() - { - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - - var lease = await _client.GetAsync("kubernetesoperator-leader", "default"); - lease!.Spec.HolderIdentity.Should().Be(Environment.MachineName); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _ns.DisposeAsync(); - await _client.DeleteAsync(await _client.ListAsync("default")); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.EnableLeaderElection = true) - .AddController(); - } - - private class TestController(InvocationCounter svc) : IEntityController - { - public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test.LeaderElector; + +public class LeaderAwarenessIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + + [Fact] + public async Task Should_Create_V1Lease_And_Start_Watcher() + { + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + + var lease = await _client.GetAsync("kubernetesoperator-leader", "default"); + lease!.Spec.HolderIdentity.Should().Be(Environment.MachineName); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _ns.DisposeAsync(); + await _client.DeleteAsync(await _client.ListAsync("default")); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.EnableLeaderElection = true) + .AddController(); + } + + private class TestController(InvocationCounter svc) : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/MlcProvider.cs b/test/KubeOps.Operator.Test/MlcProvider.cs index b7be8089..a81c17ca 100644 --- a/test/KubeOps.Operator.Test/MlcProvider.cs +++ b/test/KubeOps.Operator.Test/MlcProvider.cs @@ -1,55 +1,55 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -using KubeOps.Transpiler; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; - -namespace KubeOps.Operator.Test; - -public class MlcProvider : IAsyncLifetime -{ - private static readonly SemaphoreSlim Semaphore = new(1, 1); - - static MlcProvider() - { - MSBuildLocator.RegisterDefaults(); - } - - public MetadataLoadContext Mlc { get; private set; } = null!; - - public async Task InitializeAsync() - { - var assemblyConfigurationAttribute = - typeof(MlcProvider).Assembly.GetCustomAttribute(); - var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; - - try - { - await Semaphore.WaitAsync(); - using var workspace = MSBuildWorkspace.Create(new Dictionary - { - { "Configuration", buildConfigurationName }, - }); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - var project = await workspace.OpenProjectAsync("../../../KubeOps.Operator.Test.csproj"); - - Mlc = ContextCreator.Create(Directory - .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") - .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) - .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); - } - finally - { - Semaphore.Release(); - } - } - - public Task DisposeAsync() - { - Mlc.Dispose(); - return Task.CompletedTask; - } -} +using System.Reflection; +using System.Runtime.InteropServices; + +using KubeOps.Transpiler; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +namespace KubeOps.Operator.Test; + +public class MlcProvider : IAsyncLifetime +{ + private static readonly SemaphoreSlim Semaphore = new(1, 1); + + static MlcProvider() + { + MSBuildLocator.RegisterDefaults(); + } + + public MetadataLoadContext Mlc { get; private set; } = null!; + + public async Task InitializeAsync() + { + var assemblyConfigurationAttribute = + typeof(MlcProvider).Assembly.GetCustomAttribute(); + var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; + + try + { + await Semaphore.WaitAsync(); + using var workspace = MSBuildWorkspace.Create(new Dictionary + { + { "Configuration", buildConfigurationName }, + }); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + var project = await workspace.OpenProjectAsync("../../../KubeOps.Operator.Test.csproj"); + + Mlc = ContextCreator.Create(Directory + .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") + .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) + .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); + } + finally + { + Semaphore.Release(); + } + } + + public Task DisposeAsync() + { + Mlc.Dispose(); + return Task.CompletedTask; + } +} diff --git a/test/KubeOps.Operator.Test/NamespacedOperator.Integration.Test.cs b/test/KubeOps.Operator.Test/NamespacedOperator.Integration.Test.cs index 7e33ac37..265c995e 100644 --- a/test/KubeOps.Operator.Test/NamespacedOperator.Integration.Test.cs +++ b/test/KubeOps.Operator.Test/NamespacedOperator.Integration.Test.cs @@ -1,92 +1,92 @@ -using FluentAssertions; - -using k8s; -using k8s.Models; - -using KubeOps.Abstractions.Controller; -using KubeOps.KubernetesClient; -using KubeOps.Operator.Test.TestEntities; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Test; - -public class NamespacedOperatorIntegrationTest : IntegrationTestBase -{ - private readonly InvocationCounter _mock = new(); - private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); - private readonly TestNamespaceProvider _ns = new(); - private V1Namespace _otherNamespace = null!; - - [Fact] - public async Task Should_Call_Reconcile_On_Entity_In_Namespace() - { - var otherNsWatchCounter = new InvocationCounter { TargetInvocationCount = 1 }; - using var otherNsWatcher = - _client.Watch((_, e) => otherNsWatchCounter.Invocation(e), - @namespace: _otherNamespace.Name()); - - await _client.CreateAsync( - new V1OperatorIntegrationTestEntity("test-entity", "username", _otherNamespace.Name())); - await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); - await _mock.WaitForInvocations; - await otherNsWatchCounter.WaitForInvocations; - _mock.Invocations.Count.Should().Be(1); - otherNsWatchCounter.Invocations.Count.Should().Be(1); - } - - [Fact] - public async Task Should_Not_Call_Reconcile_On_Entity_In_Other_Namespace() - { - var watcherCounter = new InvocationCounter { TargetInvocationCount = 1 }; - using var watcher = _client.Watch((_, e) => watcherCounter.Invocation(e), - @namespace: _otherNamespace.Name()); - - await _client.CreateAsync( - new V1OperatorIntegrationTestEntity("test-entity2", "username", _otherNamespace.Name())); - await watcherCounter.WaitForInvocations; - _mock.Invocations.Count.Should().Be(0); - watcherCounter.Invocations.Count.Should().Be(1); - } - - public override async Task InitializeAsync() - { - await base.InitializeAsync(); - _otherNamespace = - await _client.CreateAsync(new V1Namespace(metadata: new(name: Guid.NewGuid().ToString().ToLower())) - .Initialize()); - await _ns.InitializeAsync(); - } - - public override async Task DisposeAsync() - { - await base.DisposeAsync(); - await _client.DeleteAsync(_otherNamespace); - await _ns.DisposeAsync(); - _client.Dispose(); - } - - protected override void ConfigureHost(HostApplicationBuilder builder) - { - builder.Services - .AddSingleton(_mock) - .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) - .AddController(); - } - - private class TestController(InvocationCounter svc) : IEntityController - { - public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - - public Task DeletedAsync(V1OperatorIntegrationTestEntity entity) - { - svc.Invocation(entity); - return Task.CompletedTask; - } - } -} +using FluentAssertions; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Controller; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Test.TestEntities; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Test; + +public class NamespacedOperatorIntegrationTest : IntegrationTestBase +{ + private readonly InvocationCounter _mock = new(); + private readonly IKubernetesClient _client = new KubernetesClient.KubernetesClient(); + private readonly TestNamespaceProvider _ns = new(); + private V1Namespace _otherNamespace = null!; + + [Fact] + public async Task Should_Call_Reconcile_On_Entity_In_Namespace() + { + var otherNsWatchCounter = new InvocationCounter { TargetInvocationCount = 1 }; + using var otherNsWatcher = + _client.Watch((_, e) => otherNsWatchCounter.Invocation(e), + @namespace: _otherNamespace.Name()); + + await _client.CreateAsync( + new V1OperatorIntegrationTestEntity("test-entity", "username", _otherNamespace.Name())); + await _client.CreateAsync(new V1OperatorIntegrationTestEntity("test-entity", "username", _ns.Namespace)); + await _mock.WaitForInvocations; + await otherNsWatchCounter.WaitForInvocations; + _mock.Invocations.Count.Should().Be(1); + otherNsWatchCounter.Invocations.Count.Should().Be(1); + } + + [Fact] + public async Task Should_Not_Call_Reconcile_On_Entity_In_Other_Namespace() + { + var watcherCounter = new InvocationCounter { TargetInvocationCount = 1 }; + using var watcher = _client.Watch((_, e) => watcherCounter.Invocation(e), + @namespace: _otherNamespace.Name()); + + await _client.CreateAsync( + new V1OperatorIntegrationTestEntity("test-entity2", "username", _otherNamespace.Name())); + await watcherCounter.WaitForInvocations; + _mock.Invocations.Count.Should().Be(0); + watcherCounter.Invocations.Count.Should().Be(1); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + _otherNamespace = + await _client.CreateAsync(new V1Namespace(metadata: new(name: Guid.NewGuid().ToString().ToLower())) + .Initialize()); + await _ns.InitializeAsync(); + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _client.DeleteAsync(_otherNamespace); + await _ns.DisposeAsync(); + _client.Dispose(); + } + + protected override void ConfigureHost(HostApplicationBuilder builder) + { + builder.Services + .AddSingleton(_mock) + .AddKubernetesOperator(s => s.Namespace = _ns.Namespace) + .AddController(); + } + + private class TestController(InvocationCounter svc) : IEntityController + { + public Task ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + + public Task DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) + { + svc.Invocation(entity); + return Task.CompletedTask; + } + } +} diff --git a/test/KubeOps.Operator.Test/TestEntities/V1OperatorIntegrationTestEntity.cs b/test/KubeOps.Operator.Test/TestEntities/V1OperatorIntegrationTestEntity.cs index e590246a..51231e36 100644 --- a/test/KubeOps.Operator.Test/TestEntities/V1OperatorIntegrationTestEntity.cs +++ b/test/KubeOps.Operator.Test/TestEntities/V1OperatorIntegrationTestEntity.cs @@ -1,35 +1,35 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Operator.Test.TestEntities; - -[KubernetesEntity(Group = "operator.test", ApiVersion = "v1", Kind = "OperatorIntegrationTest")] -public class V1OperatorIntegrationTestEntity : CustomKubernetesEntity -{ - public V1OperatorIntegrationTestEntity() - { - ApiVersion = "operator.test/v1"; - Kind = "OperatorIntegrationTest"; - } - - public V1OperatorIntegrationTestEntity(string name, string username, string ns) : this() - { - Metadata.Name = name; - Spec.Username = username; - Metadata.NamespaceProperty = ns; - } - - public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; - - public class EntitySpec - { - public string Username { get; set; } = string.Empty; - } - - public class EntityStatus - { - public string Status { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace KubeOps.Operator.Test.TestEntities; + +[KubernetesEntity(Group = "operator.test", ApiVersion = "v1", Kind = "OperatorIntegrationTest")] +public class V1OperatorIntegrationTestEntity : CustomKubernetesEntity +{ + public V1OperatorIntegrationTestEntity() + { + ApiVersion = "operator.test/v1"; + Kind = "OperatorIntegrationTest"; + } + + public V1OperatorIntegrationTestEntity(string name, string username, string ns) : this() + { + Metadata.Name = name; + Spec.Username = username; + Metadata.NamespaceProperty = ns; + } + + public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; + + public class EntitySpec + { + public string Username { get; set; } = string.Empty; + } + + public class EntityStatus + { + public string Status { get; set; } = string.Empty; + } +} diff --git a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs index 694aafeb..8102b4dc 100644 --- a/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Builder/OperatorBuilderExtensions.Test.cs @@ -1,37 +1,37 @@ -using FluentAssertions; - -using KubeOps.Abstractions.Builder; -using KubeOps.Operator.Builder; -using KubeOps.Operator.Web.Builder; -using KubeOps.Operator.Web.LocalTunnel; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace KubeOps.Operator.Web.Test.Builder; - -public class OperatorBuilderExtensionsTest -{ - private readonly IOperatorBuilder _builder = new OperatorBuilder(new ServiceCollection(), new()); - - [Fact] - public void Should_Add_Development_Tunnel() - { - _builder.AddDevelopmentTunnel(4242); - - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(IHostedService) && - s.ImplementationType == typeof(DevelopmentTunnelService) && - s.Lifetime == ServiceLifetime.Singleton); - } - - [Fact] - public void Should_Add_TunnelConfig() - { - _builder.AddDevelopmentTunnel(1337, "my-host"); - - _builder.Services.Should().Contain(s => - s.ServiceType == typeof(TunnelConfig) && - s.Lifetime == ServiceLifetime.Singleton); - } -} +using FluentAssertions; + +using KubeOps.Abstractions.Builder; +using KubeOps.Operator.Builder; +using KubeOps.Operator.Web.Builder; +using KubeOps.Operator.Web.LocalTunnel; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KubeOps.Operator.Web.Test.Builder; + +public class OperatorBuilderExtensionsTest +{ + private readonly IOperatorBuilder _builder = new OperatorBuilder(new ServiceCollection(), new()); + + [Fact] + public void Should_Add_Development_Tunnel() + { + _builder.AddDevelopmentTunnel(4242); + + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IHostedService) && + s.ImplementationType == typeof(DevelopmentTunnelService) && + s.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void Should_Add_TunnelConfig() + { + _builder.AddDevelopmentTunnel(1337, "my-host"); + + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(TunnelConfig) && + s.Lifetime == ServiceLifetime.Singleton); + } +} diff --git a/test/KubeOps.Operator.Web.Test/GlobalUsings.cs b/test/KubeOps.Operator.Web.Test/GlobalUsings.cs index e1065597..c802f448 100644 --- a/test/KubeOps.Operator.Web.Test/GlobalUsings.cs +++ b/test/KubeOps.Operator.Web.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs b/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs index 9897d287..2184379e 100644 --- a/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs +++ b/test/KubeOps.Operator.Web.Test/IntegrationTestCollection.cs @@ -1,155 +1,155 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -using k8s; -using k8s.Models; - -using KubeOps.Operator.Web.Builder; -using KubeOps.Operator.Web.LocalTunnel; -using KubeOps.Operator.Web.Test.TestApp; -using KubeOps.Transpiler; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; - -namespace KubeOps.Operator.Web.Test; - -[CollectionDefinition(Name, DisableParallelization = true)] -public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture -{ - public const string Name = "Integration Tests"; -} - -[Collection(IntegrationTestCollection.Name)] -public abstract class IntegrationTestBase; - -public sealed class CrdInstaller : IAsyncLifetime -{ - private List _crds = []; - - public async Task InitializeAsync() - { - await using var p = new MlcProvider(); - await p.InitializeAsync(); - _crds = p.Mlc.Transpile(new[] { typeof(V1OperatorWebIntegrationTestEntity) }).ToList(); - - using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); - foreach (var crd in _crds) - { - switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( - fieldSelector: $"metadata.name={crd.Name()}")) - { - case { Items: [var existing] }: - crd.Metadata.ResourceVersion = existing.ResourceVersion(); - await client.ApiextensionsV1.ReplaceCustomResourceDefinitionAsync(crd, crd.Name()); - break; - default: - await client.ApiextensionsV1.CreateCustomResourceDefinitionAsync(crd); - break; - } - } - } - - public async Task DisposeAsync() - { - using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); - foreach (var crd in _crds) - { - await client.ApiextensionsV1.DeleteCustomResourceDefinitionAsync(crd.Name()); - } - } -} - -public sealed class MlcProvider : IAsyncLifetime -{ - private static readonly SemaphoreSlim Semaphore = new(1, 1); - - static MlcProvider() - { - MSBuildLocator.RegisterDefaults(); - } - - public MetadataLoadContext Mlc { get; private set; } = null!; - - public async Task InitializeAsync() - { - var assemblyConfigurationAttribute = - typeof(MlcProvider).Assembly.GetCustomAttribute(); - var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; - - try - { - await Semaphore.WaitAsync(); - using var workspace = MSBuildWorkspace.Create(new Dictionary - { - { "Configuration", buildConfigurationName }, - }); - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - var project = await workspace.OpenProjectAsync("../../../KubeOps.Operator.Web.Test.csproj"); - - Mlc = ContextCreator.Create(Directory - .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") - .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) - .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); - } - finally - { - Semaphore.Release(); - } - } - - public Task DisposeAsync() - { - Mlc.Dispose(); - return Task.CompletedTask; - } -} - -public sealed class ApplicationProvider : IAsyncLifetime -{ - private WebApplication? _app; - - public async Task InitializeAsync() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(c => c.ListenAnyIP(5000)); - builder.Services - .AddKubernetesOperator() - .AddDevelopmentTunnel(5000); - - builder.Services.AddControllers().AddApplicationPart(typeof(ApplicationProvider).Assembly); - builder.Services.AddHealthChecks(); - - builder.Services.AddLogging(c => - { - c.AddSimpleConsole(); -#if DEBUG - c.SetMinimumLevel(LogLevel.Trace); -#else - c.SetMinimumLevel(LogLevel.None); -#endif - }); - - builder.Services.RemoveAll(); - builder.Services.AddSingleton(new WebhookLoader(typeof(ApplicationProvider).Assembly)); - - _app = builder.Build(); - - _app.UseRouting(); - _app.UseDeveloperExceptionPage(); - _app.MapControllers(); - - await _app.StartAsync(); - } - - public async Task DisposeAsync() - { - await _app!.DisposeAsync(); - } -} +using System.Reflection; +using System.Runtime.InteropServices; + +using k8s; +using k8s.Models; + +using KubeOps.Operator.Web.Builder; +using KubeOps.Operator.Web.LocalTunnel; +using KubeOps.Operator.Web.Test.TestApp; +using KubeOps.Transpiler; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Web.Test; + +[CollectionDefinition(Name, DisableParallelization = true)] +public class IntegrationTestCollection : ICollectionFixture, ICollectionFixture +{ + public const string Name = "Integration Tests"; +} + +[Collection(IntegrationTestCollection.Name)] +public abstract class IntegrationTestBase; + +public sealed class CrdInstaller : IAsyncLifetime +{ + private List _crds = []; + + public async Task InitializeAsync() + { + await using var p = new MlcProvider(); + await p.InitializeAsync(); + _crds = p.Mlc.Transpile(new[] { typeof(V1OperatorWebIntegrationTestEntity) }).ToList(); + + using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); + foreach (var crd in _crds) + { + switch (await client.ApiextensionsV1.ListCustomResourceDefinitionAsync( + fieldSelector: $"metadata.name={crd.Name()}")) + { + case { Items: [var existing] }: + crd.Metadata.ResourceVersion = existing.ResourceVersion(); + await client.ApiextensionsV1.ReplaceCustomResourceDefinitionAsync(crd, crd.Name()); + break; + default: + await client.ApiextensionsV1.CreateCustomResourceDefinitionAsync(crd); + break; + } + } + } + + public async Task DisposeAsync() + { + using var client = new Kubernetes(KubernetesClientConfiguration.BuildDefaultConfig()); + foreach (var crd in _crds) + { + await client.ApiextensionsV1.DeleteCustomResourceDefinitionAsync(crd.Name()); + } + } +} + +public sealed class MlcProvider : IAsyncLifetime +{ + private static readonly SemaphoreSlim Semaphore = new(1, 1); + + static MlcProvider() + { + MSBuildLocator.RegisterDefaults(); + } + + public MetadataLoadContext Mlc { get; private set; } = null!; + + public async Task InitializeAsync() + { + var assemblyConfigurationAttribute = + typeof(MlcProvider).Assembly.GetCustomAttribute(); + var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; + + try + { + await Semaphore.WaitAsync(); + using var workspace = MSBuildWorkspace.Create(new Dictionary + { + { "Configuration", buildConfigurationName }, + }); + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + var project = await workspace.OpenProjectAsync("../../../KubeOps.Operator.Web.Test.csproj"); + + Mlc = ContextCreator.Create(Directory + .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") + .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) + .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); + } + finally + { + Semaphore.Release(); + } + } + + public Task DisposeAsync() + { + Mlc.Dispose(); + return Task.CompletedTask; + } +} + +public sealed class ApplicationProvider : IAsyncLifetime +{ + private WebApplication? _app; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(c => c.ListenAnyIP(5000)); + builder.Services + .AddKubernetesOperator() + .AddDevelopmentTunnel(5000); + + builder.Services.AddControllers().AddApplicationPart(typeof(ApplicationProvider).Assembly); + builder.Services.AddHealthChecks(); + + builder.Services.AddLogging(c => + { + c.AddSimpleConsole(); +#if DEBUG + c.SetMinimumLevel(LogLevel.Trace); +#else + c.SetMinimumLevel(LogLevel.None); +#endif + }); + + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new WebhookLoader(typeof(ApplicationProvider).Assembly)); + + _app = builder.Build(); + + _app.UseRouting(); + _app.UseDeveloperExceptionPage(); + _app.MapControllers(); + + await _app.StartAsync(); + } + + public async Task DisposeAsync() + { + await _app!.DisposeAsync(); + } +} diff --git a/test/KubeOps.Operator.Web.Test/KubeOps.Operator.Web.Test.csproj b/test/KubeOps.Operator.Web.Test/KubeOps.Operator.Web.Test.csproj index 5d88a24a..aa013071 100644 --- a/test/KubeOps.Operator.Web.Test/KubeOps.Operator.Web.Test.csproj +++ b/test/KubeOps.Operator.Web.Test/KubeOps.Operator.Web.Test.csproj @@ -1,7 +1,7 @@ - + diff --git a/test/KubeOps.Operator.Web.Test/LocalTunnel/DevelopmentTunnelService.Test.cs b/test/KubeOps.Operator.Web.Test/LocalTunnel/DevelopmentTunnelService.Test.cs index 90753232..4a325569 100644 --- a/test/KubeOps.Operator.Web.Test/LocalTunnel/DevelopmentTunnelService.Test.cs +++ b/test/KubeOps.Operator.Web.Test/LocalTunnel/DevelopmentTunnelService.Test.cs @@ -1,32 +1,32 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.KubernetesClient; - -namespace KubeOps.Operator.Web.Test.LocalTunnel; - -public class DevelopmentTunnelServiceTest : IntegrationTestBase -{ - [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] - public async Task Should_Install_Validation_Webhooks() - { - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var validators = await client.GetAsync("dev-validators"); - validators.Should().NotBeNull(); - validators!.Webhooks.Should().HaveCount(1); - validators.Webhooks[0].Name.Should().Be("validate.weboperatorintegrationtest.weboperator.test.v1"); - validators.Webhooks[0].ClientConfig.Url.Should().Contain("/validate/v1operatorwebintegrationtestentity"); - } - - [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] - public async Task Should_Install_Mutation_Webhooks() - { - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var mutators = await client.GetAsync("dev-mutators"); - mutators.Should().NotBeNull(); - mutators!.Webhooks.Should().HaveCount(1); - mutators.Webhooks[0].Name.Should().Be("mutate.weboperatorintegrationtest.weboperator.test.v1"); - mutators.Webhooks[0].ClientConfig.Url.Should().Contain("/mutate/v1operatorwebintegrationtestentity"); - } -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.KubernetesClient; + +namespace KubeOps.Operator.Web.Test.LocalTunnel; + +public class DevelopmentTunnelServiceTest : IntegrationTestBase +{ + [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] + public async Task Should_Install_Validation_Webhooks() + { + using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; + var validators = await client.GetAsync("dev-validators"); + validators.Should().NotBeNull(); + validators!.Webhooks.Should().HaveCount(1); + validators.Webhooks[0].Name.Should().Be("validate.weboperatorintegrationtest.weboperator.test.v1"); + validators.Webhooks[0].ClientConfig.Url.Should().Contain("/validate/v1operatorwebintegrationtestentity"); + } + + [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] + public async Task Should_Install_Mutation_Webhooks() + { + using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; + var mutators = await client.GetAsync("dev-mutators"); + mutators.Should().NotBeNull(); + mutators!.Webhooks.Should().HaveCount(1); + mutators.Webhooks[0].Name.Should().Be("mutate.weboperatorintegrationtest.weboperator.test.v1"); + mutators.Webhooks[0].ClientConfig.Url.Should().Contain("/mutate/v1operatorwebintegrationtestentity"); + } +} diff --git a/test/KubeOps.Operator.Web.Test/TestApp/TestMutationWebhook.cs b/test/KubeOps.Operator.Web.Test/TestApp/TestMutationWebhook.cs index b85c229c..1d49c744 100644 --- a/test/KubeOps.Operator.Web.Test/TestApp/TestMutationWebhook.cs +++ b/test/KubeOps.Operator.Web.Test/TestApp/TestMutationWebhook.cs @@ -1,19 +1,19 @@ -using KubeOps.Operator.Web.Webhooks.Admission.Mutation; - -namespace KubeOps.Operator.Web.Test.TestApp; - -[MutationWebhook(typeof(V1OperatorWebIntegrationTestEntity))] -public class TestMutationWebhook : MutationWebhook -{ - public override MutationResult Create(V1OperatorWebIntegrationTestEntity entity, - bool dryRun) - { - if (entity.Spec.Username == "overwrite") - { - entity.Spec.Username = "overwritten"; - return Modified(entity); - } - - return NoChanges(); - } -} +using KubeOps.Operator.Web.Webhooks.Admission.Mutation; + +namespace KubeOps.Operator.Web.Test.TestApp; + +[MutationWebhook(typeof(V1OperatorWebIntegrationTestEntity))] +public class TestMutationWebhook : MutationWebhook +{ + public override MutationResult Create(V1OperatorWebIntegrationTestEntity entity, + bool dryRun) + { + if (entity.Spec.Username == "overwrite") + { + entity.Spec.Username = "overwritten"; + return Modified(entity); + } + + return NoChanges(); + } +} diff --git a/test/KubeOps.Operator.Web.Test/TestApp/TestValidationWebhook.cs b/test/KubeOps.Operator.Web.Test/TestApp/TestValidationWebhook.cs index 7f563d3b..eb2a217f 100644 --- a/test/KubeOps.Operator.Web.Test/TestApp/TestValidationWebhook.cs +++ b/test/KubeOps.Operator.Web.Test/TestApp/TestValidationWebhook.cs @@ -1,17 +1,17 @@ -using KubeOps.Operator.Web.Webhooks.Admission.Validation; - -namespace KubeOps.Operator.Web.Test.TestApp; - -[ValidationWebhook(typeof(V1OperatorWebIntegrationTestEntity))] -public class TestValidationWebhook : ValidationWebhook -{ - public override ValidationResult Create(V1OperatorWebIntegrationTestEntity entity, bool dryRun) - { - if (entity.Spec.Username == "forbidden") - { - return Fail("name may not be 'forbidden'.", 422); - } - - return Success(); - } -} +using KubeOps.Operator.Web.Webhooks.Admission.Validation; + +namespace KubeOps.Operator.Web.Test.TestApp; + +[ValidationWebhook(typeof(V1OperatorWebIntegrationTestEntity))] +public class TestValidationWebhook : ValidationWebhook +{ + public override ValidationResult Create(V1OperatorWebIntegrationTestEntity entity, bool dryRun) + { + if (entity.Spec.Username == "forbidden") + { + return Fail("name may not be 'forbidden'.", 422); + } + + return Success(); + } +} diff --git a/test/KubeOps.Operator.Web.Test/TestApp/V1OperatorWebIntegrationTestEntity.cs b/test/KubeOps.Operator.Web.Test/TestApp/V1OperatorWebIntegrationTestEntity.cs index 3fa4ed07..ee7fbaab 100644 --- a/test/KubeOps.Operator.Web.Test/TestApp/V1OperatorWebIntegrationTestEntity.cs +++ b/test/KubeOps.Operator.Web.Test/TestApp/V1OperatorWebIntegrationTestEntity.cs @@ -1,35 +1,35 @@ -using k8s.Models; - -using KubeOps.Abstractions.Entities; - -namespace KubeOps.Operator.Web.Test.TestApp; - -[KubernetesEntity(Group = "weboperator.test", ApiVersion = "v1", Kind = "WebOperatorIntegrationTest")] -public class V1OperatorWebIntegrationTestEntity : CustomKubernetesEntity -{ - public V1OperatorWebIntegrationTestEntity() - { - ApiVersion = "weboperator.test/v1"; - Kind = "WebOperatorIntegrationTest"; - } - - public V1OperatorWebIntegrationTestEntity(string name, string username) : this() - { - Metadata.Name = name; - Metadata.NamespaceProperty = "default"; - Spec.Username = username; - } - - public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; - - public class EntitySpec - { - public string Username { get; set; } = string.Empty; - } - - public class EntityStatus - { - public string Status { get; set; } = string.Empty; - } -} +using k8s.Models; + +using KubeOps.Abstractions.Entities; + +namespace KubeOps.Operator.Web.Test.TestApp; + +[KubernetesEntity(Group = "weboperator.test", ApiVersion = "v1", Kind = "WebOperatorIntegrationTest")] +public class V1OperatorWebIntegrationTestEntity : CustomKubernetesEntity +{ + public V1OperatorWebIntegrationTestEntity() + { + ApiVersion = "weboperator.test/v1"; + Kind = "WebOperatorIntegrationTest"; + } + + public V1OperatorWebIntegrationTestEntity(string name, string username) : this() + { + Metadata.Name = name; + Metadata.NamespaceProperty = "default"; + Spec.Username = username; + } + + public override string ToString() => $"Test Entity ({Metadata.Name}): {Spec.Username}"; + + public class EntitySpec + { + public string Username { get; set; } = string.Empty; + } + + public class EntityStatus + { + public string Status { get; set; } = string.Empty; + } +} diff --git a/test/KubeOps.Operator.Web.Test/Webhooks/MutationWebhook.Integration.Test.cs b/test/KubeOps.Operator.Web.Test/Webhooks/MutationWebhook.Integration.Test.cs index 1724969e..f512c165 100644 --- a/test/KubeOps.Operator.Web.Test/Webhooks/MutationWebhook.Integration.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Webhooks/MutationWebhook.Integration.Test.cs @@ -1,27 +1,27 @@ -using FluentAssertions; - -using KubeOps.KubernetesClient; -using KubeOps.Operator.Web.Test.TestApp; - -namespace KubeOps.Operator.Web.Test.Webhooks; - -public class MutationWebhookIntegrationTest : IntegrationTestBase -{ - [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] - public async Task Should_Allow_Creation_Of_Entity() - { - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var e = await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "foobar")); - e.Spec.Username.Should().Be("foobar"); - await client.DeleteAsync(e); - } - - [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] - public async Task Should_Mutate_Entity_According_To_Code() - { - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var e = await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "overwrite")); - e.Spec.Username.Should().Be("overwritten"); - await client.DeleteAsync(e); - } -} +using FluentAssertions; + +using KubeOps.KubernetesClient; +using KubeOps.Operator.Web.Test.TestApp; + +namespace KubeOps.Operator.Web.Test.Webhooks; + +public class MutationWebhookIntegrationTest : IntegrationTestBase +{ + [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] + public async Task Should_Allow_Creation_Of_Entity() + { + using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; + var e = await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "foobar")); + e.Spec.Username.Should().Be("foobar"); + await client.DeleteAsync(e); + } + + [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] + public async Task Should_Mutate_Entity_According_To_Code() + { + using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; + var e = await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "overwrite")); + e.Spec.Username.Should().Be("overwritten"); + await client.DeleteAsync(e); + } +} diff --git a/test/KubeOps.Operator.Web.Test/Webhooks/ValidationWebhook.Integration.Test.cs b/test/KubeOps.Operator.Web.Test/Webhooks/ValidationWebhook.Integration.Test.cs index a7dbd0e2..b6f6d92a 100644 --- a/test/KubeOps.Operator.Web.Test/Webhooks/ValidationWebhook.Integration.Test.cs +++ b/test/KubeOps.Operator.Web.Test/Webhooks/ValidationWebhook.Integration.Test.cs @@ -1,28 +1,28 @@ -using FluentAssertions; - -using k8s.Autorest; - -using KubeOps.KubernetesClient; -using KubeOps.Operator.Web.Test.TestApp; - -namespace KubeOps.Operator.Web.Test.Webhooks; - -public class ValidationWebhookIntegrationTest : IntegrationTestBase -{ - [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] - public async Task Should_Allow_Creation_Of_Entity() - { - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var e = await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "foobar")); - await client.DeleteAsync(e); - } - - [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] - public async Task Should_Disallow_Creation_When_Validation_Fails() - { - using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; - var ex = await Assert.ThrowsAsync(async () => - await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "forbidden"))); - ex.Message.Should().Contain("name may not be 'forbidden'"); - } -} +using FluentAssertions; + +using k8s.Autorest; + +using KubeOps.KubernetesClient; +using KubeOps.Operator.Web.Test.TestApp; + +namespace KubeOps.Operator.Web.Test.Webhooks; + +public class ValidationWebhookIntegrationTest : IntegrationTestBase +{ + [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] + public async Task Should_Allow_Creation_Of_Entity() + { + using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; + var e = await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "foobar")); + await client.DeleteAsync(e); + } + + [Fact(Skip = "This test is flakey since localtunnel is not always available. Need an alternative.")] + public async Task Should_Disallow_Creation_When_Validation_Fails() + { + using var client = new KubernetesClient.KubernetesClient() as IKubernetesClient; + var ex = await Assert.ThrowsAsync(async () => + await client.CreateAsync(new V1OperatorWebIntegrationTestEntity("test-entity", "forbidden"))); + ex.Message.Should().Contain("name may not be 'forbidden'"); + } +} diff --git a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs index 2a6017a6..8a838a14 100644 --- a/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs @@ -1,918 +1,918 @@ -using System.Collections; -using System.Collections.ObjectModel; -using System.Text.Json.Serialization; - -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; - -namespace KubeOps.Transpiler.Test; - -public class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider) -{ - [Theory] - [InlineData(typeof(StringTestEntity), "string", null, false)] - [InlineData(typeof(NullableStringTestEntity), "string", null, true)] - [InlineData(typeof(IntTestEntity), "integer", "int32", false)] - [InlineData(typeof(NullableIntTestEntity), "integer", "int32", true)] - [InlineData(typeof(LongTestEntity), "integer", "int64", false)] - [InlineData(typeof(NullableLongTestEntity), "integer", "int64", true)] - [InlineData(typeof(FloatTestEntity), "number", "float", false)] - [InlineData(typeof(NullableFloatTestEntity), "number", "float", true)] - [InlineData(typeof(DecimalTestEntity), "number", "decimal", false)] - [InlineData(typeof(NullableDecimalTestEntity), "number", "decimal", true)] - [InlineData(typeof(DoubleTestEntity), "number", "double", false)] - [InlineData(typeof(NullableDoubleTestEntity), "number", "double", true)] - [InlineData(typeof(BoolTestEntity), "boolean", null, false)] - [InlineData(typeof(NullableBoolTestEntity), "boolean", null, true)] - [InlineData(typeof(DateTimeTestEntity), "string", "date-time", false)] - [InlineData(typeof(NullableDateTimeTestEntity), "string", "date-time", true)] - [InlineData(typeof(DateTimeOffsetTestEntity), "string", "date-time", false)] - [InlineData(typeof(NullableDateTimeOffsetTestEntity), "string", "date-time", true)] - [InlineData(typeof(V1ObjectMetaTestEntity), "object", null, false)] - [InlineData(typeof(StringArrayEntity), "array", null, false)] - [InlineData(typeof(NullableStringArrayEntity), "array", null, true)] - [InlineData(typeof(EnumerableIntEntity), "array", null, false)] - [InlineData(typeof(HashSetIntEntity), "array", null, false)] - [InlineData(typeof(SetIntEntity), "array", null, false)] - [InlineData(typeof(InheritedEnumerableEntity), "array", null, false)] - [InlineData(typeof(EnumEntity), "string", null, false)] - [InlineData(typeof(NullableEnumEntity), "string", null, true)] - [InlineData(typeof(DictionaryEntity), "object", null, false)] - [InlineData(typeof(EnumerableKeyPairsEntity), "object", null, false)] - [InlineData(typeof(IntstrOrStringEntity), null, null, false)] - [InlineData(typeof(EmbeddedResourceEntity), "object", null, false)] - [InlineData(typeof(EmbeddedResourceListEntity), "array", null, false)] - public void Should_Transpile_Entity_Type_Correctly(Type type, string? expectedType, string? expectedFormat, - bool isNullable) - { - var crd = _mlc.Transpile(type); - var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - prop.Type.Should().Be(expectedType); - prop.Format.Should().Be(expectedFormat); - prop.Nullable.Should().Be(isNullable); - } - - [Theory] - [InlineData(typeof(StringArrayEntity), "string", false)] - [InlineData(typeof(NullableStringArrayEntity), "string", false)] - [InlineData(typeof(EnumerableIntEntity), "integer", false)] - [InlineData(typeof(EnumerableNullableIntEntity), "integer", true)] - [InlineData(typeof(HashSetIntEntity), "integer", false)] - [InlineData(typeof(SetIntEntity), "integer", false)] - [InlineData(typeof(InheritedEnumerableEntity), "integer", false)] - [InlineData(typeof(EmbeddedResourceListEntity), "object", false)] - public void Should_Set_Correct_Array_Type(Type type, string expectedType, bool isNullable) - { - var crd = _mlc.Transpile(type); - var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"].Items as V1JSONSchemaProps; - prop!.Type.Should().Be(expectedType); - prop.Nullable.Should().Be(isNullable); - } - - [Theory] - [InlineData(typeof(DictionaryEntity), "string", false)] - [InlineData(typeof(EnumerableKeyPairsEntity), "string", false)] - public void Should_Set_Correct_Dictionary_Additional_Properties_Type(Type type, string expectedType, bool isNullable) - { - var crd = _mlc.Transpile(type); - var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"].AdditionalProperties as V1JSONSchemaProps; - prop!.Type.Should().Be(expectedType); - prop.Nullable.Should().Be(isNullable); - } - - [Fact] - public void Should_Ignore_Entity() - { - var crds = _mlc.Transpile(new[] { typeof(IgnoredEntity) }); - crds.Count().Should().Be(0); - } - - [Fact] - public void Should_Ignore_NonEntity() - { - var crds = _mlc.Transpile(new[] { typeof(NonEntity) }); - crds.Count().Should().Be(0); - } - - [Fact] - public void Should_Ignore_Kubernetes_Entities() - { - var crds = _mlc.Transpile(new[] { typeof(V1Pod) }); - crds.Count().Should().Be(0); - } - - [Fact] - public void Should_Set_Highest_Version_As_Storage() - { - var crds = _mlc.Transpile(new[] - { - typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), - typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), - typeof(V2AttributeVersionedEntity), - }); - var crd = crds.First(c => c.Spec.Names.Kind == "VersionedEntity"); - crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); - crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v2"); - } - - [Fact] - public void Should_Set_Storage_When_Attribute_Is_Set() - { - var crds = _mlc.Transpile(new[] - { - typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), - typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), - typeof(V2AttributeVersionedEntity), - }); - var crd = crds.First(c => c.Spec.Names.Kind == "AttributeVersionedEntity"); - crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); - crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v1"); - } - - [Fact] - public void Should_Add_Multiple_Versions_To_Crd() - { - var crds = _mlc.Transpile(new[] - { - typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), - typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), - typeof(V2AttributeVersionedEntity), - }).ToList(); - crds - .First(c => c.Spec.Names.Kind == "VersionedEntity") - .Spec.Versions.Should() - .HaveCount(5); - crds - .First(c => c.Spec.Names.Kind == "AttributeVersionedEntity") - .Spec.Versions.Should() - .HaveCount(2); - } - - [Fact] - public void Should_Use_Correct_CRD() - { - var crd = _mlc.Transpile(typeof(Entity)); - var (ced, scope) = _mlc.ToEntityMetadata(typeof(Entity)); - - crd.Kind.Should().Be(V1CustomResourceDefinition.KubeKind); - crd.Metadata.Name.Should().Be($"{ced.PluralName}.{ced.Group}"); - crd.Spec.Names.Kind.Should().Be(ced.Kind); - crd.Spec.Names.ListKind.Should().Be(ced.ListKind); - crd.Spec.Names.Singular.Should().Be(ced.SingularName); - crd.Spec.Names.Plural.Should().Be(ced.PluralName); - crd.Spec.Scope.Should().Be(scope); - } - - [Fact] - public void Should_Not_Add_Status_SubResource_If_Absent() - { - var crd = _mlc.Transpile(typeof(Entity)); - crd.Spec.Versions.First().Subresources?.Status?.Should().BeNull(); - } - - [Fact] - public void Should_Add_Status_SubResource_If_Present() - { - var crd = _mlc.Transpile(typeof(EntityWithStatus)); - crd.Spec.Versions.First().Subresources.Status.Should().NotBeNull(); - } - - [Fact] - public void Should_Add_ShortNames_To_Crd() - { - var crd = _mlc.Transpile(typeof(ShortnamesEntity)); - crd.Spec.Names.ShortNames.Should() - .NotBeNull() - .And - .Contain(new[] { "foo", "bar", "baz" }); - } - - [Fact] - public void Should_Set_Description_On_Class() - { - var crd = _mlc.Transpile(typeof(ClassDescriptionAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; - specProperties.Description.Should().NotBe(""); - } - - [Fact] - public void Should_Set_Description() - { - var crd = _mlc.Transpile(typeof(DescriptionAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Description.Should().NotBe(""); - } - - [Fact] - public void Should_Set_ExternalDocs() - { - var crd = _mlc.Transpile(typeof(ExtDocsAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.ExternalDocs.Url.Should().NotBe(""); - } - - [Fact] - public void Should_Set_ExternalDocs_Description() - { - var crd = _mlc.Transpile(typeof(ExtDocsWithDescriptionAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.ExternalDocs.Description.Should().NotBe(""); - } - - [Fact] - public void Should_Set_Items_Information() - { - var crd = _mlc.Transpile(typeof(ItemsAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Type.Should().Be("array"); - (specProperties.Items as V1JSONSchemaProps)?.Type?.Should().Be("string"); - specProperties.MaxItems.Should().Be(42); - specProperties.MinItems.Should().Be(13); - } - - [Fact] - public void Should_Set_Length_Information() - { - var crd = _mlc.Transpile(typeof(LengthAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.MinLength.Should().Be(2); - specProperties.MaxLength.Should().Be(42); - } - - [Fact] - public void Should_Set_MultipleOf() - { - var crd = _mlc.Transpile(typeof(MultipleOfAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.MultipleOf.Should().Be(2); - } - - [Fact] - public void Should_Set_Pattern() - { - var crd = _mlc.Transpile(typeof(PatternAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Pattern.Should().Be(@"/\d*/"); - } - - [Fact] - public void Should_Set_RangeMinimum() - { - var crd = _mlc.Transpile(typeof(RangeMinimumAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Minimum.Should().Be(15); - specProperties.ExclusiveMinimum.Should().BeTrue(); - } - - [Fact] - public void Should_Set_RangeMaximum() - { - var crd = _mlc.Transpile(typeof(RangeMaximumAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - - specProperties.Maximum.Should().Be(15); - specProperties.ExclusiveMaximum.Should().BeTrue(); - } - - [Fact] - public void Should_Set_Required() - { - var crd = _mlc.Transpile(typeof(RequiredAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; - specProperties.Required.Should().Contain("property"); - } - - [Fact] - public void Should_Not_Contain_Ignored_Property() - { - var crd = _mlc.Transpile(typeof(IgnoreAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; - specProperties.Properties.Should().NotContainKey("property"); - } - - [Fact] - public void Should_Set_Preserve_Unknown_Fields() - { - var crd = _mlc.Transpile(typeof(PreserveUnknownFieldsAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); - } - - [Fact] - public void Should_Set_EmbeddedResource_Fields() - { - var crd = _mlc.Transpile(typeof(EmbeddedResourceAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesEmbeddedResource.Should().BeTrue(); - } - - [Fact] - public void Should_Set_Preserve_Unknown_Fields_On_Dictionaries() - { - var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); - } - - [Fact] - public void Should_Not_Set_Preserve_Unknown_Fields_On_Generic_Dictionaries() - { - var crd = _mlc.Transpile(typeof(DictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Preserve_Unknown_Fields_On_KeyValuePair_Enumerable() - { - var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Properties_On_Dictionaries() - { - var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Properties_On_Generic_Dictionaries() - { - var crd = _mlc.Transpile(typeof(DictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - } - - [Fact] - public void Should_Not_Set_Properties_On_KeyValuePair_Enumerable() - { - var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - } - - [Fact] - public void Should_Set_AdditionalProperties_On_Dictionaries_For_Value_type() - { - var crd = _mlc.Transpile(typeof(DictionaryEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.AdditionalProperties.Should().NotBeNull(); - } - - [Fact] - public void Should_Set_AdditionalProperties_On_KeyValuePair_For_Value_type() - { - var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.AdditionalProperties.Should().NotBeNull(); - } - - [Fact] - public void Should_Set_IntOrString() - { - var crd = _mlc.Transpile(typeof(IntstrOrStringEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; - specProperties.Properties.Should().BeNull(); - specProperties.XKubernetesIntOrString.Should().BeTrue(); - } - - [Fact] - public void Should_Use_PropertyName_From_JsonPropertyAttribute() - { - var crd = _mlc.Transpile(typeof(JsonPropNameAttrEntity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; - specProperties.Should().Contain(p => p.Key == "otherName"); - } - - [Fact] - public void Must_Not_Contain_Ignored_TopLevel_Properties() - { - var crd = _mlc.Transpile(typeof(Entity)); - - var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; - specProperties.Should().NotContainKeys("metadata", "apiVersion", "kind"); - } - - [Fact] - public void Should_Add_AdditionalPrinterColumns() - { - var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - apc.Should().ContainSingle(def => def.JsonPath == ".property"); - } - - [Fact] - public void Should_Add_AdditionalPrinterColumns_With_Prio() - { - var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnWideAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Priority == 1); - } - - [Fact] - public void Should_Add_AdditionalPrinterColumns_With_Name() - { - var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnNameAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Name == "OtherName"); - } - - [Fact] - public void Should_Add_GenericAdditionalPrinterColumns() - { - var crd = _mlc.Transpile(typeof(GenericAdditionalPrinterColumnAttrEntity)); - var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; - - apc.Should().NotBeNull(); - apc.Should().ContainSingle(def => def.JsonPath == ".metadata.namespace" && def.Name == "Namespace"); - } - - [Fact] - public void Should_Correctly_Use_Entity_Scope_Attribute() - { - var scopedCrd = _mlc.Transpile(typeof(Entity)); - var clusterCrd = _mlc.Transpile(typeof(ScopeAttrEntity)); - - scopedCrd.Spec.Scope.Should().Be("Namespaced"); - clusterCrd.Spec.Scope.Should().Be("Cluster"); - } - - #region Test Entity Classes - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class StringTestEntity : CustomKubernetesEntity - { - public string Property { get; set; } = string.Empty; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableStringTestEntity : CustomKubernetesEntity - { - public string? Property { get; set; } = string.Empty; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class IntTestEntity : CustomKubernetesEntity - { - public int Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableIntTestEntity : CustomKubernetesEntity - { - public int? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class LongTestEntity : CustomKubernetesEntity - { - public long Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableLongTestEntity : CustomKubernetesEntity - { - public long? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class FloatTestEntity : CustomKubernetesEntity - { - public float Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableFloatTestEntity : CustomKubernetesEntity - { - public float? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DecimalTestEntity : CustomKubernetesEntity - { - public decimal Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableDecimalTestEntity : CustomKubernetesEntity - { - public decimal? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DoubleTestEntity : CustomKubernetesEntity - { - public double Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableDoubleTestEntity : CustomKubernetesEntity - { - public double? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class BoolTestEntity : CustomKubernetesEntity - { - public bool Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableBoolTestEntity : CustomKubernetesEntity - { - public bool? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DateTimeTestEntity : CustomKubernetesEntity - { - public DateTime Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableDateTimeTestEntity : CustomKubernetesEntity - { - public DateTime? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DateTimeOffsetTestEntity : CustomKubernetesEntity - { - public DateTimeOffset Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableDateTimeOffsetTestEntity : CustomKubernetesEntity - { - public DateTimeOffset? Property { get; set; } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class V1ObjectMetaTestEntity : CustomKubernetesEntity - { - public V1ObjectMeta Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class StringArrayEntity : CustomKubernetesEntity - { - public string[] Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableStringArrayEntity : CustomKubernetesEntity - { - public string[]? Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumerableNullableIntEntity : CustomKubernetesEntity - { - public IEnumerable Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumerableIntEntity : CustomKubernetesEntity - { - public IEnumerable Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class HashSetIntEntity : CustomKubernetesEntity - { - public HashSet Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class SetIntEntity : CustomKubernetesEntity - { - public ISet Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class InheritedEnumerableEntity : CustomKubernetesEntity - { - public IntegerList Property { get; set; } = null!; - - public class IntegerList : Collection; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumEntity : CustomKubernetesEntity - { - public TestSpecEnum Property { get; set; } - - public enum TestSpecEnum - { - Value1, - Value2, - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class NullableEnumEntity : CustomKubernetesEntity - { - public TestSpecEnum? Property { get; set; } - - public enum TestSpecEnum - { - Value1, - Value2, - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class SimpleDictionaryEntity : CustomKubernetesEntity - { - public IDictionary Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class DictionaryEntity : CustomKubernetesEntity - { - public IDictionary Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EnumerableKeyPairsEntity : CustomKubernetesEntity - { - public IEnumerable> Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class IntstrOrStringEntity : CustomKubernetesEntity - { - public IntstrIntOrString Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EmbeddedResourceEntity : CustomKubernetesEntity - { - public V1Pod Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - private class EmbeddedResourceListEntity : CustomKubernetesEntity - { - public IList Property { get; set; } = null!; - } - - [Ignore] - [KubernetesEntity] - private class IgnoredEntity : CustomKubernetesEntity; - - public class NonEntity; - - [KubernetesEntity( - ApiVersion = "v1alpha1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V1Alpha1VersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v1beta1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V1Beta1VersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v2beta2", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V2Beta2VersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v2", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V2VersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v1", - Kind = "VersionedEntity", - Group = "kubeops.test.dev", - PluralName = "versionedentities")] - public class V1VersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v1", - Kind = "AttributeVersionedEntity", - Group = "kubeops.test.dev", - PluralName = "attributeversionedentities")] - [StorageVersion] - public class V1AttributeVersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v2", - Kind = "AttributeVersionedEntity", - Group = "kubeops.test.dev", - PluralName = "attributeversionedentities")] - public class V2AttributeVersionedEntity : CustomKubernetesEntity; - - [KubernetesEntity( - ApiVersion = "v1337", - Kind = "Kind", - Group = "Group", - PluralName = "Plural")] - public class Entity : CustomKubernetesEntity; - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class EntityWithStatus : CustomKubernetesEntity - { - public class EntitySpec; - - public class EntityStatus; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - [KubernetesEntityShortNames("foo", "bar", "baz")] - public class ShortnamesEntity : CustomKubernetesEntity; - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class DescriptionAttrEntity : CustomKubernetesEntity - { - [Description("Description")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ExtDocsAttrEntity : CustomKubernetesEntity - { - [ExternalDocs("url")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ExtDocsWithDescriptionAttrEntity : CustomKubernetesEntity - { - [ExternalDocs("url", "description")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ItemsAttrEntity : CustomKubernetesEntity - { - [Items(13, 42)] - public string[] Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class LengthAttrEntity : CustomKubernetesEntity - { - [Length(2, 42)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class MultipleOfAttrEntity : CustomKubernetesEntity - { - [MultipleOf(2)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class PatternAttrEntity : CustomKubernetesEntity - { - [Pattern(@"/\d*/")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class RangeMinimumAttrEntity : CustomKubernetesEntity - { - [RangeMinimum(15, true)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class RangeMaximumAttrEntity : CustomKubernetesEntity - { - [RangeMaximum(15, true)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class RequiredAttrEntity : CustomKubernetesEntity - { - public class EntitySpec - { - [Required] - public string Property { get; set; } = null!; - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class IgnoreAttrEntity : CustomKubernetesEntity - { - public class EntitySpec - { - [Ignore] - public string Property { get; set; } = null!; - } - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class PreserveUnknownFieldsAttrEntity : CustomKubernetesEntity - { - [PreserveUnknownFields] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class EmbeddedResourceAttrEntity : CustomKubernetesEntity - { - [EmbeddedResource] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class AdditionalPrinterColumnAttrEntity : CustomKubernetesEntity - { - [AdditionalPrinterColumn] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class AdditionalPrinterColumnWideAttrEntity : CustomKubernetesEntity - { - [AdditionalPrinterColumn(PrinterColumnPriority.WideView)] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class AdditionalPrinterColumnNameAttrEntity : CustomKubernetesEntity - { - [AdditionalPrinterColumn(name: "OtherName")] - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class ClassDescriptionAttrEntity : CustomKubernetesEntity - { - [Description("Description")] - public class EntitySpec; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - [GenericAdditionalPrinterColumn(".metadata.namespace", "Namespace", "string")] - public class GenericAdditionalPrinterColumnAttrEntity : CustomKubernetesEntity - { - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - [EntityScope(EntityScope.Cluster)] - public class ScopeAttrEntity : CustomKubernetesEntity - { - public string Property { get; set; } = null!; - } - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] - public class JsonPropNameAttrEntity : CustomKubernetesEntity - { - [JsonPropertyName("otherName")] - public string Property { get; set; } = null!; - } - - #endregion -} +using System.Collections; +using System.Collections.ObjectModel; +using System.Text.Json.Serialization; + +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler.Test; + +public class CrdsMlcTest(MlcProvider provider) : TranspilerTestBase(provider) +{ + [Theory] + [InlineData(typeof(StringTestEntity), "string", null, false)] + [InlineData(typeof(NullableStringTestEntity), "string", null, true)] + [InlineData(typeof(IntTestEntity), "integer", "int32", false)] + [InlineData(typeof(NullableIntTestEntity), "integer", "int32", true)] + [InlineData(typeof(LongTestEntity), "integer", "int64", false)] + [InlineData(typeof(NullableLongTestEntity), "integer", "int64", true)] + [InlineData(typeof(FloatTestEntity), "number", "float", false)] + [InlineData(typeof(NullableFloatTestEntity), "number", "float", true)] + [InlineData(typeof(DecimalTestEntity), "number", "decimal", false)] + [InlineData(typeof(NullableDecimalTestEntity), "number", "decimal", true)] + [InlineData(typeof(DoubleTestEntity), "number", "double", false)] + [InlineData(typeof(NullableDoubleTestEntity), "number", "double", true)] + [InlineData(typeof(BoolTestEntity), "boolean", null, false)] + [InlineData(typeof(NullableBoolTestEntity), "boolean", null, true)] + [InlineData(typeof(DateTimeTestEntity), "string", "date-time", false)] + [InlineData(typeof(NullableDateTimeTestEntity), "string", "date-time", true)] + [InlineData(typeof(DateTimeOffsetTestEntity), "string", "date-time", false)] + [InlineData(typeof(NullableDateTimeOffsetTestEntity), "string", "date-time", true)] + [InlineData(typeof(V1ObjectMetaTestEntity), "object", null, false)] + [InlineData(typeof(StringArrayEntity), "array", null, false)] + [InlineData(typeof(NullableStringArrayEntity), "array", null, true)] + [InlineData(typeof(EnumerableIntEntity), "array", null, false)] + [InlineData(typeof(HashSetIntEntity), "array", null, false)] + [InlineData(typeof(SetIntEntity), "array", null, false)] + [InlineData(typeof(InheritedEnumerableEntity), "array", null, false)] + [InlineData(typeof(EnumEntity), "string", null, false)] + [InlineData(typeof(NullableEnumEntity), "string", null, true)] + [InlineData(typeof(DictionaryEntity), "object", null, false)] + [InlineData(typeof(EnumerableKeyPairsEntity), "object", null, false)] + [InlineData(typeof(IntstrOrStringEntity), null, null, false)] + [InlineData(typeof(EmbeddedResourceEntity), "object", null, false)] + [InlineData(typeof(EmbeddedResourceListEntity), "array", null, false)] + public void Should_Transpile_Entity_Type_Correctly(Type type, string? expectedType, string? expectedFormat, + bool isNullable) + { + var crd = _mlc.Transpile(type); + var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + prop.Type.Should().Be(expectedType); + prop.Format.Should().Be(expectedFormat); + prop.Nullable.Should().Be(isNullable); + } + + [Theory] + [InlineData(typeof(StringArrayEntity), "string", false)] + [InlineData(typeof(NullableStringArrayEntity), "string", false)] + [InlineData(typeof(EnumerableIntEntity), "integer", false)] + [InlineData(typeof(EnumerableNullableIntEntity), "integer", true)] + [InlineData(typeof(HashSetIntEntity), "integer", false)] + [InlineData(typeof(SetIntEntity), "integer", false)] + [InlineData(typeof(InheritedEnumerableEntity), "integer", false)] + [InlineData(typeof(EmbeddedResourceListEntity), "object", false)] + public void Should_Set_Correct_Array_Type(Type type, string expectedType, bool isNullable) + { + var crd = _mlc.Transpile(type); + var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"].Items as V1JSONSchemaProps; + prop!.Type.Should().Be(expectedType); + prop.Nullable.Should().Be(isNullable); + } + + [Theory] + [InlineData(typeof(DictionaryEntity), "string", false)] + [InlineData(typeof(EnumerableKeyPairsEntity), "string", false)] + public void Should_Set_Correct_Dictionary_Additional_Properties_Type(Type type, string expectedType, bool isNullable) + { + var crd = _mlc.Transpile(type); + var prop = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"].AdditionalProperties as V1JSONSchemaProps; + prop!.Type.Should().Be(expectedType); + prop.Nullable.Should().Be(isNullable); + } + + [Fact] + public void Should_Ignore_Entity() + { + var crds = _mlc.Transpile(new[] { typeof(IgnoredEntity) }); + crds.Count().Should().Be(0); + } + + [Fact] + public void Should_Ignore_NonEntity() + { + var crds = _mlc.Transpile(new[] { typeof(NonEntity) }); + crds.Count().Should().Be(0); + } + + [Fact] + public void Should_Ignore_Kubernetes_Entities() + { + var crds = _mlc.Transpile(new[] { typeof(V1Pod) }); + crds.Count().Should().Be(0); + } + + [Fact] + public void Should_Set_Highest_Version_As_Storage() + { + var crds = _mlc.Transpile(new[] + { + typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), + typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), + typeof(V2AttributeVersionedEntity), + }); + var crd = crds.First(c => c.Spec.Names.Kind == "VersionedEntity"); + crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); + crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v2"); + } + + [Fact] + public void Should_Set_Storage_When_Attribute_Is_Set() + { + var crds = _mlc.Transpile(new[] + { + typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), + typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), + typeof(V2AttributeVersionedEntity), + }); + var crd = crds.First(c => c.Spec.Names.Kind == "AttributeVersionedEntity"); + crd.Spec.Versions.Count(v => v.Storage).Should().Be(1); + crd.Spec.Versions.First(v => v.Storage).Name.Should().Be("v1"); + } + + [Fact] + public void Should_Add_Multiple_Versions_To_Crd() + { + var crds = _mlc.Transpile(new[] + { + typeof(V1Alpha1VersionedEntity), typeof(V1Beta1VersionedEntity), typeof(V2Beta2VersionedEntity), + typeof(V2VersionedEntity), typeof(V1VersionedEntity), typeof(V1AttributeVersionedEntity), + typeof(V2AttributeVersionedEntity), + }).ToList(); + crds + .First(c => c.Spec.Names.Kind == "VersionedEntity") + .Spec.Versions.Should() + .HaveCount(5); + crds + .First(c => c.Spec.Names.Kind == "AttributeVersionedEntity") + .Spec.Versions.Should() + .HaveCount(2); + } + + [Fact] + public void Should_Use_Correct_CRD() + { + var crd = _mlc.Transpile(typeof(Entity)); + var (ced, scope) = _mlc.ToEntityMetadata(typeof(Entity)); + + crd.Kind.Should().Be(V1CustomResourceDefinition.KubeKind); + crd.Metadata.Name.Should().Be($"{ced.PluralName}.{ced.Group}"); + crd.Spec.Names.Kind.Should().Be(ced.Kind); + crd.Spec.Names.ListKind.Should().Be(ced.ListKind); + crd.Spec.Names.Singular.Should().Be(ced.SingularName); + crd.Spec.Names.Plural.Should().Be(ced.PluralName); + crd.Spec.Scope.Should().Be(scope); + } + + [Fact] + public void Should_Not_Add_Status_SubResource_If_Absent() + { + var crd = _mlc.Transpile(typeof(Entity)); + crd.Spec.Versions.First().Subresources?.Status?.Should().BeNull(); + } + + [Fact] + public void Should_Add_Status_SubResource_If_Present() + { + var crd = _mlc.Transpile(typeof(EntityWithStatus)); + crd.Spec.Versions.First().Subresources.Status.Should().NotBeNull(); + } + + [Fact] + public void Should_Add_ShortNames_To_Crd() + { + var crd = _mlc.Transpile(typeof(ShortnamesEntity)); + crd.Spec.Names.ShortNames.Should() + .NotBeNull() + .And + .Contain(new[] { "foo", "bar", "baz" }); + } + + [Fact] + public void Should_Set_Description_On_Class() + { + var crd = _mlc.Transpile(typeof(ClassDescriptionAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; + specProperties.Description.Should().NotBe(""); + } + + [Fact] + public void Should_Set_Description() + { + var crd = _mlc.Transpile(typeof(DescriptionAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Description.Should().NotBe(""); + } + + [Fact] + public void Should_Set_ExternalDocs() + { + var crd = _mlc.Transpile(typeof(ExtDocsAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.ExternalDocs.Url.Should().NotBe(""); + } + + [Fact] + public void Should_Set_ExternalDocs_Description() + { + var crd = _mlc.Transpile(typeof(ExtDocsWithDescriptionAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.ExternalDocs.Description.Should().NotBe(""); + } + + [Fact] + public void Should_Set_Items_Information() + { + var crd = _mlc.Transpile(typeof(ItemsAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Type.Should().Be("array"); + (specProperties.Items as V1JSONSchemaProps)?.Type?.Should().Be("string"); + specProperties.MaxItems.Should().Be(42); + specProperties.MinItems.Should().Be(13); + } + + [Fact] + public void Should_Set_Length_Information() + { + var crd = _mlc.Transpile(typeof(LengthAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.MinLength.Should().Be(2); + specProperties.MaxLength.Should().Be(42); + } + + [Fact] + public void Should_Set_MultipleOf() + { + var crd = _mlc.Transpile(typeof(MultipleOfAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.MultipleOf.Should().Be(2); + } + + [Fact] + public void Should_Set_Pattern() + { + var crd = _mlc.Transpile(typeof(PatternAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Pattern.Should().Be(@"/\d*/"); + } + + [Fact] + public void Should_Set_RangeMinimum() + { + var crd = _mlc.Transpile(typeof(RangeMinimumAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Minimum.Should().Be(15); + specProperties.ExclusiveMinimum.Should().BeTrue(); + } + + [Fact] + public void Should_Set_RangeMaximum() + { + var crd = _mlc.Transpile(typeof(RangeMaximumAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + + specProperties.Maximum.Should().Be(15); + specProperties.ExclusiveMaximum.Should().BeTrue(); + } + + [Fact] + public void Should_Set_Required() + { + var crd = _mlc.Transpile(typeof(RequiredAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; + specProperties.Required.Should().Contain("property"); + } + + [Fact] + public void Should_Not_Contain_Ignored_Property() + { + var crd = _mlc.Transpile(typeof(IgnoreAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["spec"]; + specProperties.Properties.Should().NotContainKey("property"); + } + + [Fact] + public void Should_Set_Preserve_Unknown_Fields() + { + var crd = _mlc.Transpile(typeof(PreserveUnknownFieldsAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); + } + + [Fact] + public void Should_Set_EmbeddedResource_Fields() + { + var crd = _mlc.Transpile(typeof(EmbeddedResourceAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesEmbeddedResource.Should().BeTrue(); + } + + [Fact] + public void Should_Set_Preserve_Unknown_Fields_On_Dictionaries() + { + var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeTrue(); + } + + [Fact] + public void Should_Not_Set_Preserve_Unknown_Fields_On_Generic_Dictionaries() + { + var crd = _mlc.Transpile(typeof(DictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Preserve_Unknown_Fields_On_KeyValuePair_Enumerable() + { + var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.XKubernetesPreserveUnknownFields.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Properties_On_Dictionaries() + { + var crd = _mlc.Transpile(typeof(SimpleDictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Properties_On_Generic_Dictionaries() + { + var crd = _mlc.Transpile(typeof(DictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + } + + [Fact] + public void Should_Not_Set_Properties_On_KeyValuePair_Enumerable() + { + var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + } + + [Fact] + public void Should_Set_AdditionalProperties_On_Dictionaries_For_Value_type() + { + var crd = _mlc.Transpile(typeof(DictionaryEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.AdditionalProperties.Should().NotBeNull(); + } + + [Fact] + public void Should_Set_AdditionalProperties_On_KeyValuePair_For_Value_type() + { + var crd = _mlc.Transpile(typeof(EnumerableKeyPairsEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.AdditionalProperties.Should().NotBeNull(); + } + + [Fact] + public void Should_Set_IntOrString() + { + var crd = _mlc.Transpile(typeof(IntstrOrStringEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties["property"]; + specProperties.Properties.Should().BeNull(); + specProperties.XKubernetesIntOrString.Should().BeTrue(); + } + + [Fact] + public void Should_Use_PropertyName_From_JsonPropertyAttribute() + { + var crd = _mlc.Transpile(typeof(JsonPropNameAttrEntity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; + specProperties.Should().Contain(p => p.Key == "otherName"); + } + + [Fact] + public void Must_Not_Contain_Ignored_TopLevel_Properties() + { + var crd = _mlc.Transpile(typeof(Entity)); + + var specProperties = crd.Spec.Versions.First().Schema.OpenAPIV3Schema.Properties; + specProperties.Should().NotContainKeys("metadata", "apiVersion", "kind"); + } + + [Fact] + public void Should_Add_AdditionalPrinterColumns() + { + var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + apc.Should().ContainSingle(def => def.JsonPath == ".property"); + } + + [Fact] + public void Should_Add_AdditionalPrinterColumns_With_Prio() + { + var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnWideAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Priority == 1); + } + + [Fact] + public void Should_Add_AdditionalPrinterColumns_With_Name() + { + var crd = _mlc.Transpile(typeof(AdditionalPrinterColumnNameAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + apc.Should().ContainSingle(def => def.JsonPath == ".property" && def.Name == "OtherName"); + } + + [Fact] + public void Should_Add_GenericAdditionalPrinterColumns() + { + var crd = _mlc.Transpile(typeof(GenericAdditionalPrinterColumnAttrEntity)); + var apc = crd.Spec.Versions.First().AdditionalPrinterColumns; + + apc.Should().NotBeNull(); + apc.Should().ContainSingle(def => def.JsonPath == ".metadata.namespace" && def.Name == "Namespace"); + } + + [Fact] + public void Should_Correctly_Use_Entity_Scope_Attribute() + { + var scopedCrd = _mlc.Transpile(typeof(Entity)); + var clusterCrd = _mlc.Transpile(typeof(ScopeAttrEntity)); + + scopedCrd.Spec.Scope.Should().Be("Namespaced"); + clusterCrd.Spec.Scope.Should().Be("Cluster"); + } + + #region Test Entity Classes + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class StringTestEntity : CustomKubernetesEntity + { + public string Property { get; set; } = string.Empty; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableStringTestEntity : CustomKubernetesEntity + { + public string? Property { get; set; } = string.Empty; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class IntTestEntity : CustomKubernetesEntity + { + public int Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableIntTestEntity : CustomKubernetesEntity + { + public int? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class LongTestEntity : CustomKubernetesEntity + { + public long Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableLongTestEntity : CustomKubernetesEntity + { + public long? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class FloatTestEntity : CustomKubernetesEntity + { + public float Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableFloatTestEntity : CustomKubernetesEntity + { + public float? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DecimalTestEntity : CustomKubernetesEntity + { + public decimal Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableDecimalTestEntity : CustomKubernetesEntity + { + public decimal? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DoubleTestEntity : CustomKubernetesEntity + { + public double Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableDoubleTestEntity : CustomKubernetesEntity + { + public double? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class BoolTestEntity : CustomKubernetesEntity + { + public bool Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableBoolTestEntity : CustomKubernetesEntity + { + public bool? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DateTimeTestEntity : CustomKubernetesEntity + { + public DateTime Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableDateTimeTestEntity : CustomKubernetesEntity + { + public DateTime? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DateTimeOffsetTestEntity : CustomKubernetesEntity + { + public DateTimeOffset Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableDateTimeOffsetTestEntity : CustomKubernetesEntity + { + public DateTimeOffset? Property { get; set; } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class V1ObjectMetaTestEntity : CustomKubernetesEntity + { + public V1ObjectMeta Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class StringArrayEntity : CustomKubernetesEntity + { + public string[] Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableStringArrayEntity : CustomKubernetesEntity + { + public string[]? Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumerableNullableIntEntity : CustomKubernetesEntity + { + public IEnumerable Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumerableIntEntity : CustomKubernetesEntity + { + public IEnumerable Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class HashSetIntEntity : CustomKubernetesEntity + { + public HashSet Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class SetIntEntity : CustomKubernetesEntity + { + public ISet Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class InheritedEnumerableEntity : CustomKubernetesEntity + { + public IntegerList Property { get; set; } = null!; + + public class IntegerList : Collection; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumEntity : CustomKubernetesEntity + { + public TestSpecEnum Property { get; set; } + + public enum TestSpecEnum + { + Value1, + Value2, + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class NullableEnumEntity : CustomKubernetesEntity + { + public TestSpecEnum? Property { get; set; } + + public enum TestSpecEnum + { + Value1, + Value2, + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class SimpleDictionaryEntity : CustomKubernetesEntity + { + public IDictionary Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class DictionaryEntity : CustomKubernetesEntity + { + public IDictionary Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EnumerableKeyPairsEntity : CustomKubernetesEntity + { + public IEnumerable> Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class IntstrOrStringEntity : CustomKubernetesEntity + { + public IntstrIntOrString Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EmbeddedResourceEntity : CustomKubernetesEntity + { + public V1Pod Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + private class EmbeddedResourceListEntity : CustomKubernetesEntity + { + public IList Property { get; set; } = null!; + } + + [Ignore] + [KubernetesEntity] + private class IgnoredEntity : CustomKubernetesEntity; + + public class NonEntity; + + [KubernetesEntity( + ApiVersion = "v1alpha1", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V1Alpha1VersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v1beta1", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V1Beta1VersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v2beta2", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V2Beta2VersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v2", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V2VersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v1", + Kind = "VersionedEntity", + Group = "kubeops.test.dev", + PluralName = "versionedentities")] + public class V1VersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v1", + Kind = "AttributeVersionedEntity", + Group = "kubeops.test.dev", + PluralName = "attributeversionedentities")] + [StorageVersion] + public class V1AttributeVersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v2", + Kind = "AttributeVersionedEntity", + Group = "kubeops.test.dev", + PluralName = "attributeversionedentities")] + public class V2AttributeVersionedEntity : CustomKubernetesEntity; + + [KubernetesEntity( + ApiVersion = "v1337", + Kind = "Kind", + Group = "Group", + PluralName = "Plural")] + public class Entity : CustomKubernetesEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class EntityWithStatus : CustomKubernetesEntity + { + public class EntitySpec; + + public class EntityStatus; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [KubernetesEntityShortNames("foo", "bar", "baz")] + public class ShortnamesEntity : CustomKubernetesEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class DescriptionAttrEntity : CustomKubernetesEntity + { + [Description("Description")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ExtDocsAttrEntity : CustomKubernetesEntity + { + [ExternalDocs("url")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ExtDocsWithDescriptionAttrEntity : CustomKubernetesEntity + { + [ExternalDocs("url", "description")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ItemsAttrEntity : CustomKubernetesEntity + { + [Items(13, 42)] + public string[] Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class LengthAttrEntity : CustomKubernetesEntity + { + [Length(2, 42)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class MultipleOfAttrEntity : CustomKubernetesEntity + { + [MultipleOf(2)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class PatternAttrEntity : CustomKubernetesEntity + { + [Pattern(@"/\d*/")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RangeMinimumAttrEntity : CustomKubernetesEntity + { + [RangeMinimum(15, true)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RangeMaximumAttrEntity : CustomKubernetesEntity + { + [RangeMaximum(15, true)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class RequiredAttrEntity : CustomKubernetesEntity + { + public class EntitySpec + { + [Required] + public string Property { get; set; } = null!; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class IgnoreAttrEntity : CustomKubernetesEntity + { + public class EntitySpec + { + [Ignore] + public string Property { get; set; } = null!; + } + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class PreserveUnknownFieldsAttrEntity : CustomKubernetesEntity + { + [PreserveUnknownFields] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class EmbeddedResourceAttrEntity : CustomKubernetesEntity + { + [EmbeddedResource] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class AdditionalPrinterColumnAttrEntity : CustomKubernetesEntity + { + [AdditionalPrinterColumn] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class AdditionalPrinterColumnWideAttrEntity : CustomKubernetesEntity + { + [AdditionalPrinterColumn(PrinterColumnPriority.WideView)] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class AdditionalPrinterColumnNameAttrEntity : CustomKubernetesEntity + { + [AdditionalPrinterColumn(name: "OtherName")] + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class ClassDescriptionAttrEntity : CustomKubernetesEntity + { + [Description("Description")] + public class EntitySpec; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [GenericAdditionalPrinterColumn(".metadata.namespace", "Namespace", "string")] + public class GenericAdditionalPrinterColumnAttrEntity : CustomKubernetesEntity + { + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + [EntityScope(EntityScope.Cluster)] + public class ScopeAttrEntity : CustomKubernetesEntity + { + public string Property { get; set; } = null!; + } + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")] + public class JsonPropNameAttrEntity : CustomKubernetesEntity + { + [JsonPropertyName("otherName")] + public string Property { get; set; } = null!; + } + + #endregion +} diff --git a/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs index b0eaaeed..7c7294cc 100644 --- a/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Entities.Mlc.Test.cs @@ -1,42 +1,42 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; - -namespace KubeOps.Transpiler.Test; - -public class EntitiesMlcTest(MlcProvider provider) : TranspilerTestBase(provider) -{ - [Theory] - [InlineData(typeof(NamespaceEntity), "Namespaced", "namespaceentity", "namespaceentities", "testing.dev/v1")] - [InlineData(typeof(ClusterEntity), "Cluster", "clusterentity", "clusterentities", "testing.dev/v1")] - public void Should_Correctly_Parse_Metadata( - Type entityType, - string expectedScope, - string singular, - string plural, - string groupVersion) - { - var (meta, scope) = _mlc.ToEntityMetadata(entityType); - - scope.Should().Be(expectedScope); - meta.SingularName.Should().Be(singular); - meta.PluralName.Should().Be(plural); - meta.GroupWithVersion.Should().Be(groupVersion); - } - - #region Test Entity Classes - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "NamespaceEntity", - PluralName = "NamespaceEntities")] - public class NamespaceEntity : CustomKubernetesEntity; - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "ClusterEntity", - PluralName = "clusterentities")] - [EntityScope(EntityScope.Cluster)] - public class ClusterEntity : CustomKubernetesEntity; - - #endregion -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler.Test; + +public class EntitiesMlcTest(MlcProvider provider) : TranspilerTestBase(provider) +{ + [Theory] + [InlineData(typeof(NamespaceEntity), "Namespaced", "namespaceentity", "namespaceentities", "testing.dev/v1")] + [InlineData(typeof(ClusterEntity), "Cluster", "clusterentity", "clusterentities", "testing.dev/v1")] + public void Should_Correctly_Parse_Metadata( + Type entityType, + string expectedScope, + string singular, + string plural, + string groupVersion) + { + var (meta, scope) = _mlc.ToEntityMetadata(entityType); + + scope.Should().Be(expectedScope); + meta.SingularName.Should().Be(singular); + meta.PluralName.Should().Be(plural); + meta.GroupWithVersion.Should().Be(groupVersion); + } + + #region Test Entity Classes + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "NamespaceEntity", + PluralName = "NamespaceEntities")] + public class NamespaceEntity : CustomKubernetesEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "ClusterEntity", + PluralName = "clusterentities")] + [EntityScope(EntityScope.Cluster)] + public class ClusterEntity : CustomKubernetesEntity; + + #endregion +} diff --git a/test/KubeOps.Transpiler.Test/Entities.Test.cs b/test/KubeOps.Transpiler.Test/Entities.Test.cs index da278671..b5a40a52 100644 --- a/test/KubeOps.Transpiler.Test/Entities.Test.cs +++ b/test/KubeOps.Transpiler.Test/Entities.Test.cs @@ -1,42 +1,42 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Entities.Attributes; - -namespace KubeOps.Transpiler.Test; - -public class EntitiesTest -{ - [Theory] - [InlineData(typeof(NamespaceEntity), "Namespaced", "namespaceentity", "namespaceentities", "testing.dev/v1")] - [InlineData(typeof(ClusterEntity), "Cluster", "clusterentity", "clusterentities", "testing.dev/v1")] - public void Should_Correctly_Parse_Metadata( - Type entityType, - string expectedScope, - string singular, - string plural, - string groupVersion) - { - var (meta, scope) = Entities.ToEntityMetadata(entityType); - - scope.Should().Be(expectedScope); - meta.SingularName.Should().Be(singular); - meta.PluralName.Should().Be(plural); - meta.GroupWithVersion.Should().Be(groupVersion); - } - - #region Test Entity Classes - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "NamespaceEntity", - PluralName = "NamespaceEntities")] - public class NamespaceEntity : CustomKubernetesEntity; - - [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "ClusterEntity", - PluralName = "clusterentities")] - [EntityScope(EntityScope.Cluster)] - public class ClusterEntity : CustomKubernetesEntity; - - #endregion -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace KubeOps.Transpiler.Test; + +public class EntitiesTest +{ + [Theory] + [InlineData(typeof(NamespaceEntity), "Namespaced", "namespaceentity", "namespaceentities", "testing.dev/v1")] + [InlineData(typeof(ClusterEntity), "Cluster", "clusterentity", "clusterentities", "testing.dev/v1")] + public void Should_Correctly_Parse_Metadata( + Type entityType, + string expectedScope, + string singular, + string plural, + string groupVersion) + { + var (meta, scope) = Entities.ToEntityMetadata(entityType); + + scope.Should().Be(expectedScope); + meta.SingularName.Should().Be(singular); + meta.PluralName.Should().Be(plural); + meta.GroupWithVersion.Should().Be(groupVersion); + } + + #region Test Entity Classes + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "NamespaceEntity", + PluralName = "NamespaceEntities")] + public class NamespaceEntity : CustomKubernetesEntity; + + [KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "ClusterEntity", + PluralName = "clusterentities")] + [EntityScope(EntityScope.Cluster)] + public class ClusterEntity : CustomKubernetesEntity; + + #endregion +} diff --git a/test/KubeOps.Transpiler.Test/GlobalUsings.cs b/test/KubeOps.Transpiler.Test/GlobalUsings.cs index e1065597..c802f448 100644 --- a/test/KubeOps.Transpiler.Test/GlobalUsings.cs +++ b/test/KubeOps.Transpiler.Test/GlobalUsings.cs @@ -1 +1 @@ -global using Xunit; +global using Xunit; diff --git a/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs b/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs index 1cd79cac..5abb5081 100644 --- a/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs +++ b/test/KubeOps.Transpiler.Test/IntegrationTestCollection.cs @@ -1,15 +1,15 @@ -using System.Reflection; - -namespace KubeOps.Transpiler.Test; - -[CollectionDefinition(Name, DisableParallelization = true)] -public class TranspilerTestCollection : ICollectionFixture -{ - public const string Name = "Transpiler Tests"; -} - -[Collection(TranspilerTestCollection.Name)] -public abstract class TranspilerTestBase(MlcProvider provider) -{ - protected readonly MetadataLoadContext _mlc = provider.Mlc; -} +using System.Reflection; + +namespace KubeOps.Transpiler.Test; + +[CollectionDefinition(Name, DisableParallelization = true)] +public class TranspilerTestCollection : ICollectionFixture +{ + public const string Name = "Transpiler Tests"; +} + +[Collection(TranspilerTestCollection.Name)] +public abstract class TranspilerTestBase(MlcProvider provider) +{ + protected readonly MetadataLoadContext _mlc = provider.Mlc; +} diff --git a/test/KubeOps.Transpiler.Test/MlcProvider.cs b/test/KubeOps.Transpiler.Test/MlcProvider.cs index 7d29eac8..cdee1aa2 100644 --- a/test/KubeOps.Transpiler.Test/MlcProvider.cs +++ b/test/KubeOps.Transpiler.Test/MlcProvider.cs @@ -1,44 +1,44 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis.MSBuild; - -namespace KubeOps.Transpiler.Test; - -public class MlcProvider : IAsyncLifetime -{ - static MlcProvider() - { - MSBuildLocator.RegisterDefaults(); - } - - public MetadataLoadContext Mlc { get; private set; } = null!; - - public async Task InitializeAsync() - { - var assemblyConfigurationAttribute = - typeof(MlcProvider).Assembly.GetCustomAttribute(); - var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; - - using var workspace = MSBuildWorkspace.Create(new Dictionary - { - { "Configuration", buildConfigurationName }, - }); - - workspace.SkipUnrecognizedProjects = true; - workspace.LoadMetadataForReferencedProjects = true; - var project = await workspace.OpenProjectAsync("../../../KubeOps.Transpiler.Test.csproj"); - - Mlc = ContextCreator.Create(Directory - .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") - .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) - .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); - } - - public Task DisposeAsync() - { - Mlc.Dispose(); - return Task.CompletedTask; - } -} +using System.Reflection; +using System.Runtime.InteropServices; + +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis.MSBuild; + +namespace KubeOps.Transpiler.Test; + +public class MlcProvider : IAsyncLifetime +{ + static MlcProvider() + { + MSBuildLocator.RegisterDefaults(); + } + + public MetadataLoadContext Mlc { get; private set; } = null!; + + public async Task InitializeAsync() + { + var assemblyConfigurationAttribute = + typeof(MlcProvider).Assembly.GetCustomAttribute(); + var buildConfigurationName = assemblyConfigurationAttribute?.Configuration ?? "Debug"; + + using var workspace = MSBuildWorkspace.Create(new Dictionary + { + { "Configuration", buildConfigurationName }, + }); + + workspace.SkipUnrecognizedProjects = true; + workspace.LoadMetadataForReferencedProjects = true; + var project = await workspace.OpenProjectAsync("../../../KubeOps.Transpiler.Test.csproj"); + + Mlc = ContextCreator.Create(Directory + .GetFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") + .Concat(Directory.GetFiles(Path.GetDirectoryName(project.OutputFilePath)!, "*.dll")) + .Distinct(), coreAssemblyName: typeof(object).Assembly.GetName().Name); + } + + public Task DisposeAsync() + { + Mlc.Dispose(); + return Task.CompletedTask; + } +} diff --git a/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs b/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs index 55da0559..609c3fcd 100644 --- a/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs +++ b/test/KubeOps.Transpiler.Test/Rbac.Mlc.Test.cs @@ -1,122 +1,122 @@ -using FluentAssertions; - -using k8s.Models; - -using KubeOps.Abstractions.Entities; -using KubeOps.Abstractions.Rbac; - -namespace KubeOps.Transpiler.Test; - -public class RbacMlcTest(MlcProvider provider) : TranspilerTestBase(provider) -{ - [Fact] - public void Should_Create_Generic_Policy() - { - var role = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() - .First(); - role.ApiGroups.Should().Contain("group"); - role.Resources.Should().Contain("configmaps"); - role.NonResourceURLs.Should().Contain("url"); - role.NonResourceURLs.Should().Contain("foobar"); - role.Verbs.Should().Contain(new[] { "get", "delete" }); - } - - [Fact] - public void Should_Calculate_Max_Verbs_For_Types() - { - var role = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() - .First(); - role.Resources.Should().Contain("rbactest1s"); - role.Verbs.Should().Contain(new[] { "get", "update", "delete" }); - } - - [Fact] - public void Should_Correctly_Calculate_All_Verb() - { - var role = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() - .First(); - role.Resources.Should().Contain("rbactest2s"); - role.Verbs.Should().Contain("*").And.HaveCount(1); - } - - [Fact] - public void Should_Group_Same_Types_Together() - { - var roles = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest1s")); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest2s")); - roles.Should().HaveCount(2); - } - - [Fact] - public void Should_Group_Types_With_Same_Verbs_Together() - { - var roles = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest1s") && - rule.Resources.Contains("rbactest4s") && - rule.Verbs.Contains("get") && - rule.Verbs.Contains("update")); - roles.Should() - .Contain( - rule => rule.Resources.Contains("rbactest2s") && - rule.Resources.Contains("rbactest3s") && - rule.Verbs.Contains("delete")); - roles.Should().HaveCount(2); - } - - [Fact] - public void Should_Not_Mix_ApiGroups() - { - var roles = _mlc - .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); - roles.Should().HaveCount(5); - } - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Delete)] - public class RbacTest1 : CustomKubernetesEntity; - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.All)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - public class RbacTest2 : CustomKubernetesEntity; - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - public class RbacTest3 : CustomKubernetesEntity; - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] - [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] - [EntityRbac(typeof(RbacTest3), Verbs = RbacVerb.Delete)] - [EntityRbac(typeof(RbacTest4), Verbs = RbacVerb.Get | RbacVerb.Update)] - public class RbacTest4 : CustomKubernetesEntity; - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [EntityRbac(typeof(V1Deployment), Verbs = RbacVerb.All)] - [EntityRbac(typeof(V1Service), Verbs = RbacVerb.All)] - [EntityRbac(typeof(V1Lease), Verbs = RbacVerb.All)] - public class RbacTest5 : CustomKubernetesEntity; - - [KubernetesEntity(Group = "test", ApiVersion = "v1")] - [GenericRbac(Urls = ["url", "foobar"], Resources = ["configmaps"], Groups = ["group"], - Verbs = RbacVerb.Delete | RbacVerb.Get)] - public class GenericRbacTest : CustomKubernetesEntity; -} +using FluentAssertions; + +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Rbac; + +namespace KubeOps.Transpiler.Test; + +public class RbacMlcTest(MlcProvider provider) : TranspilerTestBase(provider) +{ + [Fact] + public void Should_Create_Generic_Policy() + { + var role = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() + .First(); + role.ApiGroups.Should().Contain("group"); + role.Resources.Should().Contain("configmaps"); + role.NonResourceURLs.Should().Contain("url"); + role.NonResourceURLs.Should().Contain("foobar"); + role.Verbs.Should().Contain(new[] { "get", "delete" }); + } + + [Fact] + public void Should_Calculate_Max_Verbs_For_Types() + { + var role = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() + .First(); + role.Resources.Should().Contain("rbactest1s"); + role.Verbs.Should().Contain(new[] { "get", "update", "delete" }); + } + + [Fact] + public void Should_Correctly_Calculate_All_Verb() + { + var role = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList() + .First(); + role.Resources.Should().Contain("rbactest2s"); + role.Verbs.Should().Contain("*").And.HaveCount(1); + } + + [Fact] + public void Should_Group_Same_Types_Together() + { + var roles = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest1s")); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest2s")); + roles.Should().HaveCount(2); + } + + [Fact] + public void Should_Group_Types_With_Same_Verbs_Together() + { + var roles = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest1s") && + rule.Resources.Contains("rbactest4s") && + rule.Verbs.Contains("get") && + rule.Verbs.Contains("update")); + roles.Should() + .Contain( + rule => rule.Resources.Contains("rbactest2s") && + rule.Resources.Contains("rbactest3s") && + rule.Verbs.Contains("delete")); + roles.Should().HaveCount(2); + } + + [Fact] + public void Should_Not_Mix_ApiGroups() + { + var roles = _mlc + .Transpile(_mlc.GetContextType().GetCustomAttributesData()).ToList(); + roles.Should().HaveCount(5); + } + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Delete)] + public class RbacTest1 : CustomKubernetesEntity; + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.All)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + public class RbacTest2 : CustomKubernetesEntity; + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + public class RbacTest3 : CustomKubernetesEntity; + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Get)] + [EntityRbac(typeof(RbacTest1), Verbs = RbacVerb.Update)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + [EntityRbac(typeof(RbacTest2), Verbs = RbacVerb.Delete)] + [EntityRbac(typeof(RbacTest3), Verbs = RbacVerb.Delete)] + [EntityRbac(typeof(RbacTest4), Verbs = RbacVerb.Get | RbacVerb.Update)] + public class RbacTest4 : CustomKubernetesEntity; + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [EntityRbac(typeof(V1Deployment), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Service), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Lease), Verbs = RbacVerb.All)] + public class RbacTest5 : CustomKubernetesEntity; + + [KubernetesEntity(Group = "test", ApiVersion = "v1")] + [GenericRbac(Urls = ["url", "foobar"], Resources = ["configmaps"], Groups = ["group"], + Verbs = RbacVerb.Delete | RbacVerb.Get)] + public class GenericRbacTest : CustomKubernetesEntity; +}