Initial commit

This commit is contained in:
2026-06-17 22:31:40 +01:00
commit 25b1e0908d
12 changed files with 653 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.tofu]
indent_size = 2
indent_style = space
+7
View File
@@ -0,0 +1,7 @@
# General rules
.*
terraform.tfstate
terraform.tfstate.backup
!.gitignore
!.editorconfig
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env just
opentofu_dir := "infra/"
up:
just gen-ssh-pair
tofu -chdir=infra init
tofu -chdir=infra apply -auto-approve
just playbook
just get-admin-conf
tofu-init:
tofu -chdir={{ opentofu_dir }} init -upgrade
tofu-apply: tofu-init gen-ssh-pair
tofu -chdir={{ opentofu_dir }} apply -auto-approve
tofu-destroy: tofu-init
tofu -chdir={{ opentofu_dir }} apply -auto-approve -destroy
build-inventory:
#!/usr/bin/env bash
master_ips=$(tofu -chdir=infra output -json master_addresses)
worker_ips=$(tofu -chdir=infra output -json worker_addresses)
# Build the inventory file cleanly using bash loops over the JSON arrays
{
echo "[masters]"
for i in $(seq 0 $(jq 'length - 1' <<< "${master_ips}")); do
ip=$(jq -r ".[$i]" <<< "${master_ips}")
echo "master-${i} ansible_host=${ip} ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent"
done
echo ""
echo "[workers]"
for i in $(seq 0 $(jq 'length - 1' <<< "${worker_ips}")); do
ip=$(jq -r ".[$i]" <<< "${worker_ips}")
echo "worker-${i} ansible_host=${ip} ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent"
done
} > ansible/hosts.ini
playbook: build-inventory
ansible-playbook --inventory ansible/hosts.ini ansible/playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no"
# ansible-playbook --inventory ansible/hosts.ini ansible/setup-playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no"
# ansible-playbook --inventory ansible/hosts.ini ansible/master-playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no"
# ansible-playbook --inventory ansible/hosts.ini ansible/worker-playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no"
down: tofu-destroy
rm ansible/hosts.ini || true
rm -r ansible/.ssh/ || true
rm admin.conf || true
gen-ssh-pair:
#!/usr/bin/env bash
mkdir -p ansible/.ssh
[ ! -f ansible/.ssh/key ] && ssh-keygen -f ansible/.ssh/key -P '' || true
get-admin-conf:
incus exec master-0 -- cat /etc/kubernetes/admin.conf > k8s-admin.conf
+5
View File
@@ -0,0 +1,5 @@
[masters]
master-0 ansible_host=10.150.0.2 ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent
master-1 ansible_host=10.150.0.4 ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent
[workers]
+36
View File
@@ -0,0 +1,36 @@
---
- name: Initialise empty disk with BTRFS
hosts: all
vars:
target_disk: /dev/disk/by-id/ata-QEMU_HARDDISK_incus_ext
tasks:
- name: Ensure btrfs-progs, parted and blkid is installed
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- btrfs-progs
- parted
- blkid
- name: Ensure external disk has single partition on GPT
community.general.parted:
device: "{{ target_disk }}"
name: ext
number: 1
label: gpt
state: present
- name: Ensure BTRFS is formatted on external disk
community.general.filesystem:
dev: "{{ target_disk }}-part1"
fstype: btrfs
- name: Mount external disk to /mnt
ansible.posix.mount:
src: "{{ target_disk }}-part1"
path: /mnt
fstype: btrfs
state: mounted
# - name: Create BTRFS filesystem on external disk
# community.general.filesystem:
# fstype: btrfs
# dev: "{{ target_disk }}"
+154
View File
@@ -0,0 +1,154 @@
- name: Primary Master Initialisation
hosts: masters[0]
tasks:
- name: Initialise high-availability Kubernetes cluster
ansible.builtin.command: >-
kubeadm init
--pod-network-cidr="172.16.0.0/16"
--control-plane-endpoint="{{ ansible_default_ipv4.address }}:6443"
--upload-certs
args:
creates: /etc/kubernetes/admin.conf
- name: Copy Kubernetes Admin Config
ansible.builtin.command: "{{ item }}"
loop:
- mkdir -p $HOME/.kube
- cp /etc/kubernetes/admin.conf $HOME/.kube/config
- chown $(id -u):$(id -g) $HOME/.kube/config
args:
creates: $HOME/.kube/config
- name: Install Pod Network (Calico)
ansible.builtin.shell: kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/calico.yaml > pod_network_setup.txt
args:
chdir: $HOME
creates: pod_network_setup.txt
- name: Add Helm repositories
kubernetes.core.helm_repository:
name: "{{ item.name }}"
repo_url: "{{ item.url }}"
loop:
- name: metrics-server
url: https://kubernetes-sigs.github.io/metrics-server/
- name: prometheus-community
url: https://prometheus-community.github.io/helm-charts
- name: longhorn
url: https://charts.longhorn.io
- name: traefik
url: https://traefik.github.io/charts
- name: minecraft-server-charts
url: https://itzg.github.io/minecraft-server-charts/
- name: Install Metrics Server
kubernetes.core.helm:
release_name: metrics-server
release_namespace: kube-system
chart_ref: metrics-server/metrics-server
chart_version: 3.13.0
values:
args:
- --kubelet-insecure-tls
- name: Install Longhorn
kubernetes.core.helm:
release_name: longhorn
release_namespace: longhorn-system
create_namespace: true
chart_ref: longhorn/longhorn
chart_version: 1.12.0
- name: Install Prometheus
kubernetes.core.helm:
release_name: prometheus
release_namespace: monitoring
create_namespace: true
chart_ref: prometheus-community/prometheus
chart_version: 29.10.0
state: absent
- name: Install Traefik
kubernetes.core.helm:
release_name: traefik
release_namespace: default
create_namespace: true
chart_ref: traefik/traefik
chart_version: 40.2.0
values:
deployment:
kind: DaemonSet
securityContext:
capabilities:
drop: [ALL]
add: [NET_BIND_SERVICE]
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
ports:
web:
port: 80
containerPort: 80
hostPort: 80
websecure:
port: 443
containerPort: 443
hostPort: 443
- name: Install Minecraft
kubernetes.core.helm:
release_name: minecraft
release_namespace: default
chart_ref: minecraft-server-charts/minecraft
chart_version: 5.1.3
values:
minecraftServer:
eula: true
persistence:
dataDir:
enabled: true
- name: Extract Control Plane Decryption Key & Join Token
block:
- name: Generate Fresh Join Command
ansible.builtin.command: kubeadm token create --print-join-command
register: join_command_raw
- name: Upload Certs and Capture Certificate Key
shell: kubeadm init phase upload-certs --upload-certs | tail -n 1
register: cert_key_raw
- name: Set facts across playbooks
ansible.builtin.set_fact:
k8s_join_base: "{{ join_command_raw.stdout }}"
k8s_cert_key: "{{ cert_key_raw.stdout }}"
delegate_to: localhost
delegate_facts: true
- name: Join Secondary Control Planes
hosts: masters:!masters[0] # <--- Targets masters 1 and 2
tasks:
- name: Join Cluster as Control Plane Node
ansible.builtin.shell: "{{ hostvars['localhost']['k8s_join_base'] }} --control-plane --certificate-key {{ hostvars['localhost']['k8s_cert_key'] }}"
args:
creates: /etc/kubernetes/admin.conf
- name: Copy Kubernetes Admin Config
ansible.builtin.shell: "{{ item }}"
loop:
- mkdir -p $HOME/.kube
- cp /etc/kubernetes/admin.conf $HOME/.kube/config
- chown $(id -u):$(id -g) $HOME/.kube/config
args:
creates: $HOME/.kube/config
- name: Remove control plane taint
hosts: masters[0]
tasks:
- name: Remove control plane NoSchedule taint from all nodes
kubernetes.core.k8s_taint:
state: absent
name: "{{ item }}"
taints:
- key: node-role.kubernetes.io/control-plane
effect: NoSchedule
loop: "{{ groups['masters'] }}"
+87
View File
@@ -0,0 +1,87 @@
- name: Initialise Master and Worker Nodes
hosts: all
tasks:
- name: Enable IPv4 forwarding
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: 1
sysctl_set: true
- name: Enable Kernel Modules
community.general.modprobe:
name: "{{ item }}"
persistent: present
loop:
- br_netfilter
- dm-crypt
- name: Ensure /sys mount is set to shared for container runtimes
block:
- name: Remount /sys as shared immediately
ansible.builtin.command: mount --make-rshared {{ item }}
loop:
- /
- /sys
- /run
changed_when: false
- name: Create Alpine local.d script for permanent shared mount
ansible.builtin.copy:
dest: /etc/local.d/mount-shared.start
content: |
#!/bin/sh
mount --make-rshared /
mount --make-rshared /sys
mount --make-rshared /run
owner: root
group: root
mode: '0755'
- name: Ensure Alpine local service is enabled on boot
ansible.builtin.service:
name: local
enabled: true
runlevel: default
- name: Install tools
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- htop
- name: Install kubectl, kubelet, kubeadm and containerd
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- kubectl
- kubeadm
- kubelet
- containerd
- cni-plugins
- helm
- open-iscsi
- name: Start kubelet and containerd
ansible.builtin.service:
name: "{{ item }}"
enabled: true
state: started
loop:
- kubelet
- containerd
- iscsid
- name: Add /opt/cni/bin to containerd CNI binary directories
ansible.builtin.replace:
path: /etc/containerd/config.toml
regexp: "bin_dirs = \\['/usr/libexec/cni'\\]"
replace: "bin_dirs = ['/opt/cni/bin', '/usr/libexec/cni']"
notify: Restart containerd
handlers:
- name: Restart containerd
ansible.builtin.service:
name: containerd
state: restarted
+24
View File
@@ -0,0 +1,24 @@
- name: Extract Worker Join Token from Primary Master
hosts: masters[0] # <--- Targets only the first master safely
gather_facts: false
tasks:
- name: Generate worker join command
ansible.builtin.command: kubeadm token create --print-join-command
register: join_command_raw
changed_when: false # Reading/generating a token text string changes no host state
- name: Save join command globally
ansible.builtin.set_fact:
k8s_worker_join: "{{ join_command_raw.stdout }}"
delegate_to: localhost
delegate_facts: true
- name: Join Worker Nodes to Cluster
hosts: workers
gather_facts: false
tasks:
- name: Join cluster
ansible.builtin.shell: "{{ hostvars['localhost']['k8s_worker_join'] }} >> node_joined.txt"
args:
chdir: $HOME
creates: node_joined.txt
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
set -e
err() { echo "$@" >&2; exit 1; }
log() { echo "$@" >&2; }
[ "$(id -u)" -ne 0 ] && err "You must run this script as root"
# Install minimal packages for provisioning
PACKAGES="dropbear openssh-sftp-server python3 py3-yaml py3-kubernetes cloud-utils-growpart e2fsprogs-extra lsblk"
apk update && apk upgrade
apk add ${PACKAGES}
# Enable and start services
enable_and_start() {
service="$1"
rc-update add "${service}" default
service "${service}" start
}
enable_and_start dropbear
# increase root partition size
root_dev=$(readlink -f /dev/root)
disk=$(lsblk -no pkname "$root_dev")
part_num=$(echo "$root_dev" | sed 's|.*[^0-9]||')
echo "Growing /dev/${disk}${part_num}..."
# Grow partition and resize
growpart "/dev/$disk" "$part_num" || err "Partition growth failed"
resize2fs "$root_dev" || err "Filesystem resize failed"
+202
View File
@@ -0,0 +1,202 @@
# ------
# Locals
# ------
locals {
root_disk_size = "16GiB"
ext_disk_size = "16GiB"
network_cidr = "10.150.0.1/24"
master_count = 2
worker_count = 0
}
# ---------
# Resources
# ---------
resource "incus_project" "this" {
name = "deskpi-cluster"
description = "The emulated DeskPi Super6C stack."
}
resource "incus_image" "alpine" {
project = incus_project.this.name
source_image = {
remote = "images"
name = "alpine/edge"
type = "virtual-machine"
}
}
resource "incus_profile" "this" {
name = "Default"
description = "The default profile applied to both masters and workers."
project = incus_project.this.name
depends_on = [incus_network.this]
config = {
"limits.cpu" = var.cpus
"limits.memory" = var.memory
"boot.autostart" = false
"security.secureboot" = false
}
device {
name = "root"
type = "disk"
properties = {
"pool" = "default"
"path" = "/"
"size" = local.root_disk_size
}
}
}
resource "incus_network" "this" {
name = "deskpi-cluster"
description = "An emulated network for the DeskPi Super6C cluster project."
project = incus_project.this.name
type = "bridge"
config = {
"ipv4.address" = local.network_cidr
"ipv4.nat" = true
}
}
resource "incus_instance" "master" {
count = local.master_count
name = "master-${count.index}"
description = "Kubernetes Master ${count.index}"
project = incus_project.this.name
type = "virtual-machine"
image = incus_image.alpine.fingerprint
profiles = [incus_profile.this.name]
wait_for {
type = "agent"
}
file {
source_path = "bootstrap.sh"
target_path = "/bootstrap.sh"
}
file {
source_path = "../ansible/.ssh/key.pub"
target_path = "/root/.ssh/authorized_keys"
create_directories = true
}
device {
name = "eth0"
type = "nic"
properties = {
"network" = incus_network.this.name
"ipv4.address" = cidrhost(local.network_cidr, 2 + (2 * count.index))
}
}
device {
name = "ext"
type = "disk"
properties = {
"pool" = "default"
"source" = incus_storage_volume.master[count.index].name
}
}
exec = { "00-init" = { command = ["/bootstrap.sh"] } }
}
resource "incus_storage_volume" "master" {
count = local.master_count
name = "master-ext-${count.index}"
description = "External drive for master ${count.index}"
pool = "default"
project = incus_project.this.name
content_type = "block"
config = {
"size" = local.ext_disk_size
}
}
resource "incus_instance" "worker" {
count = local.worker_count
name = "worker-${count.index}"
description = "Kubernetes Worker ${count.index}"
project = incus_project.this.name
type = "virtual-machine"
image = incus_image.alpine.fingerprint
profiles = [incus_profile.this.name]
wait_for {
type = "agent"
}
file {
source_path = "bootstrap.sh"
target_path = "/bootstrap.sh"
}
file {
source_path = "../ansible/.ssh/key.pub"
target_path = "/root/.ssh/authorized_keys"
create_directories = true
}
device {
name = "eth0"
type = "nic"
properties = {
"network" = incus_network.this.name
"ipv4.address" = cidrhost(local.network_cidr, 3 + (2 * count.index))
}
}
exec = { "00-init" = { command = ["/bootstrap.sh"] } }
}
# -------
# Outputs
# -------
output "master_addresses" {
value = incus_instance.master[*].ipv4_address
}
output "worker_addresses" {
value = incus_instance.worker[*].ipv4_address
}
# ---------
# Terraform
# ---------
terraform {
required_version = "~> 1"
required_providers {
incus = {
source = "lxc/incus"
version = "~> 1"
}
}
}
provider "incus" {
}
+18
View File
@@ -0,0 +1,18 @@
variable "cpus" {
description = "The number of CPU cores allocated to each virtual machine."
type = number
default = 2
nullable = false
validation {
condition = var.cpus >= 1
error_message = "Allocated CPUs must be equal to or greater than 1."
}
}
variable "memory" {
description = "The amount of memory allocated to each virtual machine."
type = string
default = "4GiB"
nullable = false
}
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJZitGUlpSMUIydk13RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TmpBMk1EY3hNelF3TVROYUZ3MHpOakEyTURReE16UTFNVE5hTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUURoMDNtOC8zWG5mNHptUGl5M1AzWHR3K2FzRWFOd3owbGZjdmloTzNDSGVmSjRkSHRNMlRyNmtrdnUKOW10R0l5L2UzRkd6Q3dybUIwZnRNZmY1WTlTMndSVFQrV25FOXFCWXZJeERJeWZYSFQ0Z3QyL2txR01Ib3ArTQoyeTZ5QTZ6a0RsVHFTQlZvU0hNNjBtT3IrU3MxUFp6VitvdVY1OFdOT1Y1bEpOYnNTcDlUNmtmYUtId0k2RnlYCmtvRGZFSERDdlZqMHU0S1ZCUzdxNHJUWGlQenA1WUFjRG1jUWJ1T1plb0VFd1RheTJuOXA5K3VZZ2wzd0RSalkKbnpMZGZQOU9LTVN6YVhDaUJjZDBkMllEdGR0UitHQ3ZlbktEZ3JISmI0YUQ4VmwzbE1XeUh0b0RJQ1NuQ290agpuSjZxVlpKNUJGeVd4NHhSKzViR3hRa3poUUdqQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJTYTI5VjBzdlhuUnlYN3YvNUR0UDdSWHdXczhEQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQXhYNkRLOWQ4Nwp3MEJBS3diQzRXblduTHlXMTRPc3Iwd3oxK2xCSnFrSkozZHlHRlpSRkFQVVpoNzV3dU5NTUNrNEZzcjhKbjA4CndtK1ZydHVvekpmeDJ6TUs0U1QzcElYOFR5QWptLzZYRjZ2dDNKUVV2WnJvMkRzcFZDOHdBVi93Y3Iwc2ZYVkgKSVB6TjVSZ3d5WENaUW41WFJxS2JpSG5TUXdTSVp5aXRkejQ3UHF4VFo5MTJiYnVuVCtjZHJ0bS9WMnBRajlpLwpmbHhoU292MG9henpoVVNWVHZqMUJqNm1tOXlOdzBlQTVTRW1YMm9La05lVW5aZDg5MWdBZnh6d1Fxd3hYUjZqCjU0aTJyclA2R3Iyc3U3bU9sTXI5UmxuZkROQkJ3Z0VoOUVHdFU4RXpNUzF4aW82ZXJvMlN5MzY1M3E2ZzZ5S3cKWjNBbllEbGk0Rjk4Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
server: https://10.150.0.2:6443
name: kubernetes
contexts:
- context:
cluster: kubernetes
user: kubernetes-admin
name: kubernetes-admin@kubernetes
current-context: kubernetes-admin@kubernetes
kind: Config
users:
- name: kubernetes-admin
user:
client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLVENDQWhHZ0F3SUJBZ0lJY1RKVmdhbFhLRWt3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TmpBMk1EY3hNelF3TVROYUZ3MHlOekEyTURjeE16UTFNVE5hTUR3eApIekFkQmdOVkJBb1RGbXQxWW1WaFpHMDZZMngxYzNSbGNpMWhaRzFwYm5NeEdUQVhCZ05WQkFNVEVHdDFZbVZ5CmJtVjBaWE10WVdSdGFXNHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEVGVCRjkKelBKalZYajdMdFB0V1BBRklybnEyaE5vc2FtY2tjMmlUWU4xYU54bWI4a2kwMWRPOEVOT3ZPLzladzl6TStJZAphekRIMGlOR1BSRGdtYkhlZ2NDZ2VuZFlBNjN6T0xLdDM4clVoVDR0Q0hTRXdjRTVWeElwUXJOS0NjQXR5REQrCmxrL0Rza2o0cVRmWnBQcWpaVWFMVUZVLzNtM2V1Y3JrV2VrZlRaTlR4SERqdzgwVWVZRHJocjJxdmZsbjJZajMKLzB0UWdPSEdlcll2a2k5MEZLaDNFV1RnTGoyTnROeUtIY2FFSzBMRVRGREVtT251YS9XbXlFUTBELzlDVFFDYgpFQnhkVTZra2FRVlpGcGpDZWphWEF2QnN2KzZGZGlJQjkxT3F3ZnhLM1dzZlRzL1IrYklKb3RaQ1lvdkVTcVRhCjRDTDVTMEZ4UzZhZHIrdzVBZ01CQUFHalZqQlVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUsKQmdnckJnRUZCUWNEQWpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkpyYjFYU3k5ZWRISmZ1Lwova08wL3RGZkJhendNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJFMGFBYWIvMTVuZWxZdWVHOEtNV21wMnc2ClZxM2pleFE0U1o3bjQwQlJYZE5YRzhTblRnVXN6SHByNlRGcjUwajdobnlzaDIxZ3laSFliTm1QOG5STTdvRDkKMWJheWNZWGhUUXBseDA0aDRiVmt0VWlrOHBxSWZ1RGMyRHUwTmNNQVhsT09ram05TzNPL0l3dW1RUHhJdkh0ZwpRM3ROWGRpdmNWZFZhMHZNWWVXS2xqNXp1dzUzcGRQNEpSaTVOaWIzZmRKSTJCcDNBKzd0ZzFYckptSGpnM0RaCk81dEg5WFI3Y3NWUkJUbVdOL1U4UFdNTURMdHpmVStTMWFqUTFwWmVUd1l4emYxL3ZoL3B1czhkaGRpZ3plTDkKN2Z1VXl3MXhFQitkaTNQbk5UZ083c05qb3ptY0x6WU1POE9HTit5dE9QTG9GVm9tWDhtc3J0YlgrYWloCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBMDNnUmZjenlZMVY0K3k3VDdWandCU0s1NnRvVGFMR3BuSkhOb2syRGRXamNabS9KCkl0TlhUdkJEVHJ6di9XY1BjelBpSFdzd3g5SWpSajBRNEpteDNvSEFvSHAzV0FPdDh6aXlyZC9LMUlVK0xRaDAKaE1IQk9WY1NLVUt6U2duQUxjZ3cvcFpQdzdKSStLazMyYVQ2bzJWR2kxQlZQOTV0M3JuSzVGbnBIMDJUVThSdwo0OFBORkhtQTY0YTlxcjM1WjltSTkvOUxVSURoeG5xMkw1SXZkQlNvZHhGazRDNDlqYlRjaWgzR2hDdEN4RXhRCnhKanA3bXYxcHNoRU5BLy9RazBBbXhBY1hWT3BKR2tGV1JhWXdubzJsd0x3YkwvdWhYWWlBZmRUcXNIOFN0MXIKSDA3UDBmbXlDYUxXUW1LTHhFcWsydUFpK1V0QmNVdW1uYS9zT1FJREFRQUJBb0lCQUFMVWxhM25xcFFtbDlFdQorQWtPeGlUZnJPZ3NiMDl1VEJyeHEzdHlOeTQ5VklYNnBDTHNmQW4rN2NvazRWUzROcWJFZHNZVVkrTk1JUUExCjRRRGFsamxJUXl5QjBIUjZEajMzMVdkb2d6UnBzREtYdnc4cmNRR1lwa1BYNjFSVVdPTC9pazFXdERyTlhQYlAKb0FOQ09xb3d5cGU0RWR4Q09MWWJsSmRjeEl4czNmaDd0WVRsZFRDVUJxd3dsdmJnYUZ1UXdzRjByemVoTXpTMAp0SzJBUkNScmxnZzByVDA2OW1pcXRFVDdUTlhsd1U1bXNxR0FvaEk0aWRmbXZ3Y2JyRGdWb2NOOTdMYTNEKzNvCkswNE1EeEwrZ0lHYzJRVzRhNnJKMlU0K2lNNEtLcWdrak5xS3hiTWp2ZWxhcW1DNEl1a2wySzdUV0I2SVdKVWEKWWZMRnhYRUNnWUVBM2xEbEt3R08wUUExa3dqaVJiQlFBZjJITFFtTGVQNEExT0xUSHFLSlY1MzdBaWZyc2x2YgprekhNSGwySkFmVzBiSGpBMStsL2tKOU95dnVaK0FZRHd4RGVKK2dzY1d3amRId2hCWHo5Q3VnbDJpWVBiTWs3CkxkUUZ4NHhrV0Yxd0RRM3FjK2djb1BkNWlaVXdnUXpLMjZTNElzeHA1RzlpUkcyRWNIODJyREVDZ1lFQTg0SngKM1Bpc2JlQXVYNER6djUrbm5VU0VoUElBR242VG1tN0o2L1NDZU0rYjR3aHdsV1Zsd0xkY2R5dGJZV3Y2MldsQQpQV3RXd3JRT1V4NXF1K3JwN01zTkpiZTE2ZktpeWhVYk4zSVdMOU83a295RUwxWXlUNm9ETURkTjB4ZE9Cc1ZjCnZuY3NxaWk0UUFpbkRuZDU3Q2IwbXRxRWs3UTNwYlVaa3M2bXBva0NnWUE0ODdaUmx4dVVGUjZrWlRFNk14bTcKK012cFVMbW5MRUxnUnA5WkNNQndicXMvK1NRYlZBYmpKVzJxRmNJa2UySldLM1VUbXVDd0Z1K2pkbU1TTTlCQQo1NUJSM3R1THp1MGlTUzVZYnBXUWRycERzVjVnT3dqckM1YlRjUGEvNEdmMUlDeUF0dVNtbzk5QnpMTlpUQUtuCjFBSE1BRnZjUm1DQk1TRXRoSmxuOFFLQmdFdUp5WnlNbjZDdURIU2Y0ZjB0bjZkUG0vUkp4Zkp5RllPd25uN04KcUxPbFkydzRNQVNJSWRwNGh2eUl2SjRxQ3ZUcWkzMENFVFh3K1FpYnhPeHFZUXFOcXBScXVzbko5T25QUmxRYQoxZnNjS1hHNm1UNjk5SGwxK2VzNUhMRkE1M2wrTzlqeHl5ODExb0kyck92MThDSnJxekZtVy80ZHg2MWwwbnNTCkUreFJBb0dCQUpTRS9QZEcwWm4wVzlBeWZtQ2xKVHhFSnBIMk1jd3l3T0J2UEY4UnhPUCtEZDhBaTVLWEhtSGEKQjQwUW4wd0s4YVJBRm5nUGpTUC81ck9tWW5NRUI4cUEwTGNvenJON3ZhK2tveFdreVk4NmRjbHN3L3k3RVZvQQp4azh1MExka3puQmp4Mit3MmVqMnIzblBBT3RUSFk4U2NLWS83MGpiR1BDQkJPVjh1WXZrCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==