diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..af3d700 --- /dev/null +++ b/.editorconfig @@ -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 + diff --git a/.forgejo/issue_template/fejl.md b/.forgejo/issue_template/fejl.md new file mode 100644 index 0000000..b898aa6 --- /dev/null +++ b/.forgejo/issue_template/fejl.md @@ -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 + + diff --git a/.forgejo/issue_template/regel-forslag.md b/.forgejo/issue_template/regel-forslag.md new file mode 100644 index 0000000..b727397 --- /dev/null +++ b/.forgejo/issue_template/regel-forslag.md @@ -0,0 +1,21 @@ +--- +name: Forslag til regel +about: Et SQL Server-problem, som SQLInsight Pro burde finde +labels: ["regel"] +--- + +## Problemet + + + +## Hvordan man måler det + + + +## Hvilke versioner + + + +## Anbefalet løsning + + diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..43fae84 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ec9db76 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7548741 --- /dev/null +++ b/CONTRIBUTING.md @@ -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--`). 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*. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9a51e2f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,24 @@ + + + net10.0 + latest + enable + enable + true + latest-recommended + true + true + + $(NoWarn);CS1591 + SEOSoft + SQLInsight Pro + Copyright (c) SEOSoft + + + + false + false + + $(NoWarn);CA1707 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..5aedb6b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,19 @@ + + + true + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index e747e22..efa297c 100644 --- a/README.md +++ b/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. -> **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 ### Fase 0: Fundament -- [ ] Solution-struktur (.NET 10 LTS): Agent, Rules, Web, Hub, fælles domænemodel -- [ ] CI-pipeline (build, test, statisk analyse) -- [ ] 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 -- [ ] 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 -- [ ] 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 +- [x] Solution-struktur (.NET 10 LTS): Agent, Rules, Web, Hub, fælles domænemodel +- [x] CI-pipeline til Forgejo Actions (build, test, statisk analyse). Endnu ikke kørt på en runner +- [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 +- [x] Least-privilege-script til opsætning af rettigheder og en test, der verificerer det +- [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. 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) -- [ ] Datamodel for findings (fælles kontrakt mellem Agent og Hub) -- [ ] Bidragsguide, code of conduct og issue templates +- [x] Datamodel for findings (fælles kontrakt mellem Agent og Hub) +- [x] Bidragsguide, code of conduct og issue templates ### Fase 1: Community MVP - [ ] Agent som Windows Service med forbindelse til en eller flere SQL Server-instanser diff --git a/SQLInsight.slnx b/SQLInsight.slnx new file mode 100644 index 0000000..c8a68d7 --- /dev/null +++ b/SQLInsight.slnx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/db/permissions/grant-least-privilege.sql b/db/permissions/grant-least-privilege.sql new file mode 100644 index 0000000..2a9c39d --- /dev/null +++ b/db/permissions/grant-least-privilege.sql @@ -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'.'; diff --git a/docs/legacy-versions.md b/docs/legacy-versions.md new file mode 100644 index 0000000..db31a39 --- /dev/null +++ b/docs/legacy-versions.md @@ -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). diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/SQLInsight.Agent/AgentOptions.cs b/src/SQLInsight.Agent/AgentOptions.cs new file mode 100644 index 0000000..d2dd35a --- /dev/null +++ b/src/SQLInsight.Agent/AgentOptions.cs @@ -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 Instances { get; } = []; +} + +public sealed class MonitoredInstance +{ + /// Stable name shown in the UI and used as the key in SQLInsight Hub. + 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, + }; +} diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs new file mode 100644 index 0000000..4d6d23e --- /dev/null +++ b/src/SQLInsight.Agent/Program.cs @@ -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(builder.Configuration.GetSection(AgentOptions.SectionName)); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new RuleEngine(BuiltInRules.Create())); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/src/SQLInsight.Agent/Properties/launchSettings.json b/src/SQLInsight.Agent/Properties/launchSettings.json new file mode 100644 index 0000000..0649085 --- /dev/null +++ b/src/SQLInsight.Agent/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "SQLInsight.Agent": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj new file mode 100644 index 0000000..49f26d5 --- /dev/null +++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj @@ -0,0 +1,16 @@ + + + SQLInsight.Agent + sqlinsight-agent + + + + + + + + + + + + diff --git a/src/SQLInsight.Agent/Worker.cs b/src/SQLInsight.Agent/Worker.cs new file mode 100644 index 0000000..55e9b57 --- /dev/null +++ b/src/SQLInsight.Agent/Worker.cs @@ -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 options, + TimeProvider timeProvider, + ILogger 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); +} diff --git a/src/SQLInsight.Agent/appsettings.Development.json b/src/SQLInsight.Agent/appsettings.Development.json new file mode 100644 index 0000000..dc23838 --- /dev/null +++ b/src/SQLInsight.Agent/appsettings.Development.json @@ -0,0 +1,7 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + } + } +} diff --git a/src/SQLInsight.Agent/appsettings.json b/src/SQLInsight.Agent/appsettings.json new file mode 100644 index 0000000..c0de34b --- /dev/null +++ b/src/SQLInsight.Agent/appsettings.json @@ -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 + } + ] + } +} diff --git a/src/SQLInsight.Connectivity/ConnectionStrings.cs b/src/SQLInsight.Connectivity/ConnectionStrings.cs new file mode 100644 index 0000000..8fb8ffc --- /dev/null +++ b/src/SQLInsight.Connectivity/ConnectionStrings.cs @@ -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; +} diff --git a/src/SQLInsight.Connectivity/ConnectionTarget.cs b/src/SQLInsight.Connectivity/ConnectionTarget.cs new file mode 100644 index 0000000..416952c --- /dev/null +++ b/src/SQLInsight.Connectivity/ConnectionTarget.cs @@ -0,0 +1,55 @@ +namespace SQLInsight.Connectivity; + +/// +/// How to reach one monitored instance. +/// +public sealed record ConnectionTarget +{ + /// Server name, host\instance or host,port. + public required string Server { get; init; } + + public AuthenticationMode Authentication { get; init; } = AuthenticationMode.Windows; + + /// SQL login name. Only used with . + public string? UserName { get; init; } + + /// SQL login password. Only used with . + public string? Password { get; init; } + + public ConnectionProvider Provider { get; init; } = ConnectionProvider.Auto; + + /// + /// Whether the connection must be encrypted. Mandatory is the default; old unpatched instances that only speak + /// TLS 1.0 need , which the agent reports as a warning. + /// + public EncryptionMode Encryption { get; init; } = EncryptionMode.Mandatory; + + /// Skip certificate validation. Off by default; must be a deliberate choice. + public bool TrustServerCertificate { get; init; } + + /// ODBC driver used for legacy instances. + public string OdbcDriver { get; init; } = "ODBC Driver 18 for SQL Server"; + + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15); +} + +public enum AuthenticationMode +{ + /// Integrated security. Preferred; run the agent as a gMSA. + Windows, + SqlLogin, +} + +public enum ConnectionProvider +{ + /// Try Microsoft.Data.SqlClient, fall back to ODBC when the instance is too old for it. + Auto, + SqlClient, + Odbc, +} + +public enum EncryptionMode +{ + Mandatory, + Optional, +} diff --git a/src/SQLInsight.Connectivity/ISqlConnectionFactory.cs b/src/SQLInsight.Connectivity/ISqlConnectionFactory.cs new file mode 100644 index 0000000..a693ea9 --- /dev/null +++ b/src/SQLInsight.Connectivity/ISqlConnectionFactory.cs @@ -0,0 +1,9 @@ +namespace SQLInsight.Connectivity; + +public interface ISqlConnectionFactory +{ + /// + /// Opens a connection to with monitoring session settings applied. + /// + Task OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default); +} diff --git a/src/SQLInsight.Connectivity/InstanceProbe.cs b/src/SQLInsight.Connectivity/InstanceProbe.cs new file mode 100644 index 0000000..768e82c --- /dev/null +++ b/src/SQLInsight.Connectivity/InstanceProbe.cs @@ -0,0 +1,62 @@ +using System.Data.Common; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Connectivity; + +/// +/// Reads version, edition and key settings from an instance. Works on every version from SQL Server 2000: +/// only SERVERPROPERTY is used, and properties an older version does not know return NULL. +/// +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 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; +} diff --git a/src/SQLInsight.Connectivity/LegacyFailureClassifier.cs b/src/SQLInsight.Connectivity/LegacyFailureClassifier.cs new file mode 100644 index 0000000..06d7d16 --- /dev/null +++ b/src/SQLInsight.Connectivity/LegacyFailureClassifier.cs @@ -0,0 +1,42 @@ +using System.Security.Authentication; + +namespace SQLInsight.Connectivity; + +/// +/// 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. +/// +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; + } +} diff --git a/src/SQLInsight.Connectivity/OpenedConnection.cs b/src/SQLInsight.Connectivity/OpenedConnection.cs new file mode 100644 index 0000000..05eb268 --- /dev/null +++ b/src/SQLInsight.Connectivity/OpenedConnection.cs @@ -0,0 +1,22 @@ +using System.Data.Common; + +namespace SQLInsight.Connectivity; + +/// +/// An open, monitoring-hardened connection and how it was established. +/// +public sealed class OpenedConnection(DbConnection connection, ConnectionProvider provider, bool usedLegacyFallback) + : IAsyncDisposable, IDisposable +{ + public DbConnection Connection { get; } = connection; + + /// The provider actually used: or . + public ConnectionProvider Provider { get; } = provider; + + /// True when SqlClient failed and the ODBC fallback was used. + public bool UsedLegacyFallback { get; } = usedLegacyFallback; + + public ValueTask DisposeAsync() => Connection.DisposeAsync(); + + public void Dispose() => Connection.Dispose(); +} diff --git a/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj b/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj new file mode 100644 index 0000000..92f6ef8 --- /dev/null +++ b/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj @@ -0,0 +1,15 @@ + + + SQLInsight.Connectivity + + + + + + + + + + + + diff --git a/src/SQLInsight.Connectivity/SqlConnectionFactory.cs b/src/SQLInsight.Connectivity/SqlConnectionFactory.cs new file mode 100644 index 0000000..daa1f5f --- /dev/null +++ b/src/SQLInsight.Connectivity/SqlConnectionFactory.cs @@ -0,0 +1,75 @@ +using System.Data.Common; +using System.Data.Odbc; +using Microsoft.Data.SqlClient; + +namespace SQLInsight.Connectivity; + +/// +/// Opens monitoring connections with Microsoft.Data.SqlClient, falling back to ODBC for instances too old for it. +/// +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 _createSqlClient; + private readonly Func _createOdbc; + + public SqlConnectionFactory() + : this( + t => new SqlConnection(ConnectionStrings.ForSqlClient(t)), + t => new OdbcConnection(ConnectionStrings.ForOdbc(t))) + { + } + + internal SqlConnectionFactory( + Func createSqlClient, + Func createOdbc) + { + _createSqlClient = createSqlClient; + _createOdbc = createOdbc; + } + + public async Task 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 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; + } + } +} diff --git a/src/SQLInsight.Contracts/FindingsSubmission.cs b/src/SQLInsight.Contracts/FindingsSubmission.cs new file mode 100644 index 0000000..7b41204 --- /dev/null +++ b/src/SQLInsight.Contracts/FindingsSubmission.cs @@ -0,0 +1,57 @@ +namespace SQLInsight.Contracts; + +/// +/// 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 +/// for anything else. +/// +/// +/// Only findings and instance metadata are sent. Table data is never sent, and query text only when the customer +/// has allowed it. +/// +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 Results { get; init; } +} + +public sealed record InstanceSnapshot +{ + /// A stable, customer-chosen name for the instance. The hub keys history on it. + 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; } + + /// Passed, Findings, NotMeasurable or Error. + public required string Outcome { get; init; } + + public string? Reason { get; init; } + public IReadOnlyList Findings { get; init; } = []; +} + +public sealed record FindingDto +{ + /// Info, Low, Medium, High or Critical. + 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 Evidence { get; init; } = []; +} + +public sealed record EvidenceDto(string Name, string Value); diff --git a/src/SQLInsight.Contracts/SQLInsight.Contracts.csproj b/src/SQLInsight.Contracts/SQLInsight.Contracts.csproj new file mode 100644 index 0000000..9be178b --- /dev/null +++ b/src/SQLInsight.Contracts/SQLInsight.Contracts.csproj @@ -0,0 +1,5 @@ + + + SQLInsight.Contracts + + diff --git a/src/SQLInsight.Core/Findings/Finding.cs b/src/SQLInsight.Core/Findings/Finding.cs new file mode 100644 index 0000000..e97aa33 --- /dev/null +++ b/src/SQLInsight.Core/Findings/Finding.cs @@ -0,0 +1,29 @@ +namespace SQLInsight.Core.Findings; + +/// +/// One problem found on an instance, with everything needed to understand and fix it. +/// +public sealed record Finding +{ + public required string RuleId { get; init; } + public required Severity Severity { get; init; } + public required string Title { get; init; } + + /// Why this is a problem. + public required string Explanation { get; init; } + + /// What to do about it. + public required string Recommendation { get; init; } + + /// The measurements that triggered the finding. + public IReadOnlyList Evidence { get; init; } = []; + + /// A suggested fix script. Shown to the user, never executed automatically. + public string? FixScript { get; init; } + + public IReadOnlyList References { get; init; } = []; +} + +public sealed record Evidence(string Name, string Value); + +public sealed record Reference(string Title, Uri Url); diff --git a/src/SQLInsight.Core/Findings/Severity.cs b/src/SQLInsight.Core/Findings/Severity.cs new file mode 100644 index 0000000..d9627e2 --- /dev/null +++ b/src/SQLInsight.Core/Findings/Severity.cs @@ -0,0 +1,10 @@ +namespace SQLInsight.Core.Findings; + +public enum Severity +{ + Info = 0, + Low = 1, + Medium = 2, + High = 3, + Critical = 4, +} diff --git a/src/SQLInsight.Core/Instances/Capability.cs b/src/SQLInsight.Core/Instances/Capability.cs new file mode 100644 index 0000000..5a39dc7 --- /dev/null +++ b/src/SQLInsight.Core/Instances/Capability.cs @@ -0,0 +1,49 @@ +namespace SQLInsight.Core.Instances; + +/// +/// Something a collector or rule depends on that not every SQL Server version or configuration offers. +/// +public enum Capability +{ + /// Dynamic management views (sys.dm_*). SQL Server 2005+. + DynamicManagementViews, + + /// The always-on default trace. SQL Server 2005+. + DefaultTrace, + + /// The VIEW SERVER STATE permission, so monitoring does not need sysadmin. SQL Server 2005+. + ViewServerStatePermission, + + /// Extended Events. SQL Server 2008+. + ExtendedEvents, + + /// The built-in system_health Extended Events session (deadlock graphs). SQL Server 2008+. + SystemHealthSession, + + /// system_health writes to a file target, so history survives the ring buffer. SQL Server 2012+. + SystemHealthFileTarget, + + /// Always On availability group DMVs exist. SQL Server 2012+. + AvailabilityGroupDmvs, + + /// Always On is enabled on this instance (runtime setting, not just the version). + HadrEnabled, + + /// The CONNECT ANY DATABASE permission. SQL Server 2014+. + ConnectAnyDatabasePermission, + + /// Query Store. SQL Server 2016+. + QueryStore, + + /// sys.dm_exec_session_wait_stats. SQL Server 2016+. + SessionWaitStats, + + /// DATABASEPROPERTYEX(..., 'LastGoodCheckDbTime'), so CHECKDB status does not need sysadmin. SQL Server 2016 SP2+. + LastGoodCheckDbTime, + + /// sys.dm_exec_query_plan_stats (last actual execution plan). SQL Server 2019+. + LastActualPlan, + + /// Granular server permissions such as VIEW SERVER PERFORMANCE STATE. SQL Server 2022+. + GranularServerPermissions, +} diff --git a/src/SQLInsight.Core/Instances/EngineEdition.cs b/src/SQLInsight.Core/Instances/EngineEdition.cs new file mode 100644 index 0000000..a93c34d --- /dev/null +++ b/src/SQLInsight.Core/Instances/EngineEdition.cs @@ -0,0 +1,19 @@ +namespace SQLInsight.Core.Instances; + +/// +/// Values of SERVERPROPERTY('EngineEdition'). +/// +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, +} diff --git a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs new file mode 100644 index 0000000..d68a805 --- /dev/null +++ b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs @@ -0,0 +1,67 @@ +namespace SQLInsight.Core.Instances; + +/// +/// The capabilities an instance actually has, derived from its version and runtime settings. +/// +public sealed class InstanceCapabilities +{ + // Minimum build for each version-gated capability. + private static readonly Dictionary MinimumVersion = + new Dictionary + { + [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 _capabilities; + + private InstanceCapabilities(HashSet capabilities) => _capabilities = capabilities; + + public IReadOnlySet All => _capabilities; + + public static InstanceCapabilities From(InstanceInfo instance) + { + ArgumentNullException.ThrowIfNull(instance); + + var set = new HashSet(); + 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 Missing(IEnumerable required) + { + ArgumentNullException.ThrowIfNull(required); + return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList(); + } + + /// + /// The minimum version for a version-gated capability, or for runtime-only capabilities. + /// + public static SqlServerVersion? MinimumVersionFor(Capability capability) => + MinimumVersion.TryGetValue(capability, out var version) ? version : null; +} diff --git a/src/SQLInsight.Core/Instances/InstanceInfo.cs b/src/SQLInsight.Core/Instances/InstanceInfo.cs new file mode 100644 index 0000000..466f44f --- /dev/null +++ b/src/SQLInsight.Core/Instances/InstanceInfo.cs @@ -0,0 +1,26 @@ +namespace SQLInsight.Core.Instances; + +/// +/// 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 . +/// +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; } + + /// RTM, SP1, SP2 … (ProductLevel). + public string? ProductLevel { get; init; } + + /// CU level such as CU14 (ProductUpdateLevel, only on patched 2012 and later). + public string? ProductUpdateLevel { get; init; } + + public bool? IsClustered { get; init; } + + /// Whether Always On availability groups are enabled (2012 and later). + public bool? IsHadrEnabled { get; init; } + + public SqlServerRelease Release => Version.Release; +} diff --git a/src/SQLInsight.Core/Instances/SqlServerRelease.cs b/src/SQLInsight.Core/Instances/SqlServerRelease.cs new file mode 100644 index 0000000..dcdf40f --- /dev/null +++ b/src/SQLInsight.Core/Instances/SqlServerRelease.cs @@ -0,0 +1,39 @@ +namespace SQLInsight.Core.Instances; + +/// +/// A SQL Server release line. Values are ordered chronologically so releases can be compared. +/// +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", + }; +} diff --git a/src/SQLInsight.Core/Instances/SqlServerVersion.cs b/src/SQLInsight.Core/Instances/SqlServerVersion.cs new file mode 100644 index 0000000..7f2a88d --- /dev/null +++ b/src/SQLInsight.Core/Instances/SqlServerVersion.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace SQLInsight.Core.Instances; + +/// +/// A SQL Server build number as returned by SERVERPROPERTY('ProductVersion'), e.g. 16.0.4135.4. +/// +public readonly record struct SqlServerVersion(int Major, int Minor, int Build, int Revision) + : IComparable +{ + 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}"); +} diff --git a/src/SQLInsight.Core/SQLInsight.Core.csproj b/src/SQLInsight.Core/SQLInsight.Core.csproj new file mode 100644 index 0000000..b1a5cac --- /dev/null +++ b/src/SQLInsight.Core/SQLInsight.Core.csproj @@ -0,0 +1,5 @@ + + + SQLInsight.Core + + diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs new file mode 100644 index 0000000..0defc95 --- /dev/null +++ b/src/SQLInsight.Hub/Program.cs @@ -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(); diff --git a/src/SQLInsight.Hub/Properties/launchSettings.json b/src/SQLInsight.Hub/Properties/launchSettings.json new file mode 100644 index 0000000..f5612e1 --- /dev/null +++ b/src/SQLInsight.Hub/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/SQLInsight.Hub/SQLInsight.Hub.csproj b/src/SQLInsight.Hub/SQLInsight.Hub.csproj new file mode 100644 index 0000000..8027d54 --- /dev/null +++ b/src/SQLInsight.Hub/SQLInsight.Hub.csproj @@ -0,0 +1,8 @@ + + + SQLInsight.Hub + + + + + diff --git a/src/SQLInsight.Hub/appsettings.Development.json b/src/SQLInsight.Hub/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/SQLInsight.Hub/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/SQLInsight.Hub/appsettings.json b/src/SQLInsight.Hub/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/SQLInsight.Hub/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs new file mode 100644 index 0000000..06e6145 --- /dev/null +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -0,0 +1,12 @@ +using SQLInsight.Rules.Engine; +using SQLInsight.Rules.Lifecycle; + +namespace SQLInsight.Rules; + +public static class BuiltInRules +{ + public static IReadOnlyList Create() => + [ + new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()), + ]; +} diff --git a/src/SQLInsight.Rules/Engine/IRule.cs b/src/SQLInsight.Rules/Engine/IRule.cs new file mode 100644 index 0000000..1c634cc --- /dev/null +++ b/src/SQLInsight.Rules/Engine/IRule.cs @@ -0,0 +1,22 @@ +using SQLInsight.Core.Instances; + +namespace SQLInsight.Rules.Engine; + +/// +/// One piece of SQL Server expertise, expressed as a testable check. +/// +public interface IRule +{ + /// Stable identifier, e.g. SQLI-VER-001. Never reused for a different check. + string Id { get; } + + string Title { get; } + + /// + /// 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. + /// + IReadOnlyCollection RequiredCapabilities { get; } + + RuleEvaluation Evaluate(RuleContext context); +} diff --git a/src/SQLInsight.Rules/Engine/RuleContext.cs b/src/SQLInsight.Rules/Engine/RuleContext.cs new file mode 100644 index 0000000..8bcb6e2 --- /dev/null +++ b/src/SQLInsight.Rules/Engine/RuleContext.cs @@ -0,0 +1,5 @@ +using SQLInsight.Core.Instances; + +namespace SQLInsight.Rules.Engine; + +public sealed record RuleContext(InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now); diff --git a/src/SQLInsight.Rules/Engine/RuleEngine.cs b/src/SQLInsight.Rules/Engine/RuleEngine.cs new file mode 100644 index 0000000..ab51513 --- /dev/null +++ b/src/SQLInsight.Rules/Engine/RuleEngine.cs @@ -0,0 +1,71 @@ +using SQLInsight.Core.Instances; + +namespace SQLInsight.Rules.Engine; + +/// +/// 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. +/// +public sealed class RuleEngine +{ + private readonly IReadOnlyList _rules; + + public RuleEngine(IEnumerable 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 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}", + }; + } + } +} diff --git a/src/SQLInsight.Rules/Engine/RuleEvaluation.cs b/src/SQLInsight.Rules/Engine/RuleEvaluation.cs new file mode 100644 index 0000000..7d8b964 --- /dev/null +++ b/src/SQLInsight.Rules/Engine/RuleEvaluation.cs @@ -0,0 +1,52 @@ +using SQLInsight.Core.Findings; + +namespace SQLInsight.Rules.Engine; + +/// +/// What a rule concluded. Created through the static factory methods. +/// +public sealed record RuleEvaluation +{ + private RuleEvaluation(RuleOutcome outcome, IReadOnlyList findings, string? reason) + { + Outcome = outcome; + Findings = findings; + Reason = reason; + } + + public RuleOutcome Outcome { get; } + public IReadOnlyList Findings { get; } + + /// Why the rule could not be measured, if it could not. + 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 +{ + /// The rule ran and found nothing wrong. + Passed, + + /// The rule ran and found one or more problems. + Findings, + + /// The rule could not be evaluated on this instance. This is never the same as . + NotMeasurable, + + /// The rule failed while running. This is never the same as . + Error, +} diff --git a/src/SQLInsight.Rules/Engine/RuleResult.cs b/src/SQLInsight.Rules/Engine/RuleResult.cs new file mode 100644 index 0000000..ff60ea9 --- /dev/null +++ b/src/SQLInsight.Rules/Engine/RuleResult.cs @@ -0,0 +1,21 @@ +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Rules.Engine; + +/// +/// The result of running one rule against one instance. +/// +public sealed record RuleResult +{ + public required string RuleId { get; init; } + public required string RuleTitle { get; init; } + public required RuleOutcome Outcome { get; init; } + public IReadOnlyList Findings { get; init; } = []; + + /// Capabilities the instance lacks, when the outcome is for that reason. + public IReadOnlyList MissingCapabilities { get; init; } = []; + + /// Why the rule was not measurable or failed. + public string? Reason { get; init; } +} diff --git a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs new file mode 100644 index 0000000..f7f4426 --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleCatalog.cs @@ -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); + +/// +/// Microsoft's support dates per SQL Server release. Shipped as data so it can be updated with the rule pack. +/// +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 _entries; + + public SupportLifecycleCatalog(IEnumerable 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(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 Releases); +} diff --git a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs new file mode 100644 index 0000000..9de9908 --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs @@ -0,0 +1,96 @@ +using System.Globalization; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Lifecycle; + +/// +/// 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. +/// +public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRule +{ + public const string RuleId = "SQLI-VER-001"; + + /// How long before end of extended support the finding escalates to High. + 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 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 + { + 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); +} diff --git a/src/SQLInsight.Rules/Lifecycle/support-lifecycle.json b/src/SQLInsight.Rules/Lifecycle/support-lifecycle.json new file mode 100644 index 0000000..a39809f --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/support-lifecycle.json @@ -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 } + ] +} diff --git a/src/SQLInsight.Rules/SQLInsight.Rules.csproj b/src/SQLInsight.Rules/SQLInsight.Rules.csproj new file mode 100644 index 0000000..3979a7d --- /dev/null +++ b/src/SQLInsight.Rules/SQLInsight.Rules.csproj @@ -0,0 +1,11 @@ + + + SQLInsight.Rules + + + + + + + + diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs new file mode 100644 index 0000000..5d37520 --- /dev/null +++ b/src/SQLInsight.Web/Program.cs @@ -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(); diff --git a/src/SQLInsight.Web/Properties/launchSettings.json b/src/SQLInsight.Web/Properties/launchSettings.json new file mode 100644 index 0000000..9138b24 --- /dev/null +++ b/src/SQLInsight.Web/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/SQLInsight.Web/SQLInsight.Web.csproj b/src/SQLInsight.Web/SQLInsight.Web.csproj new file mode 100644 index 0000000..71968bf --- /dev/null +++ b/src/SQLInsight.Web/SQLInsight.Web.csproj @@ -0,0 +1,8 @@ + + + SQLInsight.Web + + + + + diff --git a/src/SQLInsight.Web/appsettings.Development.json b/src/SQLInsight.Web/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/SQLInsight.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/SQLInsight.Web/appsettings.json b/src/SQLInsight.Web/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/SQLInsight.Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs b/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs new file mode 100644 index 0000000..bfc434b --- /dev/null +++ b/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs @@ -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(() => ConnectionStrings.ForSqlClient(target)); + } +} diff --git a/tests/SQLInsight.Connectivity.Tests/LegacyFailureClassifierTests.cs b/tests/SQLInsight.Connectivity.Tests/LegacyFailureClassifierTests.cs new file mode 100644 index 0000000..0651358 --- /dev/null +++ b/tests/SQLInsight.Connectivity.Tests/LegacyFailureClassifierTests.cs @@ -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))); + } +} diff --git a/tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj b/tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj new file mode 100644 index 0000000..e5cd02a --- /dev/null +++ b/tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/SQLInsight.Connectivity.Tests/SqlConnectionFactoryTests.cs b/tests/SQLInsight.Connectivity.Tests/SqlConnectionFactoryTests.cs new file mode 100644 index 0000000..d864133 --- /dev/null +++ b/tests/SQLInsight.Connectivity.Tests/SqlConnectionFactoryTests.cs @@ -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(() => 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( + () => 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(() => 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 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 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(); + } + } +} diff --git a/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs b/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs new file mode 100644 index 0000000..9751ddf --- /dev/null +++ b/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs @@ -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); + } +} diff --git a/tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj b/tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj new file mode 100644 index 0000000..923a479 --- /dev/null +++ b/tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/SQLInsight.Core.Tests/SqlServerVersionTests.cs b/tests/SQLInsight.Core.Tests/SqlServerVersionTests.cs new file mode 100644 index 0000000..6c59603 --- /dev/null +++ b/tests/SQLInsight.Core.Tests/SqlServerVersionTests.cs @@ -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")); + } +} diff --git a/tests/SQLInsight.IntegrationTests/InstanceProbeTests.cs b/tests/SQLInsight.IntegrationTests/InstanceProbeTests.cs new file mode 100644 index 0000000..2b6699a --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/InstanceProbeTests.cs @@ -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); + } +} diff --git a/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs b/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs new file mode 100644 index 0000000..f10f568 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/LeastPrivilegeTests.cs @@ -0,0 +1,107 @@ +using Microsoft.Data.SqlClient; + +namespace SQLInsight.IntegrationTests; + +/// +/// Runs db/permissions/grant-least-privilege.sql and proves that the agent login can read everything it needs, +/// and nothing else. +/// +[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("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(query) >= 0); + } + + [Fact] + public async Task Agent_cannot_read_customer_data() + { + var ex = await Assert.ThrowsAsync( + () => ScalarAsync($"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( + () => ScalarAsync($"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 ScalarAsync(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); + } +} diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj new file mode 100644 index 0000000..a885e5b --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs b/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs new file mode 100644 index 0000000..5510a0d --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/SqlServerFixture.cs @@ -0,0 +1,48 @@ +using Microsoft.Data.SqlClient; +using Testcontainers.MsSql; + +namespace SQLInsight.IntegrationTests; + +/// +/// One SQL Server container for the test run. The image is chosen with SQLINSIGHT_MSSQL_IMAGE so CI can run +/// the same tests against 2017, 2019, 2022 and 2025. +/// +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 +{ + public const string Name = "SQL Server"; +} diff --git a/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs b/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs new file mode 100644 index 0000000..a909351 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/RuleEngineTests.cs @@ -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 required, Func evaluate) : IRule + { + public int Calls { get; private set; } + public string Id => id; + public string Title => $"Stub {id}"; + public IReadOnlyCollection 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(() => new RuleEngine([a, b])); + } + + [Fact] + public void Found_requires_at_least_one_finding() + { + Assert.Throws(() => 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"))); + } +} diff --git a/tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj b/tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj new file mode 100644 index 0000000..7a4f433 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs b/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs new file mode 100644 index 0000000..40a4dcc --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs @@ -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().Where(r => r != SqlServerRelease.Unknown)) + { + Assert.NotNull(catalog.Find(release)); + } + } +} diff --git a/tests/SQLInsight.Rules.Tests/TestInstances.cs b/tests/SQLInsight.Rules.Tests/TestInstances.cs new file mode 100644 index 0000000..299a518 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/TestInstances.cs @@ -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)); + } +}