Fase 0: solution structure, capability model, rule engine, least-privilege script
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
- .NET 10 solution with Core, Connectivity, Rules, Contracts, Agent, Web and Hub - Capability model gating collectors and rules by SQL Server version (2000 and later); rules that cannot run are reported as not measurable, never as passed - Connectivity via Microsoft.Data.SqlClient with ODBC fallback for legacy instances, hardened monitoring session settings valid back to SQL Server 2000 - Support lifecycle rule (SQLI-VER-001) with lifecycle data shipped as a rule pack - Version-aware least-privilege permissions script, verified by integration tests against SQL Server in Docker (reads monitoring sources, cannot read customer data) - Forgejo Actions CI, contributing guide, code of conduct, issue templates, legacy version notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
parent
63b54a8a68
commit
f8aa0c307b
75 changed files with 3466 additions and 9 deletions
389
.editorconfig
Normal file
389
.editorconfig
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# All files
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Xml files
|
||||||
|
[*.xml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Xml project files
|
||||||
|
[*.{csproj,fsproj,vbproj,proj,slnx}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Xml config files
|
||||||
|
[*.{props,targets,config,nuspec}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# C# files
|
||||||
|
[*.cs]
|
||||||
|
|
||||||
|
#### Core EditorConfig Options ####
|
||||||
|
|
||||||
|
# Indentation and spacing
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
|
|
||||||
|
# New line preferences
|
||||||
|
insert_final_newline = 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_namespace_match_folder = 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_collection_expression = when_types_loosely_match: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_foreach_explicit_cast_in_source = when_strongly_typed: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_extended_property_pattern = 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_anonymous_function = true:suggestion
|
||||||
|
csharp_prefer_static_local_function = true:warning
|
||||||
|
csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
|
||||||
|
csharp_style_prefer_readonly_struct = true:suggestion
|
||||||
|
csharp_style_prefer_readonly_struct_member = true:suggestion
|
||||||
|
|
||||||
|
# Code-block preferences
|
||||||
|
csharp_prefer_braces = true:silent
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
|
csharp_style_namespace_declarations = file_scoped:suggestion
|
||||||
|
csharp_style_prefer_method_group_conversion = true:silent
|
||||||
|
csharp_style_prefer_primary_constructors = true:suggestion
|
||||||
|
csharp_style_prefer_top_level_statements = true:silent
|
||||||
|
|
||||||
|
# Expression-level preferences
|
||||||
|
csharp_prefer_simple_default_expression = true:suggestion
|
||||||
|
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||||
|
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_inlined_variable_declaration = true:suggestion
|
||||||
|
csharp_style_prefer_index_operator = true:suggestion
|
||||||
|
csharp_style_prefer_local_over_anonymous_function = true:suggestion
|
||||||
|
csharp_style_prefer_null_check_over_type_check = true:suggestion
|
||||||
|
csharp_style_prefer_range_operator = true:suggestion
|
||||||
|
csharp_style_prefer_tuple_swap = true:suggestion
|
||||||
|
csharp_style_prefer_utf8_string_literals = 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
|
||||||
|
|
||||||
20
.forgejo/issue_template/fejl.md
Normal file
20
.forgejo/issue_template/fejl.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
name: Fejlrapport
|
||||||
|
about: Noget i SQLInsight Pro virker ikke som forventet
|
||||||
|
labels: ["fejl"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hvad skete der
|
||||||
|
|
||||||
|
## Hvad forventede du
|
||||||
|
|
||||||
|
## Miljø
|
||||||
|
|
||||||
|
- SQLInsight Pro-version:
|
||||||
|
- SQL Server-version (`SELECT @@VERSION`):
|
||||||
|
- Agent kører på (Windows/Linux, version):
|
||||||
|
- Forbindelse (SqlClient / ODBC-fallback):
|
||||||
|
|
||||||
|
## Log
|
||||||
|
|
||||||
|
<!-- Relevante linjer fra agentens log. Fjern servernavne og andet følsomt. -->
|
||||||
21
.forgejo/issue_template/regel-forslag.md
Normal file
21
.forgejo/issue_template/regel-forslag.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
name: Forslag til regel
|
||||||
|
about: Et SQL Server-problem, som SQLInsight Pro burde finde
|
||||||
|
labels: ["regel"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problemet
|
||||||
|
|
||||||
|
<!-- Hvad går galt på instansen? Hvordan opdagede du det? -->
|
||||||
|
|
||||||
|
## Hvordan man måler det
|
||||||
|
|
||||||
|
<!-- DMV'er, Query Store, XE-events, error log … Gerne med en query. -->
|
||||||
|
|
||||||
|
## Hvilke versioner
|
||||||
|
|
||||||
|
<!-- Fra hvilken SQL Server-version kan det måles? Er der forskelle mellem versioner? -->
|
||||||
|
|
||||||
|
## Anbefalet løsning
|
||||||
|
|
||||||
|
<!-- Hvad bør brugeren gøre? Links til Microsoft Learn eller anden dokumentation. -->
|
||||||
58
.forgejo/workflows/ci.yml
Normal file
58
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||||
|
DOTNET_NOLOGO: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build og unit tests
|
||||||
|
# "docker" er standard-label for en Forgejo runner. Ret til jeres runners label, hvis det er et andet.
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git -c http.extraHeader="Authorization: token ${{ secrets.GITHUB_TOKEN }}" fetch -q --depth 1 origin "${{ github.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --no-restore --configuration Release
|
||||||
|
- name: Unit tests
|
||||||
|
run: dotnet test --no-build --configuration Release --filter "Category!=Integration"
|
||||||
|
|
||||||
|
integration:
|
||||||
|
name: Integration (${{ matrix.image }})
|
||||||
|
needs: build
|
||||||
|
# Kræver en runner, der har adgang til Docker (Testcontainers starter SQL Server-containere).
|
||||||
|
# Slå til ved at sætte repo-variablen RUN_INTEGRATION_TESTS=true og give runneren labelen "docker-host".
|
||||||
|
if: ${{ vars.RUN_INTEGRATION_TESTS == 'true' }}
|
||||||
|
runs-on: docker-host
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
image:
|
||||||
|
- mcr.microsoft.com/mssql/server:2017-latest
|
||||||
|
- mcr.microsoft.com/mssql/server:2019-latest
|
||||||
|
- mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
- mcr.microsoft.com/mssql/server:2025-latest
|
||||||
|
env:
|
||||||
|
SQLINSIGHT_MSSQL_IMAGE: ${{ matrix.image }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git -c http.extraHeader="Authorization: token ${{ secrets.GITHUB_TOKEN }}" fetch -q --depth 1 origin "${{ github.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
- name: Integration tests
|
||||||
|
run: dotnet test tests/SQLInsight.IntegrationTests --configuration Release
|
||||||
482
.gitignore
vendored
Normal file
482
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from `dotnet new gitignore`
|
||||||
|
|
||||||
|
# dotenv files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# Vim temporary swap files
|
||||||
|
*.swp
|
||||||
7
CODE_OF_CONDUCT.md
Normal file
7
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
Projektet følger [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||||
|
|
||||||
|
Kort fortalt: Vær venlig og konstruktiv, også når du er uenig. Chikane og personangreb accepteres ikke.
|
||||||
|
|
||||||
|
Brud på reglerne kan rapporteres til seo@seosoft.dk. Alle henvendelser behandles fortroligt.
|
||||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Bidrag til SQLInsight Pro
|
||||||
|
|
||||||
|
Tak fordi du vil hjælpe. De mest værdifulde bidrag er **SQL Server-ekspertise**: problemer du har set i produktion, hvordan de måles, og hvordan de løses. Opret dem som et issue med skabelonen *Forslag til regel*.
|
||||||
|
|
||||||
|
## Byg og test
|
||||||
|
|
||||||
|
Du skal bruge [.NET 10 SDK](https://dotnet.microsoft.com/download) og, til integrationstests, Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build
|
||||||
|
dotnet test --filter "Category!=Integration" # unit tests, ingen afhængigheder
|
||||||
|
dotnet test tests/SQLInsight.IntegrationTests # starter SQL Server 2022 i Docker
|
||||||
|
SQLINSIGHT_MSSQL_IMAGE=mcr.microsoft.com/mssql/server:2019-latest \
|
||||||
|
dotnet test tests/SQLInsight.IntegrationTests # anden version
|
||||||
|
```
|
||||||
|
|
||||||
|
Warnings behandles som fejl, og .NET-analyzers kører på niveauet `latest-recommended`.
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
| Mappe | Indhold |
|
||||||
|
|---|---|
|
||||||
|
| `src/SQLInsight.Core` | Domænemodel: versioner, capabilities, findings |
|
||||||
|
| `src/SQLInsight.Connectivity` | Forbindelser (SqlClient + ODBC-fallback) og registrering af instansen |
|
||||||
|
| `src/SQLInsight.Rules` | Regelmotor og regler |
|
||||||
|
| `src/SQLInsight.Agent` | Windows Service / systemd-agent |
|
||||||
|
| `src/SQLInsight.Web` | On-prem web-UI (Fase 1) |
|
||||||
|
| `src/SQLInsight.Contracts` | Wire-kontrakt mellem agent og Hub |
|
||||||
|
| `src/SQLInsight.Hub` | Centralt API til findings, PostgreSQL (Fase 3) |
|
||||||
|
| `db/permissions` | Least-privilege-script til agentens login |
|
||||||
|
| `tests/` | Unit og integrationstests |
|
||||||
|
|
||||||
|
## Sådan skriver du en regel
|
||||||
|
|
||||||
|
1. Implementér `IRule` i `src/SQLInsight.Rules`. Giv den et nyt, fast id (`SQLI-<OMRÅDE>-<NR>`). Et id genbruges aldrig.
|
||||||
|
2. Angiv i `RequiredCapabilities` alt, hvad reglen kræver. Motoren rapporterer den som *ikke målbar* på versioner, der mangler noget. Reglen må aldrig selv returnere `Passed`, når den ikke har kunnet måle.
|
||||||
|
3. Et finding skal have **evidens** (de konkrete tal), **forklaring** (hvorfor det er et problem) og **anbefaling** (hvad man gør). Fix-scripts vises for brugeren og køres aldrig automatisk.
|
||||||
|
4. Skriv unit tests for alle udfald: fundet, ikke fundet, grænseværdier og versioner, hvor reglen ikke kan måles.
|
||||||
|
5. Registrér reglen i `BuiltInRules`.
|
||||||
|
|
||||||
|
## Queries mod overvågede servere
|
||||||
|
|
||||||
|
- De skal være **read-only** og kunne køre med rettighederne fra `db/permissions/grant-least-privilege.sql`. Kræver de mere, skal scriptet og `LeastPrivilegeTests` opdateres i samme ændring.
|
||||||
|
- De skal være billige: ingen `DETAILED`-scans, grænser på antal rækker og intet, der kan tage låse på brugerdata.
|
||||||
|
- Tjek, hvilke versioner DMV'en og kolonnerne findes i, og angiv det som capabilities.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
Små, fokuserede commits med en besked, der forklarer *hvorfor*.
|
||||||
24
Directory.Build.props
Normal file
24
Directory.Build.props
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||||
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- CS1591: missing XML comments. Public API docs are wanted, but not enforced on every member. -->
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<Company>SEOSoft</Company>
|
||||||
|
<Product>SQLInsight Pro</Product>
|
||||||
|
<Copyright>Copyright (c) SEOSoft</Copyright>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
<!-- CA1707: underscores in test method names are the convention here. -->
|
||||||
|
<NoWarn>$(NoWarn);CA1707</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
19
Directory.Packages.props
Normal file
19
Directory.Packages.props
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Runtime -->
|
||||||
|
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.0" />
|
||||||
|
<PackageVersion Include="System.Data.Odbc" Version="10.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
|
||||||
|
<!-- Test -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="Testcontainers.MsSql" Version="4.15.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
18
README.md
18
README.md
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
|
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
|
||||||
|
|
||||||
> **Status:** Projektet er i idéfasen. Der er endnu ingen kode. Se [Roadmap](#roadmap).
|
> **Status:** Fase 0 (fundament) er i gang. Se [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md) for at bygge og teste.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -161,15 +161,15 @@ Hvert **finding** indeholder:
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Fase 0: Fundament
|
### Fase 0: Fundament
|
||||||
- [ ] Solution-struktur (.NET 10 LTS): Agent, Rules, Web, Hub, fælles domænemodel
|
- [x] Solution-struktur (.NET 10 LTS): Agent, Rules, Web, Hub, fælles domænemodel
|
||||||
- [ ] CI-pipeline (build, test, statisk analyse)
|
- [x] CI-pipeline til Forgejo Actions (build, test, statisk analyse). Endnu ikke kørt på en runner
|
||||||
- [ ] Integrationstests mod rigtige SQL Server-containere (2017, 2019, 2022 og 2025) med Testcontainers. Versionerne 2000–2016 findes ikke som containere og testes på VM'er
|
- [x] Integrationstests mod rigtige SQL Server-containere (2017, 2019, 2022 og 2025) med Testcontainers. Indtil videre verificeret på 2022. Versionerne 2000–2016 findes ikke som containere og testes på VM'er
|
||||||
- [ ] Least-privilege-script til opsætning af rettigheder og en test, der verificerer det
|
- [x] Least-privilege-script til opsætning af rettigheder og en test, der verificerer det
|
||||||
- [ ] Capability-model: collectors og regler angiver, hvilken minimumsversion, edition og funktion de kræver
|
- [x] Capability-model: collectors og regler angiver, hvilken minimumsversion, edition og funktion de kræver
|
||||||
- [ ] Spike: forbindelse til SQL Server 2000, 2005 og 2008 (driver, TLS 1.0 og ODBC-fallback). Resultatet afgør, om legacy-laget er et separat modul
|
- [ ] Spike: forbindelse til SQL Server 2000, 2005 og 2008 (driver, TLS 1.0 og ODBC-fallback). Resultatet afgør, om legacy-laget er et separat modul. ODBC-fallback er bygget, men ikke verificeret. Se [docs/legacy-versions.md](docs/legacy-versions.md)
|
||||||
- [ ] Testmiljø med VM'er til de legacy-versioner, der ikke findes som containere (2000–2016)
|
- [ ] Testmiljø med VM'er til de legacy-versioner, der ikke findes som containere (2000–2016)
|
||||||
- [ ] Datamodel for findings (fælles kontrakt mellem Agent og Hub)
|
- [x] Datamodel for findings (fælles kontrakt mellem Agent og Hub)
|
||||||
- [ ] Bidragsguide, code of conduct og issue templates
|
- [x] Bidragsguide, code of conduct og issue templates
|
||||||
|
|
||||||
### Fase 1: Community MVP
|
### Fase 1: Community MVP
|
||||||
- [ ] Agent som Windows Service med forbindelse til en eller flere SQL Server-instanser
|
- [ ] Agent som Windows Service med forbindelse til en eller flere SQL Server-instanser
|
||||||
|
|
|
||||||
17
SQLInsight.slnx
Normal file
17
SQLInsight.slnx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/SQLInsight.Agent/SQLInsight.Agent.csproj" />
|
||||||
|
<Project Path="src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj" />
|
||||||
|
<Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" />
|
||||||
|
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
|
||||||
|
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
|
||||||
|
<Project Path="src/SQLInsight.Rules/SQLInsight.Rules.csproj" />
|
||||||
|
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
|
||||||
|
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
|
||||||
|
<Project Path="tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj" />
|
||||||
|
<Project Path="tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
105
db/permissions/grant-least-privilege.sql
Normal file
105
db/permissions/grant-least-privilege.sql
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/*
|
||||||
|
SQLInsight Pro: least-privilege rettigheder til agentens login
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
Giver agenten præcis de rettigheder, den skal bruge for at overvåge instansen. Aldrig sysadmin.
|
||||||
|
Scriptet kan køres igen uden skade, fx efter en opgradering eller når der er oprettet nye databaser.
|
||||||
|
|
||||||
|
Understøtter SQL Server 2005 og nyere. SQL Server 2000 har ikke VIEW SERVER STATE: se docs/legacy-versions.md.
|
||||||
|
|
||||||
|
Forudsætning: login'et findes allerede, fx
|
||||||
|
CREATE LOGIN [DOMÆNE\svc-sqlinsight$] FROM WINDOWS; -- gMSA (anbefalet)
|
||||||
|
|
||||||
|
Hvad der gives, og hvorfor:
|
||||||
|
Server
|
||||||
|
2005-2019: VIEW SERVER STATE DMV'er: waits, sessions, plan cache, I/O
|
||||||
|
2022+: VIEW SERVER PERFORMANCE STATE samme, mere granulært
|
||||||
|
VIEW SERVER SECURITY STATE sikkerheds-DMV'er (fx audit, certifikater)
|
||||||
|
Alle: VIEW ANY DEFINITION metadata (indexes, statistikker). Giver IKKE adgang til data.
|
||||||
|
2014+: CONNECT ANY DATABASE kan åbne alle databaser (nuværende og fremtidige). Giver IKKE adgang til data.
|
||||||
|
Databaser (kun før 2014): en bruger uden rettigheder i hver online database, så DMV'er og Query Store kan læses.
|
||||||
|
msdb: SELECT på backup-, restore-, job- og suspect_pages-tabeller. Intet andet.
|
||||||
|
|
||||||
|
Hvad der IKKE gives: adgang til tabeldata, skriverettigheder, xp_cmdshell eller sysadmin.
|
||||||
|
*/
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @LoginName sysname;
|
||||||
|
SET @LoginName = N'DOMÆNE\svc-sqlinsight$'; -- <== ret til agentens login
|
||||||
|
|
||||||
|
DECLARE @Major int;
|
||||||
|
DECLARE @Sql nvarchar(max);
|
||||||
|
DECLARE @Quoted nvarchar(300);
|
||||||
|
SET @Major = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int);
|
||||||
|
SET @Quoted = QUOTENAME(@LoginName);
|
||||||
|
|
||||||
|
IF @Major < 9
|
||||||
|
BEGIN
|
||||||
|
RAISERROR(N'SQL Server 2000 understøttes ikke af dette script. Se docs/legacy-versions.md.', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @LoginName)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR(N'Login %s findes ikke. Opret det først.', 16, 1, @LoginName);
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF IS_SRVROLEMEMBER(N'sysadmin', @LoginName) = 1
|
||||||
|
PRINT N'ADVARSEL: ' + @LoginName + N' er sysadmin. Agenten har ikke brug for det; fjern rollen.';
|
||||||
|
|
||||||
|
/* Server-rettigheder */
|
||||||
|
IF @Major >= 16
|
||||||
|
SET @Sql = N'GRANT VIEW SERVER PERFORMANCE STATE TO ' + @Quoted + N'; GRANT VIEW SERVER SECURITY STATE TO ' + @Quoted + N';';
|
||||||
|
ELSE
|
||||||
|
SET @Sql = N'GRANT VIEW SERVER STATE TO ' + @Quoted + N';';
|
||||||
|
EXEC (@Sql);
|
||||||
|
|
||||||
|
SET @Sql = N'GRANT VIEW ANY DEFINITION TO ' + @Quoted + N';';
|
||||||
|
EXEC (@Sql);
|
||||||
|
|
||||||
|
IF @Major >= 12
|
||||||
|
BEGIN
|
||||||
|
SET @Sql = N'GRANT CONNECT ANY DATABASE TO ' + @Quoted + N';';
|
||||||
|
EXEC (@Sql);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
/* Før 2014: en bruger uden rettigheder i hver online database. Kør scriptet igen, når der oprettes nye. */
|
||||||
|
DECLARE @Db sysname;
|
||||||
|
DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||||
|
SELECT name FROM sys.databases
|
||||||
|
WHERE state = 0 AND name NOT IN (N'master', N'tempdb', N'model', N'msdb') AND is_read_only = 0;
|
||||||
|
OPEN db_cursor;
|
||||||
|
FETCH NEXT FROM db_cursor INTO @Db;
|
||||||
|
WHILE @@FETCH_STATUS = 0
|
||||||
|
BEGIN
|
||||||
|
SET @Sql = N'USE ' + QUOTENAME(@Db) + N';
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(' + QUOTENAME(@LoginName, '''') + N'))
|
||||||
|
CREATE USER ' + @Quoted + N' FOR LOGIN ' + @Quoted + N';';
|
||||||
|
EXEC (@Sql);
|
||||||
|
FETCH NEXT FROM db_cursor INTO @Db;
|
||||||
|
END;
|
||||||
|
CLOSE db_cursor;
|
||||||
|
DEALLOCATE db_cursor;
|
||||||
|
END;
|
||||||
|
|
||||||
|
/* msdb: backup-historik, jobs og suspect pages */
|
||||||
|
SET @Sql = N'USE msdb;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(' + QUOTENAME(@LoginName, '''') + N'))
|
||||||
|
CREATE USER ' + @Quoted + N' FOR LOGIN ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.backupset TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.backupmediafamily TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.backupmediaset TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.restorehistory TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.suspect_pages TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.sysjobs TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.sysjobsteps TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.sysjobhistory TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.sysjobactivity TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.sysjobschedules TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.sysschedules TO ' + @Quoted + N';
|
||||||
|
GRANT SELECT ON dbo.syscategories TO ' + @Quoted + N';';
|
||||||
|
EXEC (@Sql);
|
||||||
|
|
||||||
|
PRINT N'Rettigheder givet til ' + @LoginName + N'.';
|
||||||
45
docs/legacy-versions.md
Normal file
45
docs/legacy-versions.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Legacy-versioner: SQL Server 2000–2016
|
||||||
|
|
||||||
|
SQLInsight Pro overvåger alle versioner fra SQL Server 2000. Denne side beskriver de særlige forhold for de ældste versioner, og hvad der skal verificeres i Fase 0-spiken.
|
||||||
|
|
||||||
|
## Forbindelse
|
||||||
|
|
||||||
|
| Version | Problem | Løsning |
|
||||||
|
|---|---|---|
|
||||||
|
| 2000 | Microsoft.Data.SqlClient understøtter ikke TDS 7.0/7.1 | ODBC (`System.Data.Odbc`) med en driver, der understøtter 2000 |
|
||||||
|
| 2005, 2008, 2008 R2 uden TLS 1.2-patch | Kun TLS 1.0/1.1; moderne Windows og .NET afviser handshaken | Installér TLS 1.2-opdateringen på serveren (anbefalet), ellers ODBC med `Encryption=Optional` |
|
||||||
|
| 2012, 2014 uden TLS 1.2-patch | Som ovenfor | Som ovenfor |
|
||||||
|
|
||||||
|
Agenten bruger som standard `Provider=Auto`: den prøver SqlClient først og falder kun tilbage til ODBC, når fejlen ligner en versions- eller TLS-inkompatibilitet (`LegacyFailureClassifier`). Login- og netværksfejl giver ikke fallback, for så ville den rigtige fejl blive skjult. Når fallback bruges, logges en advarsel.
|
||||||
|
|
||||||
|
## Spike: det skal verificeres på rigtige VM'er
|
||||||
|
|
||||||
|
Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers.
|
||||||
|
|
||||||
|
- [ ] Hvilken ODBC-driver virker mod SQL Server 2000 fra agentens OS? Kandidater: den indbyggede Windows-driver `SQL Server` (sqlsrv32) og `ODBC Driver 17 for SQL Server`. ODBC Driver 18 understøtter formentlig ikke 2000.
|
||||||
|
- [ ] Kan agenten på Linux nå SQL 2000 overhovedet? Hvis ikke, kræver legacy-overvågning en Windows-agent.
|
||||||
|
- [ ] Rammer `LegacyFailureClassifier` de fejlbeskeder, SqlClient faktisk giver mod 2000, 2005 og 2008 uden TLS 1.2?
|
||||||
|
- [ ] Returnerer `InstanceProbe` korrekte værdier på 2000 (`SERVERPROPERTY` for ukendte properties skal give NULL)?
|
||||||
|
- [ ] Bliver sessionsindstillingerne (`LOCK_TIMEOUT`, `DEADLOCK_PRIORITY LOW`, `READ UNCOMMITTED`) accepteret via ODBC på alle versioner?
|
||||||
|
|
||||||
|
Resultatet afgør, om legacy-laget forbliver en del af `SQLInsight.Connectivity` eller bliver et separat modul eller en separat agent.
|
||||||
|
|
||||||
|
**Testmiljø:** Én VM pr. version (2000 SP4, 2005 SP4, 2008 R2 SP3, 2012 SP4, 2014 SP3, 2016 SP3), gerne som snapshots, så de kan nulstilles. Der skal bruges licenser og installationsmedier.
|
||||||
|
|
||||||
|
## Rettigheder på SQL Server 2000
|
||||||
|
|
||||||
|
`VIEW SERVER STATE` findes ikke i SQL Server 2000. Det betyder:
|
||||||
|
|
||||||
|
- `sysprocesses`, `sysdatabases`, `sysfiles`, `sp_configure` og `msdb`-tabellerne kan læses af et almindeligt login med en bruger i `msdb`.
|
||||||
|
- `DBCC SQLPERF(WAITSTATS)` og læsning af error log kræver forhøjede rettigheder (i praksis sysadmin).
|
||||||
|
|
||||||
|
Kunden vælger derfor pr. SQL 2000-instans mellem:
|
||||||
|
|
||||||
|
1. **Begrænset overvågning** uden forhøjede rettigheder. Waits og error log vises som *ikke målbar*.
|
||||||
|
2. **Fuld overvågning** med sysadmin. Det vises tydeligt i UI'et som en risiko.
|
||||||
|
|
||||||
|
`db/permissions/grant-least-privilege.sql` afviser at køre på 2000 og henviser hertil. Et separat script til 2000 kommer med spiken.
|
||||||
|
|
||||||
|
## Åbne punkter
|
||||||
|
|
||||||
|
- Læsning af error log (`xp_readerrorlog`) kræver rettigheder, der varierer mellem versionerne. De skal kortlægges, før error log-collectoren bygges (Fase 1).
|
||||||
6
global.json
Normal file
6
global.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/SQLInsight.Agent/AgentOptions.cs
Normal file
37
src/SQLInsight.Agent/AgentOptions.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
using SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
namespace SQLInsight.Agent;
|
||||||
|
|
||||||
|
public sealed class AgentOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "SQLInsight";
|
||||||
|
|
||||||
|
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public IList<MonitoredInstance> Instances { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MonitoredInstance
|
||||||
|
{
|
||||||
|
/// <summary>Stable name shown in the UI and used as the key in SQLInsight Hub.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Server { get; set; } = string.Empty;
|
||||||
|
public AuthenticationMode Authentication { get; set; } = AuthenticationMode.Windows;
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public ConnectionProvider Provider { get; set; } = ConnectionProvider.Auto;
|
||||||
|
public EncryptionMode Encryption { get; set; } = EncryptionMode.Mandatory;
|
||||||
|
public bool TrustServerCertificate { get; set; }
|
||||||
|
|
||||||
|
public ConnectionTarget ToTarget() => new()
|
||||||
|
{
|
||||||
|
Server = Server,
|
||||||
|
Authentication = Authentication,
|
||||||
|
UserName = UserName,
|
||||||
|
Password = Password,
|
||||||
|
Provider = Provider,
|
||||||
|
Encryption = Encryption,
|
||||||
|
TrustServerCertificate = TrustServerCertificate,
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/SQLInsight.Agent/Program.cs
Normal file
18
src/SQLInsight.Agent/Program.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using SQLInsight.Agent;
|
||||||
|
using SQLInsight.Connectivity;
|
||||||
|
using SQLInsight.Rules;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent");
|
||||||
|
builder.Services.AddSystemd();
|
||||||
|
|
||||||
|
builder.Services.Configure<AgentOptions>(builder.Configuration.GetSection(AgentOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
||||||
|
builder.Services.AddSingleton(new RuleEngine(BuiltInRules.Create()));
|
||||||
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
12
src/SQLInsight.Agent/Properties/launchSettings.json
Normal file
12
src/SQLInsight.Agent/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"SQLInsight.Agent": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/SQLInsight.Agent/SQLInsight.Agent.csproj
Normal file
16
src/SQLInsight.Agent/SQLInsight.Agent.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Agent</RootNamespace>
|
||||||
|
<UserSecretsId>sqlinsight-agent</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
|
||||||
|
<ProjectReference Include="..\SQLInsight.Rules\SQLInsight.Rules.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
91
src/SQLInsight.Agent/Worker.cs
Normal file
91
src/SQLInsight.Agent/Worker.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SQLInsight.Connectivity;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Agent;
|
||||||
|
|
||||||
|
public sealed partial class Worker(
|
||||||
|
ISqlConnectionFactory connectionFactory,
|
||||||
|
RuleEngine ruleEngine,
|
||||||
|
IOptions<AgentOptions> options,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<Worker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
if (settings.Instances.Count == 0)
|
||||||
|
{
|
||||||
|
LogNoInstances(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(settings.Interval, timeProvider);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
foreach (var instance in settings.Instances)
|
||||||
|
{
|
||||||
|
await CheckInstanceAsync(instance, stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckInstanceAsync(MonitoredInstance instance, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var opened = await connectionFactory.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false);
|
||||||
|
if (opened.UsedLegacyFallback)
|
||||||
|
{
|
||||||
|
LogLegacyFallback(logger, instance.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = await InstanceProbe.DetectAsync(opened.Connection, cancellationToken).ConfigureAwait(false);
|
||||||
|
var context = new RuleContext(info, InstanceCapabilities.From(info), timeProvider.GetUtcNow());
|
||||||
|
LogDetected(logger, instance.Name, info.Release, info.Version, info.Edition);
|
||||||
|
|
||||||
|
foreach (var result in ruleEngine.Evaluate(context))
|
||||||
|
{
|
||||||
|
switch (result.Outcome)
|
||||||
|
{
|
||||||
|
case RuleOutcome.Findings:
|
||||||
|
foreach (var finding in result.Findings)
|
||||||
|
{
|
||||||
|
LogFinding(logger, instance.Name, finding.Severity, result.RuleId, finding.Title);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case RuleOutcome.NotMeasurable:
|
||||||
|
case RuleOutcome.Error:
|
||||||
|
LogNotEvaluated(logger, instance.Name, result.RuleId, result.Outcome, result.Reason);
|
||||||
|
break;
|
||||||
|
case RuleOutcome.Passed:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
LogInstanceFailed(logger, ex, instance.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "No instances configured under 'SQLInsight:Instances'.")]
|
||||||
|
private static partial void LogNoInstances(ILogger logger);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "{Instance}: SqlClient could not connect, using the legacy ODBC connection. Encryption may be weaker.")]
|
||||||
|
private static partial void LogLegacyFallback(ILogger logger, string instance);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: {Release} ({Version}, {Edition})")]
|
||||||
|
private static partial void LogDetected(ILogger logger, string instance, SqlServerRelease release, SqlServerVersion version, string edition);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "{Instance}: [{Severity}] {RuleId} {Title}")]
|
||||||
|
private static partial void LogFinding(ILogger logger, string instance, Severity severity, string ruleId, string title);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: {RuleId} {Outcome}: {Reason}")]
|
||||||
|
private static partial void LogNotEvaluated(ILogger logger, string instance, string ruleId, RuleOutcome outcome, string? reason);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Error, Message = "{Instance}: check failed")]
|
||||||
|
private static partial void LogInstanceFailed(ILogger logger, Exception exception, string instance);
|
||||||
|
}
|
||||||
7
src/SQLInsight.Agent/appsettings.Development.json
Normal file
7
src/SQLInsight.Agent/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/SQLInsight.Agent/appsettings.json
Normal file
21
src/SQLInsight.Agent/appsettings.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SQLInsight": {
|
||||||
|
"Interval": "00:15:00",
|
||||||
|
"Instances": [
|
||||||
|
{
|
||||||
|
"Name": "eksempel-sql01",
|
||||||
|
"Server": "sql01.eksempel.local",
|
||||||
|
"Authentication": "Windows",
|
||||||
|
"Provider": "Auto",
|
||||||
|
"Encryption": "Mandatory",
|
||||||
|
"TrustServerCertificate": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/SQLInsight.Connectivity/ConnectionStrings.cs
Normal file
66
src/SQLInsight.Connectivity/ConnectionStrings.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System.Data.Odbc;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
internal static class ConnectionStrings
|
||||||
|
{
|
||||||
|
public const string ApplicationName = "SQLInsight Pro";
|
||||||
|
|
||||||
|
public static string ForSqlClient(ConnectionTarget target)
|
||||||
|
{
|
||||||
|
var builder = new SqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = target.Server,
|
||||||
|
InitialCatalog = "master",
|
||||||
|
ApplicationName = ApplicationName,
|
||||||
|
ConnectTimeout = (int)target.ConnectTimeout.TotalSeconds,
|
||||||
|
Encrypt = target.Encryption == EncryptionMode.Mandatory ? SqlConnectionEncryptOption.Mandatory : SqlConnectionEncryptOption.Optional,
|
||||||
|
TrustServerCertificate = target.TrustServerCertificate,
|
||||||
|
// Monitoring must never hold a pooled session with leftover state on a busy server.
|
||||||
|
Pooling = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target.Authentication == AuthenticationMode.Windows)
|
||||||
|
{
|
||||||
|
builder.IntegratedSecurity = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.UserID = RequireUserName(target);
|
||||||
|
builder.Password = target.Password ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ForOdbc(ConnectionTarget target)
|
||||||
|
{
|
||||||
|
var builder = new OdbcConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Driver = target.OdbcDriver,
|
||||||
|
};
|
||||||
|
builder["Server"] = target.Server;
|
||||||
|
builder["Database"] = "master";
|
||||||
|
builder["APP"] = ApplicationName;
|
||||||
|
builder["Encrypt"] = target.Encryption == EncryptionMode.Mandatory ? "yes" : "no";
|
||||||
|
builder["TrustServerCertificate"] = target.TrustServerCertificate ? "yes" : "no";
|
||||||
|
|
||||||
|
if (target.Authentication == AuthenticationMode.Windows)
|
||||||
|
{
|
||||||
|
builder["Trusted_Connection"] = "yes";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder["UID"] = RequireUserName(target);
|
||||||
|
builder["PWD"] = target.Password ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireUserName(ConnectionTarget target) =>
|
||||||
|
string.IsNullOrWhiteSpace(target.UserName)
|
||||||
|
? throw new ArgumentException($"A user name is required for SQL login authentication to '{target.Server}'.", nameof(target))
|
||||||
|
: target.UserName;
|
||||||
|
}
|
||||||
55
src/SQLInsight.Connectivity/ConnectionTarget.cs
Normal file
55
src/SQLInsight.Connectivity/ConnectionTarget.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How to reach one monitored instance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ConnectionTarget
|
||||||
|
{
|
||||||
|
/// <summary>Server name, <c>host\instance</c> or <c>host,port</c>.</summary>
|
||||||
|
public required string Server { get; init; }
|
||||||
|
|
||||||
|
public AuthenticationMode Authentication { get; init; } = AuthenticationMode.Windows;
|
||||||
|
|
||||||
|
/// <summary>SQL login name. Only used with <see cref="AuthenticationMode.SqlLogin"/>.</summary>
|
||||||
|
public string? UserName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>SQL login password. Only used with <see cref="AuthenticationMode.SqlLogin"/>.</summary>
|
||||||
|
public string? Password { get; init; }
|
||||||
|
|
||||||
|
public ConnectionProvider Provider { get; init; } = ConnectionProvider.Auto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the connection must be encrypted. Mandatory is the default; old unpatched instances that only speak
|
||||||
|
/// TLS 1.0 need <see cref="EncryptionMode.Optional"/>, which the agent reports as a warning.
|
||||||
|
/// </summary>
|
||||||
|
public EncryptionMode Encryption { get; init; } = EncryptionMode.Mandatory;
|
||||||
|
|
||||||
|
/// <summary>Skip certificate validation. Off by default; must be a deliberate choice.</summary>
|
||||||
|
public bool TrustServerCertificate { get; init; }
|
||||||
|
|
||||||
|
/// <summary>ODBC driver used for legacy instances.</summary>
|
||||||
|
public string OdbcDriver { get; init; } = "ODBC Driver 18 for SQL Server";
|
||||||
|
|
||||||
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AuthenticationMode
|
||||||
|
{
|
||||||
|
/// <summary>Integrated security. Preferred; run the agent as a gMSA.</summary>
|
||||||
|
Windows,
|
||||||
|
SqlLogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConnectionProvider
|
||||||
|
{
|
||||||
|
/// <summary>Try Microsoft.Data.SqlClient, fall back to ODBC when the instance is too old for it.</summary>
|
||||||
|
Auto,
|
||||||
|
SqlClient,
|
||||||
|
Odbc,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EncryptionMode
|
||||||
|
{
|
||||||
|
Mandatory,
|
||||||
|
Optional,
|
||||||
|
}
|
||||||
9
src/SQLInsight.Connectivity/ISqlConnectionFactory.cs
Normal file
9
src/SQLInsight.Connectivity/ISqlConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
public interface ISqlConnectionFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a connection to <paramref name="target"/> with monitoring session settings applied.
|
||||||
|
/// </summary>
|
||||||
|
Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
62
src/SQLInsight.Connectivity/InstanceProbe.cs
Normal file
62
src/SQLInsight.Connectivity/InstanceProbe.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads version, edition and key settings from an instance. Works on every version from SQL Server 2000:
|
||||||
|
/// only <c>SERVERPROPERTY</c> is used, and properties an older version does not know return NULL.
|
||||||
|
/// </summary>
|
||||||
|
public static class InstanceProbe
|
||||||
|
{
|
||||||
|
internal const string Query = """
|
||||||
|
SELECT
|
||||||
|
CAST(SERVERPROPERTY('ServerName') AS nvarchar(256)),
|
||||||
|
CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)),
|
||||||
|
CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(128)),
|
||||||
|
CAST(SERVERPROPERTY('ProductUpdateLevel') AS nvarchar(128)),
|
||||||
|
CAST(SERVERPROPERTY('Edition') AS nvarchar(128)),
|
||||||
|
CAST(SERVERPROPERTY('EngineEdition') AS int),
|
||||||
|
CAST(SERVERPROPERTY('IsClustered') AS int),
|
||||||
|
CAST(SERVERPROPERTY('IsHadrEnabled') AS int)
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static async Task<InstanceInfo> DetectAsync(DbConnection connection, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(connection);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = Query;
|
||||||
|
command.CommandTimeout = 30;
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SERVERPROPERTY returned no rows.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var productVersion = GetString(reader, 1);
|
||||||
|
if (!SqlServerVersion.TryParse(productVersion, out var version))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not parse ProductVersion '{productVersion}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InstanceInfo
|
||||||
|
{
|
||||||
|
ServerName = GetString(reader, 0) ?? string.Empty,
|
||||||
|
Version = version,
|
||||||
|
ProductLevel = GetString(reader, 2),
|
||||||
|
ProductUpdateLevel = GetString(reader, 3),
|
||||||
|
Edition = GetString(reader, 4) ?? string.Empty,
|
||||||
|
EngineEdition = reader.IsDBNull(5) ? EngineEdition.Unknown : (EngineEdition)reader.GetInt32(5),
|
||||||
|
IsClustered = GetBool(reader, 6),
|
||||||
|
IsHadrEnabled = GetBool(reader, 7),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(DbDataReader reader, int ordinal) =>
|
||||||
|
reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||||
|
|
||||||
|
private static bool? GetBool(DbDataReader reader, int ordinal) =>
|
||||||
|
reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal) == 1;
|
||||||
|
}
|
||||||
42
src/SQLInsight.Connectivity/LegacyFailureClassifier.cs
Normal file
42
src/SQLInsight.Connectivity/LegacyFailureClassifier.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Security.Authentication;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a SqlClient connection failure looks like "this instance is too old for SqlClient"
|
||||||
|
/// (TLS 1.0-only, unsupported TDS version) rather than a network, login or permission problem.
|
||||||
|
/// Only the former should trigger the ODBC fallback; the others would fail on ODBC too and hide the real cause.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LegacyFailureClassifier
|
||||||
|
{
|
||||||
|
private static readonly string[] LegacyMessageFragments =
|
||||||
|
[
|
||||||
|
"pre-login handshake",
|
||||||
|
"SSL Provider",
|
||||||
|
"TLS",
|
||||||
|
"protocol version",
|
||||||
|
"server version is not supported",
|
||||||
|
"does not support the requested protocol",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static bool IsLegacyIncompatibility(Exception exception)
|
||||||
|
{
|
||||||
|
for (var current = exception; current is not null; current = current.InnerException)
|
||||||
|
{
|
||||||
|
if (current is AuthenticationException)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var fragment in LegacyMessageFragments)
|
||||||
|
{
|
||||||
|
if (current.Message.Contains(fragment, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/SQLInsight.Connectivity/OpenedConnection.cs
Normal file
22
src/SQLInsight.Connectivity/OpenedConnection.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An open, monitoring-hardened connection and how it was established.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpenedConnection(DbConnection connection, ConnectionProvider provider, bool usedLegacyFallback)
|
||||||
|
: IAsyncDisposable, IDisposable
|
||||||
|
{
|
||||||
|
public DbConnection Connection { get; } = connection;
|
||||||
|
|
||||||
|
/// <summary>The provider actually used: <see cref="ConnectionProvider.SqlClient"/> or <see cref="ConnectionProvider.Odbc"/>.</summary>
|
||||||
|
public ConnectionProvider Provider { get; } = provider;
|
||||||
|
|
||||||
|
/// <summary>True when SqlClient failed and the ODBC fallback was used.</summary>
|
||||||
|
public bool UsedLegacyFallback { get; } = usedLegacyFallback;
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => Connection.DisposeAsync();
|
||||||
|
|
||||||
|
public void Dispose() => Connection.Dispose();
|
||||||
|
}
|
||||||
15
src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj
Normal file
15
src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Connectivity</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||||
|
<PackageReference Include="System.Data.Odbc" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="SQLInsight.Connectivity.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
75
src/SQLInsight.Connectivity/SqlConnectionFactory.cs
Normal file
75
src/SQLInsight.Connectivity/SqlConnectionFactory.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Data.Odbc;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens monitoring connections with Microsoft.Data.SqlClient, falling back to ODBC for instances too old for it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlConnectionFactory : ISqlConnectionFactory
|
||||||
|
{
|
||||||
|
// Keeps the monitoring session from ever being the one that blocks or wins a deadlock on the monitored server.
|
||||||
|
// Every statement here is valid on SQL Server 2000.
|
||||||
|
internal const string SessionSettings =
|
||||||
|
"SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;";
|
||||||
|
|
||||||
|
private readonly Func<ConnectionTarget, DbConnection> _createSqlClient;
|
||||||
|
private readonly Func<ConnectionTarget, DbConnection> _createOdbc;
|
||||||
|
|
||||||
|
public SqlConnectionFactory()
|
||||||
|
: this(
|
||||||
|
t => new SqlConnection(ConnectionStrings.ForSqlClient(t)),
|
||||||
|
t => new OdbcConnection(ConnectionStrings.ForOdbc(t)))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal SqlConnectionFactory(
|
||||||
|
Func<ConnectionTarget, DbConnection> createSqlClient,
|
||||||
|
Func<ConnectionTarget, DbConnection> createOdbc)
|
||||||
|
{
|
||||||
|
_createSqlClient = createSqlClient;
|
||||||
|
_createOdbc = createOdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
|
||||||
|
switch (target.Provider)
|
||||||
|
{
|
||||||
|
case ConnectionProvider.SqlClient:
|
||||||
|
return new OpenedConnection(await OpenAsync(_createSqlClient(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.SqlClient, false);
|
||||||
|
case ConnectionProvider.Odbc:
|
||||||
|
return new OpenedConnection(await OpenAsync(_createOdbc(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.Odbc, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new OpenedConnection(await OpenAsync(_createSqlClient(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.SqlClient, false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException && LegacyFailureClassifier.IsLegacyIncompatibility(ex))
|
||||||
|
{
|
||||||
|
return new OpenedConnection(await OpenAsync(_createOdbc(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.Odbc, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<DbConnection> OpenAsync(DbConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SessionSettings;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await connection.DisposeAsync().ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/SQLInsight.Contracts/FindingsSubmission.cs
Normal file
57
src/SQLInsight.Contracts/FindingsSubmission.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
namespace SQLInsight.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What an agent sends to SQLInsight Hub when findings sync is enabled. This is a wire contract between
|
||||||
|
/// independently released agents and the hub: add optional members only, and bump
|
||||||
|
/// <see cref="CurrentSchemaVersion"/> for anything else.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only findings and instance metadata are sent. Table data is never sent, and query text only when the customer
|
||||||
|
/// has allowed it.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record FindingsSubmission
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
public required string AgentVersion { get; init; }
|
||||||
|
public required string RulePackVersion { get; init; }
|
||||||
|
public required DateTimeOffset CollectedAt { get; init; }
|
||||||
|
public required InstanceSnapshot Instance { get; init; }
|
||||||
|
public required IReadOnlyList<RuleResultDto> Results { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record InstanceSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>A stable, customer-chosen name for the instance. The hub keys history on it.</summary>
|
||||||
|
public required string InstanceKey { get; init; }
|
||||||
|
|
||||||
|
public required string ProductVersion { get; init; }
|
||||||
|
public string? ProductLevel { get; init; }
|
||||||
|
public string? ProductUpdateLevel { get; init; }
|
||||||
|
public required string Edition { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RuleResultDto
|
||||||
|
{
|
||||||
|
public required string RuleId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Passed, Findings, NotMeasurable or Error.</summary>
|
||||||
|
public required string Outcome { get; init; }
|
||||||
|
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
public IReadOnlyList<FindingDto> Findings { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FindingDto
|
||||||
|
{
|
||||||
|
/// <summary>Info, Low, Medium, High or Critical.</summary>
|
||||||
|
public required string Severity { get; init; }
|
||||||
|
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string Explanation { get; init; }
|
||||||
|
public required string Recommendation { get; init; }
|
||||||
|
public IReadOnlyList<EvidenceDto> Evidence { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EvidenceDto(string Name, string Value);
|
||||||
5
src/SQLInsight.Contracts/SQLInsight.Contracts.csproj
Normal file
5
src/SQLInsight.Contracts/SQLInsight.Contracts.csproj
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Contracts</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
29
src/SQLInsight.Core/Findings/Finding.cs
Normal file
29
src/SQLInsight.Core/Findings/Finding.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace SQLInsight.Core.Findings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One problem found on an instance, with everything needed to understand and fix it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Finding
|
||||||
|
{
|
||||||
|
public required string RuleId { get; init; }
|
||||||
|
public required Severity Severity { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Why this is a problem.</summary>
|
||||||
|
public required string Explanation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>What to do about it.</summary>
|
||||||
|
public required string Recommendation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The measurements that triggered the finding.</summary>
|
||||||
|
public IReadOnlyList<Evidence> Evidence { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>A suggested fix script. Shown to the user, never executed automatically.</summary>
|
||||||
|
public string? FixScript { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Reference> References { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record Evidence(string Name, string Value);
|
||||||
|
|
||||||
|
public sealed record Reference(string Title, Uri Url);
|
||||||
10
src/SQLInsight.Core/Findings/Severity.cs
Normal file
10
src/SQLInsight.Core/Findings/Severity.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace SQLInsight.Core.Findings;
|
||||||
|
|
||||||
|
public enum Severity
|
||||||
|
{
|
||||||
|
Info = 0,
|
||||||
|
Low = 1,
|
||||||
|
Medium = 2,
|
||||||
|
High = 3,
|
||||||
|
Critical = 4,
|
||||||
|
}
|
||||||
49
src/SQLInsight.Core/Instances/Capability.cs
Normal file
49
src/SQLInsight.Core/Instances/Capability.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
namespace SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Something a collector or rule depends on that not every SQL Server version or configuration offers.
|
||||||
|
/// </summary>
|
||||||
|
public enum Capability
|
||||||
|
{
|
||||||
|
/// <summary>Dynamic management views (<c>sys.dm_*</c>). SQL Server 2005+.</summary>
|
||||||
|
DynamicManagementViews,
|
||||||
|
|
||||||
|
/// <summary>The always-on default trace. SQL Server 2005+.</summary>
|
||||||
|
DefaultTrace,
|
||||||
|
|
||||||
|
/// <summary>The <c>VIEW SERVER STATE</c> permission, so monitoring does not need sysadmin. SQL Server 2005+.</summary>
|
||||||
|
ViewServerStatePermission,
|
||||||
|
|
||||||
|
/// <summary>Extended Events. SQL Server 2008+.</summary>
|
||||||
|
ExtendedEvents,
|
||||||
|
|
||||||
|
/// <summary>The built-in <c>system_health</c> Extended Events session (deadlock graphs). SQL Server 2008+.</summary>
|
||||||
|
SystemHealthSession,
|
||||||
|
|
||||||
|
/// <summary><c>system_health</c> writes to a file target, so history survives the ring buffer. SQL Server 2012+.</summary>
|
||||||
|
SystemHealthFileTarget,
|
||||||
|
|
||||||
|
/// <summary>Always On availability group DMVs exist. SQL Server 2012+.</summary>
|
||||||
|
AvailabilityGroupDmvs,
|
||||||
|
|
||||||
|
/// <summary>Always On is enabled on this instance (runtime setting, not just the version).</summary>
|
||||||
|
HadrEnabled,
|
||||||
|
|
||||||
|
/// <summary>The <c>CONNECT ANY DATABASE</c> permission. SQL Server 2014+.</summary>
|
||||||
|
ConnectAnyDatabasePermission,
|
||||||
|
|
||||||
|
/// <summary>Query Store. SQL Server 2016+.</summary>
|
||||||
|
QueryStore,
|
||||||
|
|
||||||
|
/// <summary><c>sys.dm_exec_session_wait_stats</c>. SQL Server 2016+.</summary>
|
||||||
|
SessionWaitStats,
|
||||||
|
|
||||||
|
/// <summary><c>DATABASEPROPERTYEX(..., 'LastGoodCheckDbTime')</c>, so CHECKDB status does not need sysadmin. SQL Server 2016 SP2+.</summary>
|
||||||
|
LastGoodCheckDbTime,
|
||||||
|
|
||||||
|
/// <summary><c>sys.dm_exec_query_plan_stats</c> (last actual execution plan). SQL Server 2019+.</summary>
|
||||||
|
LastActualPlan,
|
||||||
|
|
||||||
|
/// <summary>Granular server permissions such as <c>VIEW SERVER PERFORMANCE STATE</c>. SQL Server 2022+.</summary>
|
||||||
|
GranularServerPermissions,
|
||||||
|
}
|
||||||
19
src/SQLInsight.Core/Instances/EngineEdition.cs
Normal file
19
src/SQLInsight.Core/Instances/EngineEdition.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Values of <c>SERVERPROPERTY('EngineEdition')</c>.
|
||||||
|
/// </summary>
|
||||||
|
public enum EngineEdition
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
PersonalOrDesktop = 1,
|
||||||
|
Standard = 2,
|
||||||
|
Enterprise = 3,
|
||||||
|
Express = 4,
|
||||||
|
AzureSqlDatabase = 5,
|
||||||
|
AzureSynapse = 6,
|
||||||
|
AzureSqlManagedInstance = 8,
|
||||||
|
AzureSqlEdge = 9,
|
||||||
|
AzureSynapseServerless = 11,
|
||||||
|
FabricSqlDatabase = 12,
|
||||||
|
}
|
||||||
67
src/SQLInsight.Core/Instances/InstanceCapabilities.cs
Normal file
67
src/SQLInsight.Core/Instances/InstanceCapabilities.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
namespace SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The capabilities an instance actually has, derived from its version and runtime settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InstanceCapabilities
|
||||||
|
{
|
||||||
|
// Minimum build for each version-gated capability.
|
||||||
|
private static readonly Dictionary<Capability, SqlServerVersion> MinimumVersion =
|
||||||
|
new Dictionary<Capability, SqlServerVersion>
|
||||||
|
{
|
||||||
|
[Capability.DynamicManagementViews] = new(9, 0, 0, 0),
|
||||||
|
[Capability.DefaultTrace] = new(9, 0, 0, 0),
|
||||||
|
[Capability.ViewServerStatePermission] = new(9, 0, 0, 0),
|
||||||
|
[Capability.ExtendedEvents] = new(10, 0, 0, 0),
|
||||||
|
[Capability.SystemHealthSession] = new(10, 0, 0, 0),
|
||||||
|
[Capability.SystemHealthFileTarget] = new(11, 0, 0, 0),
|
||||||
|
[Capability.AvailabilityGroupDmvs] = new(11, 0, 0, 0),
|
||||||
|
[Capability.ConnectAnyDatabasePermission] = new(12, 0, 0, 0),
|
||||||
|
[Capability.QueryStore] = new(13, 0, 0, 0),
|
||||||
|
[Capability.SessionWaitStats] = new(13, 0, 0, 0),
|
||||||
|
[Capability.LastGoodCheckDbTime] = new(13, 0, 5026, 0), // 2016 SP2
|
||||||
|
[Capability.LastActualPlan] = new(15, 0, 0, 0),
|
||||||
|
[Capability.GranularServerPermissions] = new(16, 0, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HashSet<Capability> _capabilities;
|
||||||
|
|
||||||
|
private InstanceCapabilities(HashSet<Capability> capabilities) => _capabilities = capabilities;
|
||||||
|
|
||||||
|
public IReadOnlySet<Capability> All => _capabilities;
|
||||||
|
|
||||||
|
public static InstanceCapabilities From(InstanceInfo instance)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(instance);
|
||||||
|
|
||||||
|
var set = new HashSet<Capability>();
|
||||||
|
foreach (var (capability, minimum) in MinimumVersion)
|
||||||
|
{
|
||||||
|
if (instance.Version >= minimum)
|
||||||
|
{
|
||||||
|
set.Add(capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.IsHadrEnabled == true && set.Contains(Capability.AvailabilityGroupDmvs))
|
||||||
|
{
|
||||||
|
set.Add(Capability.HadrEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InstanceCapabilities(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Has(Capability capability) => _capabilities.Contains(capability);
|
||||||
|
|
||||||
|
public IReadOnlyList<Capability> Missing(IEnumerable<Capability> required)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(required);
|
||||||
|
return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The minimum version for a version-gated capability, or <see langword="null"/> for runtime-only capabilities.
|
||||||
|
/// </summary>
|
||||||
|
public static SqlServerVersion? MinimumVersionFor(Capability capability) =>
|
||||||
|
MinimumVersion.TryGetValue(capability, out var version) ? version : null;
|
||||||
|
}
|
||||||
26
src/SQLInsight.Core/Instances/InstanceInfo.cs
Normal file
26
src/SQLInsight.Core/Instances/InstanceInfo.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What an instance reports about itself. Collected with a query that works on every version from SQL Server 2000.
|
||||||
|
/// Properties that older versions do not expose are <see langword="null"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InstanceInfo
|
||||||
|
{
|
||||||
|
public required string ServerName { get; init; }
|
||||||
|
public required SqlServerVersion Version { get; init; }
|
||||||
|
public required string Edition { get; init; }
|
||||||
|
public required EngineEdition EngineEdition { get; init; }
|
||||||
|
|
||||||
|
/// <summary>RTM, SP1, SP2 … (<c>ProductLevel</c>).</summary>
|
||||||
|
public string? ProductLevel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>CU level such as <c>CU14</c> (<c>ProductUpdateLevel</c>, only on patched 2012 and later).</summary>
|
||||||
|
public string? ProductUpdateLevel { get; init; }
|
||||||
|
|
||||||
|
public bool? IsClustered { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether Always On availability groups are enabled (2012 and later).</summary>
|
||||||
|
public bool? IsHadrEnabled { get; init; }
|
||||||
|
|
||||||
|
public SqlServerRelease Release => Version.Release;
|
||||||
|
}
|
||||||
39
src/SQLInsight.Core/Instances/SqlServerRelease.cs
Normal file
39
src/SQLInsight.Core/Instances/SqlServerRelease.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
namespace SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A SQL Server release line. Values are ordered chronologically so releases can be compared.
|
||||||
|
/// </summary>
|
||||||
|
public enum SqlServerRelease
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
SqlServer2000 = 2000,
|
||||||
|
SqlServer2005 = 2005,
|
||||||
|
SqlServer2008 = 2008,
|
||||||
|
SqlServer2008R2 = 2009,
|
||||||
|
SqlServer2012 = 2012,
|
||||||
|
SqlServer2014 = 2014,
|
||||||
|
SqlServer2016 = 2016,
|
||||||
|
SqlServer2017 = 2017,
|
||||||
|
SqlServer2019 = 2019,
|
||||||
|
SqlServer2022 = 2022,
|
||||||
|
SqlServer2025 = 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SqlServerReleaseExtensions
|
||||||
|
{
|
||||||
|
public static string DisplayName(this SqlServerRelease release) => release switch
|
||||||
|
{
|
||||||
|
SqlServerRelease.SqlServer2000 => "SQL Server 2000",
|
||||||
|
SqlServerRelease.SqlServer2005 => "SQL Server 2005",
|
||||||
|
SqlServerRelease.SqlServer2008 => "SQL Server 2008",
|
||||||
|
SqlServerRelease.SqlServer2008R2 => "SQL Server 2008 R2",
|
||||||
|
SqlServerRelease.SqlServer2012 => "SQL Server 2012",
|
||||||
|
SqlServerRelease.SqlServer2014 => "SQL Server 2014",
|
||||||
|
SqlServerRelease.SqlServer2016 => "SQL Server 2016",
|
||||||
|
SqlServerRelease.SqlServer2017 => "SQL Server 2017",
|
||||||
|
SqlServerRelease.SqlServer2019 => "SQL Server 2019",
|
||||||
|
SqlServerRelease.SqlServer2022 => "SQL Server 2022",
|
||||||
|
SqlServerRelease.SqlServer2025 => "SQL Server 2025",
|
||||||
|
_ => "Ukendt SQL Server-version",
|
||||||
|
};
|
||||||
|
}
|
||||||
77
src/SQLInsight.Core/Instances/SqlServerVersion.cs
Normal file
77
src/SQLInsight.Core/Instances/SqlServerVersion.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A SQL Server build number as returned by <c>SERVERPROPERTY('ProductVersion')</c>, e.g. <c>16.0.4135.4</c>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SqlServerVersion(int Major, int Minor, int Build, int Revision)
|
||||||
|
: IComparable<SqlServerVersion>
|
||||||
|
{
|
||||||
|
public SqlServerRelease Release => (Major, Minor) switch
|
||||||
|
{
|
||||||
|
(8, _) => SqlServerRelease.SqlServer2000,
|
||||||
|
(9, _) => SqlServerRelease.SqlServer2005,
|
||||||
|
(10, >= 50) => SqlServerRelease.SqlServer2008R2,
|
||||||
|
(10, _) => SqlServerRelease.SqlServer2008,
|
||||||
|
(11, _) => SqlServerRelease.SqlServer2012,
|
||||||
|
(12, _) => SqlServerRelease.SqlServer2014,
|
||||||
|
(13, _) => SqlServerRelease.SqlServer2016,
|
||||||
|
(14, _) => SqlServerRelease.SqlServer2017,
|
||||||
|
(15, _) => SqlServerRelease.SqlServer2019,
|
||||||
|
(16, _) => SqlServerRelease.SqlServer2022,
|
||||||
|
(17, _) => SqlServerRelease.SqlServer2025,
|
||||||
|
_ => SqlServerRelease.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static SqlServerVersion Parse(string value) =>
|
||||||
|
TryParse(value, out var version)
|
||||||
|
? version
|
||||||
|
: throw new FormatException($"'{value}' is not a valid SQL Server version number.");
|
||||||
|
|
||||||
|
public static bool TryParse([NotNullWhen(true)] string? value, out SqlServerVersion version)
|
||||||
|
{
|
||||||
|
version = default;
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = value.Trim().Split('.');
|
||||||
|
if (parts.Length is < 2 or > 4)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var numbers = new int[4];
|
||||||
|
for (var i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out numbers[i]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
version = new SqlServerVersion(numbers[0], numbers[1], numbers[2], numbers[3]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompareTo(SqlServerVersion other)
|
||||||
|
{
|
||||||
|
var result = Major.CompareTo(other.Major);
|
||||||
|
if (result != 0) return result;
|
||||||
|
result = Minor.CompareTo(other.Minor);
|
||||||
|
if (result != 0) return result;
|
||||||
|
result = Build.CompareTo(other.Build);
|
||||||
|
return result != 0 ? result : Revision.CompareTo(other.Revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator <(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) < 0;
|
||||||
|
public static bool operator >(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) > 0;
|
||||||
|
public static bool operator <=(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) <= 0;
|
||||||
|
public static bool operator >=(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) >= 0;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Build}.{Revision}");
|
||||||
|
}
|
||||||
5
src/SQLInsight.Core/SQLInsight.Core.csproj
Normal file
5
src/SQLInsight.Core/SQLInsight.Core.csproj
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Core</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/SQLInsight.Hub/Program.cs
Normal file
7
src/SQLInsight.Hub/Program.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Central ingestion API for findings from paying customers, backed by PostgreSQL. Placeholder until Fase 3.
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
src/SQLInsight.Hub/Properties/launchSettings.json
Normal file
23
src/SQLInsight.Hub/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5296",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7203;http://localhost:5296",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/SQLInsight.Hub/SQLInsight.Hub.csproj
Normal file
8
src/SQLInsight.Hub/SQLInsight.Hub.csproj
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Hub</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
8
src/SQLInsight.Hub/appsettings.Development.json
Normal file
8
src/SQLInsight.Hub/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/SQLInsight.Hub/appsettings.json
Normal file
9
src/SQLInsight.Hub/appsettings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
12
src/SQLInsight.Rules/BuiltInRules.cs
Normal file
12
src/SQLInsight.Rules/BuiltInRules.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
using SQLInsight.Rules.Lifecycle;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules;
|
||||||
|
|
||||||
|
public static class BuiltInRules
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<IRule> Create() =>
|
||||||
|
[
|
||||||
|
new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()),
|
||||||
|
];
|
||||||
|
}
|
||||||
22
src/SQLInsight.Rules/Engine/IRule.cs
Normal file
22
src/SQLInsight.Rules/Engine/IRule.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One piece of SQL Server expertise, expressed as a testable check.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRule
|
||||||
|
{
|
||||||
|
/// <summary>Stable identifier, e.g. <c>SQLI-VER-001</c>. Never reused for a different check.</summary>
|
||||||
|
string Id { get; }
|
||||||
|
|
||||||
|
string Title { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the instance must support for this rule to be evaluated. If any are missing, the rule is reported as
|
||||||
|
/// not measurable instead of being run.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
|
||||||
|
|
||||||
|
RuleEvaluation Evaluate(RuleContext context);
|
||||||
|
}
|
||||||
5
src/SQLInsight.Rules/Engine/RuleContext.cs
Normal file
5
src/SQLInsight.Rules/Engine/RuleContext.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
public sealed record RuleContext(InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now);
|
||||||
71
src/SQLInsight.Rules/Engine/RuleEngine.cs
Normal file
71
src/SQLInsight.Rules/Engine/RuleEngine.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs rules against an instance. A rule that cannot run is reported as not measurable or as an error,
|
||||||
|
/// never silently skipped, so an old instance never looks healthier than a new one.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuleEngine
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IRule> _rules;
|
||||||
|
|
||||||
|
public RuleEngine(IEnumerable<IRule> rules)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rules);
|
||||||
|
_rules = rules.ToList();
|
||||||
|
|
||||||
|
var duplicate = _rules.GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
|
||||||
|
if (duplicate is not null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Rule id '{duplicate.Key}' is registered more than once.", nameof(rules));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<RuleResult> Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return _rules.Select(rule => Evaluate(rule, context)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuleResult Evaluate(IRule rule, RuleContext context)
|
||||||
|
{
|
||||||
|
var missing = context.Capabilities.Missing(rule.RequiredCapabilities);
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{
|
||||||
|
return new RuleResult
|
||||||
|
{
|
||||||
|
RuleId = rule.Id,
|
||||||
|
RuleTitle = rule.Title,
|
||||||
|
Outcome = RuleOutcome.NotMeasurable,
|
||||||
|
MissingCapabilities = missing,
|
||||||
|
Reason = $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var evaluation = rule.Evaluate(context);
|
||||||
|
return new RuleResult
|
||||||
|
{
|
||||||
|
RuleId = rule.Id,
|
||||||
|
RuleTitle = rule.Title,
|
||||||
|
Outcome = evaluation.Outcome,
|
||||||
|
Findings = evaluation.Findings,
|
||||||
|
Reason = evaluation.Reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
#pragma warning disable CA1031 // One faulty rule must not stop the others; the failure is reported, not hidden.
|
||||||
|
catch (Exception ex)
|
||||||
|
#pragma warning restore CA1031
|
||||||
|
{
|
||||||
|
return new RuleResult
|
||||||
|
{
|
||||||
|
RuleId = rule.Id,
|
||||||
|
RuleTitle = rule.Title,
|
||||||
|
Outcome = RuleOutcome.Error,
|
||||||
|
Reason = $"{ex.GetType().Name}: {ex.Message}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/SQLInsight.Rules/Engine/RuleEvaluation.cs
Normal file
52
src/SQLInsight.Rules/Engine/RuleEvaluation.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a rule concluded. Created through the static factory methods.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RuleEvaluation
|
||||||
|
{
|
||||||
|
private RuleEvaluation(RuleOutcome outcome, IReadOnlyList<Finding> findings, string? reason)
|
||||||
|
{
|
||||||
|
Outcome = outcome;
|
||||||
|
Findings = findings;
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuleOutcome Outcome { get; }
|
||||||
|
public IReadOnlyList<Finding> Findings { get; }
|
||||||
|
|
||||||
|
/// <summary>Why the rule could not be measured, if it could not.</summary>
|
||||||
|
public string? Reason { get; }
|
||||||
|
|
||||||
|
public static RuleEvaluation Passed() => new(RuleOutcome.Passed, [], null);
|
||||||
|
|
||||||
|
public static RuleEvaluation Found(params Finding[] findings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(findings);
|
||||||
|
if (findings.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one finding is required. Use Passed() when there is none.", nameof(findings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(RuleOutcome.Findings, findings, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RuleOutcome
|
||||||
|
{
|
||||||
|
/// <summary>The rule ran and found nothing wrong.</summary>
|
||||||
|
Passed,
|
||||||
|
|
||||||
|
/// <summary>The rule ran and found one or more problems.</summary>
|
||||||
|
Findings,
|
||||||
|
|
||||||
|
/// <summary>The rule could not be evaluated on this instance. This is never the same as <see cref="Passed"/>.</summary>
|
||||||
|
NotMeasurable,
|
||||||
|
|
||||||
|
/// <summary>The rule failed while running. This is never the same as <see cref="Passed"/>.</summary>
|
||||||
|
Error,
|
||||||
|
}
|
||||||
21
src/SQLInsight.Rules/Engine/RuleResult.cs
Normal file
21
src/SQLInsight.Rules/Engine/RuleResult.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The result of running one rule against one instance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RuleResult
|
||||||
|
{
|
||||||
|
public required string RuleId { get; init; }
|
||||||
|
public required string RuleTitle { get; init; }
|
||||||
|
public required RuleOutcome Outcome { get; init; }
|
||||||
|
public IReadOnlyList<Finding> Findings { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Capabilities the instance lacks, when the outcome is <see cref="RuleOutcome.NotMeasurable"/> for that reason.</summary>
|
||||||
|
public IReadOnlyList<Capability> MissingCapabilities { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Why the rule was not measurable or failed.</summary>
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
}
|
||||||
42
src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs
Normal file
42
src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Lifecycle;
|
||||||
|
|
||||||
|
public sealed record SupportLifecycle(SqlServerRelease Release, DateOnly? MainstreamEnd, DateOnly? ExtendedEnd);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Microsoft's support dates per SQL Server release. Shipped as data so it can be updated with the rule pack.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SupportLifecycleCatalog
|
||||||
|
{
|
||||||
|
private const string ResourceName = "SQLInsight.Rules.support-lifecycle.json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Dictionary<SqlServerRelease, SupportLifecycle> _entries;
|
||||||
|
|
||||||
|
public SupportLifecycleCatalog(IEnumerable<SupportLifecycle> entries)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
_entries = entries.ToDictionary(e => e.Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SupportLifecycleCatalog LoadEmbedded()
|
||||||
|
{
|
||||||
|
using var stream = typeof(SupportLifecycleCatalog).Assembly.GetManifestResourceStream(ResourceName)
|
||||||
|
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
|
||||||
|
var document = JsonSerializer.Deserialize<CatalogDocument>(stream, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException("The support lifecycle catalog is empty.");
|
||||||
|
return new SupportLifecycleCatalog(document.Releases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SupportLifecycle? Find(SqlServerRelease release) => _entries.GetValueOrDefault(release);
|
||||||
|
|
||||||
|
private sealed record CatalogDocument(IReadOnlyList<SupportLifecycle> Releases);
|
||||||
|
}
|
||||||
96
src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs
Normal file
96
src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flags instances running a SQL Server release that is out of, or about to leave, Microsoft support.
|
||||||
|
/// Needs no capabilities, so it runs on every version from SQL Server 2000.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRule
|
||||||
|
{
|
||||||
|
public const string RuleId = "SQLI-VER-001";
|
||||||
|
|
||||||
|
/// <summary>How long before end of extended support the finding escalates to High.</summary>
|
||||||
|
public static readonly TimeSpan ExtendedEndWarningWindow = TimeSpan.FromDays(365);
|
||||||
|
|
||||||
|
private static readonly Reference LifecycleReference =
|
||||||
|
new("Microsoft Lifecycle: SQL Server", new Uri("https://learn.microsoft.com/lifecycle/products/?terms=SQL%20Server"));
|
||||||
|
|
||||||
|
public string Id => RuleId;
|
||||||
|
public string Title => "SQL Server-versionens supportstatus";
|
||||||
|
public IReadOnlyCollection<Capability> RequiredCapabilities => [];
|
||||||
|
|
||||||
|
public RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
var release = context.Instance.Release;
|
||||||
|
if (release == SqlServerRelease.Unknown)
|
||||||
|
{
|
||||||
|
return RuleEvaluation.NotMeasurable(
|
||||||
|
$"Versionen {context.Instance.Version} er ukendt for regelpakken. Opdater SQLInsight Pro.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var lifecycle = catalog.Find(release);
|
||||||
|
if (lifecycle?.MainstreamEnd is not { } mainstreamEnd || lifecycle.ExtendedEnd is not { } extendedEnd)
|
||||||
|
{
|
||||||
|
return RuleEvaluation.NotMeasurable(
|
||||||
|
$"Supportdatoer for {release.DisplayName()} findes endnu ikke i regelpakken.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(context.Now.UtcDateTime);
|
||||||
|
var name = release.DisplayName();
|
||||||
|
var evidence = new List<Evidence>
|
||||||
|
{
|
||||||
|
new("Version", $"{name} ({context.Instance.Version}, {context.Instance.ProductLevel ?? "ukendt niveau"})"),
|
||||||
|
new("Mainstream support slutter", Format(mainstreamEnd)),
|
||||||
|
new("Extended support slutter", Format(extendedEnd)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (today > extendedEnd)
|
||||||
|
{
|
||||||
|
return RuleEvaluation.Found(new Finding
|
||||||
|
{
|
||||||
|
RuleId = Id,
|
||||||
|
Severity = Severity.Critical,
|
||||||
|
Title = $"{name} er uden support fra Microsoft",
|
||||||
|
Explanation =
|
||||||
|
$"Extended support for {name} sluttede {Format(extendedEnd)}. Instansen får ingen sikkerhedsopdateringer, " +
|
||||||
|
"og kendte sårbarheder bliver ikke lukket. Microsoft yder heller ikke support ved nedbrud.",
|
||||||
|
Recommendation =
|
||||||
|
"Planlæg en opgradering til en supporteret version. Kør Data Migration Assistant for at finde " +
|
||||||
|
"kompatibilitetsproblemer, og behold databasernes compatibility level ved første opgradering, " +
|
||||||
|
"så query-planerne ikke ændrer sig samtidig.",
|
||||||
|
Evidence = evidence,
|
||||||
|
References = [LifecycleReference],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (today > mainstreamEnd)
|
||||||
|
{
|
||||||
|
var closeToEnd = extendedEnd.ToDateTime(TimeOnly.MinValue) - today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
<= ExtendedEndWarningWindow;
|
||||||
|
return RuleEvaluation.Found(new Finding
|
||||||
|
{
|
||||||
|
RuleId = Id,
|
||||||
|
Severity = closeToEnd ? Severity.High : Severity.Medium,
|
||||||
|
Title = closeToEnd
|
||||||
|
? $"{name} mister al support {Format(extendedEnd)}"
|
||||||
|
: $"{name} er i extended support",
|
||||||
|
Explanation =
|
||||||
|
$"Mainstream support for {name} sluttede {Format(mainstreamEnd)}. Der kommer kun sikkerhedsopdateringer, " +
|
||||||
|
$"ingen fejlrettelser eller nye CU'er. Fra {Format(extendedEnd)} kommer der slet ingen opdateringer.",
|
||||||
|
Recommendation = "Planlæg opgradering til en version i mainstream support, før extended support udløber.",
|
||||||
|
Evidence = evidence,
|
||||||
|
References = [LifecycleReference],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleEvaluation.Passed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Format(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
17
src/SQLInsight.Rules/Lifecycle/support-lifecycle.json
Normal file
17
src/SQLInsight.Rules/Lifecycle/support-lifecycle.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"source": "https://learn.microsoft.com/lifecycle/products/?terms=SQL%20Server",
|
||||||
|
"comment": "End of mainstream and extended support per release. null means not yet published in the catalog.",
|
||||||
|
"releases": [
|
||||||
|
{ "release": "SqlServer2000", "mainstreamEnd": "2008-04-08", "extendedEnd": "2013-04-09" },
|
||||||
|
{ "release": "SqlServer2005", "mainstreamEnd": "2011-04-12", "extendedEnd": "2016-04-12" },
|
||||||
|
{ "release": "SqlServer2008", "mainstreamEnd": "2014-07-08", "extendedEnd": "2019-07-09" },
|
||||||
|
{ "release": "SqlServer2008R2", "mainstreamEnd": "2014-07-08", "extendedEnd": "2019-07-09" },
|
||||||
|
{ "release": "SqlServer2012", "mainstreamEnd": "2017-07-11", "extendedEnd": "2022-07-12" },
|
||||||
|
{ "release": "SqlServer2014", "mainstreamEnd": "2019-07-09", "extendedEnd": "2024-07-09" },
|
||||||
|
{ "release": "SqlServer2016", "mainstreamEnd": "2021-07-13", "extendedEnd": "2026-07-14" },
|
||||||
|
{ "release": "SqlServer2017", "mainstreamEnd": "2022-10-11", "extendedEnd": "2027-10-12" },
|
||||||
|
{ "release": "SqlServer2019", "mainstreamEnd": "2025-02-28", "extendedEnd": "2030-01-08" },
|
||||||
|
{ "release": "SqlServer2022", "mainstreamEnd": "2028-01-11", "extendedEnd": "2033-01-11" },
|
||||||
|
{ "release": "SqlServer2025", "mainstreamEnd": null, "extendedEnd": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
11
src/SQLInsight.Rules/SQLInsight.Rules.csproj
Normal file
11
src/SQLInsight.Rules/SQLInsight.Rules.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Rules</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Lifecycle\support-lifecycle.json" LogicalName="SQLInsight.Rules.support-lifecycle.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
7
src/SQLInsight.Web/Program.cs
Normal file
7
src/SQLInsight.Web/Program.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// On-prem web UI (dashboard, findings, history). Placeholder until Fase 1.
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
src/SQLInsight.Web/Properties/launchSettings.json
Normal file
23
src/SQLInsight.Web/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5225",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7224;http://localhost:5225",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/SQLInsight.Web/SQLInsight.Web.csproj
Normal file
8
src/SQLInsight.Web/SQLInsight.Web.csproj
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>SQLInsight.Web</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
8
src/SQLInsight.Web/appsettings.Development.json
Normal file
8
src/SQLInsight.Web/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/SQLInsight.Web/appsettings.json
Normal file
9
src/SQLInsight.Web/appsettings.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
namespace SQLInsight.Connectivity.Tests;
|
||||||
|
|
||||||
|
public class ConnectionStringsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SqlClient_defaults_to_mandatory_encryption_with_certificate_validation()
|
||||||
|
{
|
||||||
|
var cs = ConnectionStrings.ForSqlClient(new ConnectionTarget { Server = "sql01" });
|
||||||
|
|
||||||
|
Assert.Contains("Encrypt=True", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("Trust Server Certificate=True", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Integrated Security=True", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Application Name=\"SQLInsight Pro\"", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Odbc_uses_configured_driver_and_sql_login()
|
||||||
|
{
|
||||||
|
var cs = ConnectionStrings.ForOdbc(new ConnectionTarget
|
||||||
|
{
|
||||||
|
Server = "legacy01",
|
||||||
|
Authentication = AuthenticationMode.SqlLogin,
|
||||||
|
UserName = "sqlinsight",
|
||||||
|
Password = "secret",
|
||||||
|
Encryption = EncryptionMode.Optional,
|
||||||
|
OdbcDriver = "SQL Server",
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("Driver={SQL Server}", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("UID=sqlinsight", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Encrypt=no", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("Trusted_Connection", cs, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sql_login_without_user_name_is_rejected()
|
||||||
|
{
|
||||||
|
var target = new ConnectionTarget { Server = "sql01", Authentication = AuthenticationMode.SqlLogin };
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(() => ConnectionStrings.ForSqlClient(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Security.Authentication;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity.Tests;
|
||||||
|
|
||||||
|
public class LegacyFailureClassifierTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Tls_authentication_failure_is_legacy()
|
||||||
|
{
|
||||||
|
var ex = new InvalidOperationException("outer", new AuthenticationException("handshake failed"));
|
||||||
|
|
||||||
|
Assert.True(LegacyFailureClassifier.IsLegacyIncompatibility(ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pre_login_handshake_message_is_legacy()
|
||||||
|
{
|
||||||
|
var ex = new InvalidOperationException(
|
||||||
|
"A connection was successfully established with the server, but then an error occurred during the pre-login handshake.");
|
||||||
|
|
||||||
|
Assert.True(LegacyFailureClassifier.IsLegacyIncompatibility(ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Login failed for user 'x'.")]
|
||||||
|
[InlineData("A network-related or instance-specific error occurred while establishing a connection to SQL Server.")]
|
||||||
|
[InlineData("Cannot open database \"master\" requested by the login.")]
|
||||||
|
public void Login_and_network_failures_are_not_legacy(string message)
|
||||||
|
{
|
||||||
|
Assert.False(LegacyFailureClassifier.IsLegacyIncompatibility(new InvalidOperationException(message)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
146
tests/SQLInsight.Connectivity.Tests/SqlConnectionFactoryTests.cs
Normal file
146
tests/SQLInsight.Connectivity.Tests/SqlConnectionFactoryTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Security.Authentication;
|
||||||
|
|
||||||
|
namespace SQLInsight.Connectivity.Tests;
|
||||||
|
|
||||||
|
public class SqlConnectionFactoryTests
|
||||||
|
{
|
||||||
|
private static readonly ConnectionTarget Target = new() { Server = "sql01" };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_falls_back_to_odbc_on_legacy_failure()
|
||||||
|
{
|
||||||
|
var odbc = new FakeConnection();
|
||||||
|
var factory = new SqlConnectionFactory(
|
||||||
|
_ => new FakeConnection(new AuthenticationException("TLS 1.0 only")),
|
||||||
|
_ => odbc);
|
||||||
|
|
||||||
|
await using var opened = await factory.OpenAsync(Target);
|
||||||
|
|
||||||
|
Assert.Equal(ConnectionProvider.Odbc, opened.Provider);
|
||||||
|
Assert.True(opened.UsedLegacyFallback);
|
||||||
|
Assert.Equal(SqlConnectionFactory.SessionSettings, odbc.ExecutedCommands.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auto_does_not_fall_back_on_login_failure()
|
||||||
|
{
|
||||||
|
var odbcCreated = false;
|
||||||
|
var factory = new SqlConnectionFactory(
|
||||||
|
_ => new FakeConnection(new InvalidOperationException("Login failed for user 'x'.")),
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
odbcCreated = true;
|
||||||
|
return new FakeConnection();
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => factory.OpenAsync(Target));
|
||||||
|
Assert.False(odbcCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Explicit_sqlclient_never_falls_back()
|
||||||
|
{
|
||||||
|
var factory = new SqlConnectionFactory(
|
||||||
|
_ => new FakeConnection(new AuthenticationException("TLS 1.0 only")),
|
||||||
|
_ => new FakeConnection());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<AuthenticationException>(
|
||||||
|
() => factory.OpenAsync(Target with { Provider = ConnectionProvider.SqlClient }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Session_settings_are_applied_on_open()
|
||||||
|
{
|
||||||
|
var sqlClient = new FakeConnection();
|
||||||
|
var factory = new SqlConnectionFactory(_ => sqlClient, _ => new FakeConnection());
|
||||||
|
|
||||||
|
await using var opened = await factory.OpenAsync(Target);
|
||||||
|
|
||||||
|
Assert.False(opened.UsedLegacyFallback);
|
||||||
|
Assert.Equal(SqlConnectionFactory.SessionSettings, sqlClient.ExecutedCommands.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Failed_connection_is_disposed()
|
||||||
|
{
|
||||||
|
var failing = new FakeConnection(new InvalidOperationException("Login failed"));
|
||||||
|
var factory = new SqlConnectionFactory(_ => failing, _ => new FakeConnection());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => factory.OpenAsync(Target));
|
||||||
|
Assert.True(failing.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SuppressMessage("Design", "CA1065", Justification = "Test double; unused members throw.")]
|
||||||
|
private sealed class FakeConnection(Exception? openFailure = null) : DbConnection
|
||||||
|
{
|
||||||
|
private ConnectionState _state = ConnectionState.Closed;
|
||||||
|
|
||||||
|
public List<string> ExecutedCommands { get; } = [];
|
||||||
|
public bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
[AllowNull]
|
||||||
|
public override string ConnectionString { get; set; } = string.Empty;
|
||||||
|
public override string Database => "master";
|
||||||
|
public override string DataSource => "fake";
|
||||||
|
public override string ServerVersion => "16.0.0.0";
|
||||||
|
public override ConnectionState State => _state;
|
||||||
|
|
||||||
|
public override void Open()
|
||||||
|
{
|
||||||
|
if (openFailure is not null) throw openFailure;
|
||||||
|
_state = ConnectionState.Open;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task OpenAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Open();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Close() => _state = ConnectionState.Closed;
|
||||||
|
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
|
||||||
|
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException();
|
||||||
|
protected override DbCommand CreateDbCommand() => new FakeCommand(this);
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
return base.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeCommand(FakeConnection owner) : DbCommand
|
||||||
|
{
|
||||||
|
[AllowNull]
|
||||||
|
public override string CommandText { get; set; } = string.Empty;
|
||||||
|
public override int CommandTimeout { get; set; }
|
||||||
|
public override CommandType CommandType { get; set; }
|
||||||
|
public override bool DesignTimeVisible { get; set; }
|
||||||
|
public override UpdateRowSource UpdatedRowSource { get; set; }
|
||||||
|
protected override DbConnection? DbConnection { get; set; }
|
||||||
|
protected override DbParameterCollection DbParameterCollection => throw new NotSupportedException();
|
||||||
|
protected override DbTransaction? DbTransaction { get; set; }
|
||||||
|
|
||||||
|
public override int ExecuteNonQuery()
|
||||||
|
{
|
||||||
|
owner.ExecutedCommands.Add(CommandText);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken) => Task.FromResult(ExecuteNonQuery());
|
||||||
|
public override object? ExecuteScalar() => throw new NotSupportedException();
|
||||||
|
public override void Prepare() { }
|
||||||
|
public override void Cancel() { }
|
||||||
|
protected override DbParameter CreateDbParameter() => throw new NotSupportedException();
|
||||||
|
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs
Normal file
85
tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Core.Tests;
|
||||||
|
|
||||||
|
public class InstanceCapabilitiesTests
|
||||||
|
{
|
||||||
|
private static InstanceInfo Instance(string version, bool? hadr = null) => new()
|
||||||
|
{
|
||||||
|
ServerName = "test",
|
||||||
|
Version = SqlServerVersion.Parse(version),
|
||||||
|
Edition = "Enterprise Edition",
|
||||||
|
EngineEdition = EngineEdition.Enterprise,
|
||||||
|
IsHadrEnabled = hadr,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sql2000_has_no_version_gated_capabilities()
|
||||||
|
{
|
||||||
|
var capabilities = InstanceCapabilities.From(Instance("8.00.2039"));
|
||||||
|
|
||||||
|
Assert.Empty(capabilities.All);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sql2005_has_dmvs_but_no_extended_events()
|
||||||
|
{
|
||||||
|
var capabilities = InstanceCapabilities.From(Instance("9.00.5000.00"));
|
||||||
|
|
||||||
|
Assert.True(capabilities.Has(Capability.DynamicManagementViews));
|
||||||
|
Assert.True(capabilities.Has(Capability.ViewServerStatePermission));
|
||||||
|
Assert.False(capabilities.Has(Capability.ExtendedEvents));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sql2008_has_system_health_without_file_target()
|
||||||
|
{
|
||||||
|
var capabilities = InstanceCapabilities.From(Instance("10.0.6000.29"));
|
||||||
|
|
||||||
|
Assert.True(capabilities.Has(Capability.SystemHealthSession));
|
||||||
|
Assert.False(capabilities.Has(Capability.SystemHealthFileTarget));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("13.0.4001.0", false)] // 2016 SP1
|
||||||
|
[InlineData("13.0.5026.0", true)] // 2016 SP2
|
||||||
|
[InlineData("14.0.1000.169", true)]
|
||||||
|
public void LastGoodCheckDbTime_requires_2016_sp2(string version, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, InstanceCapabilities.From(Instance(version)).Has(Capability.LastGoodCheckDbTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Query_store_starts_with_2016()
|
||||||
|
{
|
||||||
|
Assert.False(InstanceCapabilities.From(Instance("12.0.6024.0")).Has(Capability.QueryStore));
|
||||||
|
Assert.True(InstanceCapabilities.From(Instance("13.0.6300.2")).Has(Capability.QueryStore));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Granular_permissions_start_with_2022()
|
||||||
|
{
|
||||||
|
Assert.False(InstanceCapabilities.From(Instance("15.0.4355.3")).Has(Capability.GranularServerPermissions));
|
||||||
|
Assert.True(InstanceCapabilities.From(Instance("16.0.4135.4")).Has(Capability.GranularServerPermissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("15.0.4355.3", true, true)]
|
||||||
|
[InlineData("15.0.4355.3", false, false)]
|
||||||
|
[InlineData("15.0.4355.3", null, false)]
|
||||||
|
[InlineData("10.50.6000.34", true, false)] // property cannot be true before 2012; ignore bad data
|
||||||
|
public void Hadr_requires_both_version_and_runtime_setting(string version, bool? hadr, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, InstanceCapabilities.From(Instance(version, hadr)).Has(Capability.HadrEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_lists_only_absent_capabilities()
|
||||||
|
{
|
||||||
|
var capabilities = InstanceCapabilities.From(Instance("11.0.7001.0"));
|
||||||
|
|
||||||
|
var missing = capabilities.Missing([Capability.ExtendedEvents, Capability.QueryStore, Capability.LastActualPlan]);
|
||||||
|
|
||||||
|
Assert.Equal([Capability.QueryStore, Capability.LastActualPlan], missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj
Normal file
14
tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Core\SQLInsight.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
50
tests/SQLInsight.Core.Tests/SqlServerVersionTests.cs
Normal file
50
tests/SQLInsight.Core.Tests/SqlServerVersionTests.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Core.Tests;
|
||||||
|
|
||||||
|
public class SqlServerVersionTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("8.00.2039", SqlServerRelease.SqlServer2000)]
|
||||||
|
[InlineData("9.00.5000.00", SqlServerRelease.SqlServer2005)]
|
||||||
|
[InlineData("10.0.6000.29", SqlServerRelease.SqlServer2008)]
|
||||||
|
[InlineData("10.50.6000.34", SqlServerRelease.SqlServer2008R2)]
|
||||||
|
[InlineData("11.0.7001.0", SqlServerRelease.SqlServer2012)]
|
||||||
|
[InlineData("12.0.6024.0", SqlServerRelease.SqlServer2014)]
|
||||||
|
[InlineData("13.0.6300.2", SqlServerRelease.SqlServer2016)]
|
||||||
|
[InlineData("14.0.3456.2", SqlServerRelease.SqlServer2017)]
|
||||||
|
[InlineData("15.0.4355.3", SqlServerRelease.SqlServer2019)]
|
||||||
|
[InlineData("16.0.4135.4", SqlServerRelease.SqlServer2022)]
|
||||||
|
[InlineData("17.0.1000.7", SqlServerRelease.SqlServer2025)]
|
||||||
|
[InlineData("18.0.100.1", SqlServerRelease.Unknown)]
|
||||||
|
public void Maps_product_version_to_release(string productVersion, SqlServerRelease expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, SqlServerVersion.Parse(productVersion).Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parses_sql2000_style_version_with_three_parts()
|
||||||
|
{
|
||||||
|
Assert.Equal(new SqlServerVersion(8, 0, 2039, 0), SqlServerVersion.Parse("8.00.2039"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("sixteen")]
|
||||||
|
[InlineData("16")]
|
||||||
|
[InlineData("16.0.1.2.3")]
|
||||||
|
[InlineData("-16.0.1.2")]
|
||||||
|
public void Rejects_invalid_versions(string? value)
|
||||||
|
{
|
||||||
|
Assert.False(SqlServerVersion.TryParse(value, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compares_by_build_not_by_text()
|
||||||
|
{
|
||||||
|
Assert.True(SqlServerVersion.Parse("13.0.5026.0") > SqlServerVersion.Parse("13.0.4001.0"));
|
||||||
|
Assert.True(SqlServerVersion.Parse("10.50.1600.1") > SqlServerVersion.Parse("10.0.6000.29"));
|
||||||
|
Assert.True(SqlServerVersion.Parse("9.0.1399.6") < SqlServerVersion.Parse("10.0.1600.22"));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/SQLInsight.IntegrationTests/InstanceProbeTests.cs
Normal file
48
tests/SQLInsight.IntegrationTests/InstanceProbeTests.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using SQLInsight.Connectivity;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public class InstanceProbeTests(SqlServerFixture sql)
|
||||||
|
{
|
||||||
|
private ConnectionTarget SaTarget => new()
|
||||||
|
{
|
||||||
|
Server = sql.Server,
|
||||||
|
Authentication = AuthenticationMode.SqlLogin,
|
||||||
|
UserName = "sa",
|
||||||
|
Password = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(sql.SaConnectionString).Password,
|
||||||
|
Provider = ConnectionProvider.SqlClient,
|
||||||
|
// Test containers use a self-signed certificate. A deliberate choice, as the README requires.
|
||||||
|
TrustServerCertificate = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Detects_release_matching_the_container_image()
|
||||||
|
{
|
||||||
|
await using var opened = await new SqlConnectionFactory().OpenAsync(SaTarget);
|
||||||
|
|
||||||
|
var info = await InstanceProbe.DetectAsync(opened.Connection);
|
||||||
|
|
||||||
|
var expectedYear = SqlServerFixture.Image.Split(':')[1][..4];
|
||||||
|
Assert.Equal(expectedYear, ((int)info.Release).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
Assert.Equal(EngineEdition.Enterprise, info.EngineEdition); // Developer edition reports Enterprise.
|
||||||
|
Assert.False(string.IsNullOrEmpty(info.ServerName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Full_pipeline_evaluates_every_built_in_rule()
|
||||||
|
{
|
||||||
|
await using var opened = await new SqlConnectionFactory().OpenAsync(SaTarget);
|
||||||
|
var info = await InstanceProbe.DetectAsync(opened.Connection);
|
||||||
|
var context = new RuleContext(info, InstanceCapabilities.From(info), DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
var results = new RuleEngine(BuiltInRules.Create()).Evaluate(context);
|
||||||
|
|
||||||
|
Assert.NotEmpty(results);
|
||||||
|
Assert.DoesNotContain(results, r => r.Outcome == RuleOutcome.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs
Normal file
107
tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs db/permissions/grant-least-privilege.sql and proves that the agent login can read everything it needs,
|
||||||
|
/// and nothing else.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string Login = "sqlinsight_agent";
|
||||||
|
private const string Password = "Agent-Pa55word!";
|
||||||
|
private const string Database = "SQLInsightQsTest";
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await sql.ExecuteAsSaAsync($"""
|
||||||
|
IF SUSER_ID(N'{Login}') IS NULL CREATE LOGIN [{Login}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF;
|
||||||
|
IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];
|
||||||
|
""");
|
||||||
|
await sql.ExecuteAsSaAsync($"""
|
||||||
|
USE [{Database}];
|
||||||
|
ALTER DATABASE [{Database}] SET QUERY_STORE = ON;
|
||||||
|
IF OBJECT_ID(N'dbo.CustomerData') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.CustomerData (Id int NOT NULL PRIMARY KEY, Secret nvarchar(50) NOT NULL);
|
||||||
|
INSERT dbo.CustomerData VALUES (1, N'fortroligt');
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
|
||||||
|
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
|
||||||
|
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
|
||||||
|
await sql.ExecuteAsSaAsync(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Agent_login_is_not_sysadmin()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, await ScalarAsync<int>("SELECT IS_SRVROLEMEMBER('sysadmin')"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.dm_os_wait_stats")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.dm_exec_requests r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) p")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.dm_io_virtual_file_stats(NULL, NULL)")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.dm_os_ring_buffers")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.dm_xe_sessions WHERE name = 'system_health'")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM sys.configurations")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM msdb.dbo.backupset")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM msdb.dbo.sysjobs")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM msdb.dbo.sysjobhistory")]
|
||||||
|
[InlineData("SELECT COUNT(*) FROM msdb.dbo.suspect_pages")]
|
||||||
|
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.database_query_store_options")]
|
||||||
|
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.query_store_runtime_stats")]
|
||||||
|
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.indexes WHERE object_id = OBJECT_ID(N'[{Database}].dbo.CustomerData')")]
|
||||||
|
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.dm_db_index_usage_stats")]
|
||||||
|
[InlineData($"SELECT COUNT(*) WHERE DATABASEPROPERTYEX(N'{Database}', 'LastGoodCheckDbTime') IS NOT NULL")]
|
||||||
|
public async Task Agent_can_read_monitoring_sources(string query)
|
||||||
|
{
|
||||||
|
Assert.True(await ScalarAsync<int>(query) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Agent_cannot_read_customer_data()
|
||||||
|
{
|
||||||
|
var ex = await Assert.ThrowsAsync<SqlException>(
|
||||||
|
() => ScalarAsync<int>($"SELECT COUNT(*) FROM [{Database}].dbo.CustomerData"));
|
||||||
|
Assert.Equal(229, ex.Number); // The SELECT permission was denied.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Agent_cannot_change_anything()
|
||||||
|
{
|
||||||
|
var ex = await Assert.ThrowsAsync<SqlException>(
|
||||||
|
() => ScalarAsync<int>($"USE [{Database}]; CREATE TABLE dbo.ShouldNotExist (Id int); SELECT 1"));
|
||||||
|
Assert.Equal(262, ex.Number); // CREATE TABLE permission denied.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Script_is_idempotent()
|
||||||
|
{
|
||||||
|
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
|
||||||
|
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
await sql.ExecuteAsSaAsync(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ScalarAsync<T>(string query)
|
||||||
|
{
|
||||||
|
var builder = new SqlConnectionStringBuilder(sql.SaConnectionString)
|
||||||
|
{
|
||||||
|
UserID = Login,
|
||||||
|
Password = Password,
|
||||||
|
InitialCatalog = "master",
|
||||||
|
};
|
||||||
|
await using var connection = new SqlConnection(builder.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = query;
|
||||||
|
return (T)Convert.ChangeType((await command.ExecuteScalarAsync())!, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="Testcontainers.MsSql" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
48
tests/SQLInsight.IntegrationTests/SqlServerFixture.cs
Normal file
48
tests/SQLInsight.IntegrationTests/SqlServerFixture.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Testcontainers.MsSql;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One SQL Server container for the test run. The image is chosen with <c>SQLINSIGHT_MSSQL_IMAGE</c> so CI can run
|
||||||
|
/// the same tests against 2017, 2019, 2022 and 2025.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlServerFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
public const string DefaultImage = "mcr.microsoft.com/mssql/server:2022-latest";
|
||||||
|
|
||||||
|
public static string Image => Environment.GetEnvironmentVariable("SQLINSIGHT_MSSQL_IMAGE") ?? DefaultImage;
|
||||||
|
|
||||||
|
private readonly MsSqlContainer _container = new MsSqlBuilder(Image).Build();
|
||||||
|
|
||||||
|
public string SaConnectionString => _container.GetConnectionString();
|
||||||
|
|
||||||
|
public string Server
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var builder = new SqlConnectionStringBuilder(SaConnectionString);
|
||||||
|
return builder.DataSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InitializeAsync() => _container.StartAsync();
|
||||||
|
|
||||||
|
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
|
||||||
|
|
||||||
|
public async Task ExecuteAsSaAsync(string sql)
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(SaConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
command.CommandTimeout = 120;
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class SqlServerTestGroup : ICollectionFixture<SqlServerFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "SQL Server";
|
||||||
|
}
|
||||||
94
tests/SQLInsight.Rules.Tests/RuleEngineTests.cs
Normal file
94
tests/SQLInsight.Rules.Tests/RuleEngineTests.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Tests;
|
||||||
|
|
||||||
|
public class RuleEngineTests
|
||||||
|
{
|
||||||
|
private sealed class StubRule(string id, IReadOnlyCollection<Capability> required, Func<RuleContext, RuleEvaluation> evaluate) : IRule
|
||||||
|
{
|
||||||
|
public int Calls { get; private set; }
|
||||||
|
public string Id => id;
|
||||||
|
public string Title => $"Stub {id}";
|
||||||
|
public IReadOnlyCollection<Capability> RequiredCapabilities => required;
|
||||||
|
|
||||||
|
public RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return evaluate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rule_missing_capabilities_is_not_measurable_and_never_runs()
|
||||||
|
{
|
||||||
|
var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed());
|
||||||
|
var engine = new RuleEngine([rule]);
|
||||||
|
|
||||||
|
var result = Assert.Single(engine.Evaluate(TestInstances.Context("11.0.7001.0")));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
|
||||||
|
Assert.Equal([Capability.QueryStore], result.MissingCapabilities);
|
||||||
|
Assert.Contains("SQL Server 2012", result.Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(0, rule.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rule_with_capabilities_runs()
|
||||||
|
{
|
||||||
|
var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed());
|
||||||
|
var engine = new RuleEngine([rule]);
|
||||||
|
|
||||||
|
var result = Assert.Single(engine.Evaluate(TestInstances.Context("16.0.4135.4")));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, result.Outcome);
|
||||||
|
Assert.Equal(1, rule.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Throwing_rule_is_reported_as_error_and_does_not_stop_others()
|
||||||
|
{
|
||||||
|
var failing = new StubRule("A", [], _ => throw new InvalidOperationException("boom"));
|
||||||
|
var finding = new Finding
|
||||||
|
{
|
||||||
|
RuleId = "B",
|
||||||
|
Severity = Severity.Low,
|
||||||
|
Title = "t",
|
||||||
|
Explanation = "e",
|
||||||
|
Recommendation = "r",
|
||||||
|
};
|
||||||
|
var working = new StubRule("B", [], _ => RuleEvaluation.Found(finding));
|
||||||
|
var engine = new RuleEngine([failing, working]);
|
||||||
|
|
||||||
|
var results = engine.Evaluate(TestInstances.Context("16.0.4135.4"));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Error, results[0].Outcome);
|
||||||
|
Assert.Contains("boom", results[0].Reason, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(RuleOutcome.Findings, results[1].Outcome);
|
||||||
|
Assert.Same(finding, Assert.Single(results[1].Findings));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicate_rule_ids_are_rejected()
|
||||||
|
{
|
||||||
|
var a = new StubRule("SAME", [], _ => RuleEvaluation.Passed());
|
||||||
|
var b = new StubRule("same", [], _ => RuleEvaluation.Passed());
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(() => new RuleEngine([a, b]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Found_requires_at_least_one_finding()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => RuleEvaluation.Found());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Built_in_rule_ids_are_unique()
|
||||||
|
{
|
||||||
|
var engine = new RuleEngine(BuiltInRules.Create());
|
||||||
|
|
||||||
|
Assert.NotEmpty(engine.Evaluate(TestInstances.Context("16.0.4135.4")));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj
Normal file
14
tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
93
tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs
Normal file
93
tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
using SQLInsight.Rules.Lifecycle;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Tests;
|
||||||
|
|
||||||
|
public class SupportLifecycleRuleTests
|
||||||
|
{
|
||||||
|
private static readonly SupportLifecycleRule Rule = new(SupportLifecycleCatalog.LoadEmbedded());
|
||||||
|
|
||||||
|
private static readonly DateTimeOffset Today = new(2026, 9, 29, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("8.00.2039")]
|
||||||
|
[InlineData("9.00.5000.00")]
|
||||||
|
[InlineData("10.50.6000.34")]
|
||||||
|
[InlineData("12.0.6024.0")]
|
||||||
|
[InlineData("13.0.6300.2")]
|
||||||
|
public void Out_of_support_releases_are_critical(string version)
|
||||||
|
{
|
||||||
|
var evaluation = Rule.Evaluate(TestInstances.Context(version, Today));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Findings, evaluation.Outcome);
|
||||||
|
Assert.Equal(Severity.Critical, Assert.Single(evaluation.Findings).Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("2026-09-29", Severity.Medium)] // 378 days left
|
||||||
|
[InlineData("2026-10-12", Severity.High)] // exactly 365 days left
|
||||||
|
[InlineData("2027-10-12", Severity.High)] // last day of support
|
||||||
|
[InlineData("2027-10-13", Severity.Critical)]
|
||||||
|
public void Sql2017_escalates_as_extended_support_ends(string date, Severity expected)
|
||||||
|
{
|
||||||
|
// SQL Server 2017 extended support ends 2027-10-12.
|
||||||
|
var now = DateTimeOffset.Parse(date + "T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var evaluation = Rule.Evaluate(TestInstances.Context("14.0.3456.2", now));
|
||||||
|
|
||||||
|
Assert.Equal(expected, Assert.Single(evaluation.Findings).Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Extended_support_further_out_is_medium()
|
||||||
|
{
|
||||||
|
// SQL Server 2019 left mainstream support 2025-02-28; extended runs to 2030.
|
||||||
|
var evaluation = Rule.Evaluate(TestInstances.Context("15.0.4355.3", Today));
|
||||||
|
|
||||||
|
Assert.Equal(Severity.Medium, Assert.Single(evaluation.Findings).Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mainstream_support_passes()
|
||||||
|
{
|
||||||
|
var evaluation = Rule.Evaluate(TestInstances.Context("16.0.4135.4", Today));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, evaluation.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Release_without_published_dates_is_not_measurable()
|
||||||
|
{
|
||||||
|
var evaluation = Rule.Evaluate(TestInstances.Context("17.0.1000.7", Today));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.NotMeasurable, evaluation.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_future_release_is_not_measurable()
|
||||||
|
{
|
||||||
|
var evaluation = Rule.Evaluate(TestInstances.Context("18.0.100.1", Today));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.NotMeasurable, evaluation.Outcome);
|
||||||
|
Assert.Contains("Opdater", evaluation.Reason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Runs_on_sql2000_because_it_needs_no_capabilities()
|
||||||
|
{
|
||||||
|
Assert.Empty(Rule.RequiredCapabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Catalog_covers_every_known_release()
|
||||||
|
{
|
||||||
|
var catalog = SupportLifecycleCatalog.LoadEmbedded();
|
||||||
|
|
||||||
|
foreach (var release in Enum.GetValues<SqlServerRelease>().Where(r => r != SqlServerRelease.Unknown))
|
||||||
|
{
|
||||||
|
Assert.NotNull(catalog.Find(release));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/SQLInsight.Rules.Tests/TestInstances.cs
Normal file
20
tests/SQLInsight.Rules.Tests/TestInstances.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Tests;
|
||||||
|
|
||||||
|
internal static class TestInstances
|
||||||
|
{
|
||||||
|
public static RuleContext Context(string version, DateTimeOffset? now = null)
|
||||||
|
{
|
||||||
|
var info = new InstanceInfo
|
||||||
|
{
|
||||||
|
ServerName = "test",
|
||||||
|
Version = SqlServerVersion.Parse(version),
|
||||||
|
ProductLevel = "RTM",
|
||||||
|
Edition = "Standard Edition",
|
||||||
|
EngineEdition = EngineEdition.Standard,
|
||||||
|
};
|
||||||
|
return new RuleContext(info, InstanceCapabilities.From(info), now ?? new DateTimeOffset(2026, 9, 29, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue