Jekyll2023-12-01T21:29:06+00:00https://patrickklaeren.dev/feed.xmlPatrick’s BlogRamblings of a software developerPatrick KlaerenReflecting on a difficult year2023-12-01T00:00:00+00:002023-12-01T00:00:00+00:00https://patrickklaeren.dev/2023/a-festive-reflection<p>Despite everything I preach, I remain my own worst enemy. I barely communicate and I struggle with consistency, even at the most basic of tasks, such as writing a blog post.</p>
<p>It’s been well over a year since I last committed a post to this blog. It’s unfortunate that the time to put pen to paper (or in my case, words to scren) has been so fleeting that I have all but forgotten about this space I intended document by thoughts, processes and development. I think writing about, and teaching about, programming is very important. It makes you stop. It makes you think. It makes you justify that what you’ve learnt can be truly be solidified or perpetuated in some form of more consistent medium. It’s important I’m back and intend to do so with more regular posts in the upcoming year.</p>
<p>In fact, one of my resolutions for the new year is to write more, justify more and be more kind. 2024 will be a great year.</p>
<p>One of the elements that has made 2023 a difficult year - aside from all the ongoings in the world around me - are the struggles in how to lead and dare I say: inspire. Or at least hope-to-aspire situations. I am leading a team of 15 developers, some more, some less experienced. Some are completely new to the world of business software and lack the experience to understand what’s “right” and what’s “best” - the two differ a lot. Some of them do not understand the decisions I make, or where they come from or why I might choose to say things the way I say them. It’ll all come in time and I hope that they’ll one day think back to those scenarios where they put face to palm and questioned my sanity.</p>
<p>It’s been a year where I’ve come to appreciate the “you cannot make everyone happy” idiom a lot more. It’s been a year that has taught me to mature. It’s been a year that has reignited a fuel that has been dormant for a while. Here’s to 2024.</p>Patrick KlaerenDespite everything I preach, I remain my own worst enemy. I barely communicate and I struggle with consistency, even at the most basic of tasks, such as writing a blog post.Migrating from Microsoft Teams to Discord2022-09-30T00:00:00+00:002022-09-30T00:00:00+00:00https://patrickklaeren.dev/2022/migrating-from-teams-to-discord<p>Communication is a mess in almost any human setting. Trying to get everyone onto the same page is a feat not many people can manage. You end up with a scale of free for all to micromanaging, where the latter usually alienates people but, in my experience, sometimes yields the best results. Thousands, if not millions, of solutions, or at least attempted, solutions have tried to conquer the Discord (heh, get it?) between people at work, home or in any other setting.</p>
<p>Today the options usually distil down to Slack, Microsoft Teams, Rocket Chat, or another Slack-like competitor. These apps have become “necessary evils” even though they all individually provide massive bottlenecks and drawbacks. This was particularly evident during the pandemic and lockdowns where these applications thrived and work moved to text-based back and forth conversations, sometimes arguments. We experienced this ourselves, moving first from Slack to Teams, then eventually to Discord – a platform that is not the first choice for any “professional” team, and a no-go for any company that requires strict compliance with auditing checks and the likes. Though, our choice of Discord has faired extremely well, in a world where remote colleagues now are able to sit in voice channels and people can drop in and out when they wish. Gone are the days of scheduled meetings and tight requirements for a channel to exist. Discord’s loose approach to collaboration and communication makes for an inviting difference in an otherwise samey corporate stiffness.</p>
<p>Mobilising an entire company to use any new piece of software is a new learning experience every time. Discord was no different. When you first move, you’ll be in the deep end. Integrations for anything “real” like Jira, GitHub and the like are slim. Webhooks are natively supported, and you can have GitHub post to Discord for events, but anything granular and you’re left to wonder what could be if Discord did position itself as a Slack competitor. In some regards I am thankful Discord isn’t positioning itself as such because its platform remains more open and freer to developers like me who choose to build an entire devops experience off Discord.</p>
<p>In April 2020 we started our Discord adventure and more than two years on we now have our own Discord bot that happily sits listening for events, posting to the channels where the event is relevant to and keeping every single person, on the Discord, in the know. Need a new metric? No problem. Let’s get that into the Discord bot.</p>
<p>Here’s how I achieved it.</p>
<p>In the .NET Core world, ASP.NET is a wonderfully equipped platform to build almost any application on top of almost any hosting provider. Back in the days of ASP.NET MVC (Framework) you’d often ask yourself, “can I host my ASP.NET app here” and the answer would almost without a doubt be no, no you cannot. With the advent of .NET Core running across Windows, Mac and Linux, ASP.NET Core has become a verstaile enough platform to be able to host anything from niche Discord bots to multi-million simultaneous connections to a website or API. The foundation of the bot was therefore going to be ASP.NET Core, with the Discord bot as a <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-6.0&tabs=visual-studio">hosted service</a>. While I didn’t need a website <em>yet</em>, ASP.NET Core would provide the benefits of allowing that to happen in future, while also allowing me to just <em>turn that functionality off</em> in a crude sense.</p>
<p>There are a number of open source .NET libraries that help you interface with Discord’s API. While <a href="https://github.com/discord-net/Discord.Net">Discord.NET</a> and <a href="https://github.com/DSharpPlus/DSharpPlus">DSharpPlus</a> exist, the choice for our internal bot is <a href="https://github.com/Remora/Remora.Discord">Remora.Discord</a>. The reason for this is that it is the lowest-level available library that directly tries to mimic the structure and API surface of Discord’s own API documentation. There are no expensive or convoluted abstractions, which brings benefits to both the consumers of Remora and the developers maintaining the library.</p>
<p>The project structure isolates Discord bot logic from the ASP.NET Core project. This allows us to substitute in another host for the bot if the need ever arises - though, I doubt that will ever be the case.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/
Contoso.API
Contoso.DiscordBot
</code></pre></div></div>
<p>Is a crude depiction of the project structure, where <code class="language-plaintext highlighter-rouge">Contoso.API</code> is our ASP.NET Core host and <code class="language-plaintext highlighter-rouge">Contoso.DiscordBot</code> is a .NET Core class library.</p>
<p>In <code class="language-plaintext highlighter-rouge">Contoso.DiscordBot</code> we will want to define our bot client, which will eventually be started by our ASP.NET Core host. We will also configure our Discord bot with an options class. Not everything defined here will be relevant for your own set up.</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">DiscordBotOptions</span>
<span class="p">{</span>
<span class="k">public</span> <span class="k">static</span> <span class="kt">string</span> <span class="n">Key</span> <span class="p">=></span> <span class="s">"Discord"</span><span class="p">;</span>
<span class="k">public</span> <span class="kt">ulong</span> <span class="n">GuildId</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="k">public</span> <span class="kt">ulong</span> <span class="n">VoiceChannelId</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="k">public</span> <span class="kt">ulong</span> <span class="n">VoiceRoleId</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">set</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>These options will be available bot-wide wherever required. We have configurable IDs for channels in Discord, which are referred to as Snowflakes. In the <code class="language-plaintext highlighter-rouge">appsettings.json</code>, we have the following configurations defined:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w"> </span><span class="nl">"Discord"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"Token"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
</span><span class="nl">"GuildId"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
</span><span class="nl">"VoiceRoleId"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
</span><span class="nl">"VoiceChannelId"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>It’s important to note that these configurations have to be hydrated from the hosting app, i.e. the ASP.NET Core application. On startup of the host, we push the ASP.NET Core configuration into the Discord bot. We have a <code class="language-plaintext highlighter-rouge">IBotClient</code>, and implementation of said interface, to contain the start up and run logic of the bot.</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">interface</span> <span class="nc">IBotClient</span>
<span class="p">{</span>
<span class="n">Task</span> <span class="nf">Run</span><span class="p">(</span><span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">BotClient</span> <span class="p">:</span> <span class="n">IBotClient</span>
<span class="p">{</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p"><</span><span class="n">BotClient</span><span class="p">></span> <span class="n">_logger</span><span class="p">;</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">IOptions</span><span class="p"><</span><span class="n">DiscordBotOptions</span><span class="p">></span> <span class="n">_options</span><span class="p">;</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">SlashService</span> <span class="n">_slashService</span><span class="p">;</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">DiscordGatewayClient</span> <span class="n">_discordGatewayClient</span><span class="p">;</span>
<span class="k">public</span> <span class="nf">BotClient</span><span class="p">(</span>
<span class="n">ILogger</span><span class="p"><</span><span class="n">BotClient</span><span class="p">></span> <span class="n">logger</span><span class="p">,</span>
<span class="n">IOptions</span><span class="p"><</span><span class="n">DiscordBotOptions</span><span class="p">></span> <span class="n">options</span><span class="p">,</span>
<span class="n">SlashService</span> <span class="n">slashService</span><span class="p">,</span>
<span class="n">DiscordGatewayClient</span> <span class="n">discordGatewayClient</span>
<span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
<span class="n">_options</span> <span class="p">=</span> <span class="n">options</span><span class="p">;</span>
<span class="n">_slashService</span> <span class="p">=</span> <span class="n">slashService</span><span class="p">;</span>
<span class="n">_discordGatewayClient</span> <span class="p">=</span> <span class="n">discordGatewayClient</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">Run</span><span class="p">(</span><span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
<span class="p">{</span>
<span class="k">await</span> <span class="nf">RegisterSlashCommands</span><span class="p">(</span><span class="n">cancellationToken</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">runResult</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_discordGatewayClient</span><span class="p">.</span><span class="nf">RunAsync</span><span class="p">(</span><span class="n">cancellationToken</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(!</span><span class="n">runResult</span><span class="p">.</span><span class="n">IsSuccess</span><span class="p">)</span>
<span class="p">{</span>
<span class="k">switch</span> <span class="p">(</span><span class="n">runResult</span><span class="p">.</span><span class="n">Error</span><span class="p">)</span>
<span class="p">{</span>
<span class="k">case</span> <span class="n">ExceptionError</span> <span class="n">exe</span><span class="p">:</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogError</span><span class="p">(</span><span class="n">exe</span><span class="p">.</span><span class="n">Exception</span><span class="p">,</span><span class="s">"Exception during gateway connection: {ExceptionMessage}"</span><span class="p">,</span> <span class="n">exe</span><span class="p">.</span><span class="n">Message</span><span class="p">);</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">case</span> <span class="n">GatewayWebSocketError</span><span class="p">:</span>
<span class="k">case</span> <span class="n">GatewayDiscordError</span><span class="p">:</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogError</span><span class="p">(</span><span class="s">"Gateway error: {Message}"</span><span class="p">,</span> <span class="n">runResult</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">default</span><span class="p">:</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogError</span><span class="p">(</span><span class="s">"Unknown error: {Message}"</span><span class="p">,</span> <span class="n">runResult</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">private</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">RegisterSlashCommands</span><span class="p">(</span><span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
<span class="p">{</span>
<span class="kt">var</span> <span class="n">checkSlashSupport</span> <span class="p">=</span> <span class="n">_slashService</span><span class="p">.</span><span class="nf">SupportsSlashCommands</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(!</span><span class="n">checkSlashSupport</span><span class="p">.</span><span class="n">IsSuccess</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogWarning</span><span class="p">(</span><span class="s">"The registered commands of the bot don't support slash commands: {Reason}"</span><span class="p">,</span> <span class="n">checkSlashSupport</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
<span class="kt">var</span> <span class="n">updateSlash</span> <span class="p">=</span> <span class="k">await</span> <span class="n">_slashService</span><span class="p">.</span><span class="nf">UpdateSlashCommandsAsync</span><span class="p">(</span><span class="k">new</span> <span class="nf">Snowflake</span><span class="p">(</span><span class="n">_options</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="n">GuildId</span><span class="p">),</span> <span class="n">cancellationToken</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(!</span><span class="n">updateSlash</span><span class="p">.</span><span class="n">IsSuccess</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogWarning</span><span class="p">(</span><span class="s">"Failed to update slash commands: {Reason}"</span><span class="p">,</span> <span class="n">updateSlash</span><span class="p">.</span><span class="nf">ToString</span><span class="p">());</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The <code class="language-plaintext highlighter-rouge">IBotClient</code> interface is used within the background service implementation in the ASP.NET Core host app, which becomes a hosted service.</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">BotHostedService</span> <span class="p">:</span> <span class="n">BackgroundService</span>
<span class="p">{</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">IBotClient</span> <span class="n">_botClient</span><span class="p">;</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p"><</span><span class="n">BotHostedService</span><span class="p">></span> <span class="n">_logger</span><span class="p">;</span>
<span class="k">public</span> <span class="nf">BotHostedService</span><span class="p">(</span><span class="n">IBotClient</span> <span class="n">botClient</span><span class="p">,</span> <span class="n">ILogger</span><span class="p"><</span><span class="n">BotHostedService</span><span class="p">></span> <span class="n">logger</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_botClient</span> <span class="p">=</span> <span class="n">botClient</span><span class="p">;</span>
<span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">protected</span> <span class="k">override</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">ExecuteAsync</span><span class="p">(</span><span class="n">CancellationToken</span> <span class="n">stoppingToken</span><span class="p">)</span>
<span class="p">{</span>
<span class="k">try</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">"Starting bot..."</span><span class="p">);</span>
<span class="k">await</span> <span class="n">_botClient</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="n">stoppingToken</span><span class="p">);</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">"Stopped bot!"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">e</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogCritical</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="s">"Failed starting Discord bot"</span><span class="p">);</span>
<span class="k">throw</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>On startup, two methods are invoked in the ASP.NET Core app:</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">services</span>
<span class="p">.</span><span class="nf">AddBotClient</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">)</span>
<span class="p">.</span><span class="n">AddHostedService</span><span class="p"><</span><span class="n">BotHostedService</span><span class="p">>()</span>
</code></pre></div></div>
<p>The former is an extension method defined in the Discord bot class library which isolates the logic, allowing for the configuration of the bot client to happen anywhere a Microsoft Extensions Dependency Injection container is available.</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">ServiceCollectionExtensions</span>
<span class="p">{</span>
<span class="k">public</span> <span class="k">static</span> <span class="n">IServiceCollection</span> <span class="nf">AddBotClient</span><span class="p">(</span><span class="k">this</span> <span class="n">IServiceCollection</span> <span class="n">collection</span><span class="p">,</span> <span class="n">IConfiguration</span> <span class="n">configuration</span><span class="p">)</span>
<span class="p">{</span>
<span class="kt">var</span> <span class="n">token</span> <span class="p">=</span> <span class="n">configuration</span><span class="p">[</span><span class="s">"Discord:Token"</span><span class="p">];</span>
<span class="k">return</span> <span class="n">collection</span>
<span class="p">.</span><span class="nf">AddDiscordGateway</span><span class="p">(</span><span class="n">_</span> <span class="p">=></span> <span class="n">token</span><span class="p">)</span>
<span class="p">.</span><span class="nf">AddDiscordCommands</span><span class="p">(</span><span class="k">true</span><span class="p">)</span>
<span class="p">.</span><span class="n">Configure</span><span class="p"><</span><span class="n">DiscordBotOptions</span><span class="p">>(</span><span class="n">configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="n">DiscordBotOptions</span><span class="p">.</span><span class="n">Key</span><span class="p">))</span>
<span class="p">.</span><span class="n">Configure</span><span class="p"><</span><span class="n">DiscordGatewayClientOptions</span><span class="p">>(</span><span class="n">o</span> <span class="p">=></span>
<span class="p">{</span>
<span class="n">o</span><span class="p">.</span><span class="n">Intents</span> <span class="p">|=</span> <span class="n">GatewayIntents</span><span class="p">.</span><span class="n">DirectMessageReactions</span><span class="p">;</span>
<span class="n">o</span><span class="p">.</span><span class="n">Intents</span> <span class="p">|=</span> <span class="n">GatewayIntents</span><span class="p">.</span><span class="n">GuildMessageReactions</span><span class="p">;</span>
<span class="n">o</span><span class="p">.</span><span class="n">Intents</span> <span class="p">|=</span> <span class="n">GatewayIntents</span><span class="p">.</span><span class="n">GuildPresences</span><span class="p">;</span>
<span class="n">o</span><span class="p">.</span><span class="n">Intents</span> <span class="p">|=</span> <span class="n">GatewayIntents</span><span class="p">.</span><span class="n">GuildVoiceStates</span><span class="p">;</span>
<span class="p">})</span>
<span class="p">.</span><span class="n">AddCommandGroup</span><span class="p"><</span><span class="n">GitHubCommandGroup</span><span class="p">>()</span>
<span class="p">.</span><span class="n">AddResponder</span><span class="p"><</span><span class="n">GitHubLinkResponder</span><span class="p">>()</span>
<span class="p">.</span><span class="n">AddScoped</span><span class="p"><</span><span class="n">CommandResponder</span><span class="p">>()</span>
<span class="p">.</span><span class="n">AddTransient</span><span class="p"><</span><span class="n">IBotClient</span><span class="p">,</span> <span class="n">BotClient</span><span class="p">>();</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>This is where most of the configuration magic happens, we grab the Discord token and set up intents and Remora concepts, such as Responders and Command Groups. Responders are types that, well, respond to events in the Discord guild(s) the bot is in, for example, a message is received. Command groups are types that declare available commands in Discord, typically available via a slash (or <code class="language-plaintext highlighter-rouge">/</code>) syntax.</p>
<p>One of the annoyances of any chat platform includes discussing a particular issue or pull request. I added a responder that listens for any received message and that messages’ contents includes a GitHub issue/pull request reference. I decided to denote the syntax as <code class="language-plaintext highlighter-rouge">#</code> followed by just numbers. This way if someone referenced <code class="language-plaintext highlighter-rouge">123</code>, we wouldn’t trigger the bot, but if someone referenced <code class="language-plaintext highlighter-rouge">#123</code>, the bot would trigger and look for an issue or pull request with ID 123. Regex to the rescue!</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">class</span> <span class="nc">GitHubLinkResponder</span> <span class="p">:</span> <span class="n">IResponder</span><span class="p"><</span><span class="n">IMessageCreate</span><span class="p">></span>
<span class="p">{</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">ILogger</span><span class="p"><</span><span class="n">GitHubLinkResponder</span><span class="p">></span> <span class="n">_logger</span><span class="p">;</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="n">IDiscordRestChannelAPI</span> <span class="n">_channelApi</span><span class="p">;</span>
<span class="k">public</span> <span class="nf">GitHubLinkResponder</span><span class="p">(</span><span class="n">ILogger</span><span class="p"><</span><span class="n">GitHubLinkResponder</span><span class="p">></span> <span class="n">logger</span><span class="p">,</span>
<span class="n">IDiscordRestChannelAPI</span> <span class="n">channelApi</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span> <span class="p">=</span> <span class="n">logger</span><span class="p">;</span>
<span class="n">_channelApi</span> <span class="p">=</span> <span class="n">channelApi</span><span class="p">;</span>
<span class="n">_gitHubApiService</span> <span class="p">=</span> <span class="n">gitHubApiService</span><span class="p">;</span>
<span class="n">_repositorySubscriptionService</span> <span class="p">=</span> <span class="n">repositorySubscriptionService</span><span class="p">;</span>
<span class="n">_userService</span> <span class="p">=</span> <span class="n">userService</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p"><</span><span class="n">Result</span><span class="p">></span> <span class="nf">RespondAsync</span><span class="p">(</span><span class="n">IMessageCreate</span> <span class="n">gatewayEvent</span><span class="p">,</span> <span class="n">CancellationToken</span> <span class="n">ct</span> <span class="p">=</span> <span class="k">new</span><span class="p">())</span>
<span class="p">{</span>
<span class="kt">var</span> <span class="n">processEmbeds</span> <span class="p">=</span> <span class="k">false</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">GitHubLinkHelper</span><span class="p">.</span><span class="nf">ContainsGitHubIssueLink</span><span class="p">(</span><span class="n">gatewayEvent</span><span class="p">.</span><span class="n">Content</span><span class="p">))</span>
<span class="p">{</span>
<span class="k">await</span> <span class="nf">HandleIssueLink</span><span class="p">(</span><span class="n">gatewayEvent</span><span class="p">,</span> <span class="n">ct</span><span class="p">);</span>
<span class="n">processEmbeds</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">if</span> <span class="p">(</span><span class="n">GitHubLinkHelper</span><span class="p">.</span><span class="nf">ContainsGitHubPullRequestLink</span><span class="p">(</span><span class="n">gatewayEvent</span><span class="p">.</span><span class="n">Content</span><span class="p">))</span>
<span class="p">{</span>
<span class="k">await</span> <span class="nf">HandlePullRequestLink</span><span class="p">(</span><span class="n">gatewayEvent</span><span class="p">,</span> <span class="n">ct</span><span class="p">);</span>
<span class="n">processEmbeds</span> <span class="p">=</span> <span class="k">true</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">if</span> <span class="p">(</span><span class="n">processEmbeds</span>
<span class="p">&&</span> <span class="p">!</span><span class="n">gatewayEvent</span><span class="p">.</span><span class="n">Embeds</span><span class="p">.</span><span class="nf">Any</span><span class="p">())</span>
<span class="p">{</span>
<span class="k">await</span> <span class="n">_channelApi</span><span class="p">.</span><span class="nf">EditMessageAsync</span><span class="p">(</span><span class="n">gatewayEvent</span><span class="p">.</span><span class="n">ChannelID</span><span class="p">,</span>
<span class="n">gatewayEvent</span><span class="p">.</span><span class="n">ID</span><span class="p">,</span>
<span class="n">flags</span><span class="p">:</span> <span class="n">MessageFlags</span><span class="p">.</span><span class="n">SuppressEmbeds</span><span class="p">,</span>
<span class="n">ct</span><span class="p">:</span> <span class="n">ct</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">Result</span><span class="p">.</span><span class="nf">FromSuccess</span><span class="p">();</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>This is largely a stripped down implementation - however by implementing <code class="language-plaintext highlighter-rouge">IResponder<IMessageCreate></code> on our class, we will receive any message sent to a channel in Discord where our bot is present. Every message is run through our <code class="language-plaintext highlighter-rouge">GitHubLinkHelper</code>, which scans the contents of the message against our Regex for GitHub ID syntax. If matched, we process the message and post it to Discord with context fetched from the GitHub API.</p>
<p>So why do I host the bot in an ASP.NET Core container? It allows me to post anything to a defined endpoint and have it relayed to Discord. The two concepts completely isolated, where I could route any API request to another bot or application. Since the Discord bot is configured within ASP.NET Core, I have access to the very same bot client that is running and connected to Discord. It is simply a case of getting the instance of the bot client and posting to the relevant channel.</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="nf">HttpPost</span><span class="p">(</span><span class="s">"ondeployment"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p"><</span><span class="n">IActionResult</span><span class="p">></span> <span class="nf">OnDeployment</span><span class="p">(</span><span class="n">AzureDevopsDeploymentModel</span> <span class="n">model</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogInformation</span><span class="p">(</span><span class="s">"Received {MethodName}"</span><span class="p">,</span> <span class="k">nameof</span><span class="p">(</span><span class="n">OnDeployment</span><span class="p">));</span>
<span class="k">try</span>
<span class="p">{</span>
<span class="kt">var</span> <span class="n">dto</span> <span class="p">=</span> <span class="n">model</span><span class="p">.</span><span class="nf">CreateDto</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">channelIds</span> <span class="p">=</span>
<span class="k">await</span> <span class="n">_repositorySubscriptionService</span><span class="p">.</span><span class="nf">GetDiscordChannelIdsForRepositoryName</span><span class="p">(</span><span class="n">dto</span><span class="p">.</span><span class="n">ProjectName</span><span class="p">);</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">channelId</span> <span class="k">in</span> <span class="n">channelIds</span><span class="p">)</span>
<span class="p">{</span>
<span class="k">await</span> <span class="n">_discordPostService</span><span class="p">.</span><span class="nf">PostAzurePipelinesDeployment</span><span class="p">(</span><span class="n">channelId</span><span class="p">,</span> <span class="n">dto</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">e</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">_logger</span><span class="p">.</span><span class="nf">LogCritical</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="s">"Failed {MethodName}"</span><span class="p">,</span> <span class="k">nameof</span><span class="p">(</span><span class="n">OnDeployment</span><span class="p">));</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nf">Ok</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The implementation of such a tool for internal use is very specific to our use cases. I fixed annoyances and made quality of life improvements to what everyone likes to refer to as chat ops. Today we have a Discord bot that integrates with our GitHub, Sentry, Azure Devops and SEQ.</p>Patrick KlaerenCommunication is a mess in almost any human setting. Trying to get everyone onto the same page is a feat not many people can manage. You end up with a scale of free for all to micromanaging, where the latter usually alienates people but, in my experience, sometimes yields the best results. Thousands, if not millions, of solutions, or at least attempted, solutions have tried to conquer the Discord (heh, get it?) between people at work, home or in any other setting.Why .NET Core isn’t LTS2022-05-27T00:00:00+00:002022-05-27T00:00:00+00:00https://patrickklaeren.dev/2022/why-dotnet-isnt-lts<p>New languages and frameworks all seem to have one thing in common: the rate at which they try (or have) to innovate. Drop a framework, look away for a moment and you come back to a rewritten stack of entirely new concepts. The space between version one and version two is almost unpalatable.</p>
<p>Modern software development almost demands a rapid fire of changes. There’s no room for breathing when everyone promotes “move fast and break things”. It’s a recursive pattern fueled by us and us alone. Developers demand this and yet, at the same time, developers demand stability, performance and convenience.</p>
<p>Microsoft adopted such a “most fast and break things” policy when introducing .NET Core. Way back when .NET Core 1.0 was revealed to a much smaller parade of people than today’s releases, little did I know that the entire landscape of .NET development was about to change. Up until this point it was comfy - you’d grab a cushion and deal with the quirks and bloated nature of .NET Framework, which only ran on Windows, but you’d be safe in the knowledge that, whatever you’d write would work on a target machine. .NET Framework would be preinstalled and, whatever you end up deploying, would work in 5 or 10 years time. I still have applications running .NET Framework 4.0 in production today, almost 13 years after the fact.</p>
<p>Today; we’re faced with a put up or shut up attitude. .NET (Core) is moving at a pace which demands C# versions update yearly and new or breaking API changes (because it’s a major version, duh) are made regularly. Look away for just one year and your upgrade path is littered with nuances of the version you might have skipped. While Microsoft’s documentation is likely some of the best programming documentation out there, most of it is lost to the void in unindexable YouTube videos. Good luck finding that. Not only does the pace welcome breaking API changes, but even Microsoft themselves seem to lack the ability to keep up. MAUI was slated for release alongside .NET 6 in November of 2021. A little over six months later, MAUI finally hit what the developers considered a “ready” product. We won’t talk about MAUI in this post, but there’s plenty to cover over how MAUI in itself is a fundamentally flawed and developer-hostile framework.</p>
<p>Small updates to .NET (Core) now break older frameworks. WPF likely suffers the most here. Tooltips randomly disappear. DPI scaling breaks.</p>
<p>Of course these issues happened before Core and they’ll happen long after the next big Microsoft framework is made; yet this has become so much more prevalent and pronounced as .NET becomes more popular, more demanding and the policy remains the same: move fast and break things.</p>
<p>.NET’s yearly release cadence is taxing on most, from seasoned C# developers to novices hoping to make the next big thing with .NET as their chosen weapon. Enterprises hoping to make use of .NET will likely opt for the long term support, or LTS, releases instead. These are <em>bigger</em> releases of .NET that will be supported longer than just one year. Three years at most.</p>
<p>Three years is a drop in the ocean for a bigger corporate project. Of course .NET won’t just stop working at the point it’s no longer officially supported by Microsoft, but bullish blog posts, even emails from Azure, push developers to upgrade their projects. This leads those same developers down a rabbit hole of not only finding the required time (and often funding) for said upgrade, but now documentation that previously implicitly relied on a developer upgrading their .NET application year-on-year is sat needing to be stitched together in the right order so that we can bump from one major version to another.</p>
<p>These LTS releases are an attempt at trying to instill confidence in an ecosystem that has long dropped prolonged stability and usability in favour of moving fast and breaking things.</p>Patrick KlaerenNew languages and frameworks all seem to have one thing in common: the rate at which they try (or have) to innovate. Drop a framework, look away for a moment and you come back to a rewritten stack of entirely new concepts. The space between version one and version two is almost unpalatable.Document your work early, often and securely2022-02-27T00:00:00+00:002022-02-27T00:00:00+00:00https://patrickklaeren.dev/2022/azure-ad-team-wiki<p>If there’s one thing that I would yell at myself when first getting started with software development or, for that matter, any project in general, it would be to document early, often and securely. Here’s my story of creating a software company’s wiki, backed by Azure AD, with Git as storage.</p>
<p>It’s not a secret that the time investment required for documentation is either underestimated or just not allocated in the first place. Most enterprise scenarios won’t ever account for documentation. Elaborations care little for the technical writing that will, or <em>should</em> be required and stakeholders simply do not see the return on investment for documenting systems that, in ten years, might still be running but under a completely different set of eyes from those that originally envisioned the implementation. Open source projects generally fair better in this department, but even there poor devops can cause labourious tasks and lagging updates.</p>
<p>If code is your business I cannot stress enough how you should invest early into documenting your technical processes, projects and other jobs that you might be doing regularly but are thought-expensive.</p>
<p>Identify your minimum requirements of at least getting words onto a screen, securely.</p>
<h2 id="in-an-ideal-world">In an ideal world</h2>
<p><strong>Absolutes:</strong></p>
<ul>
<li>Be backed by markdown files</li>
<li>Be source controlled in a Git repository, alongside all of our other projects</li>
<li>Allow for each pushing of new articles, whereby pushing to the repo would trigger a build pipeline</li>
<li>Have low effort authentication and authorisation, maybe even be managed by an existing SSO option. We already make use of AzureAD almost everywhere</li>
</ul>
<p><strong>Nice-to-haves:</strong></p>
<ul>
<li>Mermaid diagram integration</li>
<li>Syntax highlighting for codeblocks</li>
<li>Built in markdown editing</li>
</ul>
<h2 id="what-we-tried">What we tried</h2>
<p>For a number of years we had tried several solutions;</p>
<p><strong>A Word document in OneDrive</strong></p>
<p>While Microsoft’s realtime collaboration tools are great and worked flawlessly for us - it’s Word at the end of the day. It’s OneDrive and just yuck. There’s no real version control. It’s hard to format, and we all know the problems of dragging images around. It’s just not a flexible solution at scale. Code is horrific in Word.</p>
<p><strong>Confluence</strong></p>
<p>We were already in the Atlassian ecosystem. We used Jira, Bitbucket, and had the unfortunate luck of also using Hipchat for a small period of time. It made sense to try Confluence. Atlassian even has its own SSO capabilities across all products. One account, access to all products. Unfortunately Confluence is also incredibly convoluted. I have no doubt it functions well for large, critical, installations where there’s a strict structure, with teams to maintain the many nuts and bolts within Confluence. We’re small and don’t have time to deal with the governance models Confluence <em>would</em> want to impose on you.</p>
<p><strong>Azure DevOps Wiki</strong></p>
<p>When we migrated to Azure Devops and moved repos, CI/CD pipelines and issues to the same platform, we also decided to try out the built in Wiki functionality. Surprisingly it checks off many of the requirements. It being on Microsoft’s Azure platform means authentication just works, the wiki is source controlled, it’s immediately updated and can be edited in the browser. There is some proprietary(?) formatting to order the menu, but that’s just one file. You might think, ok, so that’s end of story? Well. Being on Azure Devops Wiki ties you into Azure DevOps. You can’t just pick up your docs repo and put it elsehwere.</p>
<p><strong>GitHub Wiki</strong></p>
<p>Same problems Azure DevOps suffers of, and no integration with any other SSO providers.</p>
<h2 id="the-solution">The solution</h2>
<p>Any other solution compromised of expensive cloud-hosted solutions that wanted to store our documents themselves or self-hosting a maintenance heavy application. I wasn’t in this to create more problems. I changed thought processes and went to look for static site from markdown generators.</p>
<p>Happily with the advent of ASP.NET Core, static file serving was baked right in and made easier than ever. What I had hoped to do was place whatever static site I would generate behind an ASP.NET Core-served website with AzureAD on the ASP.NET Core end, authenticating any request to any static file. That way, even if the underlying wiki technology changed, authentication (and, cruicially routing) would always remain the same.</p>
<p>I ended up using a new ASP.NET Core MVC app for this, for better control (pun not intended). <code class="language-plaintext highlighter-rouge">dotnet new mvc</code> or your equivalent options in your IDE of choice should be enough to get started.</p>
<p>Your next move is to add Azure AD authentication. This is documentated <a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/web-app-quickstart?pivots=devlang-aspnet-core">here</a>.</p>
<p>The <code class="language-plaintext highlighter-rouge">Program.cs</code> file will end up looking like this:</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Authentication.OpenIdConnect</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Builder</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.DependencyInjection</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Identity.Web</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Identity.Web.UI</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddControllers</span><span class="p">();</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span>
<span class="p">.</span><span class="nf">AddAuthentication</span><span class="p">(</span><span class="n">OpenIdConnectDefaults</span><span class="p">.</span><span class="n">AuthenticationScheme</span><span class="p">)</span>
<span class="p">.</span><span class="nf">AddMicrosoftIdentityWebApp</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"AzureAd"</span><span class="p">));</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddAuthorization</span><span class="p">();</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span>
<span class="p">.</span><span class="nf">AddRazorPages</span><span class="p">()</span>
<span class="p">.</span><span class="nf">AddMicrosoftIdentityUI</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseHttpsRedirection</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseRouting</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthentication</span><span class="p">();</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseAuthorization</span><span class="p">();</span>
<span class="kt">var</span> <span class="n">options</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DefaultFilesOptions</span><span class="p">();</span>
<span class="n">options</span><span class="p">.</span><span class="n">DefaultFileNames</span><span class="p">.</span><span class="nf">Clear</span><span class="p">();</span>
<span class="n">options</span><span class="p">.</span><span class="n">DefaultFileNames</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="s">"index.html"</span><span class="p">);</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseDefaultFiles</span><span class="p">(</span><span class="n">options</span><span class="p">);</span>
<span class="c1">// Must be after authorisation</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseStaticFiles</span><span class="p">(</span><span class="k">new</span> <span class="n">StaticFileOptions</span>
<span class="p">{</span>
<span class="n">OnPrepareResponse</span> <span class="p">=</span> <span class="n">ctx</span> <span class="p">=></span>
<span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ctx</span><span class="p">.</span><span class="n">Context</span><span class="p">.</span><span class="n">User</span> <span class="k">is</span> <span class="n">not</span> <span class="p">{</span> <span class="n">Identity</span><span class="p">:</span> <span class="p">{</span> <span class="n">IsAuthenticated</span><span class="p">:</span> <span class="k">true</span> <span class="p">}</span> <span class="p">})</span>
<span class="p">{</span>
<span class="n">ctx</span><span class="p">.</span><span class="n">Context</span><span class="p">.</span><span class="n">Response</span><span class="p">.</span><span class="nf">Redirect</span><span class="p">(</span><span class="s">"/MicrosoftIdentity/Account/SignIn"</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">});</span>
<span class="n">app</span><span class="p">.</span><span class="nf">UseEndpoints</span><span class="p">(</span><span class="n">endpoints</span> <span class="p">=></span>
<span class="p">{</span>
<span class="n">endpoints</span><span class="p">.</span><span class="nf">MapRazorPages</span><span class="p">();</span>
<span class="n">endpoints</span><span class="p">.</span><span class="nf">MapControllers</span><span class="p">();</span>
<span class="p">});</span>
<span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div>
<p>So important shout outs here being the enabling of static files. ASP.NET Core will serve our static files, generated by our documentation platform of choice. If the user is not authenticated, via our AzureAD, we direct them back to the Identity login page for Azure AD. Something we don’t need to worry about.</p>
<p>We have some additional logic in our Home Controller to serve the static index file, generated by MkDocs - you <em>could</em> use ASP.NET Core’s new minimal APIs to strip some of this out, but I prefer the control (again, not a pun) of controllers.</p>
<p>Our home controller looks like this:</p>
<div class="language-cs highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">System.IO</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Hosting</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.AspNetCore.Mvc</span><span class="p">;</span>
<span class="k">namespace</span> <span class="nn">Controllers</span><span class="p">;</span>
<span class="p">[</span><span class="nf">Route</span><span class="p">(</span><span class="s">"/"</span><span class="p">)]</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">HomeController</span> <span class="p">:</span> <span class="n">Controller</span>
<span class="p">{</span>
<span class="k">public</span> <span class="n">IActionResult</span> <span class="nf">Index</span><span class="p">()</span>
<span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="n">HttpContext</span><span class="p">.</span><span class="n">User</span> <span class="k">is</span> <span class="p">{</span> <span class="n">Identity</span><span class="p">:</span> <span class="p">{</span> <span class="n">IsAuthenticated</span><span class="p">:</span> <span class="k">true</span> <span class="p">}</span> <span class="p">})</span>
<span class="p">{</span>
<span class="kt">var</span> <span class="n">path</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">Combine</span><span class="p">(</span><span class="n">Directory</span><span class="p">.</span><span class="nf">GetCurrentDirectory</span><span class="p">(),</span> <span class="s">"wwwroot"</span><span class="p">,</span> <span class="s">"index.html"</span><span class="p">);</span>
<span class="k">return</span> <span class="nf">PhysicalFile</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s">"text/html"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nf">Redirect</span><span class="p">(</span><span class="s">"/MicrosoftIdentity/Account/SignIn"</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>It’s important to note that our documentation will eventually sit in the <code class="language-plaintext highlighter-rouge">wwwroot</code> folder of our ASP.NET Core site.</p>
<p>We’re using <a href="https://www.mkdocs.org/">MkDocs</a>, more specifically <a href="https://squidfunk.github.io/mkdocs-material/">Material for MkDocs</a> to generate a static site from markdown documentation. Adding a new repository, separated from our ASP.NET Core site, hosting the documentation, would allow for an agnostic host and client. I.e. docs can exist without ASP.NET Core and ASP.NET Core can exist without docs.</p>
<p>MkDocs is a Python-based framework. While you won’t be dealing with Python code, you will be dealing with <code class="language-plaintext highlighter-rouge">pip</code>. It is the bare minimum required to get MkDocs to work. This approach, of hosting the docs site through a ASP.NET Core host, however, does allow you to switch out MkDocs.</p>
<p>Our MkDocs configuration looks like this:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">site_name</span><span class="pi">:</span> <span class="s">Docs</span>
<span class="na">theme</span><span class="pi">:</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">material</span>
<span class="na">custom_dir</span><span class="pi">:</span> <span class="s">overrides</span>
<span class="na">palette</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">scheme</span><span class="pi">:</span> <span class="s">default</span>
<span class="na">toggle</span><span class="pi">:</span>
<span class="na">icon</span><span class="pi">:</span> <span class="s">material/toggle-switch-off-outline</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Switch to dark mode</span>
<span class="na">primary</span><span class="pi">:</span> <span class="s">green</span>
<span class="na">accent</span><span class="pi">:</span> <span class="s">teal</span>
<span class="pi">-</span> <span class="na">scheme</span><span class="pi">:</span> <span class="s">slate</span>
<span class="na">toggle</span><span class="pi">:</span>
<span class="na">icon</span><span class="pi">:</span> <span class="s">material/toggle-switch</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">Switch to light mode</span>
<span class="na">primary</span><span class="pi">:</span> <span class="s">green</span>
<span class="na">accent</span><span class="pi">:</span> <span class="s">teal</span>
<span class="na">features</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">navigation.tracking</span>
<span class="pi">-</span> <span class="s">navigation.tabs</span>
<span class="pi">-</span> <span class="s">navigation.sections</span>
<span class="pi">-</span> <span class="s">header.autohide</span>
<span class="na">markdown_extensions</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">pymdownx.highlight</span><span class="pi">:</span>
<span class="na">anchor_linenums</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="s">pymdownx.superfences</span><span class="pi">:</span>
<span class="na">custom_fences</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">mermaid</span>
<span class="na">class</span><span class="pi">:</span> <span class="s">mermaid</span>
<span class="na">format</span><span class="pi">:</span> <span class="kt">!!python/name:pymdownx.superfences.fence_code_format</span>
<span class="pi">-</span> <span class="s">pymdownx.tasklist</span><span class="pi">:</span>
<span class="na">custom_checkbox</span><span class="pi">:</span> <span class="no">true</span>
<span class="pi">-</span> <span class="s">pymdownx.inlinehilite</span>
<span class="pi">-</span> <span class="s">pymdownx.snippets</span>
<span class="pi">-</span> <span class="s">def_list</span>
<span class="pi">-</span> <span class="s">attr_list</span>
<span class="pi">-</span> <span class="s">md_in_html</span>
<span class="pi">-</span> <span class="s">meta</span>
<span class="na">plugins</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">search</span>
<span class="na">extra</span><span class="pi">:</span>
<span class="na">generator</span><span class="pi">:</span> <span class="no">false</span>
</code></pre></div></div>
<p>Once you’re happy, build the static docs site and place it in the <code class="language-plaintext highlighter-rouge">wwwroot</code> folder of ASP.NET Core and watch your docs be served, securely.</p>Patrick KlaerenIf there’s one thing that I would yell at myself when first getting started with software development or, for that matter, any project in general, it would be to document early, often and securely. Here’s my story of creating a software company’s wiki, backed by Azure AD, with Git as storage.Grafana reverse proxy in IIS with Azure AD2022-02-16T00:00:00+00:002022-02-16T00:00:00+00:00https://patrickklaeren.dev/2022/grafana-reverse-proxy-with-azure-ad<p>It’s 2022, it’s been almost a year since the last (and first) post. A lot has happened, including the beginning of the end of the pandemic, and a paradigm shift of telemetry and instrumentation for the software company I work at. I have finally started the transition to introduce Grafana at work - and with that transition, came the upfront cost of expensive trials and tribulations. How do I make this SSL? How do I add Azure AD to Grafana? How do I reverse proxy Grafana with IIS?</p>
<h2 id="my-environment">My environment</h2>
<p>Before anyone yells at me for not using Linux. We’re a .NET shop and a lot, if not all, our applications actively run on Windows-based hosts. This might change in the future. We’re not locked to Windows, except for our WPF apps, but we’re certainly comfy with Windows and I’m more comfortable in a Windows environment than Linux environment. So, here is the environment I’m working with:</p>
<ul>
<li>Windows Server 2019 (Standard)</li>
<li>IIS 10</li>
<li>Grafana (self hosted) 8.3.6</li>
<li>AzureAD</li>
<li>Certify The Web 5.6.5</li>
</ul>
<p>This assumes a base, vanilla, installation of Grafana situated on <code class="language-plaintext highlighter-rouge">localhost:3000</code>. If you have changed your port, adapt these instructions.</p>
<h2 id="adding-an-iis-reverse-proxy">Adding an IIS Reverse Proxy</h2>
<p>Outcome: Adding bindings for HTTP(S) traffic on Default Web Site for IIS.</p>
<ol>
<li>Launch Certify The Web, create a new managed certifficate for the target Website
<ul>
<li>Before requesting a certificate, under <code class="language-plaintext highlighter-rouge">Tasks</code> add a new task to <code class="language-plaintext highlighter-rouge">Export Certificate</code>. Under <code class="language-plaintext highlighter-rouge">Task Parameters</code>:
<ul>
<li>Keep <code class="language-plaintext highlighter-rouge">Authentication</code> as <code class="language-plaintext highlighter-rouge">Local</code></li>
<li>Change destination file path to a file path of your choice, in my case <code class="language-plaintext highlighter-rouge">C:\SSL\cert.pem</code></li>
<li>Change <code class="language-plaintext highlighter-rouge">Export As</code> to <code class="language-plaintext highlighter-rouge">PEM - Primary Certificate</code></li>
</ul>
</li>
<li>Once you have added this task, repeat the process, this time adding an export certificate task for the <code class="language-plaintext highlighter-rouge">PEM - Private Key</code></li>
</ul>
</li>
</ol>
<p>Your deployment tasks should look like this:</p>
<p><img src="https://user-images.githubusercontent.com/1341180/154351739-919db611-a68e-4b68-996f-fb5491961b9c.png" alt="image" /></p>
<ol>
<li>Request the certificate and verify the bindings have been allocated to the website on IIS</li>
<li>Install the <a href="https://www.iis.net/downloads/microsoft/url-rewrite">URL Rewrite</a> IIS module</li>
<li>Install the <a href="https://www.iis.net/downloads/microsoft/application-request-routing">Application Request Routing</a> IIS module</li>
<li>Open your target website and select the URL rewrite module, clicking <code class="language-plaintext highlighter-rouge">Add Rule(s)...</code> on the right hand panel</li>
</ol>
<p><img src="https://user-images.githubusercontent.com/1341180/154352228-42d773e1-1771-4b01-847a-7399f27af7b6.png" alt="image" /></p>
<p><img src="https://user-images.githubusercontent.com/1341180/154352248-553cf789-cdde-4f00-8298-960d6e5fc440.png" alt="image" /></p>
<ol>
<li>With the new rule dialog open, in the <code class="language-plaintext highlighter-rouge">Inbound Rules</code>, enter <code class="language-plaintext highlighter-rouge">https://localhost:3000</code>.</li>
</ol>
<h2 id="modifying-grafana-config-for-ssl">Modifying Grafana config for SSL</h2>
<p>When we are going to add AzureAD OAuth, we need a <code class="language-plaintext highlighter-rouge">https</code> redirect - unfortunately, we cannot tell Grafana to blindly run on https, it requires a valid certificate to do so. Leaving it empty will cause Grafana to (gracefully?) crash at startup.</p>
<p>Go to the Grafana configuration ini, for my Windows Server installation, that’s at <code class="language-plaintext highlighter-rouge">C:\Program Files\GrafanaLabs\grafana\conf</code>. Ensure you edit <code class="language-plaintext highlighter-rouge">sample.ini</code> or any renamed variant of this. I have called my <code class="language-plaintext highlighter-rouge">custom.ini</code>. <strong>Do not edit the defaults.ini file if you can help it.</strong></p>
<ol>
<li>Stop <code class="language-plaintext highlighter-rouge">Grafana</code> service, if it’s running</li>
<li>Open the <code class="language-plaintext highlighter-rouge">ini</code> configuration file</li>
<li>Find the <code class="language-plaintext highlighter-rouge">[server]</code> section</li>
<li>Uncomment and change <code class="language-plaintext highlighter-rouge">protocol</code> to <code class="language-plaintext highlighter-rouge">protocol = https</code></li>
<li>Uncomment and change <code class="language-plaintext highlighter-rouge">domain</code> to your desired domain, i.e. <code class="language-plaintext highlighter-rouge">domain = grafana.contoso.com</code></li>
<li>Uncomment and change <code class="language-plaintext highlighter-rouge">root_url</code> to <code class="language-plaintext highlighter-rouge">root_url = %(protocol)s://%(domain)s/</code>, the important note here is we’re removing the port</li>
<li>Uncomment and change <code class="language-plaintext highlighter-rouge">cert_file</code> to <code class="language-plaintext highlighter-rouge">cert_file = C:\SSL\cert.pem</code> or the location of your certificate file, as exported in Certify The Web’s manager</li>
<li>Uncomment and change <code class="language-plaintext highlighter-rouge">cert_key</code> to <code class="language-plaintext highlighter-rouge">cert_key = C:\SSL\key.pem</code> or the location of your certificate’s key file, as export in Certify The Web’s manager</li>
<li>Start <code class="language-plaintext highlighter-rouge">Grafana</code> service and verify it is reachable via your domain</li>
</ol>
<p>Because IIS will be running on 443. We cannot run Grafana on 443 by default, it will continue to run on 3000. By changing the <code class="language-plaintext highlighter-rouge">root_url</code> configuration, we’re forcing Grafana to act as though it’s directly hosted on 443, rather than behind a reverse proxy.</p>
<p>The disadvantage to this is that you will no longer be able to access Grafana via <code class="language-plaintext highlighter-rouge">localhost</code>, if that’s a requirement, unless you are willing to accept the ““untrusted”” certificate. I say ““untrusted”” because it was generated by us, for the target domain.</p>
<h2 id="adding-azure-ad-to-grafana">Adding Azure AD to Grafana</h2>
<p>This is mostly a <a href="https://grafana.com/docs/grafana/latest/auth/azuread/">documented process on the official Grafana documentation</a>, which is great. Assuming you have followed these steps, modified the config further to include AzureAD and restarted your Grafana service, you will now see a Log in with Microsoft button, using your AzureAD tenant.</p>
<p>The redirect URL should work, and your reverse proxy is none the wiser.</p>
<p>This process was mostly trial and error. It is not perfect by any means and I will be revisit it once we have to migrate servers/versions, but it does get AzureAD working in Grafana on self deployed environments. At least in Windows.</p>
<h2 id="want-help">Want help?</h2>
<p>Comment here and I will do my best to respond.</p>Patrick KlaerenIt’s 2022, it’s been almost a year since the last (and first) post. A lot has happened, including the beginning of the end of the pandemic, and a paradigm shift of telemetry and instrumentation for the software company I work at. I have finally started the transition to introduce Grafana at work - and with that transition, came the upfront cost of expensive trials and tribulations. How do I make this SSL? How do I add Azure AD to Grafana? How do I reverse proxy Grafana with IIS?Consistency is key, even when it makes you the party pooper2021-05-01T00:00:00+00:002021-05-01T00:00:00+00:00https://patrickklaeren.dev/2021/consistency-is-key<p>Three attempts over the past ten years in my career as a software engineer have all led to the same conclusion: I suck at writing blog posts. Or, at the very least, I suck at consistency when it comes to blog posts. Sitting down, spamming words into a wall of text isn’t the most natural thing to come to me, which is ironic given my previous inclination of wanting to pursue journalism before I left school for university.</p>
<p>If I’ve learnt anything over the past six years of commercial .NET software experience working in a team of 18 other individuals, all of which have their own needs and desires, it’s that consistency is key to succeed. In a company that is filled with personalities, one personality has to succeed in order to deliver software in a cohesive, precise manner. Even if that means being the party pooper - the one to deliver bad news. The one to poop on the code you’ve written because it doesn’t conform to company standards. It’s a skill you need to master if you want to be able to standardize and create a unit of developers who are able to contribute to a code base they might not necessarily be a part of in one or ten years time. A skill which doesn’t necessarily make you friends, or, at least very popular. Every programmer wants to be right. Every programmer thinks they write the best code. Every programmer wants to be infallible. Calling a programmer up on style or convention is likely the last thing on their mind as they try to materialize a concept thought up in the sprint planning meeting last week.</p>
<p>A large aspect of my job consists of standardizing principles in the company I work in. I manage almost every single developer. I manage almost every single project in the company. Some at higher levels, some at lower levels. I create standardized build pipelines, I create standardized code snippets: this is the way we log; this is the way we pub/sub; this is the way we do app updates. It’s a long and tiring process that isn’t necessarily realised by programmers who write the code that eventually ends up at step one of the review process: the pull request. It all starts with the pull request. An initial skim reveals if the programmer understands company conventions - understands if they can meet the abstract outline that every other solution in the company meets. I ask one question in my head: can another programmer from another project navigate their way around this project, without understanding the domain? Yes? Great. The programmer has done their done well. No? I talk to the programmer and I look at why consistency has dropped in this part of work.</p>
<p style="text-align:center"><img src="https://patrickklaeren.dev/attachments/2021-05-01-consistency-is-key-1.jpg" alt="Pull request consistency" /></p>
<p>Consistency is a large key to success in delivering good, maintainable software. My company strives for a .NET stack at every layer. From the database engine that is Microsoft SQL Server to the front end serving ASP.NET. Every developer tool enables us to dictate consistency, and every form of communication goes back to the values of the company. I want every developer in the company to understand every project, regardless of domain knowledge. Every concept should be translatable and recognisable. If developer one speaks to developer two about a problem in the logger, the issue should be unanimous and easy to fix.</p>Patrick KlaerenThree attempts over the past ten years in my career as a software engineer have all led to the same conclusion: I suck at writing blog posts. Or, at the very least, I suck at consistency when it comes to blog posts. Sitting down, spamming words into a wall of text isn’t the most natural thing to come to me, which is ironic given my previous inclination of wanting to pursue journalism before I left school for university.