+
+
+
+
\ No newline at end of file
diff --git a/CNAME b/CNAME
index 39ea3b6..999af1f 100644
--- a/CNAME
+++ b/CNAME
@@ -1 +1 @@
-brown.bg
\ No newline at end of file
+brown.bg
diff --git a/README.txt b/README.txt
deleted file mode 100644
index e380f78..0000000
--- a/README.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-This is my personal blog.
-
-To build:
- - Install pnpm
-
-
-Windows:
-```
-iwr https://get.pnpm.io/install.ps1 -useb | iex
-pnpm install
-pnpm run dev
-```
\ No newline at end of file
diff --git a/assets/app.31511a71.js b/assets/app.31511a71.js
new file mode 100644
index 0000000..d0bb791
--- /dev/null
+++ b/assets/app.31511a71.js
@@ -0,0 +1 @@
+import{d as p,o,c as i,b as t,t as d,u as g,e as a,F as A,r as P,f,g as l,h as D,i as y,j as C,_ as S,k as v,a as T,l as b,m as I,n as N,w as R,p as L,q as V,s as F,v as j,R as E,x as H,y as O,C as B,z as M,A as G,B as K,D as U,E as W,G as q}from"./chunks/framework.f55bdc3d.js";const z="/logo.webp",J=t("dt",{class:"sr-only"},"Published on",-1),Q={class:"text-base leading-6 font-medium text-gray-500 dark:text-gray-300"},X=["datetime"],k=p({__name:"Date",props:{date:null},setup(s){const e=s;function n(){return new Date(e.date.time).toISOString()}return(r,_)=>(o(),i("dl",null,[J,t("dd",Q,[t("time",{datetime:n()},d(s.date.string),9,X)])]))}}),m=JSON.parse(`[{"title":"Running MassTransit queues with KEDA","url":"/posts/scaling-masstransit-jobs","excerpt":"
We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual.\\nAll these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs.
\\n","date":{"time":1693828800000,"string":"September 4, 2023"}},{"title":"Creating Kerberos secured connections from Dotnet on Linux","url":"/posts/dotnet-kerberos","excerpt":"
For the last few years at my workplace we've been using Kerberos authentication to connect to the on-prem SQL Server databases. On Windows this just worked like magic, as the services ran as a Windows domain user and so could connect to the database without any additional configuration. However, when moving to Kubernetes and Linux systems, this didn't work out of the box.
\\n","date":{"time":1691236800000,"string":"August 5, 2023"}},{"title":"TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine","url":"/posts/init7-tv7","excerpt":"
If you're an Init7 TV customer and you want to use the Unify Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started.
\\n","date":{"time":1691150400000,"string":"August 4, 2023"}},{"title":"Dockerless docker builds in Github Actions with Dotnet 7","url":"/posts/github-actions-net7-docker","excerpt":"
How to setup a github build action that uses the dockerless build in .Net 7
Our initial solution was following this red hat blog post which details how to setup a sidecar which keeps a kerberos token valid. This worked for a number of years but did have some problems:
We ran 100+ containers and each needed a sidecar - this cost a few gigabytes of memory and some CPU allowance
When the token was being refreshed, it was not available for a few milliseconds
It was extra hassle and created slightly larger deployments and more complex Deployment configs.
When investigating a seperate issue (dotnet 7 caused some transient kerberos faults) we realised that actually the whole sidecar approach was completely unneccessary! This post will give the minimum you actually need to call Keberos services from Linux
You'll need to add the keberos tools to your docker image. Maybe you have a base image you share, maybe not. For alpine images:
RUN apk add --no-cache krb5
You'll need to have a krb5 configuration that points to your domain controller. Microsoft has an example
We put it in a custom folder as some container systems have the limitation that mounting a directory overrides all files in that directory (we'll do this later)
You then need a keytab added to the image. You will probably want this done at runtime. Get a keytab (maybe off your AD system administrators, maybe you create it yourself with ktutil) and make sure this ends up at /krb5/client.keytab. We use kubernetes secrets and volume mounts for this
Connect to the database using Integrated Security=true. This should now just work. You can check by calling klist inside the container to see that it has a ticket
`,13),r=[o];function i(l,c,d,p,h,u){return s(),a("div",null,r)}const k=e(n,[["render",i]]);export{b as __pageData,k as default};
diff --git a/assets/posts_dotnet-kerberos.md.e7e9094f.lean.js b/assets/posts_dotnet-kerberos.md.e7e9094f.lean.js
new file mode 100644
index 0000000..44ea9d6
--- /dev/null
+++ b/assets/posts_dotnet-kerberos.md.e7e9094f.lean.js
@@ -0,0 +1 @@
+import{_ as e,c as a,o as s,a as t}from"./chunks/framework.f55bdc3d.js";const b=JSON.parse('{"title":"Creating Kerberos secured connections from Dotnet on Linux","description":"","frontmatter":{"title":"Creating Kerberos secured connections from Dotnet on Linux","date":"2023-08-05T00:00:00.000Z","author":"Stephen Brown"},"headers":[],"relativePath":"posts/dotnet-kerberos.md","filePath":"posts/dotnet-kerberos.md"}'),n={name:"posts/dotnet-kerberos.md"},o=t("",13),r=[o];function i(l,c,d,p,h,u){return s(),a("div",null,r)}const k=e(n,[["render",i]]);export{b as __pageData,k as default};
diff --git a/assets/posts_github-actions-net7-docker.md.8901ce83.js b/assets/posts_github-actions-net7-docker.md.8901ce83.js
new file mode 100644
index 0000000..9f08aaa
--- /dev/null
+++ b/assets/posts_github-actions-net7-docker.md.8901ce83.js
@@ -0,0 +1,48 @@
+import{_ as s,c as a,o as n,a as o}from"./chunks/framework.f55bdc3d.js";const l="/images/github_actions_permission.png",A=JSON.parse('{"title":"Dockerless docker builds in Github Actions with Dotnet 7","description":"","frontmatter":{"title":"Dockerless docker builds in Github Actions with Dotnet 7","date":"2022-10-14T00:00:00.000Z","author":"Stephen Brown","twitter":"@evolvedlight"},"headers":[],"relativePath":"posts/github-actions-net7-docker.md","filePath":"posts/github-actions-net7-docker.md"}'),e={name:"posts/github-actions-net7-docker.md"},p=o(`
How to setup a github build action that uses the dockerless build in .Net 7
For me, one of the slowest parts of the fast build-test-deploy cycle was always waiting for docker builds to run. Especially with the practise of docker-in-docker builds which without special magic wouldn't cache anything, it could often take several minutes to create a docker application. .Net 7 promises to offer an alternative to this for simple apps - let's take a dive in.
You'll need .Net 7 for this, which isn't currently released at time of writing. However, in theory you can take a .Net 6 application and use the .Net 7 SDK to build it already, however YMMV.
Now, add a github actions to .github/workflows/whatever.yml
yaml
name:Create and publish a Docker image
+
+on:
+push:
+branches:["*"]
+pull_request:
+branches:["main"]
+
+
+env:
+REGISTRY:ghcr.io
+IMAGE_NAME:\${{ github.repository }}
+
+jobs:
+build-and-push-image:
+runs-on:ubuntu-latest
+permissions:
+contents:read
+packages:write
+
+steps:
+-uses:actions/checkout@v3
+-name:Setup .NET SDK
+uses:actions/setup-dotnet@v2
+# Package the app into a linux-x64 container based on the dotnet/aspnet image
+-name:Publish
+run:dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
+-name:Checkout repository
+uses:actions/checkout@v3
+-name:Login to GitHub Container Registry
+uses:docker/login-action@v2
+with:
+registry:ghcr.io
+username:\${{ github.actor }}
+password:\${{ secrets.GITHUB_TOKEN }}
+-name:Tag built container with Github thing
+run:|
+ docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
+-name:Push built container to Github Package Repo
+run:|
+ docker push ghcr.io/evolvedlight/dockerless-docker:1.0.0
Here's now where the weird bit comes - on github the process to get a repository seems rather backward. You need to assign permissions for the repo to write images, but there's no way to do that until you've pushed an image manually!
So let's go around the hoops - create the docker image locally, tag it, login to the Github repository and upload it:
dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
+docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
+docker login ghcr.io -u <your username>
Enter a Personal access token that you can create on your github profile page, and finally push the image:
Under "Manage Actions access" add your repository with access.
In the end it'll look like this:
Finally, rerun the action and it should work.
In the next blob post we'll look at fixing the above docker build to push with the right version numbers and tags
',27),t=[p];function c(r,i,y,D,C,d){return n(),a("div",null,t)}const u=s(e,[["render",c]]);export{A as __pageData,u as default};
diff --git a/assets/posts_github-actions-net7-docker.md.8901ce83.lean.js b/assets/posts_github-actions-net7-docker.md.8901ce83.lean.js
new file mode 100644
index 0000000..143ae6d
--- /dev/null
+++ b/assets/posts_github-actions-net7-docker.md.8901ce83.lean.js
@@ -0,0 +1 @@
+import{_ as s,c as a,o as n,a as o}from"./chunks/framework.f55bdc3d.js";const l="/images/github_actions_permission.png",A=JSON.parse('{"title":"Dockerless docker builds in Github Actions with Dotnet 7","description":"","frontmatter":{"title":"Dockerless docker builds in Github Actions with Dotnet 7","date":"2022-10-14T00:00:00.000Z","author":"Stephen Brown","twitter":"@evolvedlight"},"headers":[],"relativePath":"posts/github-actions-net7-docker.md","filePath":"posts/github-actions-net7-docker.md"}'),e={name:"posts/github-actions-net7-docker.md"},p=o("",27),t=[p];function c(r,i,y,D,C,d){return n(),a("div",null,t)}const u=s(e,[["render",c]]);export{A as __pageData,u as default};
diff --git a/assets/posts_init7-tv7.md.63fe95a3.js b/assets/posts_init7-tv7.md.63fe95a3.js
new file mode 100644
index 0000000..cfdf851
--- /dev/null
+++ b/assets/posts_init7-tv7.md.63fe95a3.js
@@ -0,0 +1 @@
+import{_ as e,c as t,o as a,a as r}from"./chunks/framework.f55bdc3d.js";const o="/images/network-iptv.png",n="/images/vlc-tv7.png",i="/images/udpxy.png",w=JSON.parse('{"title":"TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine","description":"","frontmatter":{"title":"TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine","date":"2023-08-04T00:00:00.000Z","author":"Stephen Brown"},"headers":[],"relativePath":"posts/init7-tv7.md","filePath":"posts/init7-tv7.md"}'),s={name:"posts/init7-tv7.md"},p=r('
If you're an Init7 TV customer and you want to use the Unify Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started.
Before you begin, make sure your Unify Dream Machine is updated to the latest firmware version. You can check for updates by logging into the Unify Dream Machine web interface and navigating to the "Settings" page. Your version needs to be higher than "UniFi Network Application 7.4.156".
Personally I used one called "m3u player" but there are many out there. You'll then also be able to go to your udpxy status page and see the running stream there.
',19),h=[p];function u(l,d,c,m,y,f){return a(),t("div",null,h)}const b=e(s,[["render",u]]);export{w as __pageData,b as default};
diff --git a/assets/posts_init7-tv7.md.63fe95a3.lean.js b/assets/posts_init7-tv7.md.63fe95a3.lean.js
new file mode 100644
index 0000000..94dab14
--- /dev/null
+++ b/assets/posts_init7-tv7.md.63fe95a3.lean.js
@@ -0,0 +1 @@
+import{_ as e,c as t,o as a,a as r}from"./chunks/framework.f55bdc3d.js";const o="/images/network-iptv.png",n="/images/vlc-tv7.png",i="/images/udpxy.png",w=JSON.parse('{"title":"TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine","description":"","frontmatter":{"title":"TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine","date":"2023-08-04T00:00:00.000Z","author":"Stephen Brown"},"headers":[],"relativePath":"posts/init7-tv7.md","filePath":"posts/init7-tv7.md"}'),s={name:"posts/init7-tv7.md"},p=r("",19),h=[p];function u(l,d,c,m,y,f){return a(),t("div",null,h)}const b=e(s,[["render",u]]);export{w as __pageData,b as default};
diff --git a/assets/posts_scaling-masstransit-jobs.md.6bbd5c48.js b/assets/posts_scaling-masstransit-jobs.md.6bbd5c48.js
new file mode 100644
index 0000000..eb54e4e
--- /dev/null
+++ b/assets/posts_scaling-masstransit-jobs.md.6bbd5c48.js
@@ -0,0 +1,93 @@
+import{_ as s,c as a,o as n,a as l}from"./chunks/framework.f55bdc3d.js";const A=JSON.parse('{"title":"Running MassTransit queues with KEDA","description":"","frontmatter":{"title":"Running MassTransit queues with KEDA","date":"2023-09-04T00:00:00.000Z","author":"Stephen Brown"},"headers":[],"relativePath":"posts/scaling-masstransit-jobs.md","filePath":"posts/scaling-masstransit-jobs.md"}'),o={name:"posts/scaling-masstransit-jobs.md"},p=l(`
We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual. All these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs.
They run like this:
Most are processing constantly lots of small messages. These are no problem.
Some process only a few messages a day, but we have to keep the service running all day. This is annoying.
Some process only a few messages per day, but those small messages take a lot of CPU and memory. For example they may need to deal with files in memory in formats that don't yet easily support iteratively processing the file. This is a big problem for us as the services need to be sized to handle these large requirement, but are actually only running for a few minutes a day with this load.
This post will show one approach to fix the third problem. The same tooling (KEDA) can also be used to improve the other situations but that's for another blog post. What we'll do to address the final problem is to use KEDA Job based scaling.
First, we have the publisher. This could be anything and in our example it's normally from a cron job. The send will look like this, all normal MassTransit code:
Of course, this is just the basics, your real solution would include logging and some other error handling (poison messages and so on should be handled, otherwise it will continue processing bad messages over and over).
This post now assumes you can build this into a docker image and push it to a registry you use. For this example, we'll assume the image name is example-long:latest
At this point, you have a working application that will pick up a single message, handle it, and finish. We need some way to trigger this to happen for each single message in the queue - for this comes KEDA. First, you need a secret containing the RabbitMQ connection string:
And the important bit: you need a ScaledJob spec that runs your docker image
yaml
apiVersion:keda.sh/v1alpha1
+kind:ScaledJob
+metadata:
+name:rabbitmq-consumer
+namespace:limited
+spec:
+jobTargetRef:
+template:
+spec:
+containers:
+-name:example-long
+image:example-long:latest
+imagePullPolicy:Never
+restartPolicy:Never
+backoffLimit:4
+pollingInterval:2# Optional. Default: 30 seconds
+maxReplicaCount:30# Optional. Default: 100
+successfulJobsHistoryLimit:30# Optional. Default: 100. How many completed jobs should be kept.
+failedJobsHistoryLimit:20# Optional. Default: 100. How many failed jobs should be kept.
+triggers:
+-type:rabbitmq
+metadata:
+queueName:LongRunningJobs
+queueLength:'1'
+authenticationRef:
+name:keda-trigger-auth-rabbitmq-conn
Now, when you put a message into the queue, it'll be processed as a MassTransit message in a Kubernetes Job, allowing you to run your normal worker pods with lower memory and CPU requirements.
You also have a few other benefits:
Deploys of the service don't kill a long running job
Jobs can be scheduled on other nodes
Jobs can be scheduled as lower priority
`,31),e=[p];function t(c,r,y,D,F,i){return n(),a("div",null,e)}const u=s(o,[["render",t]]);export{A as __pageData,u as default};
diff --git a/assets/posts_scaling-masstransit-jobs.md.6bbd5c48.lean.js b/assets/posts_scaling-masstransit-jobs.md.6bbd5c48.lean.js
new file mode 100644
index 0000000..2b8c94a
--- /dev/null
+++ b/assets/posts_scaling-masstransit-jobs.md.6bbd5c48.lean.js
@@ -0,0 +1 @@
+import{_ as s,c as a,o as n,a as l}from"./chunks/framework.f55bdc3d.js";const A=JSON.parse('{"title":"Running MassTransit queues with KEDA","description":"","frontmatter":{"title":"Running MassTransit queues with KEDA","date":"2023-09-04T00:00:00.000Z","author":"Stephen Brown"},"headers":[],"relativePath":"posts/scaling-masstransit-jobs.md","filePath":"posts/scaling-masstransit-jobs.md"}'),o={name:"posts/scaling-masstransit-jobs.md"},p=l("",31),e=[p];function t(c,r,y,D,F,i){return n(),a("div",null,e)}const u=s(o,[["render",t]]);export{A as __pageData,u as default};
diff --git a/assets/style.07528792.css b/assets/style.07528792.css
new file mode 100644
index 0000000..f3ac18c
--- /dev/null
+++ b/assets/style.07528792.css
@@ -0,0 +1 @@
+*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji"}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where([class~="lead"]):not(:where([class~="not-prose"] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-top:1.2em;margin-bottom:1.2em}.prose :where(a):not(:where([class~="not-prose"] *)){color:var(--tw-prose-links);text-decoration:underline;font-weight:500}.prose :where(strong):not(:where([class~="not-prose"] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(thead th strong):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(ol):not(:where([class~="not-prose"] *)){list-style-type:decimal;margin-top:1.25em;margin-bottom:1.25em;padding-left:1.625em}.prose :where(ol[type="A"]):not(:where([class~="not-prose"] *)){list-style-type:upper-alpha}.prose :where(ol[type="a"]):not(:where([class~="not-prose"] *)){list-style-type:lower-alpha}.prose :where(ol[type="A" s]):not(:where([class~="not-prose"] *)){list-style-type:upper-alpha}.prose :where(ol[type="a" s]):not(:where([class~="not-prose"] *)){list-style-type:lower-alpha}.prose :where(ol[type="I"]):not(:where([class~="not-prose"] *)){list-style-type:upper-roman}.prose :where(ol[type="i"]):not(:where([class~="not-prose"] *)){list-style-type:lower-roman}.prose :where(ol[type="I" s]):not(:where([class~="not-prose"] *)){list-style-type:upper-roman}.prose :where(ol[type="i" s]):not(:where([class~="not-prose"] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~="not-prose"] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~="not-prose"] *)){list-style-type:disc;margin-top:1.25em;margin-bottom:1.25em;padding-left:1.625em}.prose :where(ol > li):not(:where([class~="not-prose"] *))::marker{font-weight:400;color:var(--tw-prose-counters)}.prose :where(ul > li):not(:where([class~="not-prose"] *))::marker{color:var(--tw-prose-bullets)}.prose :where(hr):not(:where([class~="not-prose"] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~="not-prose"] *)){font-weight:500;font-style:italic;color:var(--tw-prose-quotes);border-left-width:.25rem;border-left-color:var(--tw-prose-quote-borders);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;padding-left:1em}.prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"] *)):after{content:close-quote}.prose :where(h1):not(:where([class~="not-prose"] *)){color:var(--tw-prose-headings);font-weight:800;font-size:2.25em;margin-top:0;margin-bottom:.8888889em;line-height:1.1111111}.prose :where(h1 strong):not(:where([class~="not-prose"] *)){font-weight:900;color:inherit}.prose :where(h2):not(:where([class~="not-prose"] *)){color:var(--tw-prose-headings);font-weight:700;font-size:1.5em;margin-top:2em;margin-bottom:1em;line-height:1.3333333}.prose :where(h2 strong):not(:where([class~="not-prose"] *)){font-weight:800;color:inherit}.prose :where(h3):not(:where([class~="not-prose"] *)){color:var(--tw-prose-headings);font-weight:600;font-size:1.25em;margin-top:1.6em;margin-bottom:.6em;line-height:1.6}.prose :where(h3 strong):not(:where([class~="not-prose"] *)){font-weight:700;color:inherit}.prose :where(h4):not(:where([class~="not-prose"] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.5em;margin-bottom:.5em;line-height:1.5}.prose :where(h4 strong):not(:where([class~="not-prose"] *)){font-weight:700;color:inherit}.prose :where(img):not(:where([class~="not-prose"] *)){margin-top:2em;margin-bottom:2em}.prose :where(figure > *):not(:where([class~="not-prose"] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~="not-prose"] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose :where(code):not(:where([class~="not-prose"] *)){color:var(--tw-prose-code);font-weight:600;font-size:.875em}.prose :where(code):not(:where([class~="not-prose"] *)):before{content:"`"}.prose :where(code):not(:where([class~="not-prose"] *)):after{content:"`"}.prose :where(a code):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(h1 code):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(h2 code):not(:where([class~="not-prose"] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~="not-prose"] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(blockquote code):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(thead th code):not(:where([class~="not-prose"] *)){color:inherit}.prose :where(pre):not(:where([class~="not-prose"] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);overflow-x:auto;font-weight:400;font-size:.875em;line-height:1.7142857;margin-top:1.7142857em;margin-bottom:1.7142857em;border-radius:.375rem;padding:.8571429em 1.1428571em}.prose :where(pre code):not(:where([class~="not-prose"] *)){background-color:transparent;border-width:0;border-radius:0;padding:0;font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}.prose :where(pre code):not(:where([class~="not-prose"] *)):before{content:none}.prose :where(pre code):not(:where([class~="not-prose"] *)):after{content:none}.prose :where(table):not(:where([class~="not-prose"] *)){width:100%;table-layout:auto;text-align:left;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.7142857}.prose :where(thead):not(:where([class~="not-prose"] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~="not-prose"] *)){color:var(--tw-prose-headings);font-weight:600;vertical-align:bottom;padding-right:.5714286em;padding-bottom:.5714286em;padding-left:.5714286em}.prose :where(tbody tr):not(:where([class~="not-prose"] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~="not-prose"] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~="not-prose"] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~="not-prose"] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~="not-prose"] *)){vertical-align:top}.prose{--tw-prose-body: #374151;--tw-prose-headings: #111827;--tw-prose-lead: #4b5563;--tw-prose-links: #111827;--tw-prose-bold: #111827;--tw-prose-counters: #6b7280;--tw-prose-bullets: #d1d5db;--tw-prose-hr: #e5e7eb;--tw-prose-quotes: #111827;--tw-prose-quote-borders: #e5e7eb;--tw-prose-captions: #6b7280;--tw-prose-code: #111827;--tw-prose-pre-code: #e5e7eb;--tw-prose-pre-bg: #1f2937;--tw-prose-th-borders: #d1d5db;--tw-prose-td-borders: #e5e7eb;--tw-prose-invert-body: #d1d5db;--tw-prose-invert-headings: #fff;--tw-prose-invert-lead: #9ca3af;--tw-prose-invert-links: #fff;--tw-prose-invert-bold: #fff;--tw-prose-invert-counters: #9ca3af;--tw-prose-invert-bullets: #4b5563;--tw-prose-invert-hr: #374151;--tw-prose-invert-quotes: #f3f4f6;--tw-prose-invert-quote-borders: #374151;--tw-prose-invert-captions: #9ca3af;--tw-prose-invert-code: #fff;--tw-prose-invert-pre-code: #d1d5db;--tw-prose-invert-pre-bg: rgb(0 0 0 / 50%);--tw-prose-invert-th-borders: #4b5563;--tw-prose-invert-td-borders: #374151;font-size:1rem;line-height:1.75}.prose :where(p):not(:where([class~="not-prose"] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(video):not(:where([class~="not-prose"] *)){margin-top:2em;margin-bottom:2em}.prose :where(figure):not(:where([class~="not-prose"] *)){margin-top:2em;margin-bottom:2em}.prose :where(li):not(:where([class~="not-prose"] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol > li):not(:where([class~="not-prose"] *)){padding-left:.375em}.prose :where(ul > li):not(:where([class~="not-prose"] *)){padding-left:.375em}.prose :where(.prose > ul > li p):not(:where([class~="not-prose"] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.25em}.prose :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.25em}.prose :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.25em}.prose :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~="not-prose"] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(hr + *):not(:where([class~="not-prose"] *)){margin-top:0}.prose :where(h2 + *):not(:where([class~="not-prose"] *)){margin-top:0}.prose :where(h3 + *):not(:where([class~="not-prose"] *)){margin-top:0}.prose :where(h4 + *):not(:where([class~="not-prose"] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~="not-prose"] *)){padding-left:0}.prose :where(thead th:last-child):not(:where([class~="not-prose"] *)){padding-right:0}.prose :where(tbody td,tfoot td):not(:where([class~="not-prose"] *)){padding:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~="not-prose"] *)){padding-left:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~="not-prose"] *)){padding-right:0}.prose :where(.prose > :first-child):not(:where([class~="not-prose"] *)){margin-top:0}.prose :where(.prose > :last-child):not(:where([class~="not-prose"] *)){margin-bottom:0}.prose-sm :where(.prose > ul > li p):not(:where([class~="not-prose"] *)){margin-top:.5714286em;margin-bottom:.5714286em}.prose-sm :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.1428571em}.prose-sm :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.1428571em}.prose-sm :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.1428571em}.prose-sm :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.1428571em}.prose-sm :where(.prose > :first-child):not(:where([class~="not-prose"] *)){margin-top:0}.prose-sm :where(.prose > :last-child):not(:where([class~="not-prose"] *)){margin-bottom:0}.prose-base :where(.prose > ul > li p):not(:where([class~="not-prose"] *)){margin-top:.75em;margin-bottom:.75em}.prose-base :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.25em}.prose-base :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.25em}.prose-base :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.25em}.prose-base :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.25em}.prose-base :where(.prose > :first-child):not(:where([class~="not-prose"] *)){margin-top:0}.prose-base :where(.prose > :last-child):not(:where([class~="not-prose"] *)){margin-bottom:0}.prose-lg :where(.prose > ul > li p):not(:where([class~="not-prose"] *)){margin-top:.8888889em;margin-bottom:.8888889em}.prose-lg :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.3333333em}.prose-lg :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.3333333em}.prose-lg :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose > :first-child):not(:where([class~="not-prose"] *)){margin-top:0}.prose-lg :where(.prose > :last-child):not(:where([class~="not-prose"] *)){margin-bottom:0}.prose-xl :where(.prose > ul > li p):not(:where([class~="not-prose"] *)){margin-top:.8em;margin-bottom:.8em}.prose-xl :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.2em}.prose-xl :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.2em}.prose-xl :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.2em}.prose-xl :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.2em}.prose-xl :where(.prose > :first-child):not(:where([class~="not-prose"] *)){margin-top:0}.prose-xl :where(.prose > :last-child):not(:where([class~="not-prose"] *)){margin-bottom:0}.prose-2xl :where(.prose > ul > li p):not(:where([class~="not-prose"] *)){margin-top:.8333333em;margin-bottom:.8333333em}.prose-2xl :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.3333333em}.prose-2xl :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.3333333em}.prose-2xl :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)){margin-top:1.3333333em}.prose-2xl :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)){margin-bottom:1.3333333em}.prose-2xl :where(.prose > :first-child):not(:where([class~="not-prose"] *)){margin-top:0}.prose-2xl :where(.prose > :last-child):not(:where([class~="not-prose"] *)){margin-bottom:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.mx-auto{margin-left:auto;margin-right:auto}.mr-2{margin-right:.5rem}.ml-2{margin-left:.5rem}.inline-block{display:inline-block}.flex{display:flex}.hidden{display:none}.h-10{height:2.5rem}.w-10{width:2.5rem}.max-w-none{max-width:none}.max-w-3xl{max-width:48rem}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(2rem * var(--tw-space-x-reverse));margin-left:calc(2rem * calc(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity))}.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.py-8{padding-top:2rem;padding-bottom:2rem}.py-12{padding-top:3rem;padding-bottom:3rem}.px-4{padding-left:1rem;padding-right:1rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.pt-6{padding-top:1.5rem}.pb-16{padding-bottom:4rem}.pt-10{padding-top:2.5rem}.pb-8{padding-bottom:2rem}.pt-8{padding-top:2rem}.pb-10{padding-bottom:2.5rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-bold{font-weight:700}.uppercase{text-transform:uppercase}.leading-9{line-height:2.25rem}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.leading-8{line-height:2rem}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:root{--c-brand: #3eaf7c;--c-brand-light: #4abf8a}nav img{vertical-align:middle}p img{margin:0 auto}.prose hr{border-top:1px solid #e5e7eb}.link{color:var(--c-brand)}.link:hover{color:var(--c-brand-light)}.header-anchor{display:none}.token.comment,.token.block-comment,.token.prolog,.token.doctype,.token.cdata{color:#999}.token.punctuation{color:#ccc}.token.tag,.token.attr-name,.token.namespace,.token.deleted{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.number,.token.function{color:#f08d49}.token.property,.token.class-name,.token.constant,.token.symbol{color:#f8c555}.token.selector,.token.important,.token.atrule,.token.keyword,.token.builtin{color:#cc99cd}.token.string,.token.char,.token.attr-value,.token.regex,.token.variable{color:#7ec699}.token.operator,.token.entity,.token.url{color:#67cdcc}.token.important,.token.bold{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:#67cdcc}button.copy{display:none}span.lang{position:absolute;right:.5em;font-size:.75em;color:#999;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.custom-block{margin:28px 0;padding:0 24px 2px;border-radius:8px;overflow-x:auto;position:relative;font-size:14px;line-height:1.3;font-weight:500;color:#444;background-color:#f9f9f9}.custom-block .custom-block-title{margin-bottom:8px;font-size:15px;font-weight:700}.custom-block.tip{border:1px solid #42b883}.custom-block.tip:before{color:#42b883}.hover\:text-gray-700:hover{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}@media (prefers-color-scheme: dark){.dark\:prose-invert{--tw-prose-body: var(--tw-prose-invert-body);--tw-prose-headings: var(--tw-prose-invert-headings);--tw-prose-lead: var(--tw-prose-invert-lead);--tw-prose-links: var(--tw-prose-invert-links);--tw-prose-bold: var(--tw-prose-invert-bold);--tw-prose-counters: var(--tw-prose-invert-counters);--tw-prose-bullets: var(--tw-prose-invert-bullets);--tw-prose-hr: var(--tw-prose-invert-hr);--tw-prose-quotes: var(--tw-prose-invert-quotes);--tw-prose-quote-borders: var(--tw-prose-invert-quote-borders);--tw-prose-captions: var(--tw-prose-invert-captions);--tw-prose-code: var(--tw-prose-invert-code);--tw-prose-pre-code: var(--tw-prose-invert-pre-code);--tw-prose-pre-bg: var(--tw-prose-invert-pre-bg);--tw-prose-th-borders: var(--tw-prose-invert-th-borders);--tw-prose-td-borders: var(--tw-prose-invert-td-borders)}.dark\:divide-slate-200\/5>:not([hidden])~:not([hidden]){border-color:#e2e8f00d}.dark\:bg-slate-900{--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity))}.dark\:text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.dark\:text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.dark\:hover\:text-gray-200:hover{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity))}}@media (min-width: 640px){.sm\:inline{display:inline}.sm\:space-x-12>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(3rem * var(--tw-space-x-reverse));margin-left:calc(3rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:leading-10{line-height:2.5rem}}@media (min-width: 768px){.md\:inline{display:inline}.md\:space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.md\:text-5xl{font-size:3rem;line-height:1}.md\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width: 1280px){.xl\:col-span-3{grid-column:span 3 / span 3}.xl\:col-start-1{grid-column-start:1}.xl\:row-span-2{grid-row:span 2 / span 2}.xl\:row-start-2{grid-row-start:2}.xl\:block{display:block}.xl\:grid{display:grid}.xl\:max-w-5xl{max-width:64rem}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:items-baseline{align-items:baseline}.xl\:gap-x-10{column-gap:2.5rem}.xl\:space-x-0>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(0px * var(--tw-space-x-reverse));margin-left:calc(0px * calc(1 - var(--tw-space-x-reverse)))}.xl\:space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.xl\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(0px * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px * var(--tw-space-y-reverse))}.xl\:divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.xl\:divide-y-0>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(0px * var(--tw-divide-y-reverse))}.xl\:divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity))}.xl\:border-b{border-bottom-width:1px}.xl\:border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.xl\:px-0{padding-left:0;padding-right:0}.xl\:pb-10{padding-bottom:2.5rem}.xl\:pb-20{padding-bottom:5rem}.xl\:pb-0{padding-bottom:0}.xl\:pt-11{padding-top:2.75rem}}@media (prefers-color-scheme: dark){@media (min-width: 1280px){.dark\:xl\:divide-slate-200\/5>:not([hidden])~:not([hidden]){border-color:#e2e8f00d}.dark\:xl\:border-slate-200\/5{border-color:#e2e8f00d}}}
diff --git a/feed.rss b/feed.rss
new file mode 100644
index 0000000..c53e7b1
--- /dev/null
+++ b/feed.rss
@@ -0,0 +1,332 @@
+
+
+
+ Steve@brown.bg
+ https://brown.bg
+ Steve's blog
+ Fri, 22 Sep 2023 21:13:34 GMT
+ https://validator.w3.org/feed/docs/rss2.html
+ https://github.com/jpmonette/feed
+ en
+
+ Steve@brown.bg
+ https://brown.bg/logo.webp
+ https://brown.bg
+
+ Copyright (c) 2021-present, Steve Brown
+
+
+ https://brown.bg/posts/scaling-masstransit-jobs
+ https://brown.bg/posts/scaling-masstransit-jobs
+ Mon, 04 Sep 2023 00:00:00 GMT
+ We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual.
+All these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs.
+]]>
+ We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual.
+All these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs. ​
+
They run like this:
+
+
Most are processing constantly lots of small messages. These are no problem.
+
Some process only a few messages a day, but we have to keep the service running all day. This is annoying.
+
Some process only a few messages per day, but those small messages take a lot of CPU and memory. For example they may need to deal with files in memory in formats that don't yet easily support iteratively processing the file. This is a big problem for us as the services need to be sized to handle these large requirement, but are actually only running for a few minutes a day with this load.
+
+
This post will show one approach to fix the third problem. The same tooling (KEDA) can also be used to improve the other situations but that's for another blog post.
+What we'll do to address the final problem is to use KEDA Job based scaling.
+
First, we have the publisher. This could be anything and in our example it's normally from a cron job. The send will look like this, all normal MassTransit code:
Of course, this is just the basics, your real solution would include logging and some other error handling (poison messages and so on should be handled, otherwise it will continue processing bad messages over and over).
+
This post now assumes you can build this into a docker image and push it to a registry you use. For this example, we'll assume the image name is example-long:latest
+
At this point, you have a working application that will pick up a single message, handle it, and finish. We need some way to trigger this to happen for each single message in the queue - for this comes KEDA.
+First, you need a secret containing the RabbitMQ connection string:
And the important bit: you need a ScaledJob spec that runs your docker image
+
yaml
apiVersion:keda.sh/v1alpha1
+kind:ScaledJob
+metadata:
+name:rabbitmq-consumer
+namespace:limited
+spec:
+jobTargetRef:
+template:
+spec:
+containers:
+-name:example-long
+image:example-long:latest
+imagePullPolicy:Never
+restartPolicy:Never
+backoffLimit:4
+pollingInterval:2# Optional. Default: 30 seconds
+maxReplicaCount:30# Optional. Default: 100
+successfulJobsHistoryLimit:30# Optional. Default: 100. How many completed jobs should be kept.
+failedJobsHistoryLimit:20# Optional. Default: 100. How many failed jobs should be kept.
+triggers:
+-type:rabbitmq
+metadata:
+queueName:LongRunningJobs
+queueLength:'1'
+authenticationRef:
+name:keda-trigger-auth-rabbitmq-conn
+
Now, when you put a message into the queue, it'll be processed as a MassTransit message in a Kubernetes Job, allowing you to run your normal worker pods with lower memory and CPU requirements.
+
You also have a few other benefits:
+
+
Deploys of the service don't kill a long running job
+
Jobs can be scheduled on other nodes
+
Jobs can be scheduled as lower priority
+
+]]>
+
+
+
+ https://brown.bg/posts/dotnet-kerberos
+ https://brown.bg/posts/dotnet-kerberos
+ Sat, 05 Aug 2023 00:00:00 GMT
+ For the last few years at my workplace we've been using Kerberos authentication to connect to the on-prem SQL Server databases. On Windows this just worked like magic, as the services ran as a Windows domain user and so could connect to the database without any additional configuration. However, when moving to Kubernetes and Linux systems, this didn't work out of the box.
+]]>
+ For the last few years at my workplace we've been using Kerberos authentication to connect to the on-prem SQL Server databases. On Windows this just worked like magic, as the services ran as a Windows domain user and so could connect to the database without any additional configuration. However, when moving to Kubernetes and Linux systems, this didn't work out of the box. ​
+
Our initial solution was following this red hat blog post which details how to setup a sidecar which keeps a kerberos token valid. This worked for a number of years but did have some problems:
+
+
We ran 100+ containers and each needed a sidecar - this cost a few gigabytes of memory and some CPU allowance
+
When the token was being refreshed, it was not available for a few milliseconds
+
It was extra hassle and created slightly larger deployments and more complex Deployment configs.
+
+
When investigating a seperate issue (dotnet 7 caused some transient kerberos faults) we realised that actually the whole sidecar approach was completely unneccessary! This post will give the minimum you actually need to call Keberos services from Linux
+
+
You'll need to add the keberos tools to your docker image. Maybe you have a base image you share, maybe not. For alpine images:
+
+
RUN apk add --no-cache krb5
+
+
You'll need to have a krb5 configuration that points to your domain controller. Microsoft has an example
We put it in a custom folder as some container systems have the limitation that mounting a directory overrides all files in that directory (we'll do this later)
+
+
+
You then need a keytab added to the image. You will probably want this done at runtime. Get a keytab (maybe off your AD system administrators, maybe you create it yourself with ktutil) and make sure this ends up at /krb5/client.keytab. We use kubernetes secrets and volume mounts for this
+
+
+
Connect to the database using Integrated Security=true. This should now just work. You can check by calling klist inside the container to see that it has a ticket
+
+
+]]>
+
+
+
+ https://brown.bg/posts/init7-tv7
+ https://brown.bg/posts/init7-tv7
+ Fri, 04 Aug 2023 00:00:00 GMT
+ If you're an Init7 TV customer and you want to use the Unify Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started.
+]]>
+ If you're an Init7 TV customer and you want to use the Unify Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started. ​
+
Before you begin, make sure your Unify Dream Machine is updated to the latest firmware version. You can check for updates by logging into the Unify Dream Machine web interface and navigating to the "Settings" page.
+Your version needs to be higher than "UniFi Network Application 7.4.156".
Personally I used one called "m3u player" but there are many out there. You'll then also be able to go to your udpxy status page and see the running stream there.
+]]>
+
+
+
+ https://brown.bg/posts/github-actions-net7-docker
+ https://brown.bg/posts/github-actions-net7-docker
+ Fri, 14 Oct 2022 00:00:00 GMT
+ How to setup a github build action that uses the dockerless build in .Net 7
+]]>
+ How to setup a github build action that uses the dockerless build in .Net 7
+
+
For me, one of the slowest parts of the fast build-test-deploy cycle was always waiting for docker builds to run. Especially with the practise of docker-in-docker builds which without special magic wouldn't cache anything, it could often take several minutes to create a docker application.
+.Net 7 promises to offer an alternative to this for simple apps - let's take a dive in.
You'll need .Net 7 for this, which isn't currently released at time of writing. However, in theory you can take a .Net 6 application and use the .Net 7 SDK to build it already, however YMMV.
Now, add a github actions to .github/workflows/whatever.yml
+
yaml
name:Create and publish a Docker image
+
+on:
+push:
+branches:["*"]
+pull_request:
+branches:["main"]
+
+
+env:
+REGISTRY:ghcr.io
+IMAGE_NAME:${{ github.repository }}
+
+jobs:
+build-and-push-image:
+runs-on:ubuntu-latest
+permissions:
+contents:read
+packages:write
+
+steps:
+-uses:actions/checkout@v3
+-name:Setup .NET SDK
+uses:actions/setup-dotnet@v2
+# Package the app into a linux-x64 container based on the dotnet/aspnet image
+-name:Publish
+run:dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
+-name:Checkout repository
+uses:actions/checkout@v3
+-name:Login to GitHub Container Registry
+uses:docker/login-action@v2
+with:
+registry:ghcr.io
+username:${{ github.actor }}
+password:${{ secrets.GITHUB_TOKEN }}
+-name:Tag built container with Github thing
+run:|
+ docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
+-name:Push built container to Github Package Repo
+run:|
+ docker push ghcr.io/evolvedlight/dockerless-docker:1.0.0
+
Here's now where the weird bit comes - on github the process to get a repository seems rather backward. You need to assign permissions for the repo to write images, but there's no way to do that until you've pushed an image manually!
+
So let's go around the hoops - create the docker image locally, tag it, login to the Github repository and upload it:
+
dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
+docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
+docker login ghcr.io -u <your username>
+
Enter a Personal access token that you can create on your github profile page, and finally push the image:
We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual.
+All these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs.
For the last few years at my workplace we've been using Kerberos authentication to connect to the on-prem SQL Server databases. On Windows this just worked like magic, as the services ran as a Windows domain user and so could connect to the database without any additional configuration. However, when moving to Kubernetes and Linux systems, this didn't work out of the box.
If you're an Init7 TV customer and you want to use the Unify Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started.
Creating Kerberos secured connections from Dotnet on Linux
Authors
Name
Stephen Brown
For the last few years at my workplace we've been using Kerberos authentication to connect to the on-prem SQL Server databases. On Windows this just worked like magic, as the services ran as a Windows domain user and so could connect to the database without any additional configuration. However, when moving to Kubernetes and Linux systems, this didn't work out of the box.
Our initial solution was following this red hat blog post which details how to setup a sidecar which keeps a kerberos token valid. This worked for a number of years but did have some problems:
We ran 100+ containers and each needed a sidecar - this cost a few gigabytes of memory and some CPU allowance
When the token was being refreshed, it was not available for a few milliseconds
It was extra hassle and created slightly larger deployments and more complex Deployment configs.
When investigating a seperate issue (dotnet 7 caused some transient kerberos faults) we realised that actually the whole sidecar approach was completely unneccessary! This post will give the minimum you actually need to call Keberos services from Linux
You'll need to add the keberos tools to your docker image. Maybe you have a base image you share, maybe not. For alpine images:
RUN apk add --no-cache krb5
You'll need to have a krb5 configuration that points to your domain controller. Microsoft has an example
We put it in a custom folder as some container systems have the limitation that mounting a directory overrides all files in that directory (we'll do this later)
You then need a keytab added to the image. You will probably want this done at runtime. Get a keytab (maybe off your AD system administrators, maybe you create it yourself with ktutil) and make sure this ends up at /krb5/client.keytab. We use kubernetes secrets and volume mounts for this
Connect to the database using Integrated Security=true. This should now just work. You can check by calling klist inside the container to see that it has a ticket
+
+
+
+
\ No newline at end of file
diff --git a/posts/dotnet-kerberos.md b/posts/dotnet-kerberos.md
deleted file mode 100644
index 38b27d0..0000000
--- a/posts/dotnet-kerberos.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-title: Creating Kerberos secured connections from Dotnet on Linux
-date: 2023-08-05
-author: Stephen Brown
----
-
-For the last few years at my workplace we've been using Kerberos authentication to connect to the on-prem SQL Server databases. On Windows this just worked like magic, as the services ran as a Windows domain user and so could connect to the database without any additional configuration. However, when moving to Kubernetes and Linux systems, this didn't work out of the box.
-
----
-
-Our initial solution was following this [Red Hat blog post](https://cloud.redhat.com/blog/kerberos-sidecar-container) which details how to set up a sidecar which keeps a kerberos token valid. This worked for a number of years but did have some problems:
-
- - We ran 100+ containers and each needed a sidecar - this cost a few gigabytes of memory and some CPU allowance
- - When the token was being refreshed, it was not available for a few milliseconds
- - It was extra hassle and created slightly larger deployments and more complex Deployment configs.
-
-When investigating a separate issue (dotnet 7 caused some transient kerberos faults) we realised that actually the whole sidecar approach was completely unnecessary! This post will give the minimum you actually need to call Kerberos services from Linux
-
-1) You'll need to add the kerberos tools to your docker image. Maybe you have a base image you share, maybe not. For alpine images:
-
-```RUN apk add --no-cache krb5```
-
-2) You'll need to have a krb5 configuration that points to your domain controller. Microsoft has an [example](https://learn.microsoft.com/en-us/sql/connect/jdbc/using-kerberos-integrated-authentication-to-connect-to-sql-server?view=sql-server-ver16#creating-a-kerberos-configuration-file)
-
-For example, ours looks like this:
-
-```
-[logging]
-default
-
-[libdefaults]
-...standard things here
-default_client_keytab_name=/krb5/client.keytab
-default_keytab_name=/krb5/krb5.keytab
-default_ccache_name=FILE:/dev/shm/ccache
-default_realm = EXAMPLE.COM
-
-[realms]
-EXAMPLE.COM = {
- kdc = ADS.EXAMPLE.COM:88
-}
-```
-
-You can then add this to the docker image like
-```
-RUN mkdir /krb5 && mkdir /customkrb5
-COPY krb5.conf /customkrb5/krb5.conf
-ENV KRB5_CONFIG=/customkrb5/krb5.conf
-```
-
-We put it in a custom folder as some container systems have the limitation that mounting a directory overrides all files in that directory (we'll do this later)
-
-3) You then need a keytab added to the image. You will probably want this done at runtime. Get a keytab (maybe off your AD system administrators, maybe you create it yourself with ktutil) and make sure this ends up at /krb5/client.keytab. We use kubernetes secrets and volume mounts for this
-
-4) Connect to the database using `Integrated Security=true`. This should now just work. You can check by calling `klist` inside the container to see that it has a ticket
\ No newline at end of file
diff --git a/posts/github-actions-net7-docker.html b/posts/github-actions-net7-docker.html
new file mode 100644
index 0000000..7ef4c6a
--- /dev/null
+++ b/posts/github-actions-net7-docker.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ Dockerless docker builds in Github Actions with Dotnet 7 | steve's blog
+
+
+
+
+
+
+
+
+
Published on
Dockerless docker builds in Github Actions with Dotnet 7
For me, one of the slowest parts of the fast build-test-deploy cycle was always waiting for docker builds to run. Especially with the practise of docker-in-docker builds which without special magic wouldn't cache anything, it could often take several minutes to create a docker application. .Net 7 promises to offer an alternative to this for simple apps - let's take a dive in.
You'll need .Net 7 for this, which isn't currently released at time of writing. However, in theory you can take a .Net 6 application and use the .Net 7 SDK to build it already, however YMMV.
Now, add a github actions to .github/workflows/whatever.yml
yaml
name:Create and publish a Docker image
+
+on:
+push:
+branches:["*"]
+pull_request:
+branches:["main"]
+
+
+env:
+REGISTRY:ghcr.io
+IMAGE_NAME:${{ github.repository }}
+
+jobs:
+build-and-push-image:
+runs-on:ubuntu-latest
+permissions:
+contents:read
+packages:write
+
+steps:
+-uses:actions/checkout@v3
+-name:Setup .NET SDK
+uses:actions/setup-dotnet@v2
+# Package the app into a linux-x64 container based on the dotnet/aspnet image
+-name:Publish
+run:dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
+-name:Checkout repository
+uses:actions/checkout@v3
+-name:Login to GitHub Container Registry
+uses:docker/login-action@v2
+with:
+registry:ghcr.io
+username:${{ github.actor }}
+password:${{ secrets.GITHUB_TOKEN }}
+-name:Tag built container with Github thing
+run:|
+ docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
+-name:Push built container to Github Package Repo
+run:|
+ docker push ghcr.io/evolvedlight/dockerless-docker:1.0.0
Here's now where the weird bit comes - on github the process to get a repository seems rather backward. You need to assign permissions for the repo to write images, but there's no way to do that until you've pushed an image manually!
So let's go around the hoops - create the docker image locally, tag it, login to the Github repository and upload it:
dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
+docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
+docker login ghcr.io -u <your username>
Enter a Personal access token that you can create on your github profile page, and finally push the image:
Under "Manage Actions access" add your repository with access.
In the end it'll look like this:
Finally, rerun the action and it should work.
In the next blob post we'll look at fixing the above docker build to push with the right version numbers and tags
+
+
+
+
\ No newline at end of file
diff --git a/posts/github-actions-net7-docker.md b/posts/github-actions-net7-docker.md
deleted file mode 100644
index 33e2764..0000000
--- a/posts/github-actions-net7-docker.md
+++ /dev/null
@@ -1,117 +0,0 @@
----
-title: Dockerless docker builds in Github Actions with Dotnet 7
-date: 2022-10-14
-author: Stephen Brown
-twitter: '@evolvedlight'
----
-
-How to set up a GitHub build action that uses the dockerless build in .NET 7
-
----
-
-## Background
-
-For me, one of the slowest parts of the fast build-test-deploy cycle was always waiting for docker builds to run. Especially with the practice of docker-in-docker builds which without special magic wouldn't cache anything, it could often take several minutes to create a docker application.
-.NET 7 promises to offer an alternative to this for simple apps - let's take a dive in.
-
-### Setup
-
-You'll need .NET 7 for this, which isn't currently released at time of writing. However, in theory you can take a .NET 6 application and use the .NET 7 SDK to build it already; however, YMMV.
-
-With this installed, let's create a new app and get started! We're following along with https://devblogs.microsoft.com/dotnet/announcing-builtin-container-support-for-the-dotnet-sdk/ but with some changes
-
-### Basic app
-
-```bash
-dotnet new webapi -o dockerless-docker
-cd dockerless-docker
-```
-
-We also need to do a couple of things: because .NET 7 isn't out yet, we need a global.json file to tell GitHub Actions to use it.
-
-That gives us our basic app - let's add a GitHub Actions build for it now, by adding the following:
-```json
-{
- "sdk": {
- "version": "7.0.100-rc.2.22477.23"
- }
-}
-```
-
-We'll also need to add a reference to this tool:
-```bash
-dotnet add package Microsoft.NET.Build.Containers
-```
-
-Now, add a GitHub Actions workflow to ```.github/workflows/whatever.yml```
-
-```yaml
-name: Create and publish a Docker image
-
-on:
- push:
- branches: [ "*" ]
- pull_request:
- branches: [ "main" ]
-
-
-env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
-
-jobs:
- build-and-push-image:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
-
- steps:
- - uses: actions/checkout@v3
- - name: Setup .NET SDK
- uses: actions/setup-dotnet@v2
- # Package the app into a linux-x64 container based on the dotnet/aspnet image
- - name: Publish
- run: dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v2
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- - name: Tag built container with GitHub metadata
- run: |
- docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
- - name: Push built container to GitHub Package Repo
- run: |
- docker push ghcr.io/evolvedlight/dockerless-docker:1.0.0
-```
-
-Here's now where the weird bit comes - on GitHub the process to get a repository seems rather backward. You need to assign permissions for the repo to write images, but there's no way to do that _until_ you've pushed an image manually!
-
-So let's go around the hoops - create the docker image locally, tag it, login to the GitHub repository and upload it:
-
-```
-dotnet publish --os linux --arch x64 --configuration Release -p:PublishProfile=DefaultContainer
-docker tag dockerless-docker:1.0.0 ghcr.io/evolvedlight/dockerless-docker:1.0.0
-docker login ghcr.io -u
-```
-
-Enter a Personal access token that you can create on your GitHub profile page, and finally push the image:
-
-```
-docker push ghcr.io/evolvedlight/dockerless-docker:1.0.0
-```
-
-Now go to your packages page, for me that's https://github.com/evolvedlight?tab=packages.
-Click on the new package page that was created, and then go to the settings for it (for me https://github.com/users/evolvedlight/packages/container/dockerless-docker/settings)
-
-Under "Manage Actions access" add your repository with access.
-
-In the end it'll look like this:
-
-
-
-Finally, rerun the action and it should work.
-
-In the next blog post we'll look at fixing the above docker build to push with the right version numbers and tags
\ No newline at end of file
diff --git a/posts/init7-tv7.html b/posts/init7-tv7.html
new file mode 100644
index 0000000..e76a910
--- /dev/null
+++ b/posts/init7-tv7.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine | steve's blog
+
+
+
+
+
+
+
+
+
Published on
TV7 (IPTV from Init7) with Dream Machine and Unity Dream machine
Authors
Name
Stephen Brown
If you're an Init7 TV customer and you want to use the Unify Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started.
Before you begin, make sure your Unify Dream Machine is updated to the latest firmware version. You can check for updates by logging into the Unify Dream Machine web interface and navigating to the "Settings" page. Your version needs to be higher than "UniFi Network Application 7.4.156".
Personally I used one called "m3u player" but there are many out there. You'll then also be able to go to your udpxy status page and see the running stream there.
+
+
+
+
\ No newline at end of file
diff --git a/posts/init7-tv7.md b/posts/init7-tv7.md
deleted file mode 100644
index 3950436..0000000
--- a/posts/init7-tv7.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-title: TV7 (IPTV from Init7) with Dream Machine and UniFi Dream Machine
-date: 2023-08-04
-author: Stephen Brown
----
-
-If you're an Init7 TV customer and you want to use the UniFi Dream Machine as your IPTV receiver, I figured it out for you! Here's a step-by-step guide to help you get started.
-
----
-
-## Step 1: Update to the Newest Dream Machine
-
-Before you begin, make sure your UniFi Dream Machine is updated to the latest firmware version. You can check for updates by logging into the UniFi Dream Machine web interface and navigating to the "Settings" page.
-Your version needs to be higher than ["UniFi Network Application 7.4.156"](https://community.ui.com/releases/UniFi-Network-Application-7-4-156/15ac6260-9cd1-4ac3-a91c-4880c1c87882).
-
-## Step 2: Enable the feature
-
-You now have a new setup that you can do on the Network application:
-
-
-
-You can also see the [basic unifi docs](https://help.ui.com/hc/en-us/articles/14957610078615-UniFi-Gateway-IGMP-Proxy-IPTV-#:~:text=How%20Does%20it%20Work%3F,box%20that%20receives%20the%20traffic.)
-
-You should now already be able to watch TV7 through VLC using this link: https://www.init7.net/en/support/faq/TV-andere-Geraete/
-
-
-
-However, I wanted to watch on a Samsung TV and none of the apps supported these multicast IPTV streams. So, I had to:
-
-## Step 3: Install agrrh-udpxy.
-
-I installed this as a docker image on a Synology NAS.
-
-The following settings worked for me:
-image: agrrh/udpxy:latest
-network: host
-cmd: -v -T -p 4022
-
-You can then go to your nas drive on port 4022, URL /status. For example mine is http://nasdrive:4022/status and I see:
-
-
-
-## Step 4: Create your own playlist that forwards to your udpxy instance instead of the original network stream
-
-You can do this by opening up the m3u file and replacing the URLs with the link to your local instance. For example, mine is here:
-https://gist.github.com/evolvedlight/e9b2a0145840ba5385478a3075aa815a
-
-## Step 5: Open this in an IPTV app.
-
-Personally I used one called "m3u player" but there are many out there. You'll then also be able to go to your udpxy status page and see the running stream there.
\ No newline at end of file
diff --git a/posts/scaling-masstransit-jobs.html b/posts/scaling-masstransit-jobs.html
new file mode 100644
index 0000000..393989c
--- /dev/null
+++ b/posts/scaling-masstransit-jobs.html
@@ -0,0 +1,112 @@
+
+
+
+
+
+ Running MassTransit queues with KEDA | steve's blog
+
+
+
+
+
+
+
+
+
Published on
Running MassTransit queues with KEDA
Authors
Name
Stephen Brown
We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual. All these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs.
They run like this:
Most are processing constantly lots of small messages. These are no problem.
Some process only a few messages a day, but we have to keep the service running all day. This is annoying.
Some process only a few messages per day, but those small messages take a lot of CPU and memory. For example they may need to deal with files in memory in formats that don't yet easily support iteratively processing the file. This is a big problem for us as the services need to be sized to handle these large requirement, but are actually only running for a few minutes a day with this load.
This post will show one approach to fix the third problem. The same tooling (KEDA) can also be used to improve the other situations but that's for another blog post. What we'll do to address the final problem is to use KEDA Job based scaling.
First, we have the publisher. This could be anything and in our example it's normally from a cron job. The send will look like this, all normal MassTransit code:
Of course, this is just the basics, your real solution would include logging and some other error handling (poison messages and so on should be handled, otherwise it will continue processing bad messages over and over).
This post now assumes you can build this into a docker image and push it to a registry you use. For this example, we'll assume the image name is example-long:latest
At this point, you have a working application that will pick up a single message, handle it, and finish. We need some way to trigger this to happen for each single message in the queue - for this comes KEDA. First, you need a secret containing the RabbitMQ connection string:
And the important bit: you need a ScaledJob spec that runs your docker image
yaml
apiVersion:keda.sh/v1alpha1
+kind:ScaledJob
+metadata:
+name:rabbitmq-consumer
+namespace:limited
+spec:
+jobTargetRef:
+template:
+spec:
+containers:
+-name:example-long
+image:example-long:latest
+imagePullPolicy:Never
+restartPolicy:Never
+backoffLimit:4
+pollingInterval:2# Optional. Default: 30 seconds
+maxReplicaCount:30# Optional. Default: 100
+successfulJobsHistoryLimit:30# Optional. Default: 100. How many completed jobs should be kept.
+failedJobsHistoryLimit:20# Optional. Default: 100. How many failed jobs should be kept.
+triggers:
+-type:rabbitmq
+metadata:
+queueName:LongRunningJobs
+queueLength:'1'
+authenticationRef:
+name:keda-trigger-auth-rabbitmq-conn
Now, when you put a message into the queue, it'll be processed as a MassTransit message in a Kubernetes Job, allowing you to run your normal worker pods with lower memory and CPU requirements.
You also have a few other benefits:
Deploys of the service don't kill a long running job
Jobs can be scheduled on other nodes
Jobs can be scheduled as lower priority
+
+
+
+
\ No newline at end of file
diff --git a/posts/scaling-masstransit-jobs.md b/posts/scaling-masstransit-jobs.md
deleted file mode 100644
index cdc319a..0000000
--- a/posts/scaling-masstransit-jobs.md
+++ /dev/null
@@ -1,183 +0,0 @@
----
-title: Running MassTransit queues with KEDA
-date: 2023-09-04
-author: Stephen Brown
----
-
-We have an interesting challenge in some of our microservices - we run lots and lots of microservices, all using a relatively small amount of memory, but the usage patterns may be unusual.
-All these microservices talk MassTransit - some also have external triggers, API endpoints or scheduled jobs.
-
----
-
-They run like this:
- - Most are processing constantly lots of small messages. These are no problem.
- - Some process only a few messages a day, but we have to keep the service running all day. This is annoying.
- - Some process only a few messages per day, but those small messages take a lot of CPU and memory. For example they may need to deal with files in memory in formats that don't yet easily support iteratively processing the file. This is a big problem for us as the services need to be sized to handle these large requirement, but are actually only running for a few minutes a day with this load.
-
-This post will show one approach to fix the third problem. The same tooling (KEDA) can also be used to improve the other situations but that's for another blog post.
-What we'll do to address the final problem is to use KEDA Job based scaling.
-
-First, we have the publisher. This could be anything and in our example it's normally from a cron job. The send will look like this, all normal MassTransit code:
-
-```c#
-var endpoint = app.Services.GetRequiredService();
-await endpoint.Publish(new ProcessVeryLargeFileTrigger(fileName));
-```
-
-`ProcessVeryLargeFileTrigger` is defined in an assembly that both producer and consumer can use.
-
-Now, we need an entrypoint that allows us to read one message from the MassTransit queue (RabbitMQ is used here), give it to MassTransit, then exit.
-
-First, our entrypoint needs to have a MassTransit consumer, in memory.
-
-```c#
-
-// Setup
-HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
-builder.Services.AddMassTransit(x =>
-{
- x.AddConsumer();
-
- x.UsingInMemory();
- x.AddConfigureEndpointsCallback((_, cfg) =>
- {
- // Optional, we like this format
- cfg.UseRawJsonSerializer(isDefault: true);
- });
-});
-```
-
-Next, we need to connect and bind to a RabbitMQ queue:
-
-```c#
-ConnectionFactory factory = new ConnectionFactory
-{
- UserName = "",
- Password = "",
- VirtualHost = "/",
- HostName = "example.default"
-};
-
-IConnection conn = factory.CreateConnection();
-IModel channel = conn.CreateModel();
-
-# Your exchange and queues are likely different
-var exchangeName = "LongRunningJobs";
-var queueName = "LongRunningJobs";
-var routingKey = "LongRunningJobs";
-channel.ExchangeDeclare(exchangeName, ExchangeType.Direct);
-channel.QueueDeclare(queueName, false, false, false, null);
-channel.QueueBind(queueName, exchangeName, routingKey, null);
-```
-
-After this, we need to try and get a single message from RabbitMQ. If there's nothing here, we exit.
-
-```c#
-BasicGetResult message = channel.BasicGet("LongRunningJobs", false);
-if (message == null) {
- // No message to pickup
- return;
-}
-
-```
-
-We can then build our normal app and inject the RabbitMQ message into it:
-
-```c#
-var app = builder.Build();
-await app.StartAsync();
-using var scope = app.Services.CreateScope();
-var ep = scope.ServiceProvider.GetRequiredService();
-```
-
-Next, inject the message:
-```c#
-try
-{
- var messageBytes = message.Body.ToArray();
- var headers = message.BasicProperties.Headers;
- await ep.Dispatch(messageBytes, headers, CancellationToken.None);
- channel.BasicAck(message.DeliveryTag, false);
-}
-catch
-{
- channel.BasicNack(message.DeliveryTag, false, true);
-}
-```
-
-Finally, we can clean up:
-```c#
-channel.Close();
-conn.Close();
-await app.StopAsync();
-```
-
-Of course, this is just the basics, your real solution would include logging and some other error handling (poison messages and so on should be handled, otherwise it will continue processing bad messages over and over).
-
-This post now assumes you can build this into a docker image and push it to a registry you use. For this example, we'll assume the image name is `example-long:latest`
-
-At this point, you have a working application that will pick up a single message, handle it, and finish. We need some way to trigger this to happen for each single message in the queue - for this comes KEDA.
-First, you need a secret containing the RabbitMQ connection string:
-
-```yaml
-apiVersion: v1
-kind: Secret
-metadata:
- name: keda-rabbitmq-secret
- namespace: limited
-data:
- host: >-
-
-type: Opaque
-```
-
-You need a trigger authentication:
-```yaml
-apiVersion: keda.sh/v1alpha1
-kind: TriggerAuthentication
-metadata:
- name: keda-trigger-auth-rabbitmq-conn
- namespace: limited
-spec:
- secretTargetRef:
- - parameter: host
- name: keda-rabbitmq-secret
- key: host
-```
-
-And the important bit: you need a ScaledJob spec that runs your docker image
-```yaml
-apiVersion: keda.sh/v1alpha1
-kind: ScaledJob
-metadata:
- name: rabbitmq-consumer
- namespace: limited
-spec:
- jobTargetRef:
- template:
- spec:
- containers:
- - name: example-long
- image: example-long:latest
- imagePullPolicy: Never
- restartPolicy: Never
- backoffLimit: 4
- pollingInterval: 2 # Optional. Default: 30 seconds
- maxReplicaCount: 30 # Optional. Default: 100
- successfulJobsHistoryLimit: 30 # Optional. Default: 100. How many completed jobs should be kept.
- failedJobsHistoryLimit: 20 # Optional. Default: 100. How many failed jobs should be kept.
- triggers:
- - type: rabbitmq
- metadata:
- queueName: LongRunningJobs
- queueLength: '1'
- authenticationRef:
- name: keda-trigger-auth-rabbitmq-conn
-```
-
-Now, when you put a message into the queue, it'll be processed as a MassTransit message in a Kubernetes Job, allowing you to run your normal worker pods with lower memory and CPU requirements.
-
-You also have a few other benefits:
- - Deploys of the service don't kill a long running job
- - Jobs can be scheduled on other nodes
- - Jobs can be scheduled as lower priority
\ No newline at end of file
diff --git a/posts/swiss-citizenship-timeline.md b/posts/swiss-citizenship-timeline.md
deleted file mode 100644
index a5c8e36..0000000
--- a/posts/swiss-citizenship-timeline.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-title: Zurich 2025 Citizenship Timeline
-date: 2025-08-16
-author: Stephen Brown
----
-
-This is the timeline of my journey towards obtaining Swiss citizenship. I will update this document as I progress through the various phases of the application process.
-The process is different per Kanton, and changed a couple of years ago, so I document it here so people can see what the normal timeline is like.
-I'm a UK citizen right now, but the process is relatively similar for other nationalities, with some differences in the required documents for the Zivilstandsregister (Civil Registry).
-The official process and website is here for Zurich: [Ordentliche Einbürgerung](https://www.zh.ch/de/migration-integration/einbuergerung/ordentliche-einbuergerung.html).
-
-## My Timeline So Far
-
-| Phase | German name of Phase | Date / Period | Notes |
-| ----- | --------------------- | --------------| ------|
-| C Permit | C-Bewilligung| 2020 | I could apply for a C Permit after 5 years of residence in Switzerland. |
-| UK Birth Certificate | | 2025-05-26 | I ordered a copy of my birth certificate from the UK government website |
-| Filled in form for entry in Civil Register | Eintrag im Zivilstandsregister | 2025-06-03 | Don't be fooled to fill in the manual PDF that's linked on the main website - you can start the process electronically at [online form](https://www.stadt-zuerich.ch/de/lebenslagen/einwohner-services/einbuergerung/registrierung-im-zivilstandsregister.html) |
-| Got email reply back from application form | | 2025-06-04 | I received an email confirming that my application was received - they asked to provide a copy of the passport and of the birth certificate |
-| UK Birth Certificate received | | 2025-06-13 | Took about 2-3 weeks to arrive but can take longer |
-| Sent documents by post to Zurich Civil registry office | | 2025-06-16 | Must be via post, they will confirm by email a day or so later |
-| Confirmation form from Civil Registry office | | ~2025-08-01 | They ask you by post to sign and confirm the details. I was on holiday so only sent it back on the 12th |
-| Civil status certificate received by Post | Personenstandsausweis | 2025-08-16 | Confirmation of entry in Civil registry |
-| Created application at [naturalization.services.zh.ch](https://naturalization.services.zh.ch/) | eEinbürgerung | 2025-10-17 | This was a bit later than the 10 year mark, as I was waiting for my partner |
-| Finished filling in, and submitted eEinbürgerung | eEinbürgerung eingereicht | 2025-10-28 | Most was very easy, but needed to write a page about hobbies, social interactions, clubs and so on |
-| Update online: Moved into pre-examination | Vorprüfung | 2026-02-05 | Nothing for me to do - just a text/email notification |
-| Update online: Moved into Municipal Naturalization Test | Prüfung Gemeinde | 2026-05-06 | Nothing for me to do - just a text/email notification |
-| Letter from Canton and letter from city | Zustellung an Ihre Wohngeminde, Wann werden auch Sie Schweizer*in | 2026-05-18 | Notification that the Canton has transferred the application to the Gemeinde. Nothing for me to do |
-| Test booklet and invitation | Ihr Einbürgerungsverfahren, Einladung zum Grundkenntnistest | 2026-06-24 | Test booklet and invitation to test - with available slots being earliest in August |
-
-
-Now it's time to wait for the next steps, which could be 2-3 months for the Grundkenntnistest invitation.
-
-(Will update as progress happens.)
diff --git a/posts/worklog-assistant.md b/posts/worklog-assistant.md
deleted file mode 100644
index e469971..0000000
--- a/posts/worklog-assistant.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Creating Worklog Assistant - the background
-date: 2024-10-05
-author: Stephen Brown
----
-
-Creating Worklog Assistant - the background of creating a new tool as a replacement for an existing but now unavailable tool.
-
----
-
-This project started as a replacement for an existing tool called Worklog Assistant. This is a tool that can log time spent on JIRA tasks and was used quite intensively by my company. It cost some reasonable amount of money, and did a relatively simple job quite well. However, this year, everything related to this company disappeared from the internet. The JIRA confluence pages disappeared, the Twitter/X feed is gone, the website is gone, and the tool is gone. I have no idea what happened, but all the license payers are left with a tool that will stop working at some point in the future as the licenses expire.
-
-So - I set about creating my own tool - and the result is https://worklogassistant.app/
-It's written in Flutter for cross-platform compatibility and is installed or run from your desktop without needing any dependencies installed (for ease of use in corporate environments).
-It's _blazingly_ fast, and is catching up with what the old platform did. It's not quite there yet, but it's getting there.
-
-The next series of blog posts will be about the journey of creating this tool.
-
-I expect to cover the following:
-
-- The initial design
-- The implementation and flutter learning experience
-- The GitHub Actions build pipeline
-- The public website
-
-Hopefully this will show the process of creating a tool from scratch, and the setup needed to ensure that it's maintainable and can be built and deployed easily.
-
-Currently the tool isn't open source, and the billing isn't implemented - the billing will come soon.
-Open source - likely not; however the process for building it will be documented here.
-
-
\ No newline at end of file
diff --git a/prettierrc b/prettierrc
deleted file mode 100644
index 154465a..0000000
--- a/prettierrc
+++ /dev/null
@@ -1,4 +0,0 @@
-semi: false
-singleQuote: true
-printWidth: 80
-trailingComma: none
\ No newline at end of file
diff --git a/public/images/vlc-tv7.jpg b/public/images/vlc-tv7.jpg
deleted file mode 100644
index 618a1c2..0000000
Binary files a/public/images/vlc-tv7.jpg and /dev/null differ
diff --git a/public/images/worklog-assistant-darkmode.png b/public/images/worklog-assistant-darkmode.png
deleted file mode 100644
index 33897f6..0000000
Binary files a/public/images/worklog-assistant-darkmode.png and /dev/null differ
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index f078101..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "compilerOptions": {
- "module": "NodeNext",
- "allowJs": true,
- "strict": true,
- "jsx": "preserve"
- },
- "include": ["./.vitepress/**/*"]
-}
\ No newline at end of file