From 57f7268b9ffc1a6fcb1389ec76311cc5cc91e507 Mon Sep 17 00:00:00 2001 From: David Young Date: Wed, 22 Feb 2023 22:26:43 +1300 Subject: [PATCH] Add post on proxmox MTU vs VLAN Signed-off-by: David Young --- _snippets/channels.md | 24 + _snippets/common-links.md | 1 + ...xmox-7-3-enforces-mtu-breaks-vault-raft.md | 175 +++++ docs/community/discord.md | 35 +- docs/community/matrix.md | 102 +++ docs/community/slack.md | 48 ++ docs/images/blog/proxmox-set-mtu-1.png | Bin 0 -> 36067 bytes docs/recipes/kubernetes/matrix.md | 612 ++++++++++++++++++ mkdocs.yml | 11 +- 9 files changed, 981 insertions(+), 27 deletions(-) create mode 100644 _snippets/channels.md create mode 100644 docs/blog/posts/notes/proxmox-7-3-enforces-mtu-breaks-vault-raft.md create mode 100644 docs/community/matrix.md create mode 100644 docs/community/slack.md create mode 100644 docs/images/blog/proxmox-set-mtu-1.png create mode 100644 docs/recipes/kubernetes/matrix.md diff --git a/_snippets/channels.md b/_snippets/channels.md new file mode 100644 index 0000000..9895140 --- /dev/null +++ b/_snippets/channels.md @@ -0,0 +1,24 @@ +## Channels + +### 📔 Information + +| Channel Name | Channel Use | +|--------------------|------------------------------------------------------------| +| #announcements | Used for important announcements | +| #changelog | Used for major changes to the cookbook (to be deprecated) | +| #cookbook-updates | Updates on all pushes to the master branch of the cookbook | +| #premix-updates | Updates on all pushes to the master branch of the premix | + +### 💬 Discussion + +| Channel Name | Channel Use | +|----------------|----------------------------------------------------------| +| #introductions | New? Pop in here and say hi :) | +| #general | General chat - anything goes | +| #cookbook | Discussions specifically around the cookbook and recipes | +| #kubernetes | Discussions about Kubernetes | +| #docker-swarm | Discussions about Docker Swarm | +| #today-i-learned | Post tips/tricks you've stumbled across +| #jobs | For seeking / advertising jobs, bounties, projects, etc | +| #advertisements | In here you can advertise your stream, services or websites, at a limit of 2 posts per day | +| #dev | Used for collaboration around current development. | diff --git a/_snippets/common-links.md b/_snippets/common-links.md index 6003ccf..d81ab31 100644 --- a/_snippets/common-links.md +++ b/_snippets/common-links.md @@ -29,6 +29,7 @@ [jellyfin]: /recipes/jellyfin/ [k8s/invidious]: /recipes/kubernetes/invidious/ [k8s/mastodon]: /recipes/kubernetes/mastodon/ +[k8s/matrix]: /recipes/kubernetes/matrix/ [metallb]: /kubernetes/loadbalancer/metallb/ [kavita]: /recipes/kavita/ [keycloak]: /recipes/keycloak/ diff --git a/docs/blog/posts/notes/proxmox-7-3-enforces-mtu-breaks-vault-raft.md b/docs/blog/posts/notes/proxmox-7-3-enforces-mtu-breaks-vault-raft.md new file mode 100644 index 0000000..498555d --- /dev/null +++ b/docs/blog/posts/notes/proxmox-7-3-enforces-mtu-breaks-vault-raft.md @@ -0,0 +1,175 @@ +--- +date: 2023-02-22 +categories: + - note +tags: + - proxmox +title: Proxmox 7.3 enforces 1500 MTU, breaks previously-working jumbo-framed VMs +description: Since upgrading to Proxmox 7.3, I discovered that my vault cluster was failing to sync. Turns out, a new setting enforcing a default MTU per-VM was the culprit! +--- + +# That time when a Proxmox upgrade silently capped my MTU + +I feed and water several Proxmox clusters, one of which was recently upgraded to PVE 7.3. This cluster runs VMs used to build a CI instance of a bare-metal Kubernetes cluster I support. Every day the CI cluster is automatically destroyed and rebuilt, to give assurance that our recent changes haven't introduced a failure which would prevent a re-install. + +Since the PVE 7.3 upgrade, the CI cluster has been failing to build, because the out-of-cluster Vault instance we use to secure etcd secrets, failed to sync. After much debugging, I'd like to present a variation of a [famous haiku](https://www.cyberciti.biz/humour/a-haiku-about-dns/)[^1] to summarize the problem: + +> It's not MTU!
+> There's no way it's MTU!
+> It was MTU. + +Here's how it went down... + + + +## Vault fails to sync + +We're using Hashicorp vault in HA mode with [integrated (raft) storage](https://developer.hashicorp.com/vault/docs/concepts/integrated-storage), and [AWSKMS auto-unsealing](https://developer.hashicorp.com/vault/docs/configuration/seal/awskms). All you have to do is initialize vault on your first node, and then include something like this in other nodes: + +``` +storage "raft" { + path = "/var/lib/vault" + node_id = "grumpy" + + retry_join { + leader_api_addr = "https://192.168.37.11:8200" + leader_ca_cert_file = "/etc/kubernetes/pki/vault/ca.pem" + } + retry_join { + leader_api_addr = "https://192.168.37.12:8200" + leader_ca_cert_file = "/etc/kubernetes/pki/vault/ca.pem" + } + retry_join { + leader_api_addr = "https://192.168.37.13:8200" + leader_ca_cert_file = "/etc/kubernetes/pki/vault/ca.pem" + } +} +``` + +When vault starts up, it'll look for the leaders in the `retry_join` config, attempt to connect to them, and use raft magic to unseal themselves and join the raft. + +On our victim cluster, instead of happily joining the raft, the other nodes were logging messages like this: + +```bash +Feb 22 00:38:04 plum vault[32791]: 2023-02-22T00:38:04.126Z [INFO] core: stored unseal keys supported, attempting fetch +Feb 22 00:38:04 plum vault[32791]: 2023-02-22T00:38:04.126Z [WARN] failed to unseal core: error="stored unseal keys are supported, but none were found" +Feb 22 00:38:04 plum vault[32791]: 2023-02-22T00:38:04.648Z [ERROR] core: failed to retry join raft cluster: retry=2s err="failed to send answer to raft leader node: context deadline exceeded" +Feb 22 00:38:06 plum vault[32791]: 2023-02-22T00:38:06.648Z [INFO] core: security barrier not initialized +Feb 22 00:38:06 plum vault[32791]: 2023-02-22T00:38:06.655Z [INFO] core: attempting to join possible raft leader node: leader_addr=https://192.168.20.11:8200 +Feb 22 00:38:06 plum vault[32791]: 2023-02-22T00:38:06.655Z [INFO] core: attempting to join possible raft leader node: leader_addr=https://192.168.20.13:8200 +Feb 22 00:38:06 plum vault[32791]: 2023-02-22T00:38:06.655Z [INFO] core: attempting to join possible raft leader node: leader_addr=https://192.168.20.12:8200 +Feb 22 00:38:06 plum vault[32791]: 2023-02-22T00:38:06.657Z [ERROR] core: failed to get raft challenge: leader_addr=https://192.168.20.13:8200 +Feb 22 00:38:06 plum vault[32791]: error= +Feb 22 00:38:06 plum vault[32791]: | error during raft bootstrap init call: Error making API request. +Feb 22 00:38:06 plum vault[32791]: | +Feb 22 00:38:06 plum vault[32791]: | URL: PUT https://192.168.20.13:8200/v1/sys/storage/raft/bootstrap/challenge +Feb 22 00:38:06 plum vault[32791]: | Code: 503. Errors: +Feb 22 00:38:06 plum vault[32791]: | +Feb 22 00:38:06 plum vault[32791]: | * Vault is sealed +Feb 22 00:38:06 plum vault[32791]: +``` + +!!! note + In hindsight, the `context deadline exceeded` was a clue, but it was hidden in the noise of multiple nodes failing to join each other. + +I discovered that an identical CI cluster on a non-upgrading proxmox didn't exhibit the error. + +After exhausting all the conventional possibilites (*vault cli version mismatch, SSL issues, DNS*), I decided to check whether MTU was still working (*this cluster had worked fine until recently*). + +Using `ping -M do 4000 `, I was surprised to discover that my CI VMs could **not** ping each other with unfragmented, large payloads. I checked the working cluster - in that environment, I **could** pass large ping payloads. + +## It's MTU, right? + +> "Ha! The VMs MTUs must be set wrong!" - me + +Nope. Checked that: + +```yaml +network: + version: 2 + ethernets: + ens18: + dhcp4: no + optional: true + mtu: 8894 + ens19: + dhcp4: no + optional: true + mtu: 8894 + bonds: + cluster: + mtu: 8894 + +``` + +## Proxmox upgrade broke MTU? + +> "OK, so maybe the proxmox upgrade has removed the MTU we set on the bridge." - also me + +Nope. Still good: + +```bash +root@proxmox:~# cat /etc/network/interfaces +auto lo +iface lo inet loopback + +iface eno1np0 inet manual + +auto vmbr0 +iface vmbr0 inet static + address 10.0.1.215 + netmask 255.255.255.0 + gateway 10.0.1.1 + bridge_ports eno1np0 + bridge_stp off + bridge_fd 0 + mtu 9000 + +iface eno2np1 inet manual +root@proxmox:~# +``` + +> "Huh." - me, final state + +OK, so every NIC on this proxmox host **should** be at MTU 9000, right? + +Well, not exactly... + +```bash +root@proxmox:~# ip link list | grep mtu +1: lo: mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 +2: eno1np0: mtu 9000 qdisc mq master vmbr0 state UP mode DEFAULT group default qlen 1000 +3: eno2np1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000 +4: vmbr0: mtu 9000 qdisc noqueue state UP mode DEFAULT group default qlen 1000 + +13: tap100i1: mtu 1500 qdisc pfifo_fast master vmbr0v180 state UNKNOWN mode DEFAULT group default qlen 1000 +14: tap100i2: mtu 1500 qdisc pfifo_fast master vmbr0v42 state UNKNOWN mode DEFAULT group default qlen 1000 +``` + +## MTU is broken on Proxmox :( + +Aha, what is going on here? Every interface seems to have been set to an MTU of 1500. + +In PVE 7.3, we now have the option to set the MTU of each network interface. This defaults to `1500`, but by setting to the magic number of `1`, the interface MTU will align with the MTU of its bridge. + +So we should be able to just set each interface's MTU to `9000`, right? + +Well no. Not if we're using VLANs (*which, of course we are, in a multi-networked replica of a real cluster, complete with multi-homed virtual firewalls!*) + +It seems that the addition of MTU settings to Proxmox virtio NICs via the UI has broken the way that MTU is set on the tap interfaces on the proxmox hypervisor. + +If you use a VLAN tag, your MTU is fixed at `1500`, regardless of what you set. If you **don't** use a VLAN tag, your MTU is set to the MTU of your bridge (*the old behaviour*), regardless of what you set. + +So I created a [bug report](https://bugzilla.proxmox.com/show_bug.cgi?id=4547). + +## Workaround for Proxmox MTU issue + +I was hoping to be able to post a workaround here, a method to allow us to continue to use jumbo frames in our VLAN-aware CI cluster environment. Unfortunately, I've not been able to find a way to make this work, so until the bug is fixed, I've had to revert my CI clusters to a `1500` MTU, which now represents a minor deviation from our production clusters :( + +## Summary + +MTU issues cause mysterious failures in mysterious ways. Always test your MTU using `ping -M do -s `, to ensure that you actually **can** pass larger-than-normal packets! + +[^1]: If it's not DNS, it's probably MTU. + +--8<-- "blog-footer.md" diff --git a/docs/community/discord.md b/docs/community/discord.md index faad220..cafcd6c 100644 --- a/docs/community/discord.md +++ b/docs/community/discord.md @@ -38,13 +38,18 @@ Your report message will immediately be deleted from the channel, and an alert r ### 📔 Information +The following channels are automated, and provide updates for issues like new PRs, subreddit posts, etc: + + | Channel Name | Channel Use | |--------------------|------------------------------------------------------------| | #announcements | Used for important announcements | | #changelog | Used for major changes to the cookbook (to be deprecated) | | #cookbook-updates | Updates on all pushes to the master branch of the cookbook | | #premix-updates | Updates on all pushes to the master branch of the premix | -| #discourse-updates | Updates to Discourse topics | +| #forum-threads | Updates to Discourse topics | +| #subreddit-posts | New topics in r/funkypenguin | + ### 💬 Discussion @@ -57,30 +62,15 @@ Your report message will immediately be deleted from the channel, and an alert r | #docker-swarm | Discussions about Docker Swarm | | #today-i-learned | Post tips/tricks you've stumbled across | #jobs | For seeking / advertising jobs, bounties, projects, etc | -| #advertisements | In here you can advertise your stream, services or websites, at a limit of 2 posts per day | +| #promotion | In here you can advertise your stream, services or websites, at a limit of 2 posts per day | | #dev | Used for collaboratio around current development. | -### Suggestions - -| Channel Name | Channel Use | -|--------------|-------------------------------------| -| #in-flight | A list of all suggestions in-flight | -| #completed | A list of completed suggestions | - -### Music - -| Channel Name | Channel Use | -|------------------|-----------------------------------| -| #music | DJs go here to control music | -| #listen-to-music | Jump in here to rock out to music | - ## How to get help. If you need assistance at any time there are a few commands that you can run in order to get help. -`!help` Shows help content. - -`!faq` Shows frequently asked questions. +* `!help` Shows help content. +* `!faq` Shows frequently asked questions. ## Spread the love (inviting others) @@ -95,10 +85,3 @@ Discord supports minimal message formatting using [markdown](https://support.dis !!! tip "Editing your most recent message" You can edit your most-recent message by pushing the up arrow, make your edits, and then push `Enter` to save! - -## How do I suggest something? - -1. Find the #completed channel (*under the **Suggestions** category*), and confirm that your suggestion hasn't already been voted on. -2. Find the #in-flight channel (*also under **Suggestions***), and confirm that your suggestion isn't already in-flight (*but not completed yet*) -3. In any channel, type `!suggest [your suggestion goes here]`. A post will be created in #in-flight for other users to vote on your suggestion. Suggestions change color as more users vote on them. -4. When your suggestion is completed (*or a decision has been made*), you'll receive a DM from carl-bot diff --git a/docs/community/matrix.md b/docs/community/matrix.md new file mode 100644 index 0000000..b0a7e9e --- /dev/null +++ b/docs/community/matrix.md @@ -0,0 +1,102 @@ +--- +title: Geek out with Funky Penguin's Matrix Server +description: Geek out in the Fediverse with our Matrix instance! +icon: simple/matrix +status: new +--- + +# How to geek out in [m]atrix + +If you're hesitant to dip your toes into the wall-garden of our [Discord server](http://chat.funkypenguin.co.nz), then Matrix is the place for you! + +!!! question "Eh? What's this federated chat thing? Who own my data? (/puts on tin-foil hat) !!" + Yeah, I know. I also thought Discord was just for the gamer kids, but it turns out it's great for a geeky community. Why? [Let me elucidate ya.](https://www.youtube.com/watch?v=1qHoSWxVqtE).. + + 1. Native markdown for code blocks + 2. Drag-drop screenshots + 3. Costs nothing, no ads + 4. Mobile notifications are reliable, individual channels mutable, etc + +## How do I join the Matrix server? + +1. If you already have a matrix account, you can use it to access our server (the magic of federation). See the channels list below for details +2. If you don't have an account yet, [create one](https://m.fnky.nz), using your email address, or just login with GitHub +3. Use the WebUI, or get one of the [many available](https://matrix.org/clients/) mobile / desktop clients +4. Join the channels you're interested in (*below*)! and say hi! + +## How do I search? + +Maybe it's just my years of using Discord, but I expected to be able to enter any search string in the main "search" field in my Matrix client, and have the relevant messages surfaced. Turns out, that's not how it works! The primary "search" field in the Elements UI, for example, searches **room names** (*i.e., show me all the rooms matching the string "chat"*). To search **within** a room, it's necessary to first navigate to that room, and then "room search" is a contextual action you can take **within** the room. I've not yet found out how do a partial string match though (*"chatgpt" matches, but "chatgp" does no*t). + + +## Code of Conduct + +With the goal of creating a safe and inclusive community, we've adopted the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/), as described [here](/community/code-of-conduct/), and use of the Matrix instance is subject to this code of conduct. + +## Channels + +### 📔 Information + +| Channel Name | Channel Use | +|--------------------|------------------------------------------------------------| +| [#announcements](https://matrix.to/#/#announcements:m.fnky.nz) | Used for important announcements | +| #cookbook-updates | Updates on all pushes to the master branch of the cookbook | +| #premix-updates | Updates on all pushes to the master branch of the premix | +| #discourse-updates | Updates to Discourse topics | + +### 💬 Discussion + +| Channel Name | Channel Use | +|----------------|----------------------------------------------------------| +| #introductions | New? Pop in here and say hi :) | +| [#general](https://matrix.to/#/#general:m.fnky.nz) | General chat - anything goes | +| [#cookbook](https://matrix.to/#/#cookbook:m.fnky.nz) | Discussions specifically around the cookbook and recipes | +| [#kubernetes](https://matrix.to/#/#kubernetes:m.fnky.nz) | Discussions about Kubernetes | +| [#docker-swarm](https://matrix.to/#/#docker-swarm:m.fnky.nz) | Discussions about Docker Swarm | +| #today-i-learned | Post tips/tricks you've stumbled across +| [#jobs](https://matrix.to/#/#jobs:m.fnky.nz) | For seeking / advertising jobs, bounties, projects, etc | +| [#advertisements](https://matrix.to/#/#advertisments:m.fnky.nz) | In here you can advertise your stream, services or websites, at a limit of 2 posts per day | +| [#dev](https://matrix.to/#/#dev:m.fnky.nz) | Used for collaboratio around current development. | + +### Suggestions + +| Channel Name | Channel Use | +|--------------|-------------------------------------| +| #in-flight | A list of all suggestions in-flight | +| #completed | A list of completed suggestions | + +### Music + +| Channel Name | Channel Use | +|------------------|-----------------------------------| +| #music | DJs go here to control music | +| #listen-to-music | Jump in here to rock out to music | + +## How to get help. + +If you need assistance at any time there are a few commands that you can run in order to get help. + +`!help` Shows help content. + +`!faq` Shows frequently asked questions. + +## Spread the love (inviting others) + +Invite your co-geeks to Discord by: + +1. Sending them a link to , or +2. Right-click on the Discord server name and click "Invite People" + +## Formatting your message + +Discord supports minimal message formatting using [markdown](https://support.discord.com/hc/en-us/articles/210298617-Markdown-Text-101-Chat-Formatting-Bold-Italic-Underline-). + +!!! tip "Editing your most recent message" + You can edit your most-recent message by pushing the up arrow, make your edits, and then push `Enter` to save! + +## How do I suggest something? + +1. Find the #completed channel (*under the **Suggestions** category*), and confirm that your suggestion hasn't already been voted on. +2. Find the #in-flight channel (*also under **Suggestions***), and confirm that your suggestion isn't already in-flight (*but not completed yet*) +3. In any channel, type `!suggest [your suggestion goes here]`. A post will be created in #in-flight for other users to vote on your suggestion. Suggestions change color as more users vote on them. +4. When your suggestion is completed (*or a decision has been made*), you'll receive a DM from carl-bot diff --git a/docs/community/slack.md b/docs/community/slack.md new file mode 100644 index 0000000..059e56e --- /dev/null +++ b/docs/community/slack.md @@ -0,0 +1,48 @@ +--- +title: Engage with the Geek Cookbook in GitHub +description: How to use GitHub to interact with the Geek Cookbook community +icon: fontawesome/brands/slack +status: new +--- + +# How to geek on Slack + +## Get yer invite! + +Joing a Slack server requires an invite. To get yours, go [here](https://communityinviter.com/apps/funkypenguin/geek-with-us), or use the form below: + +
+ + +## Code of Conduct + +With the goal of creating a safe and inclusive community, we've adopted the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/), as described [here](/community/code-of-conduct/). Use of the Slack server is subject to this code of conduct. + +## Spread the love (inviting others) + +Invite your co-geeks to Slack by sending them a link to this page, or directly to the [invite generator](https://communityinviter.com/apps/funkypenguin/geek-with-us) + +## Formatting your message + +See Slack's help page on formatting messages, [here](https://slack.com/help/articles/202288908-Format-your-messages). + +!!! tip "Editing your most recent message" + You can edit your most-recent message by pushing the up arrow, make your edits, and then push `Enter` to save! + + +--8<-- "common-links.md" diff --git a/docs/images/blog/proxmox-set-mtu-1.png b/docs/images/blog/proxmox-set-mtu-1.png new file mode 100644 index 0000000000000000000000000000000000000000..d33a566ae0e9815ea5045bd3d5a5c7aa7e8774a6 GIT binary patch literal 36067 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

^#=OL_yC>tvM6%`Z!Y zJ;sY4+U=*kLwWETFQoL@%gfFm;al36~P zrLWPn!Gd0$UR@+*#fGfQS_=pETQtUMx$^=3UejF8S0|m56Y_A*Bq6$+XY5RfWK(9A zlIHEO#$XCa_VG*QtAd zaf>g?)y9;bu<}f8Dju$JxeHpSpa8r6N4ILqZlRj z!s{?L3+6B}`j%8;HJ`A^+Z#ai6DA-wZv*K@Y-kVMmwza=oW%lm6~e)i7}bp3y9-tgBj2y6rGPC&TX`z-~s#Pi#)EzhZ6_9AW{(s=a;JU!i=AJK^GeO(;AVMjn^(O-xB2!R8ZqvKa! z>{Xc22X0zuJEvl!%R-@ay`_HZZ<4}&&y*>Jh$afZgKeSj^3@6}Xr6b?s9McpO*koA zKO_5U&LDFIy?p`@8cPi5ogWOMSt!OY{4D7Wl&ItpDe1IK!?636pnUMWhU*yqU@H|7*7Lyu7$SL=1TPmmqb`%J&vn0nPZLyPRf5PP-h`9xgxlFhNgT z?#V!b!XI8msZd;bM8{jrB0PSMwQ}0ys+c|BH8oRF`R`7`h_M2&Wi{Eft?BJm@8`bl zFQgty{ROJ*-^2BS`X zVps!PdG-HQPUaaXd9#9=B2I+^IkE-Vy=?{PK(4&8Uj;ek{JCEg z73h_rTbq(ICaJ-a=V=6);g2Y~ZUUnM)1fp~00ou71Rnmoqfht)DZo0ONKyUiAYY;L z!R)hY$y}fF3AH&fbO9p(Qy7bC*zFgfJnZfiRxg%~DF`h*h2UZzP)dKI3pH>vrrJ$S zgYTa4_Z`+BrD4`;Gdj4VEl~tv z!PRblv4~zg0sP-DXReCGfu8g_4b$2tfGV%bac4B6$w(prou{)GP-qv%?k0VNW61W8 zmHuP%5*c-@f_E*IkY(K&OH%~?aH0fo;E5Vi{QfJ%9Uxbl;{glK;BnZTE;H_X+RMIT zn8fE&RNe+)yH5U6%@RF7{h5=tCMs1zO+aR&Io-($$Xu9jcXHD$XDiGJqyN`Ce(xV6 z$prfvwpPxUB}MUMLhQy*0Ho!d;P>|YU~VeSb@ka6=lU`b!s{J1oJJyJ5(9C+!1a1e zWE9}c_dKNlEj~lP{r6n}CGLPKuIedFU3maBQ9HD%nxsodACZ;tM*Ec_?0HCo&FW#gmTV+X%J4_%uorT;6yhb2ld8m($w-2XCK4j|d`ZSdT1;;p<(`ue3ij!Wu zh8yUWR)F5N8j#Q3u0|J;dIKa%{!324pTP;B-8ZnK5}hR6Gc%fuk#6}7!e2_>iPSY% z0^4_0o4viNkw@B2f?iwJA5=N)0YcsSn)1O^zIy)lw3Q}u8({Z;AplV>+K`#9R{vHy z3QLfc7+)v{P5{6y36)rI-g(A2;TJ8WCh3P7DV)e%f& z*N(wB1h8FmubVSFbHbL7lz)$Da}knU;!<3T$UO`k!^emF2)9B?U@Q9*@8tlbg%cTo zF6}T(n%=sGKdB2vjeJM2vjBKwK;*E<^wUcf3XGy^w-XYNHQTUm0I=Bc|16MVO+(s*N-{tg#YGgfW^oW(Wu&Nq-{06G^0L6 zhLHxg#NJ@T-!_*P%%U!-^jh}%_sRowr57lEmQ7}X;rgPPnUtzh?=HYC_??gMWd1#l z=+`gWa{cw{#CCtWG)aYRLgKH!z5(^L2;EVUx8C0Ab6Na%|EFpEm?TO7&=^Mfigcbl zS5oFz8CUsJC{ROZjBbM>hDR=m2z(Btty9|i>H@yM_aJYQM0HHr%cUTZKF8}zLCE~_ z-<-1n1C7(7Td2XY2V^F$YYZ2%|IG0~J_6CtRzl$@um5Y^58A)S$|;atD(GMOVg2^6 zC%_Bw?S{Y$zEJsZ?xYraT5Hlx3;6)o;h%+&6%6Q(Q9P;hqW|3Ktv48u7z+()xX}KY zeT+yzGj~iBJ}(Y&0DU^VAjRLso+B0jZ|4AnwA&8{W&rCvB#r`RXSthRc=7@upAsKO zVW|GYXSVM5!u!9ebq)MoqcTsWNQ*}cNa+~S&+L5P0c3GWkp{|t?;4kZ14ZRMk#`0s zd1O2f08teJvz7UeD5SrKU=A77Q4cliBp{_SHR*F)Ru=ke-Za7onU{tG0!_NHL)r7c zFZ&@ZJaUu0%JL)nUtbj>3^5n?8yuuEq+KfYh~AF#KdJl3(e)>?C2GjpE58qRVvt$y17 zeh%b40BN`aExGkQ-CGC`w$SACtIm3At-0pEB^G5cL`#i7J5`K1;0p&?tLoIaTKykx z<6=0ISmr@Qhn##D(|mKL9^*n)qaq(Vhs8SmtWN&UuC;d zcN%bK|7ApPwv_fMJZ0!$*qmk$UG+OV(AFSiZDltm` z4&<(+5%>jtGBJp&I*{7r7s((w$k1qIB(Uhvs&rf zip>HC!U4HZ(PA1=Lz&ONz#Qu-ZRdDv2euInV2Lu}mTnS3-Y(j-c^Px7ea&b|yh zr2=^S#AL4|kH5iUxU#b%K{joDw`uif&xK9LR;kXuap_k@Rv!R(|M`cq&Ed59T(OWd zbGczxRPY=+0$NQQNQ}iP$5ny6wH|_!=RG3hB-!F#_&os-H>MJ<5$MQ#cJd+)hL|fA z-g+NJKylq@?me;~{N%I4hx>O;jN??OiX#)h4jTUL|CMZAa zf#$krly5!e#M7SSncwn6yubJ%s8x9y*Ia};K(k?1tlO7c`Zg;p+rS_OWv#n&W>%d#$67SUrJK`rG(B{_mB4%a^|O#HL6v90Y=;aJ zTM)9Y4TUhC(3eu{JLO8@>5-SI`W!Rn^QwVI#!nO50F^wF{8&z+yjReC;)}x^Z9-NZ2UYWu12jf zi6 zB6;+zx%QLy?#Ut1_YsmUiK!5K${X_~OJ&o42TFTRJvxIOR4v($pjiHq!QIa)yjZ!b zH(ur+I_7kQwNH`cEfZ!VntF!K;ssH+ekQlbKY964Wgk1oM zrF6f`@|!N5uu+*NVXto=wh^F}=$HJEHH?&n&!#FVldnMm%y<7B<8+B;q4Q_Z{%Gcl z;i6NC02uV8Zftru61|9Z8tE8E+LxKdhlx^w+wvsgj$*x5jS$3&XBqx|ORjs@VWQ_beI{Z+AXkvR_l@jsR-0S>;V`E<|e^a(@Eji2fRj>bM|YcyCCe3xZ1n zZ;wb?pL{ps5m3~){87=`xD7BwEbcVIdq=h*cyn2O z1JBiGX}!Z3rJ*}-cyWKN$BP^(L)^weZv&w=U?zid0}xA$-c!IQ?+`5c(LQ!K$T>76 zCEj}qAzF*h_yWFdbT2Fn-Gg3(A3m$|@wRNr3AYO>5t`{T1aU=20raS%NT=_0>#Ots z+?}Wg$K>UlV-HYV$Ljt-ku--?@U2aHRHb^ypd)k6LldS5<=}r#@16CB1%+v64m2Sg ztFDf#w*&JIopwO9q)sVwv4TUE^yhQ<9$P_iv=Zfc(j z@}Xa?bQ1^hxE$*x39kTQ0S2q#MnPqlqiCm$@GX&?1!Z7;ve5(J#hS;N44JzL_@px% zKi}5n>y-RZ%+P(-oYyoNh}k6=#UNpn8>0Y}#4ET{20n-8pf_=|zJatC+S_GMU2T9^0*~2DFfhJ;cXCqlv2}$vNjGjszX#QNxqMD*!ew1+y{1F{93`G|?=miPu znxSQb{VvvCwRf6-b+kBN=$9%e(h3QzR8txc;AeAPyDhmsaE7(6+$Z zM{fBectq4aa`p`7xw!JAST4zWa0)pWKEVY!tol)o7@cG=pNJ0kIR&35rI~*m&%ejmh|XQ;1S7CJfD4u#fTN7##SL~v_Fm$OM`hnT-IzBAk~Lyg8_mr~1oVY8*s!-x>9^M>s)%=lv4 z(0(-U)X?cW-jVCbfeg8qSsi1)9w)oRh$qq;9s|$k_ia=^p~E)9ctpgF&8EJnGj9{0 z?6ws$vhBgXihds-6isgW(Z)v#Q+cWblq_`dP~McUO(>Cc`|ABrjF&+jjhu`SOy+B@7JT!ke**1h=wy2TZt3)N2J$vLrl%PjaIAOllJ3X(RHB zW-mMiDlHqsDtrXXG~6;ojEVMHHy)la|A|?Q5A$p2#?OyM$40r?X_zR|CDAJ#8zPxP zSnFE$6mkjSMp~2@_;T!8?0z4+2_1hMNBp7PJ~U2X$AWQ!d%vJ4S3tW($)uG~(SD^f z5A@?%lQiQ=Sg66zXP66iUJ0{4I$q>(sD5`WM5C8E$EsUQq{PO-qsQZqeZQ^k$Trq_(rbR2SZ$PPPTTtyGw&?@P`fDzwAOQebH2bT z(cG!~K6P;T4=8j93!XE*^u0MbsWo1saWqVnein61*_1l*tU@7~?&$T%W^cj=1YS$y zP#f$8jIMk^7`{>h8dm9~kwz&pXmoB7_N2xu;}jb9Yh@!l!EbdOmy^w4E5#LpX`1vr z#xkk#obf6l!o?mxL?|niS-x(&fcJ`iUDccWwALHCpNI0G88lcCFT7p={cAAPxxo9= zpC9AXU?(b}afT}lAqakkC=@RY&tCpML5$tiBtiR(DtTS*{a8}Y3k`-_=n5H`a09j| zG0vMQgKs(Prn@NVN^l2`x|0D$>Qa%a^bHA6{*$|%i9(I{E4gjE5`kBet@(#sjz(48 z-7ounKguuTv2OewFUJQ>)bh6;qO_jY_rWQMgYmCYT>iWbpTK4(4Z|5Br+b}1B5ejY zvPvJQ!1+1^Y7Kv*kLsOW@NYH)o=J`*oSm`eCKM+-Y|nUI=px61>i!mjWY^JaLn*Hl z&*#1|5`q@clsAS-((GC@_b-}-X%o4;P7xJ0Yt1KL^_fMGlMGo1zmSd))H)LKCo@b- z)LVZ`b&~2c%xc7`(|NxYpIv50l~9U!^4P~PUno)JvblI-7S4`Ah0|M^KTk=r^L^(| z>A=R`o5uf1kDyGXCC;vrhH=be;nVup)5m)&gA3eV_+b=dvYpHuv+|9uVNpTC7L=$Z zZZ}b7?DfqsKST6h9I{dI2uZ*AS6hSxB=E1EA(-@Fj`l=5TJNqxFa9l> zb0-XyAM8KMd=L^};rq0{PoVE&cl+#uvxTG*#WQ!fd93NNqz4vf@^kbt%0NHoUa;G+ zd|S+p@L?#IC;ZOaNJ_gXeoo91!|u2JGGra(KV#jyDYHkj+B`#jvmV^)*@)nFYP~Az z+)O0B8rrUV-%lw|x<3efEDzUxS2^J#h_ub$CU9CLzOpqhOV4lEDe8$y+*D{w`7JWF zQ)1{C^HrJ-@uTp5iV{%n1z8%X0HDFLjym$Apftb8SDexX^JGdjSuJ*r z!Y)Nk$W@;z0T~99t2_IiM+4Oa!dASs@IQ}?*(9ibo0$_X)fW0coopbctYn~Y;!Raz z;sV1mQz`Lo%m4IB%W2u*U1~iEfS5zEaDG+7D4jXc7Exs~#$P1g^Uo7yI^~4qjygyoUFxx8>ia z9*ASjSUhZ#|7AkJ1)=Yy(48#wa_?vY%%r8%P^c29VPM(zlTDP&nVAuHlX-ADJ0UOf24=83}Zw&qGbI*s+ z2Ud>irT>hEEE4$Ts=;y@kda}8A>p;J1F&lARGC_-$vsu&-yuzqLrkvA#wHpr{FU2+ z7{nD0g>3&nQd!F^ezzK6(~n9>sopN1$AT#$3Q`zXbyiK?!Y^z=2||^Ide&$=%S9sK zUj9|T^%tvYUyM_~Y|>xM>NRnY!h|Z||Nm=Db~*&@s1VRiu^j@TRU>}8qcUjenQUu^s9Ea1`JtS>Geb@l$EyL^v zh^*UtX~14nN9v+mu^_UolufT8};{|ff20V6mrIpw0*7_nS<(DA`uK_J}T zC>%Q=ab-JPi)L@Isc7zFb-nk$3r1mc+})V3GM0#Q)+pC9W=YJK2;6o8;;W6EAJUUX9n=XxadNA-xz4 zGHVEeAigIWQ#@vkI#8p}0n#Qd@E#}tZNX-!$XA>-BC!IB^Lk;wTUxrG8JfD&j8j-f zqV@g{-Vj3|DH4rU&a=0DoRf)YE$?qT!0$y>*c#3HsFDS^j19^JAW%jeq@zv3z^$o$ zLjL~!U7w!hj+n4Xu7a|72}rnAc?9Avecvn*S+AZ5j7E?U%s*2h(&2f!7U~z*Kb&s> zp#@-#|C47yOPwI+&17d5qq_jwa8wTyH5G9Hf#_bSULopM+XPf20_bS5cR|FdM!?yEL zjaGNNc>wZ5Fh!*40KmB%#&C~TWefS}#FGns+9bNRWjX_T3a#U%b~^}D0@{apZObh4 zgmTtFkW5TQBnUv0ukvYZ?tt!T{oOX0#L%h=X-xp2@+0;An-)w)0>q7?=(E?%$c~RS zB_*Y>>NY5NR5K|TyU= zGwSvE+&AQx0CaD>@okc8q}3Wp=ZA|AB`E&@5r>w^_k^Q882_COlyH;ph}JtCp+&nB z00+oS$i1B(Z8dbFnz=_!IBU(A`@7d?D&K3&V;M7Zmp#@c<1@r!Wm6($@e0_ z{hUgycCb_U{@%LZasK&6Cd_M{?s`(daAWCr0RHp%cM2p7189lP~{s`9OdIYWQ-6h8afC$>9Qgq~tv@fqo6F60@~)OJJTw$?6tnkyFKG>U;lIFL z(g1?Yg?}ceKXd;!9$t?g()q1m4gh1PJ69hW*B&kACpZ612wJDge1eCSlUL9ra(^NL z5XTl8kK~9yIi`@94lW#MJyR`90Mc(p!oc$2%fj}{M#EpF_3CdFl{ju=Jt2DqA7I{O zkC-U8HxoRXf730wZzlx?)+><8Ky)PK`U}91yX6|f?M~}`N6|7Dqnnn&K`D`r$J>4B9e9{OYtin(_Zi5Ma_$o;{w-+1*RLRq(zj3O zL%2ffr|+37nnFI{yzgZPLR`18=j=(|_hMX`5L2x{OyVj$66IyLKaYR#`h<(Guyj(E zfQLIL2IwT)AtBbWS8-tVgzm8Lhs zwKCu)Hxj9``n6L|ax}TkWMR~ubMoMq>H{1Rlsuq_%^Ge5ybg(>3idB(dQ{2k-_oMToJ%4g(%RtWJfQ>F)$aLytI2K-4+#Cw*khBt{HPW;9HssV5vH#DGo3*<_>u0Y(8i({pkpN@Qz3&9enN<|@$-%J(?MjNJsbCF)1R?MhwUwHA zU(NC!Bq`s5py5qHKwrc>{Mr6V`iH{r;@L-TOMlP%SYF|p<4y|PBN^VUQGLusMK6%Z zVOZm_{9qgX2cXrgcQ@+}4e9JDsFvAYu|Lu|=gl`~%MClJcCClgDyzV00L^u83i{>` z8%5H24E-@cMe}&|4OW>!2uKC&YiZ*6`isc?F1#9+{TZ6WT<)*H)i)*qDki8~XC)Ws z{B5s!duha}Ef)cw-QXQ?7nq7Ek6pIOvp=-9&X`>%?8}8IL<%1RvZ5>7`-=mWyu#CN zywFwt0xF;3?>p5)r3KO#A&=>(%*K=a%d_f~;9I>h#L4pIWbJTPdF~c@0Joj+z;sP`J-s(8V$bhtoF3=%kQ@=D@=>8{c))1& ze|i5Ht$k6CDRNls_Qrf@P&t&)Q1{Q*BP48LoEsFp`6Jg31*0SkH$h9 zWDribK2kh0_p@9f!7H~*C|4FabXW+mf7|R#xBts7E!Pf1Y2fUaF2y_&18J%wOP0U_ zdnh{-rijDz#%#bz7eNIX2FXjVOxA^PMui+<<^@aGg@RtL#%)ZYs0MNEa_gSDc`&O; z#+G!gsg|`Qy5@Dco`-lOZcF%hoqlhO-Hry)NImt9Vj=!6cc(cE+1D4lwToVZ>-R03{gBXEa~Llqi=m~Ylp(#Wbn`%AnBzNXKwCb_B`0b zHrpo+9^-M*69HKHu(Ds@{n;(a1NOe-?Qe++T9sagRkv|ucO}dMbB2NJsY(}{b=Du@ zxs6xnFkz%(M5_}y^>~_+cXGX60rqmb-bD(=C6Dg&e3Itr z*bl_^-AnE4E;L4ZxPU-O1xXHuW?Ypm0avpg?zg*|&&=%A$B9NjO&^aW5C$xmugbgK zW)|1?!t0-%c??ilhFUk<0o%KuSjkHP1&g~rl4BQ;;PoUGxcW7&a9r$a&+RpOjc6aJ z0+-#aIxzX4A0Z54_=5nNQlPl$%j!S(+sX)lujnap)+z_=r4~rn64LM38S9NoA{$(7 z7T4-}$PH2A)`s&K8;qe@j9Xa)gPcL!euadEPF@&cSW|+n+ukA(LpT~+J zBkMGjrgpY&CZPHKUFIcr8gW%HaHqvJdWDhX%HUnI_+QB*b{ewFuNbk zU)eg_;+dl<^k8Fd01>*p$|-#w^YQ_aM`4nGP` zekrrF4WbHezN03IMSmudgdDX~(1N{VlF~_GN=e!hI2(XHT=Jp7$YI_{w)CBplWDqd z_o1*B@+P$bOuj5|48Ll;5+y!dKWeQsor>e%6P@?FE;y(ezTNmkY8{eL3TETP)ojT} zftja^gIOWUysv19vo)K=SF2O}?zSWw%R+?hGpKX=7{Dm}VbuI?Bk5X4L*rT6VS>b{ z>qZd}_PfvM$HNuYIEd!*8qelSsrsx%rG<<>_!^pZXL{?q&o0A?*Dz7ys)tw#E>`{a zy0#YI;yEM@xL*ds+9mfMG=gUA-Jethc5`)zYnYvAh&|R~+rD-3`;+D}GS0UbuY@ZpiR3sOO<)?Q?gLf3 zRCMvtFI^OdP{>(7A`iQZkNP8=6+e(Z#_0l-P7H!H7Z_sPa|QjM9Y=C9 zxE+v9#fDRVh?DxQ>$mP#Xs>><1T)hfrC<7luX!bS5PBIzL(GT+SORGbLlcJ@KN>zelHep4}$~#O;SSLBo274X*lK|3KOX!lWzV zfdEv`%+xrzNa6<|Z{C;a;2!&gNk5)l@XR=IvP9VYEv29va}2?b_=!1`dGmm8X~OqPZ$_F zGzv5sbLgjgv$gWUk#F3GlBbXoLpvTHuU&#m!?lIC@TV}%sZD`Tz0G4lm|LumFac;8 zx>T;&{W%4!?5rqiHAghb1RCU8?By6{S2W-1&lVR5WeE95=M!$2_eRom$K?2A%1sJk zB){7GoNxI|@ymxEh3gNN3Gou2BF*1PwA6tuF0-vY{5nu6628b{CEjXDDwDwr6lxK zoF!pWLVL7^gPgJ4bzb2a4n3~F!k`QZ$7W_OmY9#tTsBQS3vjnY=JYEDX($T{-tLIW zy=kK05NrC(jFMHO&AK)&YD<@%K;F%O4Tz&2;|7;m*;-`OrZJj(4iDQDVE zH?by$T(G5j#A@M&Lqq9qPc`=IWVOW0*+-jF#AW;OM?5=*wllTDwZi#57ZDG&bP%}^ zf9q0vE8S2Z1^{9+C`}4Eqng*=i%$;x@#qxb7qflE+x6CI==u5qPiPWDPJ(B^cru9F zJC)nr;7udU)~3hMGFPDVx4PK-IoQY;CZ0Qj$!BGTs)$n@)YKP6q_^IOCV^1Vv_{SA zfB$)fIpkGP+68ZC^NZg{e=s5GpA!=k1rg7vlOhKk1CN!MjH^tb;(kTr7fMBU6PeU0 zv^0ERSYlXH2{R?`3ZU1YKlS5cMKGU4q5#6+;y*!OlAcyDF8kNXm_Xo22O8;b2GrXP zGQp;R{IBWy9G9^q9x*fUq#Ze~xTB-o)6wyte;f#S2Tku)&Yz{b}C2u%{>^%evUTH=VR!?RoV4IKcIOce%5~X1Sx@e2|cs z-t6`DLP9uF9Fo2oVG`z?XlK41rT?$;{3s zPzJWGWuo|P!D&yTtnCsT=4iaNxj$AwYJx|hoWIV$wYc-j4w_~GiB>#U5R>t-*mt;$ zYk)=PB3yHwBI`5s`7@7Ns|4}O+f(v4zli51^@a#SW&utoAwjP3{oYU6ZfCqUQVcFS50cicHtG_uR3uKIOg^(RNByqO+Po zMt+~&3t=fdgNUIuwlU<#9m3TS&TG27hLgG6MpneVL%XZXZXay zlul{=ba%{tEzA*s8drFuy&BPDbeT_rRpCp1%oqmnT0e`^r_`U9wN$Y@rq@`i%0xz(BH}I%&(D~PJ4T14MsGuX zZPn`dK8wRRM*n3f;G|G3C}eTb8)_|=ceBiA=tiE)WtcLg8xz)XO6Ai@j7zR5jOHR_ zmrI+SeD7r7pQmRR=J1syqNU36Sa4#W5Uc4L8zPk*XnzJfH!%5!xw4IuuMMZ)-z}Z* zVBudtG3^!M@GlxZ!@uiVp|rr4uvZ63ZqOO!jiY%rDCilODyU#jXA-;uFK z%%TJc%f98pE*Q68@h_F^ky1jADV zNaIU7Ih-=XB>~sD&S?ylDRP;hwkP(BCrcJwx%)1RQIwe;o{`^CeH{S6LaMq3iL4ni$DEHMS50v}>+r&!1>ydmb?(4H`l>J8V*c$~yVI%iLJV0St1;)B(4 zN9^@iKBl}6P&D@+Zx92Y*Gi9Qf-H@XGB7*oT}^LbxuA-W}t0 zOZ?B_cCOWhBcz*<7oL5C)MKQ>7c&)$J3j^2e=WdfH9civGkVk8gE5A2eApdU>2=VGVCkf5X5>{s z6`F6qcrTjGLXIPFjBt%wi`5}*mEaXI&Ucb`I8$lqi4Pdtm8_1Z9fFIt9kNGdq>j8c zg0`_DGL)u@MG=9m<`*tFa0tsPOS%oRT~3{mf*MGnz4sa4;GgI}LOI9nVmvi9^&Wcw z5;;jBKDmfpQ1vEUtcqh_Towc#^*;*PkyLVRn76fUXCshxXvY*;)gH`7UQHnvT=vUl zTcYdDP6l~|RmaraWN?P(qnV7D;Vh>a8XPbTZo^+c+siXR3{bPNzaFdzp^vrnyE&m@ zVG<0uvgOJy^eOg&r>ZQs4fQp}S~W<=7kOiB6^&9n{`la-r%}s=U&B{0AXE2xhyBr+ z-qx|8J=LN*73^rZi+^qO_1vO&NhT)!^qgh#heVxaw3q26iS_*ccv1hu;$|vd<4{6B4 zn*+ZH1yPn;qg(%*YcvJ?7Svy3O>PZk;JVPG|8i<1ky1L$>;Q3+XByjKh}^HqA%R{? zbPA;_s5&9S;a0SkrA}g)$(WR3vhT&r)EaCm`_zES*uaFjSt4V^DiFAl$PCby^1yg% zX)?5wYLuHHfT7GSqV`7r2c%aC0_27|rvtn|o7KceA{ddrSg2e|AC`06?`eOf* zCBXbFj}nXh?>)Q%_b^4MuO;}OdpHRha(rUCfA8Y~mTYS#*Hn?m!S7XIb=~avv-MG7 zmdPAo5bAnhuLUO>XcQfE^0PvSu7MYD?ne)O5BJCO3k%mM3!1H0n>y!AZ9;BmUs|Gs z&K5T%_FFTjzZBZWb0{FmQ`hhi(sPyY4P?fM^#Wh+5eUiJ`=XIz!Wy>=lHnIX8Zs$g zRKoX(iDt;R%)Q47jC?poFl^m&JJ*~3saJ4Mk|LqGuzkt25~BB_Tx=*6sVBLLmRdf< znA+uiNck)-aW(MWypJq`N@7H0x(Q7BKLRNxa%dhC#r>a{Sn@8+l%H2%m3AcwizSLV zI(P^zzEIh4g3hUv z0ity}ABYMcLWl!qD!Pg=7vKg*p9|ugf$W53dbkc`88mvGTg-riXK6>AGM_*QoL&;W zV&d9510=*=Pxb$3shY!}mJvHJFv}(({$;RR!f$TmmwxG>I3(F@Mvr~hqW;mN8Bf=} zrQOr#I1T^uUDKuEQNtAG%Tm=vyXEGw!v$4u@l3TYD)-42|A}to&XZ|7rr)a8xAu)+ z^acrOcYyRZ3v$SZzDOvbzdo#A1Jwe=2VL1q@D3bD0q_t9H(;%Ccq`QQ4P>2v2K<=P zTi^Q)AR}^*7kTs))Y*nSu0fx~SAa9c4PvY!(06?^esA_}!7|5hOZd+Q4loo~Da<<4 zAA%h7S^=PSimpe9C8Ui(@&8Gh7}fiF)43+)m&dLY zejPor`?KFJt+nSD??)`{fBfo}GHA^C-IaCsO(x0$bysC3c7JL7VVm0q+v#Z!!9#Ix z$3Uxt)fMM#uEN-W(u`N+P8-`vh>*|K{_XJLshMX{CqpysrgR^eq6-cIck{VuW%{iR zfDOq9XI>bp$aJ3ek=TQE?6WvtKD&ARJN=bH>!b&gZNvM;<0Zm6?M z*si!!(&at;pu8ZgOkdz^cGqI>8y|kuS29EDijCc_R6$nDw-T53i|)n#-h|zPN__ut zj(-MjKrVxy&QJGet=H-XTvrFRWAN1Q31FZb_U9WN2>Co$A)$A`;K~*{mSd|taLE>*4!4$JFgZTDnWa1QtN^t%& zM`)m`yD!&5ag-b}gf3KGb2R{NqpA{ezi@tpq_4nWd#r1KW#na~inuehUxA({>XyY1sC4pJDyS!3ei(g> zPPQA*6|3f9Hk?av2ZFchc94&U9*rW&GgH_&6KHH7jme8Y1^(LF9-4lFW?0H#hbVP$=y^We%>x0Mbkrc^7_+hL5 zi%wss+mlpuZp+wrqe2(I=o_u1sJ!;4sh6|7v`ixiR>Lc=nwIbVLTq*wyBR!VCSnd3 z&SQ0NW9j!ItuS(Sv7eErFhh8(mBoY`_`jaZR{y>U?j z9X(+Bz9zowRLnAeQ(VRy!K$xWh!t(H-hL^dN04K0W~(yKZ-3R~eq>U~8p5ddvWo}l zjrbRz;OVd0tl!<{s-U1y4&)@o)Ohn%{jN_El|u$^8BFI}<_&K~zAn`aubT3@sM%ci zFlaQr(cf!@sZ_sPO=1=#DqHjScb2){%B<8|%v4{jE68ju+pa|2m6)4xQH|hn@n~;b z`eE2WXVvE%6`6;`)=)2HO-f(ur;@%Xr9OG%yBP5Fuqq|I9>Xo}1`dJ|%h$2cq(iC& zSiCpg1Hb!0I%6NDbJsJ1CZMb5$Lu^G6A?jGi9)@@2uCN|1NAxSCYEM-CFA2ZWL0?OLpq>WFR}a$%n?cc=QZz+p5;z*dD$FFquJ?<7J6am41K z`rBIVQQ8B-FRp;%CiwDrS!DMkCRhI>N7NQU%4cbiNGp;ter{;&ta$d&Nyzj&YtHwz zpbj}4Hgsvz{VCoN@v&j5X8Ui;S2Hc{~V znp6BIdbSN()rUm|67GVdZOrIoVqf8*MaXKSV%=>|uDlycAvZ}ER#k%=WvQ48fKBE^I! ztuuHN=^d5on;0PGV!RSpN-p7VRDYdq*HTRn!KUxjM`i$?e5=`4wd8C7=eQ^du;z1q=Al> zjx?`0PR?UrZqSeS9qy25-Hku!+k^%ESu6=Jx_JRH=N!hHu*l8500HfGa}UQ~%G3Ss zXg2UJbk8T=+60tZ)1ADEdh2HAIaLx)FQjcrj&?4NcH{d#V_RI3MN z3^(F>{Fy4$h13gQJQ{_eGFfHi&vEYc=|yb;C5xd#i`v2r)${6PhR?GOO{?L(LkjST zHKudsQ@{hn8ufb6mn z&~4*#+c<>bub4i?R2GP!Qz^ZqY5R5g$~0^ln|5zSq+GQ%Fuh>Q{CUoL}TNQiSClxZ}-ZKZBW$?|DHl7Dn$uZEyf%gKww(< zz#*4eik%p8tgfb&fQwhBpH2w0BzC;O*_#mhl7*d}{pXK&?kuo3qp8yAv>&&AoUJ#) z%XQ}m(xc{Ei9}KqBLBdDh(acAR$vyPa<~b8!+>K!KXuW=V?x$0|C;`X&V>%1Tg=r~ z#|&?auCnd6H&5B&*%x)R;+l_rjV|%+tt*ye+HviUv^qM;)NTajrZSqD+F+qo1gHOZ9nwx&3wVXvJ45$n(K5r z5u!P|GbsDjESE=*uV_Z*AReBpUfA2L$)VauGn^lEL&U*xf^C6qA?9@HBPas#1Iit&OmZ-AMDpJT3)){ z`Yd8teS4hQcm7^o%1%9yu`BH!PZ%Jz=uQ}XhC{g|w8V+>O6ex1Yh*~raqSbQX&>~HKw-$V zl7-pI81cy<$UReZcpx9i@suM-95SxVt_^h~7-mC6Jn z;WihmHixSCYs_etVQF!H&y1<|adWRj&qb>>U}&84tQ-9g9+4Z2x#9!Y+7&HW$F)G&lFM8K)T$jH*H3Svr=3x3X5-nnoYci_U z=o7wHg4XlzViSn@*%R*f(6?hN%E3UWn?O~5tnl50=kM8(B%F!IUd#2de5j(u4oadQ z&X*c^Z!da*sAoIt$z$+$5eFJuPDF)+4-}7KafsUQ?z_SQIN|D-r>@CDGliR@9{E@mI%7eAc3{# z`K$Vqk;zbFCRgP8_jJaxgN!r5?6m&=E*({4gaUNurj^xk{vPYDcQHlw;U@oJJwQrN z^-b6`=k|%2x!`W7V?Z$jcLX&5X7G>kn#VC`TK|V-9Ch@$9UP z_i7KyG?D&;mHnSVRX`*hqXrzM)*`l%O@kLX9u_;pgF*t+zsLz44OEHvip~}HBc8aa z1-@J{(0IcOSPV`xNBp-%hjjGxa3^RXx?R;V4p`k?SLapT?cXDL8u~=unM+X$5aR$h z>_5=T06H`ut{$?|vqr9LDc=iCbI^D%A1t=coc6F-_jAW9Dt@K%-?ke1KOfDP zjKyIuNx>%_qoPeL8Ev|DNI+*eVLD8}W1816Kv9;!@w$&ME~%RF&wK0|SvUTF&JF!W z6%W;h7#03z)U02+`!&v6dO2>36iEptE^&+A;W}e{Vx)o~cUvl_|MS@Xy(^?ch~Hu{ zQ40DU=|acBhjWe|{uYm}<*@2gSU1m8mqaYh=ddR24_MuMjQ>9G#Y3z3uk}j;3EESI ri8I^G+MOJ2Rmq*4&bSx+)F%u*<*4suI)W5X;Gdk7l4OOrQSko*o8`iB literal 0 HcmV?d00001 diff --git a/docs/recipes/kubernetes/matrix.md b/docs/recipes/kubernetes/matrix.md new file mode 100644 index 0000000..d6b0532 --- /dev/null +++ b/docs/recipes/kubernetes/matrix.md @@ -0,0 +1,612 @@ +--- +title: Install Matrix in Kubernetes +description: How to install your own Matrix instance using Kubernetes +status: new +--- + +# Install Invidious in Kubernetes + +# gotchas + +Create signing key first. Else you'll ban yourself from the federation! + +```bash +~ ❯ docker run --rm -it ananace/matrix-synapse generate_signing_key ed25519 a_cQgt sOaAmEl7a9s2S0RCr7FT9nzuSjEjYVRrNNzwIKsutzA +~ ❯ +``` + + +Thanks to [Sealed Secrets](/kubernetes/sealed-secrets/), we have a safe way of committing secrets into our repository, so to create this cloudflare secret, you'd run something like this: + +```bash + kubectl create secret generic matrix-synapse-signingkey \ + --namespace matrix \ + --dry-run=client \ + --from-literal=signing.key=YOURSIGNINGKEYGOESHERE -o json \ + | kubeseal --cert \ + > /matrix/sealedsecret-matrix-synapse-signingkey.yaml +``` + + +echo -e ed25519 a_oDEG IiFDnZsM4TaROTdnFmcfa15Ee/srcF/J2bQ9VP5o0Pk +Why not Dendrite? + +AFAIK, it woen't yet work with SSO (login with GitHub), and requires nats for messaging, which will consume more PVCs on my limited DO cluster! + + +### Create matrix_media_repo database + +```bash +kubectl exec -n matrix matrix-synapse-postgresql-0 -it -- \ +/bin/bash -c PGPASSWORD=$POSTGRES_PASSWORD createdb matrix_media_repo -U synapse +``` + +### Create mautrix-discord database + +```bash +kubectl exec -n matrix matrix-synapse-postgresql-0 -it -- \ +/bin/bash -c PGPASSWORD=$POSTGRES_PASSWORD createdb matrix_discord -U synapse # (1)! +``` + +1. No hyphens allowed in database names, apparently! + +### Register admin user + +kubectl -n matrix exec -it $(kubectl -n matrix get pod -l "app.kubernetes.io/name=matrix-synapse" -o jsonpath='{.items[0].metadata.name}') /bin/bash + +```bash +root@matrix-synapse-5d7cf8579-zjk7c:/# register_new_matrix_user -k '' https://matrix.funkypenguin.co.nz +New user localpart [root]: root +Password: +Confirm password: +Make admin [no]: yes +Sending registration request... +Success! +root@matrix-synapse-5d7cf8579-zjk7c:/# +``` + + + +YouTube is ubiquitious now. Almost every video I'm sent, takes me to YouTube. Worse, every YouTube video I watch feeds Google's profile about me, so shortly after enjoying the latest Marvel movie trailers, I find myself seeing related adverts on **unrelated** websites. + +Creepy :bug:! + +As the connection between the videos I watch and the adverts I see has become move obvious, I've become more discerning re which videos I choose to watch, since I don't necessarily **want** algorithmically-related videos popping up next time I load the YouTube app on my TV, or Marvel merchandise advertised to me on every second news site I visit. + +This is a PITA since it means I have to "self-censor" which links I'll even click on, knowing that once I *do* click the video link, it's forever associated with my Google account :facepalm: + +After playing around with [some of the available public instances](https://docs.invidious.io/instances/) for a while, today I finally deployed my own instance of [Invidious](https://invidious.io/) - an open source alternative front-end to YouTube. + +![Invidious Screenshot](/images/invidious.png){ loading=lazy } + +Here's an example from my public instance (*yes, running on Kubernetes*): + + + +## Invidious requirements + +!!! summary "Ingredients" + + Already deployed: + + * [x] A [Kubernetes cluster](/kubernetes/cluster/) (*not running Kubernetes? Use the [Docker Swarm recipe instead][invidious]*) + * [x] [Flux deployment process](/kubernetes/deployment/flux/) bootstrapped + * [x] An [Ingress](/kubernetes/ingress/) to route incoming traffic to services + * [x] [Persistent storage](/kubernetes/persistence/) to store persistent stuff + * [x] [External DNS](/kubernetes/external-dns/) to create an DNS entry + + New: + + * [ ] Chosen DNS FQDN for your instance + +## Preparation + +### GitRepository + +The Invidious project doesn't currently publish a versioned helm chart - there's just a [helm chart stored in the repository](https://github.com/invidious/invidious/tree/main/chart) (*I plan to submit a PR to address this*). For now, we use a GitRepository instead of a HelmRepository as the source of a HelmRelease. + +```yaml title="/bootstrap/gitrepositories/gitepository-invidious.yaml" +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: GitRepository +metadata: + name: invidious + namespace: flux-system +spec: + interval: 1h0s + ref: + branch: master + url: https://github.com/iv-org/invidious +``` + +### Namespace + +We need a namespace to deploy our HelmRelease and associated ConfigMaps into. Per the [flux design](/kubernetes/deployment/flux/), I create this example yaml in my flux repo at `/bootstrap/namespaces/namespace-invidious.yaml`: + +```yaml title="/bootstrap/namespaces/namespace-invidious.yaml" +apiVersion: v1 +kind: Namespace +metadata: + name: invidious +``` + +### Kustomization + +Now that the "global" elements of this deployment (*just the GitRepository in this case*) have been defined, we do some "flux-ception", and go one layer deeper, adding another Kustomization, telling flux to deploy any YAMLs found in the repo at `/invidious`. I create this example Kustomization in my flux repo: + +```yaml title="/bootstrap/kustomizations/kustomization-invidious.yaml" +apiVersion: kustomize.toolkit.fluxcd.io/v1beta1 +kind: Kustomization +metadata: + name: invidious + namespace: flux-system +spec: + interval: 15m + path: invidious + prune: true # remove any elements later removed from the above path + timeout: 2m # if not set, this defaults to interval duration, which is 1h + sourceRef: + kind: GitRepository + name: flux-system + validation: server + healthChecks: + - apiVersion: apps/v1 + kind: Deployment + name: invidious-invidious # (1)! + namespace: invidious + - apiVersion: apps/v1 + kind: StatefulSet + name: invidious-postgresql + namespace: invidious +``` + +1. No, that's not a typo, just another pecularity of the helm chart! + +### ConfigMap + +Now we're into the invidious-specific YAMLs. First, we create a ConfigMap, containing the entire contents of the helm chart's [values.yaml](https://github.com/iv-org/invidious/blob/master/kubernetes/values.yaml). Paste the values into a `values.yaml` key as illustrated below, indented 4 spaces (*since they're "encapsulated" within the ConfigMap YAML*). I create this example yaml in my flux repo: + +```yaml title="invidious/configmap-invidious-helm-chart-value-overrides.yaml" +apiVersion: v1 +kind: ConfigMap +metadata: + name: invidious-helm-chart-value-overrides + namespace: invidious +data: + values.yaml: |- # (1)! + # +``` + +1. Paste in the contents of the upstream `values.yaml` here, intended 4 spaces, and then change the values you need as illustrated below. + +Values I change from the default are: + +```yaml +postgresql: +image: + tag: 14 +auth: + username: invidious + password: + database: invidious +primary: + initdb: + username: invidious + password: + scriptsConfigMap: invidious-postgresql-init + persistence: + size: 1Gi # (1)! + podAnnotations: # (2)! + backup.velero.io/backup-volumes: backup + pre.hook.backup.velero.io/command: '["/bin/bash", "-c", "PGPASSWORD=$POSTGRES_PASSWORD pg_dump -U postgres -d $POSTGRES_DB -h 127.0.0.1 > /scratch/backup.sql"]' + pre.hook.backup.velero.io/timeout: 3m + post.hook.restore.velero.io/command: '["/bin/bash", "-c", "[ -f \"/scratch/backup.sql\" ] && PGPASSWORD=$POSTGRES_PASSWORD psql -U postgres -h 127.0.0.1 -d $POSTGRES_DB -f /scratch/backup.sql && rm -f /scratch/backup.sql;"]' + extraVolumes: + - name: backup + emptyDir: + sizeLimit: 1Gi + extraVolumeMounts: + - name: backup + mountPath: /scratch + resources: + requests: + cpu: "10m" + memory: 32Mi + +# Adapted from ../config/config.yml +config: +channel_threads: 1 +feed_threads: 1 +db: + user: invidious + password: + host: invidious-postgresql + port: 5432 + dbname: invidious +full_refresh: false +https_only: true +domain: in.fnky.nz # (3)! +external_port: 443 # (4)! +banner: ⚠️ Note - This public Invidious instance is sponsored ❤️ by Funky Penguin's Geek Cookbook. It's intended to support the published Docker Swarm recipes, but may be removed at any time without notice. # (5)! +default_user_preferences: # (6)! + quality: dash # (7)! auto-adapts or lets you choose > 720P +``` + +1. 1Gi is fine for the database for now +2. These annotations / extra Volumes / volumeMounts support automated backup using Velero +3. Invidious needs this to generate external links for sharing / embedding +4. Invidious needs this too, to generate external links for sharing / embedding +5. It's handy to tell people what's special about your instance +6. Check out the [official config docs](https://github.com/iv-org/invidious/blob/master/config/config.example.yml) for comprehensive details on how to configure / tweak your instance! +7. Default all users to DASH (*adaptive*) quality, rather than limiting to 720P (*the default*) + +### HelmRelease + +Finally, having set the scene above, we define the HelmRelease which will actually deploy the invidious into the cluster. I save this in my flux repo: + +```yaml title="/invidious/helmrelease-invidious.yaml" +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: invidious + namespace: invidious +spec: + chart: + spec: + chart: ./charts/invidious + sourceRef: + kind: GitRepository + name: invidious + namespace: flux-system + interval: 15m + timeout: 5m + releaseName: invidious + valuesFrom: + - kind: ConfigMap + name: invidious-helm-chart-value-overrides + valuesKey: values.yaml # (1)! +``` + +1. This is the default, but best to be explicit for clarity + +### Ingress / IngressRoute + +Oddly, the upstream chart doesn't include any Ingress resource. We have to manually create our Ingress as below (*note that it's also possible to use a Traefik IngressRoute directly*) + +```yaml title="/invidious/ingress-invidious.yaml" +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: invidious + namespace: invidious +spec: + ingressClassName: nginx + rules: + - host: in.fnky.nz + http: + paths: + - backend: + service: + name: invidious + port: + number: 3000 + path: / + pathType: ImplementationSpecific +``` + +An alternative implementation using an `IngressRoute` could look like this: + +```yaml title="/invidious/ingressroute-invidious.yaml" +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: in.fnky.nz + namespace: invidious +spec: + routes: + - match: Host(`in.fnky.nz`) + kind: Rule + services: + - name: invidious-invidious + kind: Service + port: 3000 +``` + +### Create postgres-init ConfigMap + +Another pecularity of the Invidious helm chart is that you have to create your own ConfigMap containing the PostgreSQL data structure. I suspect that the helm chart has received minimal attention in the past 3+ years, and this could probably easily be turned into a job as a pre-install helm hook (*perhaps a future PR?*). + +In the meantime, you'll need to create ConfigMap manually per the [repo instructions](https://github.com/iv-org/invidious/tree/master/kubernetes#installing-helm-chart), or cheat, and copy the one I paste below: + +??? example "Configmap (click to expand)" + ```yaml title="/invidious/configmap-invidious-postgresql-init.yaml" + apiVersion: v1 + kind: ConfigMap + metadata: + name: invidious-postgresql-init + namespace: invidious + data: + annotations.sql: | + -- Table: public.annotations + + -- DROP TABLE public.annotations; + + CREATE TABLE IF NOT EXISTS public.annotations + ( + id text NOT NULL, + annotations xml, + CONSTRAINT annotations_id_key UNIQUE (id) + ); + + GRANT ALL ON TABLE public.annotations TO current_user; + channel_videos.sql: |+ + -- Table: public.channel_videos + + -- DROP TABLE public.channel_videos; + + CREATE TABLE IF NOT EXISTS public.channel_videos + ( + id text NOT NULL, + title text, + published timestamp with time zone, + updated timestamp with time zone, + ucid text, + author text, + length_seconds integer, + live_now boolean, + premiere_timestamp timestamp with time zone, + views bigint, + CONSTRAINT channel_videos_id_key UNIQUE (id) + ); + + GRANT ALL ON TABLE public.channel_videos TO current_user; + + -- Index: public.channel_videos_ucid_idx + + -- DROP INDEX public.channel_videos_ucid_idx; + + CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx + ON public.channel_videos + USING btree + (ucid COLLATE pg_catalog."default"); + + channels.sql: |+ + -- Table: public.channels + + -- DROP TABLE public.channels; + + CREATE TABLE IF NOT EXISTS public.channels + ( + id text NOT NULL, + author text, + updated timestamp with time zone, + deleted boolean, + subscribed timestamp with time zone, + CONSTRAINT channels_id_key UNIQUE (id) + ); + + GRANT ALL ON TABLE public.channels TO current_user; + + -- Index: public.channels_id_idx + + -- DROP INDEX public.channels_id_idx; + + CREATE INDEX IF NOT EXISTS channels_id_idx + ON public.channels + USING btree + (id COLLATE pg_catalog."default"); + + nonces.sql: |+ + -- Table: public.nonces + + -- DROP TABLE public.nonces; + + CREATE TABLE IF NOT EXISTS public.nonces + ( + nonce text, + expire timestamp with time zone, + CONSTRAINT nonces_id_key UNIQUE (nonce) + ); + + GRANT ALL ON TABLE public.nonces TO current_user; + + -- Index: public.nonces_nonce_idx + + -- DROP INDEX public.nonces_nonce_idx; + + CREATE INDEX IF NOT EXISTS nonces_nonce_idx + ON public.nonces + USING btree + (nonce COLLATE pg_catalog."default"); + + playlist_videos.sql: | + -- Table: public.playlist_videos + + -- DROP TABLE public.playlist_videos; + + CREATE TABLE IF NOT EXISTS public.playlist_videos + ( + title text, + id text, + author text, + ucid text, + length_seconds integer, + published timestamptz, + plid text references playlists(id), + index int8, + live_now boolean, + PRIMARY KEY (index,plid) + ); + + GRANT ALL ON TABLE public.playlist_videos TO current_user; + playlists.sql: | + -- Type: public.privacy + + -- DROP TYPE public.privacy; + + CREATE TYPE public.privacy AS ENUM + ( + 'Public', + 'Unlisted', + 'Private' + ); + + -- Table: public.playlists + + -- DROP TABLE public.playlists; + + CREATE TABLE IF NOT EXISTS public.playlists + ( + title text, + id text primary key, + author text, + description text, + video_count integer, + created timestamptz, + updated timestamptz, + privacy privacy, + index int8[] + ); + + GRANT ALL ON public.playlists TO current_user; + session_ids.sql: |+ + -- Table: public.session_ids + + -- DROP TABLE public.session_ids; + + CREATE TABLE IF NOT EXISTS public.session_ids + ( + id text NOT NULL, + email text, + issued timestamp with time zone, + CONSTRAINT session_ids_pkey PRIMARY KEY (id) + ); + + GRANT ALL ON TABLE public.session_ids TO current_user; + + -- Index: public.session_ids_id_idx + + -- DROP INDEX public.session_ids_id_idx; + + CREATE INDEX IF NOT EXISTS session_ids_id_idx + ON public.session_ids + USING btree + (id COLLATE pg_catalog."default"); + + users.sql: |+ + -- Table: public.users + + -- DROP TABLE public.users; + + CREATE TABLE IF NOT EXISTS public.users + ( + updated timestamp with time zone, + notifications text[], + subscriptions text[], + email text NOT NULL, + preferences text, + password text, + token text, + watched text[], + feed_needs_update boolean, + CONSTRAINT users_email_key UNIQUE (email) + ); + + GRANT ALL ON TABLE public.users TO current_user; + + -- Index: public.email_unique_idx + + -- DROP INDEX public.email_unique_idx; + + CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx + ON public.users + USING btree + (lower(email) COLLATE pg_catalog."default"); + + videos.sql: |+ + -- Table: public.videos + + -- DROP TABLE public.videos; + + CREATE UNLOGGED TABLE IF NOT EXISTS public.videos + ( + id text NOT NULL, + info text, + updated timestamp with time zone, + CONSTRAINT videos_pkey PRIMARY KEY (id) + ); + + GRANT ALL ON TABLE public.videos TO current_user; + + -- Index: public.id_idx + + -- DROP INDEX public.id_idx; + + CREATE UNIQUE INDEX IF NOT EXISTS id_idx + ON public.videos + USING btree + (id COLLATE pg_catalog."default"); + ``` + +## :octicons-video-16: Install Invidious! + +Commit the changes to your flux repository, and either wait for the reconciliation interval, or force a reconcilliation[^1] using `flux reconcile source git flux-system`. You should see the kustomization appear... + +```bash +~ ❯ flux get kustomizations | grep invidious +invidious main/d34779f False True Applied revision: main/d34779f +~ ❯ +``` + +The helmrelease should be reconciled... + +```bash +~ ❯ flux get helmreleases -n invidious +NAME REVISION SUSPENDED READY MESSAGE +invidious 1.1.1 False True Release reconciliation succeeded +~ ❯ +``` + +And you should have happy Invidious pods: + +```bash +~ ❯ k get pods -n invidious +NAME READY STATUS RESTARTS AGE +invidious-invidious-64f4fb8d75-kr4tw 1/1 Running 0 77m +invidious-postgresql-0 1/1 Running 0 11h +~ ❯ +``` + +... and finally check that the ingress was created as desired: + +```bash +~ ❯ k get ingress -n invidious +NAME CLASS HOSTS ADDRESS PORTS AGE +invidious in.fnky.nz 80, 443 19h +~ ❯ +``` + +Or in the case of an ingressRoute: + +```bash +~ ❯ k get ingressroute -n invidious +NAME AGE +in.fnky.nz 19h +``` + +Now hit the URL you defined in your config, you'll see the basic search screen. Enter a search phrase (*"marvel movie trailer"*) to see the YouTube video results, or paste in a YouTube URL such as `https://www.youtube.com/watch?v=bxqLsrlakK8`, change the domain name from `www.youtube.com` to your instance's FQDN, and watch the fun [^2]! + +You can also install a range of browser add-ons to automatically redirect you from youtube.com to your Invidious instance. I'm testing "[libredirect](https://addons.mozilla.org/en-US/firefox/addon/libredirect/)" currently, which seems to work as advertised! + +## Summary + +What have we achieved? We have an HTTPS-protected private YouTube frontend - we can now watch whatever videos we please, without feeding Google's profile on us. We can also subscribe to channels without requiring a Google account, and we can share individual videos directly via our instance (*by generating links*). + +!!! summary "Summary" + Created: + + * [X] We are free of the creepy tracking attached to YouTube videos! + +--8<-- "recipe-footer.md" + +[^1]: There is also a 3rd option, using the Flux webhook receiver to trigger a reconcilliation - to be covered in a future recipe! +[^2]: Gotcha! diff --git a/mkdocs.yml b/mkdocs.yml index f3e8f56..bf7d3c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -259,6 +259,7 @@ nav: # - Kiali: kubernetes/wip.md - Invidious: recipes/kubernetes/invidious.md - Mastodon: recipes/kubernetes/mastodon.md + # - Matrix: recipes/kubernetes/matrix.md # - NGINX Ingress: kubernetes/wip.md # - Polaris: kubernetes/wip.md # - Portainer: kubernetes/wip.md @@ -293,6 +294,8 @@ nav: - Contribute: community/contribute.md - Code of Conduct: community/code-of-conduct.md - Discord: community/discord.md + # - Slack: community/slack.md + # - Matrix: community/matrix.md - Reddit: community/reddit.md - Mastodon: community/mastodon.md - Forum: community/discourse.md @@ -364,7 +367,13 @@ extra: link: 'https://so.fnky.nz/@funkypenguin' - icon: 'fontawesome/brands/github' link: 'https://github.com/funkypenguin' - - icon: 'fontawesome/brands/stack-overflow' + - icon: 'fontawesome/brands/discord' + link: 'http://chat.funkypenguin.co.nz' + - icon: 'simple/matrix' + link: 'http://m.fnky.nz' + - icon: 'fontawesome/brands/slack' + link: 'https://communityinviter.com/apps/funkypenguin/geek-with-us' + - icon: 'fontawesome/brands/stack-overflow' link: 'https://stackoverflow.com/cv/funkypenguin' - icon: 'fontawesome/brands/linkedin' link: 'https://www.linkedin.com/in/funkypenguin'