From dda44ce003901776385cd831517c80993a78bf0a Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 8 Dec 2024 20:04:58 +0100 Subject: [PATCH 1/5] Initial --- AppsAnalysis/.csharpierignore | 1 + AppsAnalysis/.editorconfig | 28 ++ AppsAnalysis/AppsAnalysis.sln | 34 ++ AppsAnalysis/Directory.Build.props | 32 ++ AppsAnalysis/README.md | 45 +++ AppsAnalysis/config.sample.ini | 6 + AppsAnalysis/img/access-token-config.png | Bin 0 -> 44158 bytes AppsAnalysis/img/analysis.png | Bin 0 -> 22733 bytes AppsAnalysis/img/download-progress.png | Bin 0 -> 48215 bytes .../Altinn.Analysis.Cli.csproj | 25 ++ .../src/Altinn.Analysis.Cli/Program.cs | 136 +++++++ .../Properties/launchSettings.json | 18 + .../Altinn.Analysis/Altinn.Analysis.csproj | 15 + .../src/Altinn.Analysis/AppsAnalyzer.cs | 346 +++++++++++++++++ .../src/Altinn.Analysis/AppsFetcher.cs | 352 ++++++++++++++++++ AppsAnalysis/src/Altinn.Analysis/Constants.cs | 11 + .../src/Altinn.Analysis/GiteaClient.cs | 278 ++++++++++++++ .../src/Altinn.Analysis/ValueColumn.cs | 16 + 18 files changed, 1343 insertions(+) create mode 100644 AppsAnalysis/.csharpierignore create mode 100644 AppsAnalysis/.editorconfig create mode 100644 AppsAnalysis/AppsAnalysis.sln create mode 100644 AppsAnalysis/Directory.Build.props create mode 100644 AppsAnalysis/README.md create mode 100644 AppsAnalysis/config.sample.ini create mode 100644 AppsAnalysis/img/access-token-config.png create mode 100644 AppsAnalysis/img/analysis.png create mode 100644 AppsAnalysis/img/download-progress.png create mode 100644 AppsAnalysis/src/Altinn.Analysis.Cli/Altinn.Analysis.Cli.csproj create mode 100644 AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs create mode 100644 AppsAnalysis/src/Altinn.Analysis.Cli/Properties/launchSettings.json create mode 100644 AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj create mode 100644 AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs create mode 100644 AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs create mode 100644 AppsAnalysis/src/Altinn.Analysis/Constants.cs create mode 100644 AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs create mode 100644 AppsAnalysis/src/Altinn.Analysis/ValueColumn.cs diff --git a/AppsAnalysis/.csharpierignore b/AppsAnalysis/.csharpierignore new file mode 100644 index 00000000..7bd4dc47 --- /dev/null +++ b/AppsAnalysis/.csharpierignore @@ -0,0 +1 @@ +**/repos/ \ No newline at end of file diff --git a/AppsAnalysis/.editorconfig b/AppsAnalysis/.editorconfig new file mode 100644 index 00000000..748dce03 --- /dev/null +++ b/AppsAnalysis/.editorconfig @@ -0,0 +1,28 @@ +[*.cs] + +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = suggestion + +# S125: Sections of code should not be commented out +dotnet_diagnostic.S125.severity = suggestion + +# S112: General exceptions should never be thrown +dotnet_diagnostic.S112.severity = suggestion + +# CA2201: Exception type is not sufficiently specific +dotnet_diagnostic.CA2201.severity = suggestion + +# S1135: Track uses of "TODO" tags +dotnet_diagnostic.S1135.severity = suggestion + +# S4487: Unread private class field +dotnet_diagnostic.S4487.severity = suggestion + +# S1481: Unused local variables should be removed +dotnet_diagnostic.S1481.severity = suggestion + +# CA1050: Declare types in namespaces +dotnet_diagnostic.CA1050.severity = suggestion + +# S3903: Types should be defined in named namespaces +dotnet_diagnostic.S3903.severity = suggestion diff --git a/AppsAnalysis/AppsAnalysis.sln b/AppsAnalysis/AppsAnalysis.sln new file mode 100644 index 00000000..6dfd1843 --- /dev/null +++ b/AppsAnalysis/AppsAnalysis.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C17303EC-5744-4990-806D-9FAAD4040DE9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.Analysis", "src\Altinn.Analysis\Altinn.Analysis.csproj", "{108D9742-E8FE-4DA4-B8B9-E6CC849DD3ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Altinn.Analysis.Cli", "src\Altinn.Analysis.Cli\Altinn.Analysis.Cli.csproj", "{54883DAB-F56B-4005-871E-4BADA984405C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {108D9742-E8FE-4DA4-B8B9-E6CC849DD3ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {108D9742-E8FE-4DA4-B8B9-E6CC849DD3ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {108D9742-E8FE-4DA4-B8B9-E6CC849DD3ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {108D9742-E8FE-4DA4-B8B9-E6CC849DD3ED}.Release|Any CPU.Build.0 = Release|Any CPU + {54883DAB-F56B-4005-871E-4BADA984405C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54883DAB-F56B-4005-871E-4BADA984405C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54883DAB-F56B-4005-871E-4BADA984405C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54883DAB-F56B-4005-871E-4BADA984405C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {108D9742-E8FE-4DA4-B8B9-E6CC849DD3ED} = {C17303EC-5744-4990-806D-9FAAD4040DE9} + {54883DAB-F56B-4005-871E-4BADA984405C} = {C17303EC-5744-4990-806D-9FAAD4040DE9} + EndGlobalSection +EndGlobal diff --git a/AppsAnalysis/Directory.Build.props b/AppsAnalysis/Directory.Build.props new file mode 100644 index 00000000..61f63877 --- /dev/null +++ b/AppsAnalysis/Directory.Build.props @@ -0,0 +1,32 @@ + + + enable + enable + latest + true + Recommended + strict + true + true + false + NU1901;NU1902;NU1903;NU1904 + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/AppsAnalysis/README.md b/AppsAnalysis/README.md new file mode 100644 index 00000000..25e336d6 --- /dev/null +++ b/AppsAnalysis/README.md @@ -0,0 +1,45 @@ +# Apps analysis + +Meant to gather statistics on code from all apps in Altinn 3. + +## Prereqs + +A config file at `config.ini` +```sh +cp config.sample.ini config.ini +``` +Make the appropriate changes. +The password needs to be a token from Altinn Studio with `Read` permissions for `organization` and `repository` scope. +See below: +![Access token config](img/access-token-config.png) + +Example +```ini +username=martinothamar +password=8e************************ +directory=C:\code\digdir\altinn\analysis +clear_directory=true +``` + +This would target Altinn Studio prod by default. + +## Run + +Go into CLI directory: +```sh +cd src/Altinn.Analysis.Cli +``` + +Fetch +```sh +dotnet run -c Release -- fetch -c ../../config.ini +``` + +![Downloading apps](img/download-progress.png) + +Analyze +```sh +dotnet run -c Release -- analyze -c ../../config.ini +``` + +![Analyzing apps](img/analysis.png) diff --git a/AppsAnalysis/config.sample.ini b/AppsAnalysis/config.sample.ini new file mode 100644 index 00000000..a4e49f6e --- /dev/null +++ b/AppsAnalysis/config.sample.ini @@ -0,0 +1,6 @@ +directory= +altinn_url= +username= +password= +clear_directory= +max_parallelism= diff --git a/AppsAnalysis/img/access-token-config.png b/AppsAnalysis/img/access-token-config.png new file mode 100644 index 0000000000000000000000000000000000000000..c46b4513f9408efd4fa4b1a4475b5330298b8fd6 GIT binary patch literal 44158 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 literal 0 HcmV?d00001 diff --git a/AppsAnalysis/img/analysis.png b/AppsAnalysis/img/analysis.png new file mode 100644 index 0000000000000000000000000000000000000000..af9061d10b1ff669a0c77922f6b283f5756756ac GIT binary patch literal 22733 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 + + + Exe + net8.0 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + diff --git a/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs b/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs new file mode 100644 index 00000000..11280af1 --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using Altinn.Analysis; +using ConsoleAppFramework; + +var app = ConsoleApp.Create(); +app.Add(); +await app.RunAsync(args); + +public class Commands +{ +#pragma warning disable CA1822 // Mark members as static + + /// Fetch Altinn apps into a local directory + /// -u, username for Altinn Studio. + /// -p, password for Altinn Studio. + /// -d, local directory to place fetched apps. + /// -cd, clear directory before fetching. + /// -mp, make parallelism for running analysis (including fetching data). + /// -au, URL for Altinn Studio APIs. + /// -c, Config file for this CLI. + [Command("fetch")] + public async Task Fetch( + string? username = null, + string? password = null, + string? directory = null, + bool? clearDirectory = null, + int? maxParallelism = null, + string? altinnUrl = null, + string? configFile = null, + CancellationToken cancellationToken = default + ) + { + var config = await TryReadConfigFile(configFile, cancellationToken); + var analyzer = new AppsFetcher( + new( + directory ?? config.Directory ?? "repos/", + username ?? config.Username ?? throw new Exception("Need username config to fetch"), + password ?? config.Password ?? throw new Exception("Need password config to fetch"), + maxParallelism ?? config.MaxParallelism ?? Environment.ProcessorCount, + clearDirectory ?? config.ClearDirectory ?? false, + altinnUrl ?? config.AltinnUrl ?? "https://altinn.studio" + ) + ); + await analyzer.Fetch(cancellationToken); + } + + /// Analyze Altinn apps in a directory + /// -d, local directory to place fetched apps. + /// -mp, make parallelism for running analysis (including fetching data). + /// -c, Config file for this CLI. + [Command("analyze")] + public async Task Analyze( + string? directory = null, + int? maxParallelism = null, + string? configFile = null, + CancellationToken cancellationToken = default + ) + { + var config = await TryReadConfigFile(configFile, cancellationToken); + var analyzer = new AppsAnalyzer( + new( + directory ?? config.Directory ?? "repos/", + maxParallelism ?? config.MaxParallelism ?? Environment.ProcessorCount + ) + ); + await analyzer.Analyze(cancellationToken); + } + + private static async Task TryReadConfigFile( + string? configFile, + CancellationToken cancellationToken + ) + { + var result = new ConfigFile(); + + if (string.IsNullOrWhiteSpace(configFile)) + return result; + + if (!File.Exists(configFile)) + throw new Exception($"Could not find config file at: '{configFile}'"); + + var lines = await File.ReadAllLinesAsync(configFile, cancellationToken); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + if (line.Split('=') is not [var key, var value]) + throw new Exception($"Invalid config at line: {line}"); + + switch (key) + { + case "directory": + result = result with { Directory = value }; + break; + case "username": + result = result with { Username = value }; + break; + case "password": + result = result with { Password = value }; + break; + case "clear_directory": + if (!bool.TryParse(value, out var clearDirectory)) + throw new Exception( + $"Unexpected value for 'clear_directory': '{value}' - expected boolean" + ); + result = result with { ClearDirectory = clearDirectory }; + break; + case "max_parallelism": + if (!int.TryParse(value, CultureInfo.InvariantCulture, out var maxParallelism)) + throw new Exception( + $"Unexpected value for 'max_parallelism': '{value}' - expected integer" + ); + result = result with { MaxParallelism = maxParallelism }; + break; + case "altinn_url": + result = result with { AltinnUrl = value }; + break; + default: + throw new Exception($"Unexpected key in config: '{key}'"); + } + } + + return result; + } + + private readonly record struct ConfigFile + { + public string? Directory { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public bool? ClearDirectory { get; init; } + public int? MaxParallelism { get; init; } + public string? AltinnUrl { get; init; } + } +} diff --git a/AppsAnalysis/src/Altinn.Analysis.Cli/Properties/launchSettings.json b/AppsAnalysis/src/Altinn.Analysis.Cli/Properties/launchSettings.json new file mode 100644 index 00000000..b04b06e0 --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis.Cli/Properties/launchSettings.json @@ -0,0 +1,18 @@ +{ + "profiles": { + "Fetch": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "commandLineArgs": "fetch -c $(MSBuildProjectDirectory)/../../config.ini" + }, + "Analyze": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "commandLineArgs": "analyze -c $(MSBuildProjectDirectory)/../../config.ini" + }, + } +} \ No newline at end of file diff --git a/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj b/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj new file mode 100644 index 00000000..47a33dcf --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs b/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs new file mode 100644 index 00000000..2f0d63bd --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs @@ -0,0 +1,346 @@ +using System.Collections.Concurrent; +using System.Collections.Specialized; +using System.Diagnostics; +using Buildalyzer; +using Spectre.Console; + +namespace Altinn.Analysis; + +public sealed record AnalysisConfig(string Directory, int MaxParallelism); + +public sealed class AppsAnalyzer +{ + private readonly AnalysisConfig _config; + + private DirectoryInfo? _directory; + private int _parallelism; + + public AppsAnalyzer(AnalysisConfig config) + { + _config = config; + } + + private bool VerifyConfig() + { + var table = new Table(); + table.Border(TableBorder.None); + table.AddColumn(new TableColumn("")); + table.AddColumn(new TableColumn("")); + + _parallelism = Math.Min(Constants.LimitMaxParallelism, _config.MaxParallelism); + var directory = Path.GetFullPath( + _config.Directory.Replace( + "~", + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ) + ); + table.AddRow( + new Markup(nameof(FetchConfig.Directory)), + new Markup($"= [bold]{directory}[/]") + ); + table.AddRow(new Markup("Parallelism"), new Markup($"= [bold]{_parallelism}[/]")); + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + if (!Debugger.IsAttached) + { + var proceed = AnsiConsole.Prompt(new ConfirmationPrompt($"Continue?")); + return proceed; + } + + return true; + } + + public async Task Analyze(CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine("[blue]Analyzing Altinn apps[/]... Config:"); + if (!VerifyConfig()) + return; + + var directory = Path.GetFullPath( + _config.Directory.Replace( + "~", + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ) + ); + _directory = new DirectoryInfo(directory); + if (!_directory.Exists) + { + AnsiConsole.MarkupLine( + $"Directory for downloaded apps [red]doesn't exist[/]: [bold]{directory}[/]" + ); + return; + } + + var repos = GetRepos(cancellationToken); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule($"Analysing [blue]{repos.Count}[/] apps").LeftJustified()); + + RepoAnalysis[]? results = null; + await AnsiConsole + .Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new ElapsedTimeColumn(), + new SpinnerColumn() + ) + .HideCompleted(true) + .AutoClear(true) + .StartAsync(async ctx => + { + results = await Task.Run( + () => AnalyzeRepos(ctx, repos, cancellationToken), + cancellationToken + ); + }); + + Debug.Assert(results is not null); + AnsiConsole.MarkupLine($"[green]Successfully[/] analyzed [blue]{results.Length}[/] apps"); + + // Ample opportunity for SIMD belowow... + var invalidProjects = results.Count(r => r.ValidProject is false); + var failedBuilds = results.Count(r => r.Builds is false); + var noAppLib = results.Count(r => r.HasAppLib is false); + var oldAppLib = results.Count(r => r.HasLatestAppLib is false); + var aOkay = results.Count(r => r.OK); + + AnsiConsole.WriteLine(); + AnsiConsole.Write( + new BarChart() + .Width(60) + .Label("[green bold underline]Analysis[/]") + .LeftAlignLabel() + .AddItem("Invalid project", invalidProjects, Color.Red) + .AddItem("Failed builds", failedBuilds, Color.Red) + .AddItem("Missing Altinn.App.Core", noAppLib, Color.Red) + .AddItem("Old Altinn.App.Core", oldAppLib, Color.Yellow) + .AddItem("On Altinn.App.Core v8", aOkay, Color.Green) + ); + } + + private List GetRepos(CancellationToken cancellationToken) + { + Debug.Assert(_directory is not null); + + var result = new List(64); + var orgsCounter = 0; + foreach (var orgDir in Directory.EnumerateDirectories(_directory.FullName)) + { + var org = orgDir.Substring(orgDir.LastIndexOf(Path.DirectorySeparatorChar) + 1); + cancellationToken.ThrowIfCancellationRequested(); + + var reposCounter = 0; + foreach (var repoDir in Directory.EnumerateDirectories(orgDir)) + { + var name = repoDir.Substring(repoDir.LastIndexOf(Path.DirectorySeparatorChar) + 1); + var repoDirInfo = new DirectoryInfo( + Path.Combine(repoDir, Constants.MainBranchFolder) + ); + if (!repoDirInfo.Exists) + { + AnsiConsole.MarkupLine( + $"Couldn't find main branch folder for repo: '{repoDirInfo.Name}'" + ); + continue; + } + result.Add(new Repo(repoDirInfo, org, name)); + reposCounter++; + + if (reposCounter == Constants.LimitRepos) + break; + } + + orgsCounter++; + + if (orgsCounter == Constants.LimitOrgs) + break; + } + + return result; + } + + private RepoAnalysis[] AnalyzeRepos( + ProgressContext ctx, + List repos, + CancellationToken cancellationToken + ) + { + var options = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = _parallelism, + }; + + var results = new ConcurrentBag(); + + Parallel.ForEach( + repos, + options, + repo => + { + var (dir, org, name) = repo; + var task = ctx.AddTask($"[green]{org}/{name}[/]", autoStart: false, maxValue: 1); + task.IsIndeterminate = true; + task.StartTask(); + + var manager = new AnalyzerManager(); + var projectFile = Path.Combine(dir.FullName, "App", "App.csproj"); + + void Exit(in RepoAnalysis result) + { + results.Add(result); + task.Increment(1.0); + task.StopTask(); + } + + if (!File.Exists(projectFile)) + { + Exit(new RepoAnalysis(repo, validProject: false)); + return; + } + + const string Tfm = "net8.0"; + IAnalyzerResults buildResults; + try + { + var project = manager.GetProject(projectFile); + cancellationToken.ThrowIfCancellationRequested(); + + buildResults = project.Build(Tfm); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Exit(new RepoAnalysis(repo, validProject: false)); + return; + } + + if (!buildResults.TryGetTargetFramework(Tfm, out var result)) + { + Exit(new RepoAnalysis(repo, validProject: false)); + return; + } + + if (!result.Succeeded) + { + Exit(new RepoAnalysis(repo, validProject: true, builds: false)); + return; + } + + var appCoreRef = result.PackageReferences.FirstOrDefault(r => + r.Key == "Altinn.App.Core" + ); + if (appCoreRef.Key is null) + { + Exit( + new RepoAnalysis(repo, validProject: true, builds: true, hasAppLib: false) + ); + return; + } + + if (!appCoreRef.Value.TryGetValue("Version", out var version)) + { + Exit( + new RepoAnalysis(repo, validProject: true, builds: true, hasAppLib: false) + ); + return; + } + + if (!version.StartsWith('8')) + { + Exit( + new RepoAnalysis( + repo, + validProject: true, + builds: true, + hasAppLib: true, + HasLatestAppLib: false + ) + ); + return; + } + + // AnsiConsole.MarkupLine($"Analyzing project: '{org}/{name}'"); + Exit( + new RepoAnalysis( + repo, + validProject: true, + builds: true, + hasAppLib: true, + HasLatestAppLib: true + ) + ); + } + ); + + return results.ToArray(); + } + + private sealed record Repo(DirectoryInfo Dir, string Org, string Name); + + // TODO: Consider SoA if this approach works OK + private readonly record struct RepoAnalysis + { + private static readonly BitVector32.Section _validProject = BitVector32.CreateSection(2); + private static readonly BitVector32.Section _builds = BitVector32.CreateSection( + 2, + _validProject + ); + private static readonly BitVector32.Section _hasAppLib = BitVector32.CreateSection( + 2, + _builds + ); + private static readonly BitVector32.Section _hasLatestAppLib = BitVector32.CreateSection( + 2, + _hasAppLib + ); + + private readonly BitVector32 _bits; + + public RepoAnalysis( + Repo repo, + bool? validProject = null, + bool? builds = null, + bool? hasAppLib = null, + bool? HasLatestAppLib = null + ) + { + Repo = repo; + _bits = new BitVector32(0); + _bits[_validProject] = ToValue(validProject); + _bits[_builds] = ToValue(builds); + _bits[_hasAppLib] = ToValue(hasAppLib); + _bits[_hasLatestAppLib] = ToValue(HasLatestAppLib); + } + + public readonly Repo Repo; + + public readonly bool? ValidProject => FromValue(_validProject); + public readonly bool? Builds => FromValue(_builds); + public readonly bool? HasAppLib => FromValue(_hasAppLib); + public readonly bool? HasLatestAppLib => FromValue(_hasLatestAppLib); + + public bool OK => + (_bits[_validProject] & _bits[_builds] & _bits[_hasAppLib] & _bits[_hasLatestAppLib]) + == 1; + + private static int ToValue(bool? value) => + value switch + { + null => 2, + false => 0, + true => 1, + }; + + private bool? FromValue(BitVector32.Section section) => + _bits[section] switch + { + 2 => default(bool?), + 0 => false, + 1 => true, + var u => throw new Exception($"Unexpected value: {u}"), + }; + } +} diff --git a/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs b/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs new file mode 100644 index 00000000..647b2f2c --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs @@ -0,0 +1,352 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Threading.Channels; +using Spectre.Console; + +namespace Altinn.Analysis; + +public sealed record FetchConfig( + string Directory, + string Username, + string Password, + int MaxParallelism, + bool ClearDirectory, + string AltinnUrl +); + +public sealed class AppsFetcher +{ + private readonly FetchConfig _config; + private readonly GiteaClient _giteaClient; + + private DirectoryInfo? _directory; + private int _parallelism; + + public AppsFetcher(FetchConfig config) + { + _config = config; + _giteaClient = new GiteaClient(config); + } + + private bool VerifyConfig() + { + var table = new Table(); + table.Border(TableBorder.None); + table.AddColumn(new TableColumn("")); + table.AddColumn(new TableColumn("")); + + _parallelism = Math.Min(Constants.LimitMaxParallelism, _config.MaxParallelism); + var clearDirectory = _config.ClearDirectory; + var clearDirectoryConf = clearDirectory + .ToString(CultureInfo.InvariantCulture) + .ToLowerInvariant(); + var directory = Path.GetFullPath( + _config.Directory.Replace( + "~", + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ) + ); + table.AddRow( + new Markup(nameof(FetchConfig.Directory)), + new Markup($"= [bold]{directory}[/]") + ); + table.AddRow( + new Markup(nameof(FetchConfig.Username)), + new Markup($"= [bold]{_config.Username}[/]") + ); + table.AddRow( + new Markup(nameof(FetchConfig.Password)), + new Markup($"= [bold]{_config.Password[..2]}**********[/]") + ); + table.AddRow( + new Markup(nameof(FetchConfig.ClearDirectory)), + new Markup($"= [bold]{clearDirectoryConf}[/]") + ); + table.AddRow(new Markup("Parallelism"), new Markup($"= [bold]{_parallelism}[/]")); + table.AddRow( + new Markup(nameof(FetchConfig.AltinnUrl)), + new Markup($"= [bold]{_config.AltinnUrl}[/]") + ); + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + if (!Debugger.IsAttached) + { + var proceed = AnsiConsole.Prompt(new ConfirmationPrompt($"Continue?")); + return proceed; + } + + return true; + } + + private void PrepareDirectory() + { + var directory = Path.GetFullPath( + _config.Directory.Replace( + "~", + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + ) + ); + _directory = new DirectoryInfo(directory); + + if (_config.ClearDirectory && _directory.Exists) + { + AnsiConsole.MarkupLine("Clearing directory..."); + _directory.Delete(recursive: true); + } + + if (!_directory.Exists) + _directory = Directory.CreateDirectory(directory); + } + + public async Task Fetch(CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine("[blue]Fetching Altinn apps[/]... Config:"); + if (!VerifyConfig()) + return; + + PrepareDirectory(); + + var apps = await GetApps(cancellationToken); + + AnsiConsole.WriteLine(); + if (!Debugger.IsAttached) + { + var proceed = AnsiConsole.Prompt( + new ConfirmationPrompt($"Continue to download apps locally?") + ); + if (!proceed) + return; + } + + await DownloadApps(apps, cancellationToken); + } + + private async Task GetApps(CancellationToken cancellationToken) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("Finding organizations and apps").LeftJustified()); + GetAppsResult? result = null; + await AnsiConsole + .Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new ValueProgressColumn(), + new ElapsedTimeColumn(), + new SpinnerColumn() + ) + .HideCompleted(true) + .AutoClear(true) + .StartAsync(async ctx => + { + result = await GetApps(ctx, cancellationToken); + }); + Debug.Assert(result is not null); + var (orgs, repos, _) = result; + AnsiConsole.MarkupLine( + $"Fetched organizations and apps from Altinn Studio - [green]{orgs.Count} organizations[/], [blue]{repos.Count} apps[/]" + ); + return result; + } + + private async Task GetApps( + ProgressContext ctx, + CancellationToken cancellationToken + ) + { + AnsiConsole.MarkupLine($"Fetching organizations and apps from Altinn Studio Gitea"); + + ConcurrentDictionary> reposByOrg = new(); + + var options = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = _parallelism, + }; + await Parallel.ForEachAsync( + _giteaClient.GetOrgs(cancellationToken), + options, + async (org, canncellationToken) => + { + var task = ctx.AddTask($"[green]{org.FullName} ({org.Name})[/]", autoStart: false); + + task.IsIndeterminate = true; + task.StartTask(); + + var repos = reposByOrg.GetOrAdd(org, _ => new List(4)); + + await foreach (var repo in _giteaClient.GetRepos(org.Name, cancellationToken)) + { + repos.Add(repo); + var maxValue = task.MaxValue; + if (repos.Count >= maxValue) + task.MaxValue = maxValue + 25; + task.Increment(1); + } + task.MaxValue = repos.Count; + + ctx.Refresh(); + task.StopTask(); + } + ); + + return new GetAppsResult( + reposByOrg.Keys.ToArray(), + reposByOrg.SelectMany(kvp => kvp.Value).ToArray(), + reposByOrg.ToDictionary(kvp => kvp.Key, kvp => (IReadOnlyList)kvp.Value) + ); + } + + private async Task DownloadApps( + GetAppsResult apps, + CancellationToken cancellationToken + ) + { + var (orgs, repos, _) = apps; + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule($"Downloading apps").LeftJustified()); + + DownloadAppsResult? result = null; + await AnsiConsole + .Progress() + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new ValueProgressColumn(), + new PercentageColumn(), + new ElapsedTimeColumn(), + new SpinnerColumn() + ) + .HideCompleted(true) + .AutoClear(true) + .StartAsync(async ctx => + { + result = await DownloadApps(ctx, apps, cancellationToken); + }); + + Debug.Assert(result is not null); + + AnsiConsole.MarkupLine( + $"[green]Successfully[/] fetched [blue]{result.Repos.Count}[/] apps" + ); + + return result; + } + + private async Task DownloadApps( + ProgressContext ctx, + GetAppsResult basicInformation, + CancellationToken cancellationToken + ) + { + Debug.Assert(_directory is not null); + + var queue = Channel.CreateBounded<( + GiteaOrg Org, + GiteaRepo Repo, + ProgressTask Task, + DirectoryInfo OrgDirectory + )>( + new BoundedChannelOptions(_parallelism * 2) + { + SingleWriter = true, + SingleReader = false, + } + ); + + var results = new ConcurrentBag(); + + var processors = new Task[_parallelism]; + for (int i = 0; i < _parallelism; i++) + { + processors[i] = Task.Run( + async () => + { + var reader = queue.Reader; + while (await reader.WaitToReadAsync(cancellationToken)) + { + while (reader.TryRead(out var entry)) + { + var (org, repo, task, orgDirectory) = entry; + var branch = repo.DefaultBranch; + if (string.IsNullOrWhiteSpace(branch)) + throw new Exception( + $"Default branch not set for repo: '{repo.FullName}'" + ); + + var repoDirectory = orgDirectory.CreateSubdirectory(repo.Name); + var branchDirectory = repoDirectory.CreateSubdirectory( + Constants.MainBranchFolder + ); + await _giteaClient.DownloadRepoArchive( + org, + repo, + branchDirectory, + branch, + cancellationToken: cancellationToken + ); + + results.Add(new(org, repo, branch, branchDirectory)); + + task.Increment(1); + + if (task.IsFinished) + task.StopTask(); + } + } + }, + cancellationToken + ); + } + + var options = new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = _parallelism, + }; + await Parallel.ForEachAsync( + basicInformation.Orgs, + options, + async (org, cancellationToken) => + { + var repos = basicInformation.ReposByOrg[org]; + var task = ctx.AddTask( + $"[green]{org.FullName} ({org.Name})[/]", + autoStart: true, + maxValue: repos.Count + ); + var orgDirectory = _directory.CreateSubdirectory(org.Name); + foreach (var repo in repos) + { + await queue.Writer.WriteAsync( + (org, repo, task, orgDirectory), + cancellationToken + ); + } + } + ); + + queue.Writer.Complete(); + + await Task.WhenAll(processors); + + return new DownloadAppsResult(results.ToArray()); + } + + private sealed record GetAppsResult( + IReadOnlyList Orgs, + IReadOnlyList Repos, + IReadOnlyDictionary> ReposByOrg + ); + + private sealed record DownloadAppsResult(IReadOnlyList Repos); + + private sealed record RepoInfo( + GiteaOrg Org, + GiteaRepo Repo, + string Branch, + DirectoryInfo MainDir + ); +} diff --git a/AppsAnalysis/src/Altinn.Analysis/Constants.cs b/AppsAnalysis/src/Altinn.Analysis/Constants.cs new file mode 100644 index 00000000..95417d48 --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/Constants.cs @@ -0,0 +1,11 @@ +namespace Altinn.Analysis; + +internal static class Constants +{ + internal const string MainBranchFolder = "main"; + + // Following settings are to tweak during test-runs + internal static readonly int LimitOrgs = 10; + internal static readonly int LimitRepos = 10; + internal static readonly int LimitMaxParallelism = 6; +} diff --git a/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs b/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs new file mode 100644 index 00000000..876605b7 --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs @@ -0,0 +1,278 @@ +using System.Globalization; +using System.IO.Compression; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; +using LibGit2Sharp; + +namespace Altinn.Analysis; + +public sealed record GiteaOrg( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("avatar_url")] string? AvatarUrl, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("full_name")] string? FullName, + [property: JsonPropertyName("location")] string? Location, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("visibility")] string? Visibility +) +{ + public bool Equals(GiteaOrg? other) => Id == other?.Id; + + public override int GetHashCode() => Id.GetHashCode(); +} + +public sealed record GiteaRepo( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("clone_url")] string CloneUrl, + [property: JsonPropertyName("ssh_url")] string? SshUrl, + [property: JsonPropertyName("url")] string? Url, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("full_name")] string FullName, + [property: JsonPropertyName("default_branch")] string? DefaultBranch, + [property: JsonPropertyName("link")] string? Link, + [property: JsonPropertyName("private")] bool? Private +) +{ + private string[]? _split; + + [JsonIgnore] + public string Org + { + get + { + _split ??= FullName.Split('/'); + return _split[0]; + } + } + + public bool Equals(GiteaOrg? other) => Id == other?.Id; + + public override int GetHashCode() => Id.GetHashCode(); +} + +internal sealed record GiteaArchiveStatus([property: JsonPropertyName("complete")] bool Complete); + +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(GiteaOrg))] +[JsonSerializable(typeof(GiteaRepo))] +[JsonSerializable(typeof(GiteaArchiveStatus))] +internal sealed partial class GiteaClientJsonContext : JsonSerializerContext { } + +public sealed class GiteaClient +{ + private const int PageSize = 50; + private const string PageSizeStr = "50"; + + private readonly HttpClient _httpClient; + private readonly FetchConfig _config; + + public GiteaClient(FetchConfig config) + { + _config = config; + HttpClient httpClient = new( + new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(15), // Recreate every 15 minutes + } + ); + httpClient.BaseAddress = new Uri(config.AltinnUrl); + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + httpClient.DefaultRequestHeaders.Add("Authorization", $"token {config.Password}"); + _httpClient = httpClient; + } + + public async IAsyncEnumerable GetOrgs( + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + var page = 1; + while (true) + { + var pageParam = page.ToString(CultureInfo.InvariantCulture); + var url = $"/repos/api/v1/orgs?page={pageParam}&limit={PageSizeStr}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + var enumerable = JsonSerializer.DeserializeAsyncEnumerable( + stream, + GiteaClientJsonContext.Default.GiteaOrg, + cancellationToken: cancellationToken + ); + + var counter = 0; + await foreach (var org in enumerable) + { + if (org is null) + throw new JsonException("Got 'null' org in response"); + if (string.IsNullOrWhiteSpace(org.Name)) + throw new JsonException($"Got invalid name for org with ID: {org.Id}"); + + yield return org; + counter++; + + if (counter == Constants.LimitOrgs) + break; + } + + if (counter < PageSize) + { + break; + } + + page++; + } + } + + public async IAsyncEnumerable GetRepos( + string org, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + var page = 1; + while (true) + { + var pageParam = page.ToString(CultureInfo.InvariantCulture); + var orgParam = HttpUtility.UrlEncode(org); + var url = $"/repos/api/v1/orgs/{orgParam}/repos?page={pageParam}&limit={PageSizeStr}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + var enumerable = JsonSerializer.DeserializeAsyncEnumerable( + stream, + GiteaClientJsonContext.Default.GiteaRepo, + cancellationToken: cancellationToken + ); + + var counter = 0; + await foreach (var repo in enumerable) + { + if (repo is null) + throw new JsonException("Got 'null' repo in response"); + + yield return repo; + counter++; + + if (counter == Constants.LimitRepos) + break; + } + + if (counter < PageSize) + { + break; + } + + page++; + } + } + + public Task CloneRepo( + GiteaRepo repo, + string path, + string? branch = null, + CancellationToken cancellationToken = default + ) + { + return Task.Run( + () => + { + var options = new CloneOptions(); + options.FetchOptions.CredentialsProvider = (_url, _user, _cred) => + new UsernamePasswordCredentials + { + Username = _config.Username, + Password = _config.Password, + }; + if (!string.IsNullOrWhiteSpace(branch)) + options.BranchName = branch; + + cancellationToken.ThrowIfCancellationRequested(); + Repository.Clone(repo.CloneUrl, path, options); + }, + cancellationToken + ); + } + + public async Task DownloadRepoArchive( + GiteaOrg org, + GiteaRepo repo, + DirectoryInfo path, + string branch, + CancellationToken cancellationToken = default + ) + { + var url = $"/repos/api/v1/repos/{org.Name}/{repo.Name}/archive/{branch}.zip"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + // AnsiConsole.MarkupLine($"Couldn't download branch '{branch}' for repo '{repo.FullName}'"); + // Some repos are just completely empty + return; + } + + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + + using var zipStream = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in zipStream.Entries) + { + if (entry.Name == "") + continue; + + await using var entryStream = entry.Open(); + + // Make sure we are consistent with directory separator chars + var entryFullPath = + Path.DirectorySeparatorChar == '\\' + ? entry.FullName.Replace('/', '\\') + : entry.FullName; + + // Archive entry FullName starts with repo name/dir, but we just want all the contents of the + // repo inside the input path, so we strip out the "root" directory from the full name + var firstDirSlash = entryFullPath.IndexOf(Path.DirectorySeparatorChar); + var entryPath = Path.Combine(path.FullName, entryFullPath.Substring(firstDirSlash + 1)); + + // Make sure the directory we want to write to exists + var directory = + Path.GetDirectoryName(entryPath) + ?? throw new Exception("Couldn't get archive entry directory"); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + await using var entryFileStream = File.Open( + entryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read + ); + await entryStream.CopyToAsync(entryFileStream, cancellationToken); + } + } +} diff --git a/AppsAnalysis/src/Altinn.Analysis/ValueColumn.cs b/AppsAnalysis/src/Altinn.Analysis/ValueColumn.cs new file mode 100644 index 00000000..13381bed --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/ValueColumn.cs @@ -0,0 +1,16 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace Altinn.Analysis; + +internal sealed class ValueProgressColumn : ProgressColumn +{ + public override IRenderable Render( + RenderOptions options, + ProgressTask task, + TimeSpan deltaTime + ) => + new Markup($"{task.Value}/{task.MaxValue}") + .Overflow(Overflow.Ellipsis) + .Justify(Justify.Right); +} From 7e11edf6436bf7b4baf800cccff5f1887dc7a92d Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 8 Dec 2024 23:42:12 +0100 Subject: [PATCH 2/5] Download deployed apps --- .gitignore | 2 + .../src/Altinn.Analysis/AppsFetcher.cs | 54 +++++++++--- AppsAnalysis/src/Altinn.Analysis/Constants.cs | 6 +- .../KubernetesWrapperClient.cs | 84 +++++++++++++++++++ 4 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 AppsAnalysis/src/Altinn.Analysis/KubernetesWrapperClient.cs diff --git a/.gitignore b/.gitignore index 0f27a213..6c256e99 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,5 @@ AppsMonitoring/**/appsettings.Secret.json AppsMonitoring/**/data.db AppsMonitoring/infra/appsettings.*.json AppsMonitoring/infra/**/result.yaml + +AppsAnalysis/config.ini diff --git a/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs b/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs index 647b2f2c..ac9c6823 100644 --- a/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs +++ b/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs @@ -19,6 +19,7 @@ public sealed class AppsFetcher { private readonly FetchConfig _config; private readonly GiteaClient _giteaClient; + private readonly KubernetesWrapperClient _kubernetesWrapperClient; private DirectoryInfo? _directory; private int _parallelism; @@ -27,6 +28,7 @@ public AppsFetcher(FetchConfig config) { _config = config; _giteaClient = new GiteaClient(config); + _kubernetesWrapperClient = new KubernetesWrapperClient(); } private bool VerifyConfig() @@ -123,6 +125,11 @@ public async Task Fetch(CancellationToken cancellationToken) await DownloadApps(apps, cancellationToken); } + private sealed record OrgRecord(List Repos) + { + public int Skipped { get; set; } + } + private async Task GetApps(CancellationToken cancellationToken) { AnsiConsole.WriteLine(); @@ -143,10 +150,12 @@ await AnsiConsole { result = await GetApps(ctx, cancellationToken); }); - Debug.Assert(result is not null); - var (orgs, repos, _) = result; + // Debug.Assert(result is not null); + // ! TODO + var (orgs, repos, reposByOrg) = result!; + var skippedTotal = reposByOrg.Sum(r => r.Value.Skipped); AnsiConsole.MarkupLine( - $"Fetched organizations and apps from Altinn Studio - [green]{orgs.Count} organizations[/], [blue]{repos.Count} apps[/]" + $"Fetched organizations and apps from Altinn Studio - [green]{orgs.Count} organizations[/], [blue]{repos.Count} apps[/] (skipped {skippedTotal} undeployed apps)" ); return result; } @@ -158,7 +167,7 @@ CancellationToken cancellationToken { AnsiConsole.MarkupLine($"Fetching organizations and apps from Altinn Studio Gitea"); - ConcurrentDictionary> reposByOrg = new(); + ConcurrentDictionary reposByOrg = new(); var options = new ParallelOptions { @@ -175,17 +184,33 @@ await Parallel.ForEachAsync( task.IsIndeterminate = true; task.StartTask(); - var repos = reposByOrg.GetOrAdd(org, _ => new List(4)); + var deploymentstt02 = await _kubernetesWrapperClient.GetDeployments( + org.Name, + "tt02" + ); + var deploymentsProd = await _kubernetesWrapperClient.GetDeployments( + org.Name, + "prod" + ); + HashSet deployedApps = new(deploymentstt02.Select(d => d.Repo)); + deployedApps.UnionWith(deploymentsProd.Select(d => d.Repo)); + + task.MaxValue = deployedApps.Count; + + var orgRecord = reposByOrg.GetOrAdd(org, _ => new(new List(4))); await foreach (var repo in _giteaClient.GetRepos(org.Name, cancellationToken)) { - repos.Add(repo); + if (deployedApps.Contains(repo.Name)) + orgRecord.Repos.Add(repo); + else + orgRecord.Skipped += 1; var maxValue = task.MaxValue; - if (repos.Count >= maxValue) + if (orgRecord.Repos.Count >= maxValue) task.MaxValue = maxValue + 25; task.Increment(1); } - task.MaxValue = repos.Count; + task.MaxValue = orgRecord.Repos.Count; ctx.Refresh(); task.StopTask(); @@ -194,8 +219,11 @@ await Parallel.ForEachAsync( return new GetAppsResult( reposByOrg.Keys.ToArray(), - reposByOrg.SelectMany(kvp => kvp.Value).ToArray(), - reposByOrg.ToDictionary(kvp => kvp.Key, kvp => (IReadOnlyList)kvp.Value) + reposByOrg.SelectMany(kvp => kvp.Value.Repos).ToArray(), + reposByOrg.ToDictionary( + kvp => kvp.Key, + kvp => ((IReadOnlyList)kvp.Value.Repos, kvp.Value.Skipped) + ) ); } @@ -311,9 +339,9 @@ await Parallel.ForEachAsync( options, async (org, cancellationToken) => { - var repos = basicInformation.ReposByOrg[org]; + var (repos, skipped) = basicInformation.ReposByOrg[org]; var task = ctx.AddTask( - $"[green]{org.FullName} ({org.Name})[/]", + $"[green]{org.FullName} ({org.Name})[/] (skipped: {skipped})", autoStart: true, maxValue: repos.Count ); @@ -338,7 +366,7 @@ await queue.Writer.WriteAsync( private sealed record GetAppsResult( IReadOnlyList Orgs, IReadOnlyList Repos, - IReadOnlyDictionary> ReposByOrg + IReadOnlyDictionary Repos, int Skipped)> ReposByOrg ); private sealed record DownloadAppsResult(IReadOnlyList Repos); diff --git a/AppsAnalysis/src/Altinn.Analysis/Constants.cs b/AppsAnalysis/src/Altinn.Analysis/Constants.cs index 95417d48..21b67d21 100644 --- a/AppsAnalysis/src/Altinn.Analysis/Constants.cs +++ b/AppsAnalysis/src/Altinn.Analysis/Constants.cs @@ -5,7 +5,7 @@ internal static class Constants internal const string MainBranchFolder = "main"; // Following settings are to tweak during test-runs - internal static readonly int LimitOrgs = 10; - internal static readonly int LimitRepos = 10; - internal static readonly int LimitMaxParallelism = 6; + internal static readonly int LimitOrgs = -1; + internal static readonly int LimitRepos = -1; + internal static readonly int LimitMaxParallelism = 8; } diff --git a/AppsAnalysis/src/Altinn.Analysis/KubernetesWrapperClient.cs b/AppsAnalysis/src/Altinn.Analysis/KubernetesWrapperClient.cs new file mode 100644 index 00000000..c5f467fc --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/KubernetesWrapperClient.cs @@ -0,0 +1,84 @@ +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Text.Json.Serialization; + +namespace Altinn.Analysis; + +// {"version":"1.2.15","release":"krt-krt-1001a-1" + +public sealed record Deployment(string Version, string Repo); + +public sealed class KubernetesWrapperClient +{ + private readonly HttpClient _httpClient; + + public KubernetesWrapperClient() + { + HttpClient httpClient = new( + new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(15), // Recreate every 15 minutes + ConnectTimeout = TimeSpan.FromSeconds(5), + } + ); + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + _httpClient = httpClient; + } + + private static string BaseUrl(string org, string env) => + $"https://{org}.apps{(env == "prod" ? "" : "." + env)}.altinn.no"; + + public async Task> GetDeployments(string org, string env) + { + var url = $"{BaseUrl(org, env)}/kuberneteswrapper/api/v1/deployments"; + + IReadOnlyList? response = null; + try + { + response = await _httpClient.GetFromJsonAsync>(url); + } + catch (HttpRequestException e) + when (e.InnerException is SocketException { SocketErrorCode: var errorCode } + && (errorCode is SocketError.HostNotFound or SocketError.TimedOut) + ) + { + // There is an org in CDN, but they have no DNS record, probably aren't any deployments... + return []; + } + catch (TaskCanceledException) + { + // Could be connect timeout + return []; + } + + if (response is null) + throw new Exception($"Could not get deployments for: {org}, {env}"); + + var deployments = new List(response.Count); + for (int i = 0; i < response.Count; i++) + { + var deployment = response[i]; + if (string.IsNullOrWhiteSpace(deployment.Release)) + continue; + + if (deployment.Release == "kuberneteswrapper") + continue; + + var split = deployment.Release.Split('-', 2, StringSplitOptions.RemoveEmptyEntries); + if (split.Length != 2) + throw new Exception( + $"Invalid deployment release string format for: {org}, {env} - {deployment.Release}" + ); + var appId = split[1]; + + deployments.Add(new Deployment(deployment.Version, appId)); + } + + return deployments; + } + + internal sealed record DeploymentInfoDto( + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("release")] string Release + ); +} From 2efb3e6de65228bb169244b4a44e8f3d9eba752a Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 11 Aug 2025 08:49:25 +0200 Subject: [PATCH 3/5] Fixes and improvements --- AppsAnalysis/.editorconfig | 6 + .../src/Altinn.Analysis.Cli/Program.cs | 26 +- .../Altinn.Analysis/Altinn.Analysis.csproj | 2 + .../src/Altinn.Analysis/AppAnalyzer.cs | 291 +++++++++++++++ .../src/Altinn.Analysis/AppsAnalyzer.cs | 339 +++++++++--------- .../src/Altinn.Analysis/AppsFetcher.cs | 39 +- .../src/Altinn.Analysis/GiteaClient.cs | 102 +++++- .../src/Altinn.Analysis/SyncThreadPool.cs | 119 ++++++ 8 files changed, 739 insertions(+), 185 deletions(-) create mode 100644 AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs create mode 100644 AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs diff --git a/AppsAnalysis/.editorconfig b/AppsAnalysis/.editorconfig index 748dce03..e31fe572 100644 --- a/AppsAnalysis/.editorconfig +++ b/AppsAnalysis/.editorconfig @@ -26,3 +26,9 @@ dotnet_diagnostic.CA1050.severity = suggestion # S3903: Types should be defined in named namespaces dotnet_diagnostic.S3903.severity = suggestion + +# S108: Nested blocks of code should not be left empty +dotnet_diagnostic.S108.severity = suggestion + +# S1199: Nested code blocks should not be used +dotnet_diagnostic.S1199.severity = suggestion diff --git a/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs b/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs index 11280af1..1111e4f9 100644 --- a/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs +++ b/AppsAnalysis/src/Altinn.Analysis.Cli/Program.cs @@ -1,6 +1,8 @@ -using System.Globalization; +using System.Diagnostics; +using System.Globalization; using Altinn.Analysis; using ConsoleAppFramework; +using Spectre.Console; var app = ConsoleApp.Create(); app.Add(); @@ -30,8 +32,9 @@ public async Task Fetch( CancellationToken cancellationToken = default ) { + var timer = Stopwatch.StartNew(); var config = await TryReadConfigFile(configFile, cancellationToken); - var analyzer = new AppsFetcher( + using var analyzer = new AppsFetcher( new( directory ?? config.Directory ?? "repos/", username ?? config.Username ?? throw new Exception("Need username config to fetch"), @@ -42,6 +45,15 @@ public async Task Fetch( ) ); await analyzer.Fetch(cancellationToken); + timer.Stop(); + var elapsed = timer.Elapsed switch + { + var v when v >= TimeSpan.FromHours(1) => $"{v.TotalHours:0.00}h", + var v when v >= TimeSpan.FromMinutes(1) => $"{v.TotalMinutes:0.00}m", + var v => $"{v.TotalSeconds:0.00}s", + }; + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine($"[green]Fetch completed in {elapsed}[/]"); } /// Analyze Altinn apps in a directory @@ -56,6 +68,7 @@ public async Task Analyze( CancellationToken cancellationToken = default ) { + var timer = Stopwatch.StartNew(); var config = await TryReadConfigFile(configFile, cancellationToken); var analyzer = new AppsAnalyzer( new( @@ -64,6 +77,15 @@ public async Task Analyze( ) ); await analyzer.Analyze(cancellationToken); + timer.Stop(); + var elapsed = timer.Elapsed switch + { + var v when v >= TimeSpan.FromHours(1) => $"{v.TotalHours:0.00}h", + var v when v >= TimeSpan.FromMinutes(1) => $"{v.TotalMinutes:0.00}m", + var v => $"{v.TotalSeconds:0.00}s", + }; + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine($"[green]Analysis completed in {elapsed}[/]"); } private static async Task TryReadConfigFile( diff --git a/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj b/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj index 47a33dcf..d528c637 100644 --- a/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj +++ b/AppsAnalysis/src/Altinn.Analysis/Altinn.Analysis.csproj @@ -9,7 +9,9 @@ + + diff --git a/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs b/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs new file mode 100644 index 00000000..32efb1d1 --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs @@ -0,0 +1,291 @@ +using System.Collections.Concurrent; +using System.Collections.Specialized; +using Buildalyzer; +using Buildalyzer.Workspaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; + +namespace Altinn.Analysis; + +public sealed record AppRepository(DirectoryInfo Dir, string Org, string Name); + +// TODO: Consider SoA if this approach works OK +public readonly record struct AppAnalysisResult +{ + private static readonly BitVector32.Section _timedOut = BitVector32.CreateSection(2); + private static readonly BitVector32.Section _validProject = BitVector32.CreateSection( + 2, + _timedOut + ); + private static readonly BitVector32.Section _builds = BitVector32.CreateSection( + 2, + _validProject + ); + private static readonly BitVector32.Section _hasAppLib = BitVector32.CreateSection(2, _builds); + private static readonly BitVector32.Section _hasLatestAppLib = BitVector32.CreateSection( + 2, + _hasAppLib + ); + + private readonly BitVector32 _bits; + + public AppAnalysisResult( + AppRepository repo, + bool? timedOut = null, + bool? validProject = null, + bool? builds = null, + bool? hasAppLib = null, + bool? HasLatestAppLib = null, + string? appLibVersion = null, + uint? warningCount = null, + Dictionary>? symbolReferenceCounts = null + ) + { + AppRepository = repo; + _bits = new BitVector32(0); + _bits[_timedOut] = ToValue(timedOut); + _bits[_validProject] = ToValue(validProject); + _bits[_builds] = ToValue(builds); + _bits[_hasAppLib] = ToValue(hasAppLib); + _bits[_hasLatestAppLib] = ToValue(HasLatestAppLib); + AppLibVersion = appLibVersion; + WarningCount = warningCount; + SymbolReferenceCountsBySymbol = symbolReferenceCounts ?? new(); + } + + public AppRepository AppRepository { get; } + + public readonly IReadOnlyDictionary< + string, + List + > SymbolReferenceCountsBySymbol { get; } + + public string? AppLibVersion { get; } + + public uint? WarningCount { get; } + + public readonly bool? TimedOut => FromValue(_timedOut); + public readonly bool? ValidProject => FromValue(_validProject); + public readonly bool? Builds => FromValue(_builds); + public readonly bool? HasAppLib => FromValue(_hasAppLib); + public readonly bool? HasLatestAppLib => FromValue(_hasLatestAppLib); + + public bool OK => + (_bits[_validProject] & _bits[_builds] & _bits[_hasAppLib] & _bits[_hasLatestAppLib]) == 1; + + private static int ToValue(bool? value) => + value switch + { + null => 2, + false => 0, + true => 1, + }; + + private bool? FromValue(BitVector32.Section section) => + _bits[section] switch + { + 2 => default(bool?), + 0 => false, + 1 => true, + var u => throw new Exception($"Unexpected value: {u}"), + }; +} + +public sealed class AppAnalyzer : IDisposable +{ + private readonly SyncThreadPool _threadPool; + + public AppAnalyzer(int threadCount) + { + _threadPool = new SyncThreadPool(threadCount); + } + + public async Task Analyze( + AppRepository app, + string[] findReferences, + ConcurrentBag results, + CancellationToken cancellationToken + ) + { + var result = await _threadPool.RunAsync( + async (CancellationToken cancellationToken) => + { + var manager = new AnalyzerManager(); + var dir = app.Dir; + var projectFile = Path.Combine(dir.FullName, "App", "App.csproj"); + + if (!File.Exists(projectFile)) + return new AppAnalysisResult(app, timedOut: false, validProject: false); + + IAnalyzerResults buildResults; + IProjectAnalyzer project; + try + { + project = manager.GetProject(projectFile); + cancellationToken.ThrowIfCancellationRequested(); + + buildResults = project.Build(); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new AppAnalysisResult(app, timedOut: false, validProject: false); + } + + var builds = buildResults.OverallSuccess; + var result = buildResults.FirstOrDefault(r => r.Succeeded); + if (result is null) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds + ); + + var appLibRef = result.PackageReferences.FirstOrDefault(r => + // Main + r.Key == "Altinn.App.Api" + // PR releases + || r.Key == "Altinn.App.Api.Experimental" + // <=6.0 + || r.Key == "Altinn.App.Common" + ); + string? version = null; + var hasAppLib = + appLibRef.Key is not null + && appLibRef.Value.TryGetValue("Version", out version); + var HasLatestAppLib = version is not null && version.StartsWith('8'); + if (!builds || !hasAppLib || !HasLatestAppLib) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version + ); + + using var workspace = new AdhocWorkspace(); + // workspace.WorkspaceFailed += (sender, args) => logger?.LogError("Workspace failed: {Diagnostic}{NewLine}", args.Diagnostic, System.Environment.NewLine); + Project roslynProject; + Compilation? compilation; + try + { + result.AddToWorkspace(workspace, addProjectReferences: true); + roslynProject = workspace.CurrentSolution.Projects.First(); + compilation = await roslynProject.GetCompilationAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: false, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version + ); + } + + if (compilation is null) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: false, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version + ); + + var diagnostics = compilation.GetDiagnostics(cancellationToken); + var warningCount = (uint) + diagnostics.Count(d => + d.Id != "CS1701" && d.Severity == DiagnosticSeverity.Warning + ); + builds = + builds + && !diagnostics.Any(d => + d.Severity >= DiagnosticSeverity.Error && !d.IsSuppressed + ); + if (!builds) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version, + warningCount: warningCount + ); + + var referenceCounts = new Dictionary>(16); + await PopulateReferenceCounts( + findReferences, + roslynProject, + compilation, + referenceCounts, + cancellationToken + ); + + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version, + warningCount: warningCount, + symbolReferenceCounts: referenceCounts + ); + }, + cancellationToken + ); + results.Add(result); + } + + private static async Task PopulateReferenceCounts( + string[] findReferences, + Project roslynProject, + Compilation compilation, + Dictionary> referenceCounts, + CancellationToken cancellationToken + ) + { + foreach (var symbolRef in findReferences) + { + if (!referenceCounts.TryGetValue(symbolRef, out var symbolRefs)) + referenceCounts[symbolRef] = symbolRefs = new List(16); + + var symbols = DocumentationCommentId.GetSymbolsForDeclarationId(symbolRef, compilation); + foreach (var symbol in symbols) + { + var references = await SymbolFinder.FindReferencesAsync( + symbol, + roslynProject.Solution, + cancellationToken + ); + + foreach (var reference in references) + { + // Filter out references to the symbol itself + // (sometimes we get both the interface method and the implementation method) + if (!SymbolEqualityComparer.Default.Equals(reference.Definition, symbol)) + continue; + + foreach (var location in reference.Locations) + symbolRefs.Add(reference); + } + } + } + } + + public void Dispose() + { + _threadPool.Dispose(); + } +} diff --git a/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs b/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs index 2f0d63bd..f44bce45 100644 --- a/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs +++ b/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs @@ -1,7 +1,8 @@ using System.Collections.Concurrent; -using System.Collections.Specialized; using System.Diagnostics; -using Buildalyzer; +using System.Globalization; +using CsvHelper; +using Microsoft.CodeAnalysis.Text; using Spectre.Console; namespace Altinn.Analysis; @@ -77,7 +78,7 @@ public async Task Analyze(CancellationToken cancellationToken) AnsiConsole.WriteLine(); AnsiConsole.Write(new Rule($"Analysing [blue]{repos.Count}[/] apps").LeftJustified()); - RepoAnalysis[]? results = null; + AppAnalysisResult[]? results = null; await AnsiConsole .Progress() .Columns( @@ -90,41 +91,152 @@ await AnsiConsole .AutoClear(true) .StartAsync(async ctx => { - results = await Task.Run( - () => AnalyzeRepos(ctx, repos, cancellationToken), - cancellationToken - ); + results = await AnalyzeRepos(ctx, repos, cancellationToken); }); Debug.Assert(results is not null); AnsiConsole.MarkupLine($"[green]Successfully[/] analyzed [blue]{results.Length}[/] apps"); - // Ample opportunity for SIMD belowow... + // Ample opportunity for SIMD below... + var timedOut = results.Count(r => r.TimedOut is true); var invalidProjects = results.Count(r => r.ValidProject is false); var failedBuilds = results.Count(r => r.Builds is false); var noAppLib = results.Count(r => r.HasAppLib is false); var oldAppLib = results.Count(r => r.HasLatestAppLib is false); var aOkay = results.Count(r => r.OK); + AnsiConsole.MarkupLine("[green]Overview[/]"); AnsiConsole.WriteLine(); AnsiConsole.Write( new BarChart() .Width(60) .Label("[green bold underline]Analysis[/]") .LeftAlignLabel() + .AddItem("Timed out", timedOut, Color.Red) .AddItem("Invalid project", invalidProjects, Color.Red) .AddItem("Failed builds", failedBuilds, Color.Red) - .AddItem("Missing Altinn.App.Core", noAppLib, Color.Red) - .AddItem("Old Altinn.App.Core", oldAppLib, Color.Yellow) - .AddItem("On Altinn.App.Core v8", aOkay, Color.Green) + .AddItem("Missing applib", noAppLib, Color.Red) + .AddItem("Old applib", oldAppLib, Color.Yellow) + .AddItem("On applib v8", aOkay, Color.Green) ); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[green]Symbol references totals[/]"); + var symbolReferences = results + .SelectMany(r => + r.SymbolReferenceCountsBySymbol.Select(kvp => + (Symbol: kvp.Key, References: kvp.Value.AsReadOnly(), App: r.AppRepository) + ) + ) + .GroupBy(r => r.Symbol) + .Select(grp => new + { + Symbol = grp.Key, + Count = grp.Sum(x => x.References.Count), + Apps = string.Join( + ", ", + grp.Where(x => x.References.Count > 0) + .Select(x => $"{x.App.Org}/{x.App.Name}") + .Order() + ), + }) + .OrderByDescending(x => x.Count) + .ToArray(); + + var table = new Table(); + table.Border(TableBorder.Rounded); + table.AddColumn(new TableColumn("Symbol")); + table.AddColumn(new TableColumn("Reference count")); + table.AddColumn(new TableColumn("Apps")); + + foreach (var item in symbolReferences) + { + table.AddRow(item.Symbol, item.Count.ToString(CultureInfo.InvariantCulture), item.Apps); + } + AnsiConsole.Write(table); + + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[blue]Writing code dump for references[/]..."); + var data = results + .SelectMany(re => + re.SymbolReferenceCountsBySymbol.SelectMany(kvp => + kvp.Value.Select(r => + (Symbol: kvp.Key, App: re.AppRepository, Reference: r) + ) + ) + ) + .GroupBy(r => r.Symbol); + + foreach (var item in data) + { + var refToFind = item.Key; + var filePath = Path.Join( + _directory.FullName, + $"dump_{refToFind.Substring(2).Replace('.', '_')}.md" + ); + if (File.Exists(filePath)) + File.Delete(filePath); + await using var file = File.OpenWrite(filePath); + await using var writer = new StreamWriter(file); + foreach (var reference in item) + { + foreach (var location in reference.Reference.Locations) + { + var documentPath = location.Document.FilePath; + var code = await location.Document.GetTextAsync(cancellationToken); + var lineNumber = code + .Lines.GetLineFromPosition(location.Location.SourceSpan.Start) + .LineNumber; + var codeLink = $"{documentPath} {lineNumber + 1}"; + var span = ExpandSpanToIncludeSurroundingLines( + code, + location.Location.SourceSpan + ); + var codeWithContext = code.GetSubText(span); + await writer.WriteLineAsync( + $"\n{codeLink}: \n```csharp\n{codeWithContext.ToString()}\n```\n" + ); + } + } + } + + AnsiConsole.MarkupLine($"[green]Successfully[/] wrote code dumps for references"); + } + + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[blue]Writing CSV report[/]..."); + + // Write CSV report to analysis directory + var filename = Path.Combine(_directory.FullName, "apps.csv"); + await using var writer = new StreamWriter(filename); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + + var records = results + .Select(r => new AppCsvRow + { + Org = r.AppRepository.Org, + Name = r.AppRepository.Name, + TimedOut = r.TimedOut, + Builds = r.Builds, + AppLibVersion = r.AppLibVersion, + WarningCount = r.WarningCount, + }) + .ToArray(); + await csv.WriteRecordsAsync(records, cancellationToken); + + AnsiConsole.MarkupLine( + $"[green]Successfully[/] wrote CSV report to: [bold]{filename}[/]" + ); + } } - private List GetRepos(CancellationToken cancellationToken) + private List GetRepos(CancellationToken cancellationToken) { Debug.Assert(_directory is not null); - var result = new List(64); + var result = new List(64); var orgsCounter = 0; foreach (var orgDir in Directory.EnumerateDirectories(_directory.FullName)) { @@ -145,7 +257,7 @@ private List GetRepos(CancellationToken cancellationToken) ); continue; } - result.Add(new Repo(repoDirInfo, org, name)); + result.Add(new AppRepository(repoDirInfo, org, name)); reposCounter++; if (reposCounter == Constants.LimitRepos) @@ -161,186 +273,93 @@ private List GetRepos(CancellationToken cancellationToken) return result; } - private RepoAnalysis[] AnalyzeRepos( + private async Task AnalyzeRepos( ProgressContext ctx, - List repos, + List repos, CancellationToken cancellationToken ) { + using var appAnalyzer = new AppAnalyzer(_parallelism); + + var results = new ConcurrentBag(); var options = new ParallelOptions { CancellationToken = cancellationToken, MaxDegreeOfParallelism = _parallelism, }; - - var results = new ConcurrentBag(); - - Parallel.ForEach( + await Parallel.ForEachAsync( repos, options, - repo => + async (repo, cancellationToken) => { var (dir, org, name) = repo; var task = ctx.AddTask($"[green]{org}/{name}[/]", autoStart: false, maxValue: 1); task.IsIndeterminate = true; task.StartTask(); - var manager = new AnalyzerManager(); - var projectFile = Path.Combine(dir.FullName, "App", "App.csproj"); - - void Exit(in RepoAnalysis result) - { - results.Add(result); - task.Increment(1.0); - task.StopTask(); - } - - if (!File.Exists(projectFile)) - { - Exit(new RepoAnalysis(repo, validProject: false)); - return; - } - - const string Tfm = "net8.0"; - IAnalyzerResults buildResults; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(1)); + cancellationToken = cts.Token; + + // Doc for selecting refs: + // https://github.com/dotnet/roslyn/blob/7ead97fc404947f81a4ee3c6543212d60fddfd03/src/RoslynAnalyzers/Microsoft.CodeAnalysis.BannedApiAnalyzers/BannedApiAnalyzers.Help.md + string[] findRefs = + [ + // "M:Altinn.App.Core.Internal.Auth.IAuthorizationClient.GetUserRoles(System.Int32,System.Int32)", + // "T:Altinn.App.Core.Features.IValidator", + "T:Altinn.App.Core.Infrastructure.Clients.Storage.DataClient", + "M:Altinn.App.Core.Infrastructure.Clients.Storage.DataClient.#ctor", + // "T:Altinn.App.Core.Features.IProcessTaskEnd", + ]; try { - var project = manager.GetProject(projectFile); - cancellationToken.ThrowIfCancellationRequested(); - - buildResults = project.Build(Tfm); - cancellationToken.ThrowIfCancellationRequested(); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Exit(new RepoAnalysis(repo, validProject: false)); - return; - } - - if (!buildResults.TryGetTargetFramework(Tfm, out var result)) - { - Exit(new RepoAnalysis(repo, validProject: false)); - return; - } - - if (!result.Succeeded) - { - Exit(new RepoAnalysis(repo, validProject: true, builds: false)); - return; - } - - var appCoreRef = result.PackageReferences.FirstOrDefault(r => - r.Key == "Altinn.App.Core" - ); - if (appCoreRef.Key is null) - { - Exit( - new RepoAnalysis(repo, validProject: true, builds: true, hasAppLib: false) - ); - return; + await appAnalyzer.Analyze(repo, findRefs, results, cancellationToken); } - - if (!appCoreRef.Value.TryGetValue("Version", out var version)) + catch (OperationCanceledException) { - Exit( - new RepoAnalysis(repo, validProject: true, builds: true, hasAppLib: false) - ); - return; + results.Add(new(repo, timedOut: true)); } - if (!version.StartsWith('8')) - { - Exit( - new RepoAnalysis( - repo, - validProject: true, - builds: true, - hasAppLib: true, - HasLatestAppLib: false - ) - ); - return; - } - - // AnsiConsole.MarkupLine($"Analyzing project: '{org}/{name}'"); - Exit( - new RepoAnalysis( - repo, - validProject: true, - builds: true, - hasAppLib: true, - HasLatestAppLib: true - ) - ); + task.Increment(1.0); + task.StopTask(); } ); return results.ToArray(); } - private sealed record Repo(DirectoryInfo Dir, string Org, string Name); - - // TODO: Consider SoA if this approach works OK - private readonly record struct RepoAnalysis + private sealed record AppCsvRow { - private static readonly BitVector32.Section _validProject = BitVector32.CreateSection(2); - private static readonly BitVector32.Section _builds = BitVector32.CreateSection( - 2, - _validProject - ); - private static readonly BitVector32.Section _hasAppLib = BitVector32.CreateSection( - 2, - _builds - ); - private static readonly BitVector32.Section _hasLatestAppLib = BitVector32.CreateSection( - 2, - _hasAppLib - ); - - private readonly BitVector32 _bits; - - public RepoAnalysis( - Repo repo, - bool? validProject = null, - bool? builds = null, - bool? hasAppLib = null, - bool? HasLatestAppLib = null - ) - { - Repo = repo; - _bits = new BitVector32(0); - _bits[_validProject] = ToValue(validProject); - _bits[_builds] = ToValue(builds); - _bits[_hasAppLib] = ToValue(hasAppLib); - _bits[_hasLatestAppLib] = ToValue(HasLatestAppLib); - } + public required string Org { get; init; } + public required string Name { get; init; } + public required bool? TimedOut { get; init; } + public required bool? Builds { get; init; } + public required string? AppLibVersion { get; init; } + public required uint? WarningCount { get; init; } + } - public readonly Repo Repo; + private static TextSpan ExpandSpanToIncludeSurroundingLines( + SourceText sourceText, + TextSpan originalSpan + ) + { + // Get the lines that contain the start and end of the original span + var startLine = sourceText.Lines.GetLineFromPosition(originalSpan.Start); + var endLine = sourceText.Lines.GetLineFromPosition(originalSpan.End); - public readonly bool? ValidProject => FromValue(_validProject); - public readonly bool? Builds => FromValue(_builds); - public readonly bool? HasAppLib => FromValue(_hasAppLib); - public readonly bool? HasLatestAppLib => FromValue(_hasLatestAppLib); + // Get the line numbers + int startLineNumber = startLine.LineNumber; + int endLineNumber = endLine.LineNumber; - public bool OK => - (_bits[_validProject] & _bits[_builds] & _bits[_hasAppLib] & _bits[_hasLatestAppLib]) - == 1; + // Expand to include one line before and after (with bounds checking) + int expandedStartLineNumber = Math.Max(0, startLineNumber - 1); + int expandedEndLineNumber = Math.Min(sourceText.Lines.Count - 1, endLineNumber + 1); - private static int ToValue(bool? value) => - value switch - { - null => 2, - false => 0, - true => 1, - }; + // Get the expanded lines + var expandedStartLine = sourceText.Lines[expandedStartLineNumber]; + var expandedEndLine = sourceText.Lines[expandedEndLineNumber]; - private bool? FromValue(BitVector32.Section section) => - _bits[section] switch - { - 2 => default(bool?), - 0 => false, - 1 => true, - var u => throw new Exception($"Unexpected value: {u}"), - }; + // Create the expanded TextSpan + return TextSpan.FromBounds(expandedStartLine.Start, expandedEndLine.End); } } diff --git a/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs b/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs index ac9c6823..06908662 100644 --- a/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs +++ b/AppsAnalysis/src/Altinn.Analysis/AppsFetcher.cs @@ -15,7 +15,7 @@ public sealed record FetchConfig( string AltinnUrl ); -public sealed class AppsFetcher +public sealed class AppsFetcher : IDisposable { private readonly FetchConfig _config; private readonly GiteaClient _giteaClient; @@ -94,7 +94,7 @@ private void PrepareDirectory() if (_config.ClearDirectory && _directory.Exists) { - AnsiConsole.MarkupLine("Clearing directory..."); + AnsiConsole.MarkupLine($"Clearing directory '{_directory.FullName}'..."); _directory.Delete(recursive: true); } @@ -308,13 +308,29 @@ DirectoryInfo OrgDirectory var branchDirectory = repoDirectory.CreateSubdirectory( Constants.MainBranchFolder ); - await _giteaClient.DownloadRepoArchive( - org, - repo, - branchDirectory, - branch, - cancellationToken: cancellationToken - ); + + if ( + branchDirectory.Exists + && new DirectoryInfo( + Path.Join(branchDirectory.FullName, ".git") + ).Exists + ) + { + await _giteaClient.UpdateRepo( + repo, + branchDirectory, + cancellationToken + ); + } + else + { + await _giteaClient.CloneRepo( + repo, + branchDirectory, + branch, + cancellationToken: cancellationToken + ); + } results.Add(new(org, repo, branch, branchDirectory)); @@ -363,6 +379,11 @@ await queue.Writer.WriteAsync( return new DownloadAppsResult(results.ToArray()); } + public void Dispose() + { + _giteaClient.Dispose(); + } + private sealed record GetAppsResult( IReadOnlyList Orgs, IReadOnlyList Repos, diff --git a/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs b/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs index 876605b7..0e59665f 100644 --- a/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs +++ b/AppsAnalysis/src/Altinn.Analysis/GiteaClient.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using System.Web; using LibGit2Sharp; +using Spectre.Console; namespace Altinn.Analysis; @@ -62,12 +63,14 @@ internal sealed record GiteaArchiveStatus([property: JsonPropertyName("complete" [JsonSerializable(typeof(GiteaArchiveStatus))] internal sealed partial class GiteaClientJsonContext : JsonSerializerContext { } -public sealed class GiteaClient +public sealed class GiteaClient : IDisposable { private const int PageSize = 50; private const string PageSizeStr = "50"; private readonly HttpClient _httpClient; + private readonly SyncThreadPool _threadPool; + private readonly FetchConfig _config; public GiteaClient(FetchConfig config) @@ -83,6 +86,7 @@ public GiteaClient(FetchConfig config) httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); httpClient.DefaultRequestHeaders.Add("Authorization", $"token {config.Password}"); _httpClient = httpClient; + _threadPool = new SyncThreadPool(config.MaxParallelism); } public async IAsyncEnumerable GetOrgs( @@ -185,28 +189,93 @@ public async IAsyncEnumerable GetRepos( } } - public Task CloneRepo( + public Task CloneRepo( GiteaRepo repo, - string path, + DirectoryInfo path, string? branch = null, CancellationToken cancellationToken = default ) { - return Task.Run( - () => + return _threadPool.RunAsync( + (CancellationToken cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var options = new CloneOptions(); + options.FetchOptions.CredentialsProvider = (_url, _user, _cred) => + new UsernamePasswordCredentials + { + Username = _config.Username, + Password = _config.Password, + }; + options.FetchOptions.Depth = 1; + if (!string.IsNullOrWhiteSpace(branch)) + options.BranchName = branch; + + return Repository.Clone(repo.CloneUrl, path.FullName, options); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine( + $"Error cloning repo '{repo.CloneUrl}' with branch '{branch}' to '{path.FullName}': {ex.Message}" + ); + return ""; + } + }, + cancellationToken + ); + } + + public async Task UpdateRepo( + GiteaRepo repo, + DirectoryInfo path, + CancellationToken cancellationToken = default + ) + { + await _threadPool.RunAsync( + (CancellationToken cancellationToken) => { - var options = new CloneOptions(); - options.FetchOptions.CredentialsProvider = (_url, _user, _cred) => - new UsernamePasswordCredentials + cancellationToken.ThrowIfCancellationRequested(); + try + { + using var repository = new Repository(path.FullName); + + // Fetch the latest with depth 1 + var fetchOptions = new FetchOptions { - Username = _config.Username, - Password = _config.Password, + Depth = 1, + CredentialsProvider = (_url, _user, _cred) => + new UsernamePasswordCredentials + { + Username = _config.Username, + Password = _config.Password, + }, }; - if (!string.IsNullOrWhiteSpace(branch)) - options.BranchName = branch; - cancellationToken.ThrowIfCancellationRequested(); - Repository.Clone(repo.CloneUrl, path, options); + Commands.Fetch( + repository, + "origin", + repository + .Network.Remotes["origin"] + .FetchRefSpecs.Select(x => x.Specification), + fetchOptions, + "Fetching latest" + ); + + // Hard reset to the remote branch + var remoteBranch = repository.Branches[ + $"origin/{repository.Head.FriendlyName}" + ]; + repository.Reset(ResetMode.Hard, remoteBranch.Tip); + + return true; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"Error updating repo '{repo.FullName}': {ex.Message}"); + return false; + } }, cancellationToken ); @@ -275,4 +344,9 @@ public async Task DownloadRepoArchive( await entryStream.CopyToAsync(entryFileStream, cancellationToken); } } + + public void Dispose() + { + _threadPool.Dispose(); + } } diff --git a/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs b/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs new file mode 100644 index 00000000..bdef1670 --- /dev/null +++ b/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs @@ -0,0 +1,119 @@ +using System.Threading.Channels; + +namespace Altinn.Analysis; + +/// +/// Threadpool for running synchronous work +/// Passes `TaskCreationOptions.LongRunning` to individual task/threads +/// to make sure the scheduler spawns additional threads for each task. +/// Scheduling sync work on default TaskScheduler tasks/threads would be bad +/// +internal sealed class SyncThreadPool : IDisposable +{ + private readonly Channel> _workQueue; + private readonly Task[] _workers; + private readonly CancellationTokenSource _cts = new(); + + public SyncThreadPool(int threadCount = 0) + { + // Default to number of physical cores if not specified + if (threadCount <= 0) + threadCount = Environment.ProcessorCount; + + _workQueue = Channel.CreateUnbounded>(); + _workers = new Task[threadCount]; + + for (int i = 0; i < threadCount; i++) + { + _workers[i] = Task + .Factory.StartNew( + Worker, + _cts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ) + .Unwrap(); + } + } + + private async Task Worker() + { + try + { + await foreach (var work in _workQueue.Reader.ReadAllAsync(_cts.Token)) + { + await work(_cts.Token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + // Log the exception or handle it as needed + await Console.Error.WriteLineAsync($"Worker encountered an error: {ex.Message}"); + Environment.Exit(1); + } + } + + public Task RunAsync( + Func operation, + CancellationToken cancellationToken = default + ) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _workQueue.Writer.TryWrite(poolCancellationToken => + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + poolCancellationToken, + cancellationToken + ); + try + { + var result = operation(linkedCts.Token); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + return Task.CompletedTask; + }); + + return tcs.Task; + } + + public Task RunAsync( + Func> operation, + CancellationToken cancellationToken = default + ) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _workQueue.Writer.TryWrite(async poolCancellationToken => + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + poolCancellationToken, + cancellationToken + ); + try + { + var result = await operation(linkedCts.Token); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; + } + + public void Dispose() + { + _workQueue.Writer.Complete(); + _cts.Cancel(); + Task.WaitAll(_workers); + _cts.Dispose(); + } +} From b3a18d556b70b91c18376352279c04a5c3b950c0 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sat, 16 Aug 2025 22:16:44 +0200 Subject: [PATCH 4/5] rebase, lf --- .../src/Altinn.Analysis/AppAnalyzer.cs | 582 +++++++++--------- .../src/Altinn.Analysis/SyncThreadPool.cs | 238 +++---- 2 files changed, 410 insertions(+), 410 deletions(-) diff --git a/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs b/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs index 32efb1d1..6f473974 100644 --- a/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs +++ b/AppsAnalysis/src/Altinn.Analysis/AppAnalyzer.cs @@ -1,291 +1,291 @@ -using System.Collections.Concurrent; -using System.Collections.Specialized; -using Buildalyzer; -using Buildalyzer.Workspaces; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.FindSymbols; - -namespace Altinn.Analysis; - -public sealed record AppRepository(DirectoryInfo Dir, string Org, string Name); - -// TODO: Consider SoA if this approach works OK -public readonly record struct AppAnalysisResult -{ - private static readonly BitVector32.Section _timedOut = BitVector32.CreateSection(2); - private static readonly BitVector32.Section _validProject = BitVector32.CreateSection( - 2, - _timedOut - ); - private static readonly BitVector32.Section _builds = BitVector32.CreateSection( - 2, - _validProject - ); - private static readonly BitVector32.Section _hasAppLib = BitVector32.CreateSection(2, _builds); - private static readonly BitVector32.Section _hasLatestAppLib = BitVector32.CreateSection( - 2, - _hasAppLib - ); - - private readonly BitVector32 _bits; - - public AppAnalysisResult( - AppRepository repo, - bool? timedOut = null, - bool? validProject = null, - bool? builds = null, - bool? hasAppLib = null, - bool? HasLatestAppLib = null, - string? appLibVersion = null, - uint? warningCount = null, - Dictionary>? symbolReferenceCounts = null - ) - { - AppRepository = repo; - _bits = new BitVector32(0); - _bits[_timedOut] = ToValue(timedOut); - _bits[_validProject] = ToValue(validProject); - _bits[_builds] = ToValue(builds); - _bits[_hasAppLib] = ToValue(hasAppLib); - _bits[_hasLatestAppLib] = ToValue(HasLatestAppLib); - AppLibVersion = appLibVersion; - WarningCount = warningCount; - SymbolReferenceCountsBySymbol = symbolReferenceCounts ?? new(); - } - - public AppRepository AppRepository { get; } - - public readonly IReadOnlyDictionary< - string, - List - > SymbolReferenceCountsBySymbol { get; } - - public string? AppLibVersion { get; } - - public uint? WarningCount { get; } - - public readonly bool? TimedOut => FromValue(_timedOut); - public readonly bool? ValidProject => FromValue(_validProject); - public readonly bool? Builds => FromValue(_builds); - public readonly bool? HasAppLib => FromValue(_hasAppLib); - public readonly bool? HasLatestAppLib => FromValue(_hasLatestAppLib); - - public bool OK => - (_bits[_validProject] & _bits[_builds] & _bits[_hasAppLib] & _bits[_hasLatestAppLib]) == 1; - - private static int ToValue(bool? value) => - value switch - { - null => 2, - false => 0, - true => 1, - }; - - private bool? FromValue(BitVector32.Section section) => - _bits[section] switch - { - 2 => default(bool?), - 0 => false, - 1 => true, - var u => throw new Exception($"Unexpected value: {u}"), - }; -} - -public sealed class AppAnalyzer : IDisposable -{ - private readonly SyncThreadPool _threadPool; - - public AppAnalyzer(int threadCount) - { - _threadPool = new SyncThreadPool(threadCount); - } - - public async Task Analyze( - AppRepository app, - string[] findReferences, - ConcurrentBag results, - CancellationToken cancellationToken - ) - { - var result = await _threadPool.RunAsync( - async (CancellationToken cancellationToken) => - { - var manager = new AnalyzerManager(); - var dir = app.Dir; - var projectFile = Path.Combine(dir.FullName, "App", "App.csproj"); - - if (!File.Exists(projectFile)) - return new AppAnalysisResult(app, timedOut: false, validProject: false); - - IAnalyzerResults buildResults; - IProjectAnalyzer project; - try - { - project = manager.GetProject(projectFile); - cancellationToken.ThrowIfCancellationRequested(); - - buildResults = project.Build(); - cancellationToken.ThrowIfCancellationRequested(); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - return new AppAnalysisResult(app, timedOut: false, validProject: false); - } - - var builds = buildResults.OverallSuccess; - var result = buildResults.FirstOrDefault(r => r.Succeeded); - if (result is null) - return new AppAnalysisResult( - app, - timedOut: false, - validProject: true, - builds: builds - ); - - var appLibRef = result.PackageReferences.FirstOrDefault(r => - // Main - r.Key == "Altinn.App.Api" - // PR releases - || r.Key == "Altinn.App.Api.Experimental" - // <=6.0 - || r.Key == "Altinn.App.Common" - ); - string? version = null; - var hasAppLib = - appLibRef.Key is not null - && appLibRef.Value.TryGetValue("Version", out version); - var HasLatestAppLib = version is not null && version.StartsWith('8'); - if (!builds || !hasAppLib || !HasLatestAppLib) - return new AppAnalysisResult( - app, - timedOut: false, - validProject: true, - builds: builds, - hasAppLib: hasAppLib, - HasLatestAppLib: HasLatestAppLib, - appLibVersion: version - ); - - using var workspace = new AdhocWorkspace(); - // workspace.WorkspaceFailed += (sender, args) => logger?.LogError("Workspace failed: {Diagnostic}{NewLine}", args.Diagnostic, System.Environment.NewLine); - Project roslynProject; - Compilation? compilation; - try - { - result.AddToWorkspace(workspace, addProjectReferences: true); - roslynProject = workspace.CurrentSolution.Projects.First(); - compilation = await roslynProject.GetCompilationAsync(cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - return new AppAnalysisResult( - app, - timedOut: false, - validProject: true, - builds: false, - hasAppLib: hasAppLib, - HasLatestAppLib: HasLatestAppLib, - appLibVersion: version - ); - } - - if (compilation is null) - return new AppAnalysisResult( - app, - timedOut: false, - validProject: true, - builds: false, - hasAppLib: hasAppLib, - HasLatestAppLib: HasLatestAppLib, - appLibVersion: version - ); - - var diagnostics = compilation.GetDiagnostics(cancellationToken); - var warningCount = (uint) - diagnostics.Count(d => - d.Id != "CS1701" && d.Severity == DiagnosticSeverity.Warning - ); - builds = - builds - && !diagnostics.Any(d => - d.Severity >= DiagnosticSeverity.Error && !d.IsSuppressed - ); - if (!builds) - return new AppAnalysisResult( - app, - timedOut: false, - validProject: true, - builds: builds, - hasAppLib: hasAppLib, - HasLatestAppLib: HasLatestAppLib, - appLibVersion: version, - warningCount: warningCount - ); - - var referenceCounts = new Dictionary>(16); - await PopulateReferenceCounts( - findReferences, - roslynProject, - compilation, - referenceCounts, - cancellationToken - ); - - return new AppAnalysisResult( - app, - timedOut: false, - validProject: true, - builds: builds, - hasAppLib: hasAppLib, - HasLatestAppLib: HasLatestAppLib, - appLibVersion: version, - warningCount: warningCount, - symbolReferenceCounts: referenceCounts - ); - }, - cancellationToken - ); - results.Add(result); - } - - private static async Task PopulateReferenceCounts( - string[] findReferences, - Project roslynProject, - Compilation compilation, - Dictionary> referenceCounts, - CancellationToken cancellationToken - ) - { - foreach (var symbolRef in findReferences) - { - if (!referenceCounts.TryGetValue(symbolRef, out var symbolRefs)) - referenceCounts[symbolRef] = symbolRefs = new List(16); - - var symbols = DocumentationCommentId.GetSymbolsForDeclarationId(symbolRef, compilation); - foreach (var symbol in symbols) - { - var references = await SymbolFinder.FindReferencesAsync( - symbol, - roslynProject.Solution, - cancellationToken - ); - - foreach (var reference in references) - { - // Filter out references to the symbol itself - // (sometimes we get both the interface method and the implementation method) - if (!SymbolEqualityComparer.Default.Equals(reference.Definition, symbol)) - continue; - - foreach (var location in reference.Locations) - symbolRefs.Add(reference); - } - } - } - } - - public void Dispose() - { - _threadPool.Dispose(); - } -} +using System.Collections.Concurrent; +using System.Collections.Specialized; +using Buildalyzer; +using Buildalyzer.Workspaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; + +namespace Altinn.Analysis; + +public sealed record AppRepository(DirectoryInfo Dir, string Org, string Name); + +// TODO: Consider SoA if this approach works OK +public readonly record struct AppAnalysisResult +{ + private static readonly BitVector32.Section _timedOut = BitVector32.CreateSection(2); + private static readonly BitVector32.Section _validProject = BitVector32.CreateSection( + 2, + _timedOut + ); + private static readonly BitVector32.Section _builds = BitVector32.CreateSection( + 2, + _validProject + ); + private static readonly BitVector32.Section _hasAppLib = BitVector32.CreateSection(2, _builds); + private static readonly BitVector32.Section _hasLatestAppLib = BitVector32.CreateSection( + 2, + _hasAppLib + ); + + private readonly BitVector32 _bits; + + public AppAnalysisResult( + AppRepository repo, + bool? timedOut = null, + bool? validProject = null, + bool? builds = null, + bool? hasAppLib = null, + bool? HasLatestAppLib = null, + string? appLibVersion = null, + uint? warningCount = null, + Dictionary>? symbolReferenceCounts = null + ) + { + AppRepository = repo; + _bits = new BitVector32(0); + _bits[_timedOut] = ToValue(timedOut); + _bits[_validProject] = ToValue(validProject); + _bits[_builds] = ToValue(builds); + _bits[_hasAppLib] = ToValue(hasAppLib); + _bits[_hasLatestAppLib] = ToValue(HasLatestAppLib); + AppLibVersion = appLibVersion; + WarningCount = warningCount; + SymbolReferenceCountsBySymbol = symbolReferenceCounts ?? new(); + } + + public AppRepository AppRepository { get; } + + public readonly IReadOnlyDictionary< + string, + List + > SymbolReferenceCountsBySymbol { get; } + + public string? AppLibVersion { get; } + + public uint? WarningCount { get; } + + public readonly bool? TimedOut => FromValue(_timedOut); + public readonly bool? ValidProject => FromValue(_validProject); + public readonly bool? Builds => FromValue(_builds); + public readonly bool? HasAppLib => FromValue(_hasAppLib); + public readonly bool? HasLatestAppLib => FromValue(_hasLatestAppLib); + + public bool OK => + (_bits[_validProject] & _bits[_builds] & _bits[_hasAppLib] & _bits[_hasLatestAppLib]) == 1; + + private static int ToValue(bool? value) => + value switch + { + null => 2, + false => 0, + true => 1, + }; + + private bool? FromValue(BitVector32.Section section) => + _bits[section] switch + { + 2 => default(bool?), + 0 => false, + 1 => true, + var u => throw new Exception($"Unexpected value: {u}"), + }; +} + +public sealed class AppAnalyzer : IDisposable +{ + private readonly SyncThreadPool _threadPool; + + public AppAnalyzer(int threadCount) + { + _threadPool = new SyncThreadPool(threadCount); + } + + public async Task Analyze( + AppRepository app, + string[] findReferences, + ConcurrentBag results, + CancellationToken cancellationToken + ) + { + var result = await _threadPool.RunAsync( + async (CancellationToken cancellationToken) => + { + var manager = new AnalyzerManager(); + var dir = app.Dir; + var projectFile = Path.Combine(dir.FullName, "App", "App.csproj"); + + if (!File.Exists(projectFile)) + return new AppAnalysisResult(app, timedOut: false, validProject: false); + + IAnalyzerResults buildResults; + IProjectAnalyzer project; + try + { + project = manager.GetProject(projectFile); + cancellationToken.ThrowIfCancellationRequested(); + + buildResults = project.Build(); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new AppAnalysisResult(app, timedOut: false, validProject: false); + } + + var builds = buildResults.OverallSuccess; + var result = buildResults.FirstOrDefault(r => r.Succeeded); + if (result is null) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds + ); + + var appLibRef = result.PackageReferences.FirstOrDefault(r => + // Main + r.Key == "Altinn.App.Api" + // PR releases + || r.Key == "Altinn.App.Api.Experimental" + // <=6.0 + || r.Key == "Altinn.App.Common" + ); + string? version = null; + var hasAppLib = + appLibRef.Key is not null + && appLibRef.Value.TryGetValue("Version", out version); + var HasLatestAppLib = version is not null && version.StartsWith('8'); + if (!builds || !hasAppLib || !HasLatestAppLib) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version + ); + + using var workspace = new AdhocWorkspace(); + // workspace.WorkspaceFailed += (sender, args) => logger?.LogError("Workspace failed: {Diagnostic}{NewLine}", args.Diagnostic, System.Environment.NewLine); + Project roslynProject; + Compilation? compilation; + try + { + result.AddToWorkspace(workspace, addProjectReferences: true); + roslynProject = workspace.CurrentSolution.Projects.First(); + compilation = await roslynProject.GetCompilationAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: false, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version + ); + } + + if (compilation is null) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: false, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version + ); + + var diagnostics = compilation.GetDiagnostics(cancellationToken); + var warningCount = (uint) + diagnostics.Count(d => + d.Id != "CS1701" && d.Severity == DiagnosticSeverity.Warning + ); + builds = + builds + && !diagnostics.Any(d => + d.Severity >= DiagnosticSeverity.Error && !d.IsSuppressed + ); + if (!builds) + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version, + warningCount: warningCount + ); + + var referenceCounts = new Dictionary>(16); + await PopulateReferenceCounts( + findReferences, + roslynProject, + compilation, + referenceCounts, + cancellationToken + ); + + return new AppAnalysisResult( + app, + timedOut: false, + validProject: true, + builds: builds, + hasAppLib: hasAppLib, + HasLatestAppLib: HasLatestAppLib, + appLibVersion: version, + warningCount: warningCount, + symbolReferenceCounts: referenceCounts + ); + }, + cancellationToken + ); + results.Add(result); + } + + private static async Task PopulateReferenceCounts( + string[] findReferences, + Project roslynProject, + Compilation compilation, + Dictionary> referenceCounts, + CancellationToken cancellationToken + ) + { + foreach (var symbolRef in findReferences) + { + if (!referenceCounts.TryGetValue(symbolRef, out var symbolRefs)) + referenceCounts[symbolRef] = symbolRefs = new List(16); + + var symbols = DocumentationCommentId.GetSymbolsForDeclarationId(symbolRef, compilation); + foreach (var symbol in symbols) + { + var references = await SymbolFinder.FindReferencesAsync( + symbol, + roslynProject.Solution, + cancellationToken + ); + + foreach (var reference in references) + { + // Filter out references to the symbol itself + // (sometimes we get both the interface method and the implementation method) + if (!SymbolEqualityComparer.Default.Equals(reference.Definition, symbol)) + continue; + + foreach (var location in reference.Locations) + symbolRefs.Add(reference); + } + } + } + } + + public void Dispose() + { + _threadPool.Dispose(); + } +} diff --git a/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs b/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs index bdef1670..125a669d 100644 --- a/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs +++ b/AppsAnalysis/src/Altinn.Analysis/SyncThreadPool.cs @@ -1,119 +1,119 @@ -using System.Threading.Channels; - -namespace Altinn.Analysis; - -/// -/// Threadpool for running synchronous work -/// Passes `TaskCreationOptions.LongRunning` to individual task/threads -/// to make sure the scheduler spawns additional threads for each task. -/// Scheduling sync work on default TaskScheduler tasks/threads would be bad -/// -internal sealed class SyncThreadPool : IDisposable -{ - private readonly Channel> _workQueue; - private readonly Task[] _workers; - private readonly CancellationTokenSource _cts = new(); - - public SyncThreadPool(int threadCount = 0) - { - // Default to number of physical cores if not specified - if (threadCount <= 0) - threadCount = Environment.ProcessorCount; - - _workQueue = Channel.CreateUnbounded>(); - _workers = new Task[threadCount]; - - for (int i = 0; i < threadCount; i++) - { - _workers[i] = Task - .Factory.StartNew( - Worker, - _cts.Token, - TaskCreationOptions.LongRunning, - TaskScheduler.Default - ) - .Unwrap(); - } - } - - private async Task Worker() - { - try - { - await foreach (var work in _workQueue.Reader.ReadAllAsync(_cts.Token)) - { - await work(_cts.Token); - } - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - // Log the exception or handle it as needed - await Console.Error.WriteLineAsync($"Worker encountered an error: {ex.Message}"); - Environment.Exit(1); - } - } - - public Task RunAsync( - Func operation, - CancellationToken cancellationToken = default - ) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _workQueue.Writer.TryWrite(poolCancellationToken => - { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - poolCancellationToken, - cancellationToken - ); - try - { - var result = operation(linkedCts.Token); - tcs.SetResult(result); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - return Task.CompletedTask; - }); - - return tcs.Task; - } - - public Task RunAsync( - Func> operation, - CancellationToken cancellationToken = default - ) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - _workQueue.Writer.TryWrite(async poolCancellationToken => - { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - poolCancellationToken, - cancellationToken - ); - try - { - var result = await operation(linkedCts.Token); - tcs.SetResult(result); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }); - - return tcs.Task; - } - - public void Dispose() - { - _workQueue.Writer.Complete(); - _cts.Cancel(); - Task.WaitAll(_workers); - _cts.Dispose(); - } -} +using System.Threading.Channels; + +namespace Altinn.Analysis; + +/// +/// Threadpool for running synchronous work +/// Passes `TaskCreationOptions.LongRunning` to individual task/threads +/// to make sure the scheduler spawns additional threads for each task. +/// Scheduling sync work on default TaskScheduler tasks/threads would be bad +/// +internal sealed class SyncThreadPool : IDisposable +{ + private readonly Channel> _workQueue; + private readonly Task[] _workers; + private readonly CancellationTokenSource _cts = new(); + + public SyncThreadPool(int threadCount = 0) + { + // Default to number of physical cores if not specified + if (threadCount <= 0) + threadCount = Environment.ProcessorCount; + + _workQueue = Channel.CreateUnbounded>(); + _workers = new Task[threadCount]; + + for (int i = 0; i < threadCount; i++) + { + _workers[i] = Task + .Factory.StartNew( + Worker, + _cts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ) + .Unwrap(); + } + } + + private async Task Worker() + { + try + { + await foreach (var work in _workQueue.Reader.ReadAllAsync(_cts.Token)) + { + await work(_cts.Token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + // Log the exception or handle it as needed + await Console.Error.WriteLineAsync($"Worker encountered an error: {ex.Message}"); + Environment.Exit(1); + } + } + + public Task RunAsync( + Func operation, + CancellationToken cancellationToken = default + ) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _workQueue.Writer.TryWrite(poolCancellationToken => + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + poolCancellationToken, + cancellationToken + ); + try + { + var result = operation(linkedCts.Token); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + return Task.CompletedTask; + }); + + return tcs.Task; + } + + public Task RunAsync( + Func> operation, + CancellationToken cancellationToken = default + ) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _workQueue.Writer.TryWrite(async poolCancellationToken => + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + poolCancellationToken, + cancellationToken + ); + try + { + var result = await operation(linkedCts.Token); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; + } + + public void Dispose() + { + _workQueue.Writer.Complete(); + _cts.Cancel(); + Task.WaitAll(_workers); + _cts.Dispose(); + } +} From 5ee672939943569d5e1f5b17a5897f8662964ded Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sat, 16 Aug 2025 22:22:59 +0200 Subject: [PATCH 5/5] dont limit parallelism on analyze --- AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs b/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs index f44bce45..16139fdb 100644 --- a/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs +++ b/AppsAnalysis/src/Altinn.Analysis/AppsAnalyzer.cs @@ -28,7 +28,7 @@ private bool VerifyConfig() table.AddColumn(new TableColumn("")); table.AddColumn(new TableColumn("")); - _parallelism = Math.Min(Constants.LimitMaxParallelism, _config.MaxParallelism); + _parallelism = _config.MaxParallelism; var directory = Path.GetFullPath( _config.Directory.Replace( "~",