From c1eca5d865a6a5a84de775d6850aed9d4287d01c Mon Sep 17 00:00:00 2001 From: samir-tahir Date: Sat, 13 Jul 2024 09:55:43 +0100 Subject: [PATCH] feat: Add validating webhook for private key retrieval option - Add e2e tests - Add webhook test --- .github/workflows/e2e-tests.yaml | 77 +++++++ .github/workflows/tests.yaml | 11 +- .gitignore | 3 + Makefile | 13 +- PROJECT | 3 + README.md | 17 +- api/v1/githubapp_webhook.go | 101 +++++++++ api/v1/githubapp_webhook_test.go | 98 +++++++++ api/v1/webhook_suite_test.go | 145 +++++++++++++ api/v1/zz_generated.deepcopy.go | 2 +- charts/github-app-operator/.helmignore | 23 ++ charts/github-app-operator/Chart.lock | 6 + charts/github-app-operator/Chart.yaml | 28 +++ .../charts/cert-manager-v1.12.2.tgz | Bin 0 -> 68114 bytes .../templates/_helpers.tpl | 62 ++++++ .../templates/deployment.yaml | 111 ++++++++++ .../templates/githubapp-crd.yaml | 135 ++++++++++++ .../templates/leader-election-rbac.yaml | 59 ++++++ .../templates/manager-rbac.yaml | 94 ++++++++ .../templates/metrics-reader-rbac.yaml | 14 ++ .../templates/metrics-service.yaml | 17 ++ .../templates/proxy-rbac.yaml | 40 ++++ .../templates/selfsigned-issuer.yaml | 13 ++ .../templates/serviceaccount.yaml | 11 + .../templates/serving-cert.yaml | 21 ++ .../validating-webhook-configuration.yaml | 31 +++ .../templates/webhook-service.yaml | 15 ++ charts/github-app-operator/values.yaml | 84 ++++++++ cmd/main.go | 6 + config/certmanager/certificate.yaml | 35 +++ config/certmanager/kustomization.yaml | 5 + config/certmanager/kustomizeconfig.yaml | 8 + config/crd/kustomization.yaml | 8 +- .../patches/cainjection_in_githubapps.yaml | 7 + config/crd/patches/webhook_in_githubapps.yaml | 16 ++ config/default/kustomization.yaml | 200 +++++++++--------- config/default/manager_webhook_patch.yaml | 23 ++ config/default/webhookcainjection_patch.yaml | 25 +++ config/manager/kustomization.yaml | 2 +- config/manager/manager.yaml | 5 +- config/webhook/kustomization.yaml | 6 + config/webhook/kustomizeconfig.yaml | 22 ++ config/webhook/manifests.yaml | 26 +++ config/webhook/service.yaml | 15 ++ internal/controller/githubapp_controller.go | 4 +- .../controller/githubapp_controller_test.go | 1 + internal/controller/suite_test.go | 4 +- .../controller/test_helpers/test_helpers.go | 1 + internal/controller/vault.go | 1 + scripts/delete_vault.sh | 2 +- scripts/install_and_setup_vault_k8s.sh | 3 +- test/e2e/e2e_test.go | 29 ++- test/utils/utils.go | 46 +++- 53 files changed, 1608 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/e2e-tests.yaml create mode 100644 api/v1/githubapp_webhook.go create mode 100644 api/v1/githubapp_webhook_test.go create mode 100644 api/v1/webhook_suite_test.go create mode 100644 charts/github-app-operator/.helmignore create mode 100644 charts/github-app-operator/Chart.lock create mode 100644 charts/github-app-operator/Chart.yaml create mode 100644 charts/github-app-operator/charts/cert-manager-v1.12.2.tgz create mode 100644 charts/github-app-operator/templates/_helpers.tpl create mode 100644 charts/github-app-operator/templates/deployment.yaml create mode 100644 charts/github-app-operator/templates/githubapp-crd.yaml create mode 100644 charts/github-app-operator/templates/leader-election-rbac.yaml create mode 100644 charts/github-app-operator/templates/manager-rbac.yaml create mode 100644 charts/github-app-operator/templates/metrics-reader-rbac.yaml create mode 100644 charts/github-app-operator/templates/metrics-service.yaml create mode 100644 charts/github-app-operator/templates/proxy-rbac.yaml create mode 100644 charts/github-app-operator/templates/selfsigned-issuer.yaml create mode 100644 charts/github-app-operator/templates/serviceaccount.yaml create mode 100644 charts/github-app-operator/templates/serving-cert.yaml create mode 100644 charts/github-app-operator/templates/validating-webhook-configuration.yaml create mode 100644 charts/github-app-operator/templates/webhook-service.yaml create mode 100644 charts/github-app-operator/values.yaml create mode 100644 config/certmanager/certificate.yaml create mode 100644 config/certmanager/kustomization.yaml create mode 100644 config/certmanager/kustomizeconfig.yaml create mode 100644 config/crd/patches/cainjection_in_githubapps.yaml create mode 100644 config/crd/patches/webhook_in_githubapps.yaml create mode 100644 config/default/manager_webhook_patch.yaml create mode 100644 config/default/webhookcainjection_patch.yaml create mode 100644 config/webhook/kustomization.yaml create mode 100644 config/webhook/kustomizeconfig.yaml create mode 100644 config/webhook/manifests.yaml create mode 100644 config/webhook/service.yaml diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml new file mode 100644 index 00000000..bf506b15 --- /dev/null +++ b/.github/workflows/e2e-tests.yaml @@ -0,0 +1,77 @@ +name: E2E Tests + +# Trigger the workflow on pull requests and direct pushes to any branch +on: + push: + pull_request: + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + # Pull requests from the same repository won't trigger this checks as they were already triggered by the push + if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) + steps: + - name: Clone the code + uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '~1.22' + - name: Install Helm and Kubectl + if: matrix.os == 'macos-latest' + run: | + brew install helm + brew install kubectl + - name: Setup Minikube cluster + if: matrix.os != 'macos-latest' + uses: medyagh/setup-minikube@latest + # This step is needed as the following one tries to remove + # kustomize for each test but has no permission to do so + - name: Remove pre-installed kustomize + if: matrix.os != 'macos-latest' + run: sudo rm -f /usr/local/bin/kustomize + - name: Perform the E2E test + if: matrix.os != 'macos-latest' + run: | + chmod -R +x scripts + + export "GITHUB_PRIVATE_KEY=${{ secrets.GH_TEST_APP_PK }}" + export "GH_APP_ID=${{ secrets.GH_APP_ID }}" + export "GH_INSTALL_ID=${{ secrets.GH_INSTALL_ID }}" + export "VAULT_ADDR=http://vault.default:8200" + export "VAULT_ROLE_AUDIENCE=githubapp" + export "VAULT_ROLE=githubapp" + + eval $(minikube docker-env) + + # Run tests + make test-e2e || true + + # debug + #docker images + #kubectl -n github-app-operator-system describe po + #kubectl -n github-app-operator-system describe deploy + #echo 'kubectl get mutatingwebhookconfiguration cert-manager-webhook -o jsonpath={.webhooks[*].clientConfig.caBundle}' + #kubectl get mutatingwebhookconfiguration cert-manager-webhook -o jsonpath={.webhooks[*].clientConfig.caBundle} + #kubectl -n cert-manager describe deploy,po + + #echo "######### gh operator logs ##########" + #kubectl -n github-app-operator-system logs deploy/github-app-operator-controller-manager + + #echo "######### cert-manager-webhook logs ##########" + #kubectl -n cert-manager logs deploy/cert-manager-webhook + - name: Report failure + uses: nashmaniac/create-issue-action@v1.2 + # Only report failures of pushes (PRs have are visible through the Checks section) to the default branch + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + title: 🐛 Unit tests failed on ${{ matrix.os }} for ${{ github.sha }} + token: ${{ secrets.GITHUB_TOKEN }} + labels: kind/bug + body: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6acec69b..1e5617e0 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,6 +36,14 @@ jobs: - name: Remove pre-installed kustomize if: matrix.os != 'macos-latest' run: sudo rm -f /usr/local/bin/kustomize + - name: Perform the webhook tests + if: matrix.os != 'macos-latest' + run: | + export "GH_APP_ID=${{ secrets.GH_APP_ID }}" + export "GH_INSTALL_ID=${{ secrets.GH_INSTALL_ID }}" + + # Run webhook tests + make test-webhooks # Install vault to minikube cluster to test vault case with kubernetes auth - name: Install and configure Vault if: matrix.os != 'macos-latest' @@ -45,7 +53,7 @@ jobs: cd scripts chmod +x install_and_setup_vault_k8s.sh ./install_and_setup_vault_k8s.sh - - name: Perform the test + - name: Perform the controller integration tests if: matrix.os != 'macos-latest' run: | export "GITHUB_PRIVATE_KEY=${{ secrets.GH_TEST_APP_PK }}" @@ -54,6 +62,7 @@ jobs: export "VAULT_ADDR=http://localhost:8200" export "VAULT_ROLE_AUDIENCE=githubapp" export "VAULT_ROLE=githubapp" + export ENABLE_WEBHOOKS=false # Run vault port forward in background kubectl port-forward vault-0 8200:8200 & diff --git a/.gitignore b/.gitignore index 7a7feec5..0cd49620 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ Dockerfile.cross # Test binary, built with `go test -c` *.test +# test data +cluster-keys.json + # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/Makefile b/Makefile index 22dde5e7..82f988ab 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Image URL to use all building/pushing image targets -IMG ?= controller:latest +IMG ?= samirtahir91076/github-app-operator:latest # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.29.0 @@ -62,7 +62,11 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v -E '/e2e|v1|utils|cmd|test_helpers|vault') -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v -E '/e2e|v1|utils|cmd|test_helpers|vault') -v -ginkgo.v -coverprofile cover.out + +.PHONY: test-webhooks +test-webhooks: manifests generate fmt vet envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./api/v1/ -v -ginkgo.v -coverprofile cover.out # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. @@ -194,10 +198,11 @@ HELMIFY ?= $(LOCALBIN)/helmify .PHONY: helmify helmify: $(HELMIFY) ## Download helmify locally if necessary. $(HELMIFY): $(LOCALBIN) - test -s $(LOCALBIN)/helmify || GOBIN=$(LOCALBIN) go install github.com/arttor/helmify/cmd/helmify@v0.4.5 + test -s $(LOCALBIN)/helmify || GOBIN=$(LOCALBIN) go install github.com/arttor/helmify/cmd/helmify@v0.4.14 helm: manifests kustomize helmify - $(KUSTOMIZE) build config/default | $(HELMIFY) charts/github-app-operator + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default | $(HELMIFY) -cert-manager-as-subchart charts/github-app-operator ################################## # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist diff --git a/PROJECT b/PROJECT index dcf8b6c9..4ff62e3d 100644 --- a/PROJECT +++ b/PROJECT @@ -17,4 +17,7 @@ resources: kind: GithubApp path: github-app-operator/api/v1 version: v1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/README.md b/README.md index 53b4b6ac..7c7b6e81 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The `github-app-operator` is a Kubernetes operator that generates an access toke ### Private Key Retrieval Options > [!TIP] -> There is a sample constraint template and constraint for Gatekeeper to restrict the type of private key source in the `gatekeeper-policy` folder since we can't restrict it to be unique in the GithubApp CRD. +> There is a sample constraint template and constraint for Gatekeeper to restrict the type of private key source in the `gatekeeper-policy` folder if you dont want to use the validating webhook built-in. #### 1. Using a Kubernetes Secret @@ -216,8 +216,17 @@ EOF ### To deploy with Helm using public Docker image A helm chart is generated using `make helm` when a new tag is pushed, i.e a release. -You can pull the helm chart from this repos packages -- See the [packages page](https://github.com/samirtahir91/github-app-operator/pkgs/container/github-app-operator%2Fhelm-charts%2Fgithub-app-operator) +This chart will have webhooks and cert manager enabled. +If you want to install without webhooks and cert manager required use the local manual chart. +```sh +cd charts/github-app-operator +helm upgrade --install -n github-app-operator-system . --create-namespace \ + --set webhook.enabled=false \ + --set controllerManager.manager.env.enableWebhooks="false" +``` + +You can pull the automatically built helm chart from this repos packages +- See the [packages](https://github.com/samirtahir91/github-app-operator/pkgs/container/github-app-operator%2Fhelm-charts%2Fgithub-app-operator) - Pull with helm: - ```sh helm pull oci://ghcr.io/samirtahir91/github-app-operator/helm-charts/github-app-operator --version @@ -282,6 +291,7 @@ export GH_INSTALL_ID= export "VAULT_ADDR=http://localhost:8200" # this can be local k8s Vault or some other Vault export "VAULT_ROLE_AUDIENCE=githubapp" export "VAULT_ROLE=githubapp" +export "ENABLE_WEBHOOKS=false" ``` - This uses Vault, you can spin up a simple Vault server using this script. - It will use Helm and configure the Vault server with a test private key as per the env var ${GITHUB_PRIVATE_KEY}. @@ -305,6 +315,7 @@ export GITHUB_PRIVATE_KEY= export GH_APP_ID= export GH_INSTALL_ID= USE_EXISTING_CLUSTER=false make test +USE_EXISTING_CLUSTER=false make test-webhooks ``` **Generate coverage html report:** diff --git a/api/v1/githubapp_webhook.go b/api/v1/githubapp_webhook.go new file mode 100644 index 00000000..6a9a5162 --- /dev/null +++ b/api/v1/githubapp_webhook.go @@ -0,0 +1,101 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var githubapplog = logf.Log.WithName("githubapp-resource") + +// SetupWebhookWithManager will setup the manager to manage the webhooks +func (r *GithubApp) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. +// NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here. +// Modifying the path for an invalid path can cause API server errors; failing to locate the webhook. +// +kubebuilder:webhook:path=/validate-githubapp-samir-io-v1-githubapp,mutating=false,failurePolicy=fail,sideEffects=None,groups=githubapp.samir.io,resources=githubapps,verbs=create;update,versions=v1,name=vgithubapp.kb.io,admissionReviewVersions=v1 + +var _ webhook.Validator = &GithubApp{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *GithubApp) ValidateCreate() (admission.Warnings, error) { + githubapplog.Info("validate create", "name", r.Name) + + // Ensure only one of googlePrivateKeySecret, privateKeySecret, or vaultPrivateKey is specified + err := validateGithubAppSpec(r) + if err != nil { + return nil, err + } + + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *GithubApp) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { + githubapplog.Info("validate update", "name", r.Name) + + // Ensure only one of googlePrivateKeySecret, privateKeySecret, or vaultPrivateKey is specified + err := validateGithubAppSpec(r) + if err != nil { + return nil, err + } + + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *GithubApp) ValidateDelete() (admission.Warnings, error) { + githubapplog.Info("validate delete", "name", r.Name) + + // TODO(user): fill in your validation logic upon object deletion. + return nil, nil +} + +// validateGithubAppSpec validates that only one of googlePrivateKeySecret, privateKeySecret, or vaultPrivateKey is specified +func validateGithubAppSpec(r *GithubApp) error { + count := 0 + + if r.Spec.GcpPrivateKeySecret != "" { + count++ + } + if r.Spec.PrivateKeySecret != "" { + count++ + } + if r.Spec.VaultPrivateKey != nil { + count++ + } + + if count != 1 { + return fmt.Errorf("exactly one of googlePrivateKeySecret, privateKeySecret, or vaultPrivateKey must be specified") + } + + return nil +} diff --git a/api/v1/githubapp_webhook_test.go b/api/v1/githubapp_webhook_test.go new file mode 100644 index 00000000..1c04285c --- /dev/null +++ b/api/v1/githubapp_webhook_test.go @@ -0,0 +1,98 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "fmt" + "os" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // github app private key secret + privateKeySecret = "gh-app-key-test" +) + +var ( + appId int + installId int + acessTokenSecretName string +) + +// Function to initialise vars for github app +func init() { + var err error + appId, err = strconv.Atoi(os.Getenv("GH_APP_ID")) + if err != nil { + panic(err) + } + installId, err = strconv.Atoi(os.Getenv("GH_INSTALL_ID")) + if err != nil { + panic(err) + } + acessTokenSecretName = fmt.Sprintf("github-app-access-token-%s", strconv.Itoa(appId)) +} + +var _ = Describe("GithubApp Webhook", func() { + var ( + obj *GithubApp + validator GithubApp + rolloutDeploymentSpec *RolloutDeploymentSpec + vaultPrivateKeySpec *VaultPrivateKeySpec + gcpPrivateKeySecret string + ) + BeforeEach(func() { + obj = &GithubApp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gh-app-webhook-test", + Namespace: "default", + }, + Spec: GithubAppSpec{ + AppId: appId, + InstallId: installId, + PrivateKeySecret: privateKeySecret, + RolloutDeployment: rolloutDeploymentSpec, + VaultPrivateKey: vaultPrivateKeySpec, + AccessTokenSecret: acessTokenSecretName, + GcpPrivateKeySecret: gcpPrivateKeySecret, + }, + } + + validator = GithubApp{} + + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating GithubApp under Validating Webhook", func() { + It("Should deny creation if more than one of googlePrivateKeySecret, privateKeySecret, or vaultPrivateKey is specified", func() { + obj.Spec.GcpPrivateKeySecret = "this-should-fail" + Expect(validator.ValidateCreate()).Error().To( + MatchError(ContainSubstring("exactly one of googlePrivateKeySecret, privateKeySecret, or vaultPrivateKey must be specified")), + "Private key source validation to fail for more than one option") + }) + }) + +}) diff --git a/api/v1/webhook_suite_test.go b/api/v1/webhook_suite_test.go new file mode 100644 index 00000000..174030d0 --- /dev/null +++ b/api/v1/webhook_suite_test.go @@ -0,0 +1,145 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "path/filepath" + "runtime" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + admissionv1 "k8s.io/api/admission/v1" + // +kubebuilder:scaffold:imports + apimachineryruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment +var ctx context.Context +var cancel context.CancelFunc + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", + fmt.Sprintf("1.30.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "config", "webhook")}, + }, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + scheme := apimachineryruntime.NewScheme() + err = AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + + err = admissionv1.AddToScheme(scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = (&GithubApp{}).SetupWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return err + } + return conn.Close() + }).Should(Succeed()) + +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index bcc8c2ba..03e354cc 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1 import ( - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/charts/github-app-operator/.helmignore b/charts/github-app-operator/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/charts/github-app-operator/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/github-app-operator/Chart.lock b/charts/github-app-operator/Chart.lock new file mode 100644 index 00000000..4e777df7 --- /dev/null +++ b/charts/github-app-operator/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: cert-manager + repository: https://charts.jetstack.io + version: v1.12.2 +digest: sha256:0c772741e7db522f947508a4754ab4e34d91f6a0878a496f36ed08fdcb371a9b +generated: "2024-11-14T20:09:04.560710432Z" diff --git a/charts/github-app-operator/Chart.yaml b/charts/github-app-operator/Chart.yaml new file mode 100644 index 00000000..a54c6c30 --- /dev/null +++ b/charts/github-app-operator/Chart.yaml @@ -0,0 +1,28 @@ +apiVersion: v2 +name: github-app-operator +description: A Helm chart for Kubernetes +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "0.1.0" + +dependencies: + - name: cert-manager + repository: https://charts.jetstack.io + condition: certmanager.enabled + alias: certmanager + version: "v1.12.2" diff --git a/charts/github-app-operator/charts/cert-manager-v1.12.2.tgz b/charts/github-app-operator/charts/cert-manager-v1.12.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..13d233c26dbad7c5725fb5d5897d0be259a88382 GIT binary patch literal 68114 zcmV)DK*7HsiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0POwycH20T0F3Y7dJ6oU-D7)xmTdW!#5*&)ZadxM>BRA|ow@ex z7$PAFV-jEkpdC$5zsvV5-;;d@g$qH7k|sEUYC-4ruCB4YP! zjD_k=aGLzbI-kA0y}jeZL-==ZZ?F1q|L}PKKl+DS&DlGnE@5y~tC-;>+m@%%fqMXT-4nkNcI>eD0=Yt;QyO|(@Bm_%xf;NUYkz`}K zkfbB_J(--Ke{~Q-Xh_+Jh)htXPEdbufA8$ugR{MZgY(00501V$ebYbdzkbu--#^|v z?w=hWADw^OKX?N*bCH~&u~L~l+1(8h6NZux5*l^y$wZdE={JJ1QFniDzu(<^-QC~o zHtn@H%0?ZW(LWF&DQ73>q2Iw-7X0k>_j~)Dn8--bOu@@j^gEKIDAIcc4Y@$!XgXt_ zl424g3P6f@S{ifdM~6nbSyx5i$DXN{OClo96k%$93%l{rL9{n`z5jA=_$rED@5Qh8 zUL79q#mBFY_V$loACms7gIB|&;cmi59RAmpDNd4J#zvicGI``8mM5KVc`99WpASek zAtT=DqJ$_(SR^Ky>U5DxWJiDXpxXTHZ&1Itzu(*I?rnBbOqs%z5dqDT6w~CS)Ot54 z^1ma_M+q4To@Y`|wSy4D8jiDSbmk*VXS#Mr@?1m&hO^Y_JXDe4OYhkAKBb92;3Ow< z?F?W({XaU~+k07||A%{f{g(cJj^_nBCqtYk3Yo>9qG-L&3v@fC5>bgT`t#|#x7{HZ zDOQTaXh;*H3!IZE!GfR%EGQl%L?Xq}fS?RZNn%8q;%LHiffPxz1S=wYol(LEIMGb! z1-d3fA_$9!uE!ZcT%eQ-f+UFqQLedMH}*It3}tzeP&Ps|#Umo24MBzlLC~~GFrqu6 zd^hHi+*MfK%iV~xAsyv{bTb}zHF*V!va1{HLTlYj&d^>7Pis~p51nZ@UCk&@F!bvhgg9#MUvlBc?h>$(#=36fG2N~4g(d&Z7 z_yIt#JJL06XOzJ&w`lHnh#n})rQ)e?(5xpJ^-#v+TU&7#Q8p4p%G;o&)3bMP&c--N z2pf?bo;(ooyW88Vz5azM3k@MuUe~Hr04pmT_Uz(}cHz|v9HpdJE&b5%HR@Fl-z;J{ z;4`A-9vWdqZZZ-*ZG1IDn{GeiY08K;s zP?TU<+BQS8_T|OPc&umaYMtpymj<7;peF`6IswtlVA%;$A}8?T1v(v#1Q}`AoF%zb zL|k*b@xS+*55<Ch+kFwV+J#nO zXxI%b9G4xso-B`VNR;dOUh#xR6Ej1?G~tR*5Qw=(9+l)Y%T7hgMN$0)x*-Z6`9Q>g zOATy3ER#%hjoFCk*L$doj?p7kV|0uLL{DE=O}SLIKG9n<;iI?Yfg~qrU-yv^924;- zArXk27U1t6h!8X;(1Jz)nc=9ix~3)~wkiU;0`+}`6SM(xot#KTQlL`0_1iH)aV|g= zFf@p9*0t?Ln8h^KbQ?X=BteguDujoM2*d}HhzC6}Y+EvQ^kjqa=@CmPMcK##AR}Gx_*UP zbP!4@I}K~<7Hisr*FjMYqe?lzPeK9pG@5F1!x3hHSej~Yk>03>`rFVL7YL(_h=?$S zN5qJ6w#fd`e{FXhLj|+9;+7nN{u_=5(L3VG8l7y-+7_=TgDk-3SnuQ7?3f3pjuKC zB%ws=-Z2{FBQrW6J_CY=^dpFsWp38*^$$8PmQwFP?%O@yd#x*eki-qHM?NZnPsK?3 z`EMqLB0VS+~}rh-H&nIMg?1joIOE%co(18lTo_L8GaEKdiTYz|T6#1IR5N0y>7 ziE|BkEYW5rxMqHojW!Whh_i@vQvC6ZvnUsWFy)0AsR>c119GTFQ9&}zow%5spdWKQ z(Tw#UL`j9C`?3WkSXzxyf95%i#e7B>IYAisn3c878{e{hTlBcFr_K~hgOMYJfjh(` zOE^ffk|@J6G=4Y*!^pE2L&Uhncq(u8vcjViJx?qqp067U zhGIG*(qbETL&R*N>p)Rc-~`Dr-ak4%*&}-|_v1K@`@@&RSHtMl>%G@_Kf=eu<0JC& zI64~c#rwl(ICwQ6hc90r9AR>Bc=VFI8Vn*b*w^GoEd|<_)3n_Eh#9jyJp>HNmQZoRQI1PKkvb@o> zZUXD0)iJ8Zlp!iRFAQ)ETPi986dCX_gmcAHtgM>iR2Rz@3$t3U6Is#C4)HP&UhIx4 z^?{RwKT1$#0C$Fz5v?c+12d3NA}72I2(sM8s-~S6hz?82G_|U3g zMz|`}<1tCHVV)Qb)pnr~O2ZC0*kP?k8{=V2GQwiQq6ylP`M|v0?y&M??ch(N2>6~s z)r>@RsOjV5m;j@|ZjuO_f&nGBaPCZr@rE+#{@kRhjVP`G5bQPsBh9o-TYBEQwH<^_ zLsX&>VZ<<+Tsnz&m?sH*cgiF*$qHQ~P-1W#Q}<_Kp7#1FukfQyiEc_Tg4ROAwi6Fd zHTuE4POmOXn`8)V)HZ``JH461DwOdhMwEnl9Ckw~7rf>7ggIp+7_bIo{g^W>6piu( zgErTB(Xiz|8Z4)lk7-fb@cSKab8(xcylDkV`GYgVIEB)xt)8S93Xu?vb*9}wxqbi0 z^v)i7;i(|Ku+NEjAi_-V{PJe6k8VgZL}z0X-9u5bK;3pG9$5Tc9HnG8W^%8;`@*u4 zE-Sj}4!P*U=dL8lup2=)H8PV_LXQwsxv^H?^d88#Pz61Rav|x1B^7oSCAw+1ZVWtO3t%2gCY~zQcd9}6K!kxeNc%6)8@&vkk8(h26v22R?3y$1 z)__0&2taLoPf)^{?mo`JOcdd0teeV+7;=#oB)oPwvuFCAC6fRPPKyl|Y3MU8sWJi> zTF7C#|B?a9Z#y)gnwx{gk|RlgFQ7uMHtmRV7 zy)p2EmxL1|#TeYzGI~s<5%;Z;dSTJwizkBodKb&57v?c^H)d|{^Jhj66 zrq;jQA&MiYu63tUMOl7=`g^I`U&>3fnd{mvMT5hFn5MGgw zYPaLw*d9O_&e^HFW>5J#!F^t^|Fc2^o<$GQx>9TR1~kgfl##300Ix1B6)c%>30wr*Gfd zrN*(|uNs2i(u5fEQ6NgxfVk6n;7OhmvqS8UcbXg7H>)#!bPfV&szu}%RvOhmPAhEx zDP3IgSWC4TkEdGAcpR8zOPw@n*|O%&3ny?j9QFdaW4hfLgVf4`WrlJ`GZQVg(qVBH zfRQa!ruA#$vB1#5~26Y3#q|xgzN3pojjbHQFIJaygX1OWE;I!h3$LE(fcV!cWvv(Aevr34D^CK7cJ%bD9OgcqvcJDBis4!G9Z_4&yPo0mCg9dh?a(0+%P68Rj259SfG5F-<)ow%VJ~ z?Jh&9y7c9H%HsMj-wB>)M)Jr6Pqk8!%aWzb6HP#@lrw4vT5CHm8oAJl5R2fr!jMy- zvsjFXy5d5epx1|c`=%0B;}f)-5+!J4jQP%(YOJjKjf@1&$Sq9?&(#S!u&+zfHL4N+ z5gK#Gh1(Hc>M%^3$ja%Zr@lrvy}iBq`_=XPKm8dI_CN(^n%ERItl8=BncyEaV(1?y zySwJ+tG!oy8_qCQDk7)KFzyQU_h0t*dV9V8PQoLcXqgj)q!6!9heOKLWJm8^i}&$= zgXGibV8ttHL9s5*=<3MqFsR5(p{=uU#33bDQFph*SpOx+uje8o&9N>Fr=VnEXfm`JQO$lJnjG z3oru;o{+EzLOjy|;Nk>bu<{pUu{{_FJ$IseQ)>Cg&4OZutC&XwW@>8vl@?FeZ zqoS0Z(0tewKzZ|HjuZRVii8#b=AAiyIYF1)t|{q+eOVqq12`E^_-JyIY02V@GpPio zOnpL7nGPj%82Jw818&A39T-#m@#da9o}hgT5my5o;@pp3dElCd+TvP^{=g(wR1T>* zByf;CWs^rdvE_ie72LrgC2Rmo(E?1xSyt(idLmqMZkkuci>@c3?SD2z>|(#A4}=jZ zuLL(zF$fvO@pC8}JCWLI&}Gr9*Ll%-fzFLC1RAC{Q4UCq)*NsYOBen*Ncdp))!y;oV6XpjFFNkygI7Zm z$47@z^qR!vFp7`&UmoE;jt>UAQEIjhj?-P7WxKhe$*$B)G|G4kKV@&kU%cHv?04Vp zA0Bm*aNae?Vw!zZMR9UY5BF*|8Q~krlg~T!GSvKqd8BM z=-2(dy}h0XJe9!#K+YnbYJru+Fh4NiYg*}`=p4r2Sc0=iFBBRH9OuST58xY<09o`j z>y{p6SSsW&r8f&`8bd0l_p{IMYftd&&hTfR;)9P}jqz#PcHASFI?}cXm3%b-Y=C|o3ENpL|W+CU<-TGbK6|S(uHL%3Zvc)Zb#O44QnA(ipV5YHc^**kQCt|&|ZS`iILCg@LdQ;o#t-s6l zcQ&f!fv{KR6jxA$FYqYD8`i~8Vu9N#`3HdcbJAl^0VAYWv)eYom$#Hbb#wjz5 znKs+NPG#!WG+itK{tASHRs|tF! zx9PBM1>hD17Rnf@#$2jPqF_cTkBxoG-tRN!d{`*3x-tZ=C5AM|;ZnGzLR&JA#t2Jv z`o|me=KGs%<9uZ$k)Z-8uJptweS2?onyxVd_I`vQJMSQ5Dtl2&Av)2yHK zOo_(kpjeYl=j_`nIMywgNu2-lmGQ>pIQ|AFm__>4N!cG=n#)91d-DUQEQX*=Gi_=w@H#Bj zE!hGK?z&m00_XKJ6g(|#4>bEG;nBT&0lb^W;sou%3H9Et5#mXy35|NPXd?sK@@mhw zkqNGN#FG2n1tm# zn*94FV5c1)9v<{6ogXinjN(f{x`R;YRpow#ZdDPc>>pk&sd{isU88!Yrr2T@tu^-R zwZ_)2Vr!5komC8e0Wd$MQ8wsipyp<$|;Iu9v0$wL$j--x&R znGM_Z<$mL|l{K0Bp0h>i>=a#8uwu%pw2}>|1n19S1`7IXEkUg%=$To9Rx;Qu<1Oy7 zzo8B3IT(W$3A{K;iG*M-C#zDSqKVH#g@XQCDpXH}oF9wALgg9GsJW8{fvDiJaK@$T z+KaZxm=qvN0H$ind#m#T5f*1$b3Y@cMm!~jn9;NM^nD*R%@SgrW(=*sp*Hw?KIl3- z2NndGT?KJHh#YrbAW35qX-pUVBXzM4rlRkd_)D4K13XA3g~fzPbMj~`pOelDZ*Vdl zxu{xz2*2lpO$qL4=)I=9kJLIxTG*KL%@j}I91HOr?#!6Ge7e~0`2aEU06GF(Smt`} za5X#dv=G(u6?MLDJ#wwL(0;*+ z7l;?;Ko?MNQ%^loiQW4+qnl2bDf~uHP(L+Q^==v9=$;RUZ)r+3+YEo6pu<&kt}3ej zo)0<*{eurq%Mn@;8j~dL$?-0@LX>FW)aULap`)=nK^y&z>5t}yb~m#^o{2Nmg-;+r zQOt1H6& zm6Q`NNm&d5;U2;htUpfqN`+T9Nq>=gP%)6KGZ{OVX)JL_B zJ{IMF=!GxDuR>3uOK(B1&zQLgWl=^f)6- zZ3f8+x>Dg0giEB*!gjCIc?a=RnTcBtuHHsPj_D9?4=Yf)rRJull5w^Mf0v3Qc-paH z8izb*;Pu3Fq1A~zCCo6clTK%e_*9+Fw_H>QH`Q}B2vj*H7u+z^i`5CjY#Fv>!&-*{ zzagL#MhSSd7{|Ih>;BP|W18M|mYPm3@rdl&26kIc@^w8k=>Olfo=b%6sqE>gAm-Em z{{B(Fn*Zmxf6(TC_$<#)KkfbsJ<#+7Nb!&+M3chbq#7*In4F+r?f(38N0;in`Iwmt z?PcA=M(niZx-RbwRDk{q&Pxm@UepB#&n!P;YxyqcE2$a4XGk*grNJ zH8K-|?!J)cCI(7Ph{!w4OxOgycKZD$&azDq#x`~F%|IjGbXOtt50@LN6Y(^|ioywk zF)5l{4ST)UfsYiD*2Y@1H=Uz(c}qGRv|qm}@^dkC!HP{Ie$Sfw{>J*FN;XUr9zYM? zj&>%Prm5F|>iw6J_|lSP4DxEs)j-qaUwM0aW?wY{j_)ksg1#%7YBz`gKO zQ=!10X3d1CXn6)LDhjmZC;-+cq)7XXC{nmCPa#TckVXoqG^7nPYcvx}>Q*735VKof zjYu?XZzi3no_T(1Vo^2SubE<0nx8>7Dy3T5Q3cxTR^ku0?6+TVXnz_{)(UaLL z6KXp1`&m)*S)$QJx@3TxaD&iAe3J%0OTo8*Sk&=1{q?eTKRdNJ)Y{G8&V>Ijm(~nz z{Qo!5#@{zqz#*5%vYaFe$FT`#2v;ASk$HOfh6b9pr$bHp9J{p#5dT6y=3IGj(;*^1 zqAkC4W0Ir+N6;YWV%xp1?a$r??hYoVOJ`7UC%8ZV+;L~d0Cy-=Z1Z9%dUZGuYyp$w zv=`=aghEL1KjSo+S1xS1c0qhj9CQ2}<`lKgfR!va)(yZc2^?Lxl&{nz8?bkeNs^X` ztm%XrZ0!`Of()&u(mNNJd$6+_2x)Ss1D?GjqBH}5lN8Lr3pOFCODP2H#8gH(B#|8x zK{_=Krn;E}`C&N8|C(C?V&pTRA}lqeiy zg-?9;_7c&o)eI19z98XMsmhEUYin8m7UN};Wiz`gbyXMWc&G&GK0>;>*b~qS#oGC#jG|CT2EV()mCM>b(sfkcX{4U)?KAv4!X$eo)${MZbGPx z?Mq_(Iv~6%jF-j!MmykD*#5kg@xIH|3N=eI)u>;X2E9yevl*_8O?t2N?Y15KJ*9X7 z>;v2Me0xu?+zTC|F_zzxNzJU##yro(jbN5@9USXP#JoB)0a3FER&1V1UtUzH$!xBN z6zUW!ta#;$Pf@A%?$1ig9$cjfu%}ya)==GpV};Ts>cw*~4)FobJI{W!;ba2uncw~S zXWgU!4M!bb@-?5FV?NR^p#E0Rc(8BlLg;^WG3)hiia2HbBv8cvb6WlDDLg_y|BQkj zH!5>1!OhR)DsXkL$3rOAD_r8sjbJg7CzYP2bvu{3y9K{_WV$rPy9aO$(}5|(E(ljP0=)BY1z zrLeS=suY*NKFcUVFaC0^l7IhN#xidl;ia{r#`IOTIsMOLg!8&~SKtD9#OI#+L|q2A z?1~Dd3`^_ixW4BZ(OklD%uHDHiNIkmmK25EO_S9+4F3GmrjcL7v)KNZ@i_L;%LWj= zY)M1lqWd35FOMqrzx|i}m#zKpb3C){e>G9-X4wSIWxB#Dcr{(MH4lDH^Pn>teD#*M z1*W$;94$pegf7b=6+!=>Hoq<1O*zh@>8KK=v9_z{#o-#zf0=b{TwcKwvce(I!ux+O zEB8P4`!A10Ub*Xb>cpk*BeU_*H1Il8`M$52)MfCsRv zbI&bTQ3YG>Gw*3c|5tVVvd;qgzkhsKq5u7thy7Om|18hvp#R=HG$0BGHllLvl?nbY zo!HlSn%KCa-cg|k>6j}$+ay;u_^1)<(WZ%vxN8sOH=8$SYXr@mp!(*TqEjx(X_lRe zv^ZA6S%&rRVkjD?8cj@?c&g>0G-|JYX0`3r_{(K3-f56;XK^4@JBPC{8Y=qh-%P3p z<0}ENUWer*Ul55n&9pA3l8I}jQ#zIDqD>Q6<{i#9&2N%sYI076{h0}FVhr2gl0=BFj^y*@>K5dkFd2w}CesOhiUc7*~tREy1EO}b ze3AgLfd9YTfBCY?|Bnt@{{LB?C-8qj410^~Ng7|v`JZO(({v@Y_ia?-bZz|25Td~J zw-Lr=v)`@N@2?vpqBeHWMnWb0`-oLEhBB3I>e@A|Vy#V*2f}8yXz)+}l|w!<{Z}@t zb)Ux3dNPKXS=V!s>(53bD1~m5x-XX_u)aVOC^Q^;MjH;=rZS~b!-Z|!Kl!K#)6kQ1 zdNEu>iW9N**=*D6rYh#tm-Z7F3q}Dg6N#z zUd0tcpNmm#lLu~^rUvCT5&o{lcBiP7df6a0b18{2=g1$`q9 zt6h=cgURD6mCy?H@|~U&M(1{E3aqcgYl7p+oc`eZa@}Pl>Y0=}*JKErY^qh8@@MI0 zb4f&PgBIKQl5IAEOiHB0qv<|xst|L5M@~?GsbTor@-7+OElr73IL(4)Vj?3!GvhIG zRxM}}WGnwukj&(GbSeF`5JZH@1^sBk{!xiIX#MCBOP7AIhc1+h?>EY^z)ab+O*Ydo zWoV4Ej4;_XdGu@+C>#kcC6Xk=nie9Sxy#W#be2%Ul!R>5E^wgDzM162b?L`!0u^3= zdv5Yc830k z+?nB-!Oj2())Vc@F<0I=x`R@JnZ`e=inWyhD>h09g1qct($q-lKxy(ShH!-Zm7pDI z92BGr30F6!7Th3n@LD%X1&Pbi5V{avskCi1u7Y1`FT7ViAq0 zB9Y1k*~J;{!eE)%^J)CTGwjU@_-ZL4a#GG*SZguI;G3y#Gxbi9yX!YMw?!Vgkk%EL zSO8lCin5`mkGdpmT-^#`u|ZPUzCgsZB$Dba;)Expcn_U<^fc!Rnv61ZhEtN9X@T)m zgBnuVbv5{!1G^e+Zoy_MMJN872#Awsi+eBNmM|vosL!t@Wb`2nU2twg4s`<*J1&ZxQo*rUMD zbPlx5A!7k zJUcZ)j*}zcL{;2-Sa@jyq)uv1{=*Mig465+q+>(n4LdP>*6cyMbD^hlK2Av1JSON) zw+W5iAxM+y88wyQyL|_5<=t$M4>(T5<@*@cH9Nn4z?zGr3mBiI2wHVO{!fd(*Bjif`cX0rW{q+T?OHsJX*70z{!sd^ViCH7F!*VvCMyuf*$fnUdrQryd7KhCdN+$w zPLn)0XOA2MqPG}8NPtRJH#M}Frqs)hwF3bW^3#n26w)T+xD2v#ta%)k1~w95uHL-s z5*Bg24L%<2y{@H+0Z|+5N{X=~`h#tK6w=;n9t zKfFEHE9($}GD&0Oi*$B#4U#`P4@px@M^xbimKG$pjQYQYd7K^F;P2QNtb=qusGelU*^4 zj`m;ey^z+U?69}r>-P>;sMFs^KQQzM7>^&QO~#>D1M6`m34fF)=!Xrvm44XRK|gFv z0o%NW`7njF_DGe6ws~J&gW@e72||ijTKlUh=>-`3QzXGJR~&JcOl&h`>J?`84{LVB zxfk81iG4wb~^tbn`TYXtDjtilS(yDC+Xp(GeqJ2jN6= zbkF!B^J?QM+6pF$PL0c~nrxRRt3i(jM5-_II4B0QtO7Z?_eRA z-&%u?*^WU?jTe-~2AF`femCOUz4$%gv2VZ_dO<3@gHvcLTwgE{XKl4P2BfD-($ix+ z^J6QrZ~9l^N}A)lnn}))nr0d;7MU%86;@B-%b#@*wI3H*HV{g%RJQ^%N!?NVtddp4 zn5yV>x8i_4;zAIaakzO=oaIU7(P@m?i1igeX%A;LO6G@S7} zQ`%S?$9akw5(LL^J{=TvL_8)6)5NnUn6~l&+p&ZrwdZ+)VEM#(no*|$YmBo!B=ple1> zo^hPuS-+FSoCghM!z72Ji4hh9W2Q;mtv1c|1~|I!^5O6ajJ`_iK&OjOkP^9nrMQ4Z zNbs(D`}|Y5!M6UdPx60-x3!M1^E_VXxVjRdNlWJFEwnhiD^jF;YY|6;Nv+mmhVo4Rj-5-R^%$K}&f$oe3lh6i z^6x!KwmYZjQBM=f-BtG79e8Wrj)`k4?7)&;Y5!Th>hFh8g7Wvv8&7MNZN$>x2ovRr)&GW=p=+-ZG1PY#SjBZqHR zG4N_~Cg_8P4IFb7lsP1s(HK)^+1;a{N)cvue_7kvlt^JQBFa+gLf^p*ohI2Bqi+eE z-#OOBSu#QH+O>B~y6bj_rd)TViOX+_=>@%&X8Iegx!gIQQqItT+s29jFVLwwi|`k3 z=eU%v(U4fbKz@BN3zojTPE&T%0*N(;lm7fBjQA-g<~*{p)H9b>LngzpcLH?yWLC>3 zm?f7raM0;vEeluOlF`}=$)$wcWG-%hYID)~o9nsX&v-hZjKo)O-qo1#)0DHzCZ~X# zXTGdqYo@LNm_6Zgtv94XD}xIjbls5u=+ z_<_?Hjf?^fcV&!$37y?sOG`Zrf7N8fc!+Cp?Dp-A<%ZJ8?U+rF4dQ}uqO6-GoOl&P z2xQFx8RG}abFt9#<+#6prZ35A*hS50DxQo{z?GGt7-& zWAR<64j_{sxlksg(IzH4#Ohd*uOeN~0G0#O0!3A}c z<`U#O%8-`OWKm}@LEmvuUVB$q1SzP!fX5TeMu61W{b(0rcfTkVT^6CiMdLJ%1(C7= zTE8h3FC(|{_IRG?ZH|tOn!nbaAES68+_wuGEs(BkY}sve>%tQpCUMwTG&6?3cdoP`QHetgkuDT*mea z!oq?40ri1NGE6;1XQp9#s^`u#}Hn#AaA^79jI1 zR%ns!Y@Rkaeau0u)kK!T=iMuvTgF+H#tX2`q9IM3{rt|=?)~Fl-gyG)jQDm%O3H5# z<$$nAOh6pXrL%oH-~>o|HwCaeLw)_wVMLxf8zUy7X-Z;>6-g$ZAy_u%$vQ@7ry(z^ zPEUzks_F#6f%0?A(phI*Ej@bucdf7H92A%@BzSbI_TLz*uBtU_ga{0>D`f~z9mP~; z>0#p>9V`IhV~DY{%_xobGHSG+(L4t0LDyiXB178e7pT#IHP|epAxw#hL$?YPHzm4@ zvBFLmDa|h${El0csx1QBwmJTEJ;*VX@he z<<^~6R1#G2NbW|QX;oN)HQ<4g$K6LR?kO8}weZ(9m?OaxMDD(Tf9gy)OU*sIk7R$p z5%;_L{_Li|znB5qaxD*BnFm%O_%t?{_4oh1*FG9dGy02WkB>ex;F#;P_W=JxU!RIz$tmBSuOdP3CA>2we_*z4YL1 z{p<5fB*~B9*t>&d9*q&U2UU*lfsZAyCjbM^)tE4)=^#Y0yCEcW(-`N2Uc}SgQt0B+ z58wuJ_h5hTU}vS8hll%z*3RioZIJU6CFFr5r2~7JXBijv2%>b;&j!=AfxREcL=d<5 z0j2lwwax$|>-gmy0o?I@spY#H0tn`PH^Z$h0q0(n zjT&LuZ3VGFvf(3vv+)G%=P?$sL|aBGslWwPd(`5shOp&fS49WiJZqzhB*% zA-Ep(yBB@<4u8Jd(OfsvOyL2f>Qa0U+gg+09cVpk3Oz3P{;J;F#->d$8KfJn+;yo+ zaY8xfk7Wv1oKFfe^4A=U#|09kt9gf(!u0kqvP&j-M5H^vv1c`xQF_ZL9Vpu=UW8-l#qP~6j8u#*F$~8 z!o)*!jscij86H1ks?6PZ@Oiavkl}<+lSe#})fuN)MPu)J?kS!XMpNU>V!JCt8@va~ z5a&^p3+eGzufQh2c@yw`fTR0IEMo9;!-_(>>=_PSYe8V>d(L+QlG*|quLsbpddAaR9_7^j=EUQkaMLqNV-GfqS zx@rf`QK!a-$9t+Bp_H;*>u1l=hewbHDk*2bqf&7(c}r8eSc9vpG9f2sz%0+ct(RjhNbQ2eE8p* z3c7CC7B3m@yt&-$66@VFK{(3@7UjZzh(?f%V-q69?xJ3`RA~a5{lrEq&~ocPa%S#I z>&4j#W99P)A|3_Ri=ulTVCiEAh7Fb(1yQobo2`JwCs6FGY0nlc@@X)3uuN+-G@CZX zS?#J?VR`Bv-4l+Fn#Y<(O{B~4;`f62TV(vVn7V9eJ^eOM67;8|z1M~ST9M9}3enaK zLBVL_yC0ACUhii2^yBUeO-XBGGdvN6A+Ma61BUX+oh!ZIa>3jcnjO1*LGO9v;Ng^mtEYTlrfAVkf*0Wdu-sN)>| zpC`a`K^sJjzP_m3WB71=@k}o;eC{CFZ5-=1j&&Qyx{YH!dyH)y>o$({7mj27ln~FK zIkxks4VYVsnAbQ^uJJMYJP~r23ysSbOOS0)+%_og=Lm|+2PP1v4c|B;m_g0N+$l{t zl$Ke{B5K{pH|rvU22obwKuMFQ0oKY@o^jSg-*VyIT6Z8reO+UE-Te`0e}4zu8=&iI z+`hkl{9A8&lDdJ{JC)&3iFgjgDK__T)!4bmI6SnS&J3-zXf0+R7?~$HHab3>QL2q& z+{Q76=gD!5Er=O{IOEff(W-brGx{}Fr5p8Hg$2YOh?ZN!0s}Wiy;{f$tG%t ztTP@1z1yzFqBRD^E*?GIEAJ$J)7)R-w&J0%=x)%I!)wW(9H3qbjH-z9JhB&QYCPj> zLX`LzNd7hhcr7~;Se62uM;Ne|!VjX{Vh~r!E{X}AOVEY8-4M3vV%u_ofGsS&YK*JX zp{h5oPptqrfLXaK!2MEGms02i>qug5$vL+Wzi73=Wj_hpan;Z(?$&KZLXhWs^DIQlSTycx+oheObDwP6L$mx_K$VsDyQ&a9OYTWQ=>;f^MLx%n8bN9 z&$RudIU7f;g%jSm4TSAYS8cNnkFm5aiV*GikH#x|PQrQ69h{FFwOiYJLs#?d?gm|T z-8k+Xh0ubi^*hu04J+vg-a)OP5irCL886t*P90gdt?R~uMnWH;n!}o@dHU=M8amTQ zAU`m4MTDf%2kW(cEp8cHUuCsP8IoXS5_9=*U)ZOQr4nUWC>L)z2_!GA`VA2+mC>DW zHsS_PEgs0CPM95vx{HL?N7B0Su`2MWtgf10pgL*2PT2W^=_AHCNeCMe*~3vv+6)rq z3=%V7GMz@kff3#XNhI9+=@b$k@bEgw3Q=mRK~9M2mt8&VhS?%k%liNpivYQcJO$I? z@fRjxun|3{nmgwyrfj+P%g%QQD71MIYV#s2n_i+(9))TSlNHk{`0Zy` z2ci@6VxF3}`y!5|7@uT@PS4)Gv8y&U)W%r}97rm!+hhf8vVt~Q!CI0Pte9p1o4_c> zVe!ND+nOTNe93*8m!sgGOrHXFk>p9;UA`_-{GLo3Y-gA@yC@gc)9AUW4=R&vqz8Yv ze(QENkigThk;>%|b7oeivR2k!TII9gUN}Zu&Z(|c3vYBtU0UVoAq~0ML8Le8A@UKY zSweadPraF}@a?hz!_6!>o7;*~#W?G_!YBrr#dZf*;D6N%dc6ZB z(Oo;b3%@K4ujWB)uU(`aSRTNaWD;iM(d!I0au<8n*a3>G%1-+;GKw{oIv9IU+RFY` zFkBYiarc)?H-Cu|Gzds*Yg(}fGqkCPv}xZ%R9qqi7hwarVAeVd8m3=yS0u&a-rSn8 zM(~*cZsW@?9LO&tzU<=64!4df78Is@7uvu6mJ9T|+uN(XexU1FT4G6;ksN?(Ig((V z6@2Cv?o(bvjcS9=9N~;*cfSrz;J^Rl_ehQNbdYJ)?zg>P!%($op%p}@kwAcM*WhpO z%%S=D<;|Rd53nT1hd!Fb&F@b4kB*Vdef*O(i~}k1QI&xMhg@a^w|}|?iB!bSt*^Iq zCJDon7q0nPc=luHJ5;qsd7zka6aaA zSGV4cncPOFR~H%<20~L4HKu?$#aJyeE@Qpm=o=06~+Hr3iI9;7@<&0twn^@-x%-hfl+GK#kV|HHrU+mH;wp z76%Z0{jmViGVuUW!i958HkCx%OeLz`Gtb=AXG($prMyU#L9QO@K!8 z+%y=s@Nf`Ndm33TFY|$)>BadP6beH>FV2^unRMuKg;Nvc>?{z6;NnO^hRQlUzq`38 zMUGwvPN21jU^Aw1alV!*w8Xq6=3gl>C+vX=&Qih_TB1r|2(NM&nj5r)wp_?q_;i&I z5*nQ)Jdd~6MG_WY#WPQLY6wlMoQ)~NOV#*}h!nG>s(iTlPA@z_ZE?jhmx{!H;f&03 zuLz%+>*eizHK$zbS|1x#-~wDa)N+|{TtotDE0@pac2I|T zwdG8+i?9~G8WmdmX?yHn`?NY&t8;x0oeQ|eFv0aVnQLe7_{N#9`q?-}l z5|vFo8GFq?mxizu`TlIUonr1ibjcMtv0)W3v#_|=?7mVHe2^b4(nLH>3AkgXM2$Ik z6I11`th#HTE|VP}qKt@?N^>o7Z=IT#Ur$HY1EvT=}D({W4_AFq-fcP+ZD2Fi^RSdr0OG%x$0U!J~u z<7$P8{l}II8$rdUs`Q!1#+b!gAJ(;<>|$m+dpT&0LF1qAw(ed23 z5XX9EAsfq>M+DD38?BY}&Q0a_y7IHM5U8dOl1(Y9YP~vYo|b8~OzU$nEisJt`v=EM z(W&ci&vYHMwQ)XSIHeK#0Jqhp!wusXVy^k!K!om%ZL~Noy&cQ$_Ol{*0Pku5iR#mI zCl-w@Mdg1&Vx9$J;|M_pE zyMSQ2){0wAsNZydH$P*Lbac8XH+(Iu;dbcl&Bb>?w)o(dwL@_Y;wjkn^!|vA^af(g z-JKpt-;)W%&btG56chME(@fKLIhprXgeB=xCJ6-IeIVN>=z(VdIImQ}OBSFA91Sfh^ zCJ--DukVH3vOYS|4w7LmERDQ9{3v@k#sB0CZi~zKzB}ZiJL0>l9 z1pYDCT5H1jeV$p;0FbqE!dog;;aViW0WlikjG_o9$=Xx{!@`%dV6Qa$)LQF-3-z!sn&1(EP1C7Y`4w~@X^_GKEv>6ESx`afE z3lp~6o!HhK%YMs6k?+uEXekH125PjU0U+~4mB8rb4C)dhVwp-`v7Y2!}6Mc#?1&dw9y3 zh-Oi#Xw~1Mv5^`5MVBhZECl!3jnKzw}0TsxuYLZ#Ue+#m( zBUj&s4c;u=eK(&B6U~*rP&CM?V5JZaGDvTbaVhB_0S^p50J2od05dcJf5XVi<`~qH zl}c>o?Lc#z>N_RT7&9u(F^&1I#=h@NW!T3i#5^5!8BtnVKt4pf zF+D?IR34K=IzozUNl0G9Ld#ka;nH-_#To5MI`XMwwtJ=iE#?E}+K|tFLefU29fup- z=2&s5u{3TomvzJuhNy@aZAggegi5Wz5kX0KIEG zuM1PcG*>^G-%-MXFs;5W2gyxH#^nHj*)$Ax$`CVC8vum+q#8O?+*fv{23#J1&x0jj^lGvmX zAWQ`*ORN$WGy*WOqzepv_cltot?TOm#rk36j0^I^hI78ql*g|KP$WH6tByi;t+-(I zhSoqdXIs12CD5&`=~wO`nMY$y00T_Z0dT5k zvH<4HbGse%!-m~@y@KW1ZTrK9Nt0x31U+=iy>T|;A{5cL9Bvqm=!cCP!^VHuFnH_+ zu)SlK-#bxRd#6i8%Fz!SKWv~abMe$r;BCM&>k6brJ25wPbnz@@TQTqiyUq!k36e(5 zr1;}o!bWO*g7%LN8db@(R4UjB`oI7D*Z=($cmKK9ef_tszjp0kzjCj(|7Yum-rVoo zzxvMxJW|EI|+H?uhTYL-UJYl5~%g$jq6L=j^Zn#2f`mK&H76q_vfJ~GkHc6S_0r$VUJu{@f483s{bH#0{X+sB8A0>C<*oyD?dX9zDy9U zKgM)uc2VMPd|9g~qo0S zv2Ln>YFelCm>g+QLa<0C-hhtA{Bf5KkwfhOS7Ef@+C%v)7aG>OVUx@il7AVs7uumR z)DvY6$I`<*(d(3OHge{sDhlHWj^GegkfDxh9kN78cRVF6&{=ORK{S1eTyk`0r}3k* zd??kV+~AYO=AD9y?}WE5;JBo(r?+3zpBZF0U7rck(OS4Opu5>A*ql#GR|^@e&7%bdbF%I27L~?B z&SLkMuXdu>@>g!TPtaROr7i*8D@1TBTE9fv7VI|=cPlErWxQZN4$L~nZ-WX_ISqXpHKjv@6-o$}3UNj}LeY8wp`flUP5s<7 z^{dt;8_U~Iwc*%9vRe&;WzHSmyXbN5lnn%EtuO`$Z1yb67MXDe?~=y^XdoBp%-Cr! zt^j)=iu8`Nu>iesh1YT@PT5EhIoFapbrxysSmj~rJT*KbGzks^=aa#tI49u(r98Hw z7cXq1g|JAOO<%9LvF8_Qtu>4{cYBW{NxI-dVbbyWes=%#=f7;~dYjc@ZK90F_PEVD z!aPu$3cY%;rh6fpgzf?^!Yd=D~;P&UChe?li?V7fJ0n&h!_! znCLVp&jHuw5>oAIO)xeuYbIi~Uw4{24b*aS&}AWe8zf^5m0`_)_z(rYuh!GqbPsFr z9M|QRq*;RJdrM3$i>J(Pddjv^q^tJ6jA}Y>uk$~!mAxPIRMOrx9<48Psx>P+{$jYm zNZt{J5q}^;fRS(Lo!*1#diD)9NcGz24*4crJfCNbx zN`frDevP!Vg0$*{Xw*xO#Y3?W7@Ct_I+imM)u zCcx!Aa;1kSgCkIgR_l1YwycinGjEgw-PWqBt7DVQ8*UlnZ-;E4@9*kdz)e)%zc8(w4|Lw_-YCe5reM zpKkvThmxc1}oGv+eR0Y5} z{1^}k&3Ryk04RJ{EPR@7QnVT@(2DRd_3X9Db1+NX_*q;Sd^NA%d?Zn>8eD>}K2s8{ z1(P#Tn(l$2+Yi=6$N{{LG-5^N(6Dt6aPl$J8`PYF`=gn`q;#unkSlkY)o1pAb%S;v zC{Mr_9i&KsQZBr>9W-w|nTu*vqLfQjz|NU`bTj@IhKWKi6_R)G!L@^kihA2SU`oxEhKU~IV1lS3 z!nx54o{!8-NMbuKUm(_UmF5v_gf`6NHXPH^%^3$PGe0>0v$na23qfSY8PtR?Z;G+~ zx32_#91fUcIvP7zVk;1r*I=1FhL&=^uRaZ(@vBcohO+r+3LFYnTM?0Zm;pkVo5)rO zO>JBS19Y^9wg9E5+LVCaU7l_C&?#cM-(mAQVBEKF8?xz@d|OM;bwwg(o@Kn&t54U? zm#}EDFMC~e1Jk9eu1bDgZ8zpsYEOkwhAk)k4P7GA9fYOi5d}%oZ>sW6G&5g*t_9t)}(LRgG(D(f+G^6*qlpa z4KS?8h(;(SVnk4;IqQ0`ie{b(8W~gOuA61{_ZzH*9mIeM3oTJ!X>`nV6>2StThzb73 z6-tRhSe1;yG$sBhuZX=oC=P34#F@41>&5G>HiC#?>72OTD0r%SrA#@3poc&llx#e) zI5)}Q7|J0l)wUU}ggjJmw!=*vDm`o^%w6dR!juU}Lmp_P{QTg~236+xZnZ;$m4v<3 z1-$Omj%j+X>Yk^_V^e*vLNhAThYcw^Q{7RzA9Lyke**Hpzh3^32R)e=Jc0gK6`MRs zG@_bwpXmwLoW~dtls1<+8Z?Jsrsm&z!EcF3e<8OrZXtOPpa+EOD^S2dDz>Mc zB)%%c$6fZ*KrpvAGa|YMrR>GQY1lY;epR@EEJa!M=(M7f;nW zyGYq1r^VT7>z}79ilEN91FZE~VL(azF(+aISBeXDD2T)9a_vP}lBMopPd{rhBCJR2 zUf($ys=~F}w6W+oi?h4BVV!?Q z8Ky#NdEQz)!*ABw6gHzSv{=)z2C{t$tSb zwE9{3Y4x*KKb!Nk`dO==EqYr0Y?Y@_4%VcYh1!yJgaBDkVMU|DyVs{_g}>ITrG*+; zy^a>@aZ?&`^L4ea+e>*37#tjyt_j(0of}jUH`@D1#c0c&MsMrIWL%X$ zGDW*Z34cBft|6tH+odB`?{Ve5d3(O#W$k8kH7e7@8fG7N`Ew4cI_AQea>r}7(UGtMYF;S+2&qIjBYTrUW#SB*1h3q zT*Yr_54@Ho5DDuEh?B(LHg}4Iad&KxC#3p(SueJ88xP;>xIhHAG_8iz7@F$NA@W95 zejrZN7|{p93QE5vx3`_lvQ`fCNjS)_14{qfCOiZt)$t~!85b-KMVQ(Yq#yU*ASdW{>wK`I(BegoxN>8gJRi9QzDm|@^WS>?? zYIUT_fLa}?)sfuu%h8e65rew*Pg<*g(o^Zpa+gXvl@B^nY%5r7n9ktQDoyk)z9WUt$x<(XN#UzKU?K#<9t1D zKRWbW?zpsZzFOb9220$=`Kms@usB~OKd&zr>}&a9v=P7Bh+l2QuQuY>jHiwG)!t@r zZ?k_5R{r)j`}EV^X17m!o4viw-dgOdPkWnv#?xBo+uQ6jo;KoF>m?ODt&Y^{NUe^v z($nfl)u+{wN>8gJ*{9W!S{c#aH)8P6$KH@18YQ6_0jg3x6SN1HG0N_=uP%}$SQIciXX;^w8E zqQ!a(3)MsKIJnyk`3V{;mC2Lc-GWEYB_vi0V&}kgo$ZxqM!#uH3nR*Ck^bbU6@n{F#Qx@-_oW}pNxzq$# zBC}5y0yHsM)+$geYq#m5uCSS&CSop)KbfFhP&GMAu#}fe zY{*8`c&hcLbZF^$NWF_AavDWEXKL*evCSiF+W%CuP&^?9<<`p;gLao&fzQPeZ`~f& zlHIKzEl?7rFhs@EqQ3c})d)60nq4XzQZa}zs027}{@N?OJ7hQ{k@_vlrIYQvpyw6U zU%S74`z(2-nZgseE*l9jmFVI`Gy9whI{q_F&1 z8#JhyI|Wucu4xQ;|81QOc{-rXbY_}%O9!pH1z<7!gl;|ucvmwj;i83q{ zjq(Hwr1#p0i^*qMfdUC{NW@vZq_$tC$kn#OH37~-1hu})G$qwduvM1&_|Q=qzvXv~ z6#Ymg35_#UB5E#AYzO4t@46oPW};PVy^=eKDyPCqqIR6TXt#u2oMOWcxgZZjpskog z9k>s$-9vvNf*Vr~85vh}yM^FF1jwuew0bPj9@;Y32xyv;m|{he$+kI%cNY;>S?X18 zuLQSNjt`fp36pyQX8ZxH79~<>3>g&^@I0B%IhzR1A_8lUBZD46{-N;*BSA*6tIW>$ zM0?80dbhg#f*vZw%gF6sSkyfN1^M198c6a)qo6I)LpOPraiOYwGbUGDD8vOiOLD1* zxVQo!<_r)$4oI0Rya*Z#=Pws1fThWL9nI7VZhxF#-duvZ%#FqDOba_h5IVf)ybby7 z*BhV`d-+WBxG_!=!bbL+fZ4-FFUz+Uo;5pFf8Dfz+APXhbDx$~AWX7RQ0$!?YHVvp zy|-1y7*1KV`e^%(3~B@UaUJIp%*;{|JkyTWKwNAh9L5Y(sky|xY;LlacE2*kb(#wJ zU>1s-*S(TDVdM!bW@u{7bEc&}Kb%0}r@#;I3CaYCNDSsJdxauq@-KniotuUAx2?>0 zoUG(m3yfL5Bg>FW%Wn%p>}xOFTO$EwrQ~^&Q}F!WpKoq1 z`!8PZIq7Nd?Bd$}|JuuBOsH~60slUuPb~Qrzd83~S!As?{@v9Z@6~|4pN74MZ7)4M zrZYS&gTG#LgPM?(MIn^UW~>+H?GYk;o@xP_@#joVfrO!Jc*K&4NbVe%b8Zayh2Nn0C;lIIQ{h0}kdMNLIA;Yyk*+Xr#lJ$ZI<{o*CN`P&UE zM52onI2X(t)_lllR(Gvlyks@sOZB|}vq%>ck4U*!bp?fL(m6G$gA;Sqozf30;JXofyXToqm;B~HyjU9Svs^$SjAg%u zE4btVsaAbhyw7DJmi}54y80Dllk^yFn*Wp41s{w0-^yXg<*#5ttbzUb(`WsD|Fg=i zf6RC;#d|9hb_=`v4LFYPUw(EcReH=$OJnu)ie`ke7$ER{HWikq+;aB~2zp=%T6Lr` zr6|n6$)x=$S9~mrfz+Q-Jp)=4&+}u}`|(F+#Wc?xD(ux>a8vHF0sHBvo_~H&_if`x zx0*sKW4TJiG28p`M>e<;nczkYAO_>7pZ4IbU$Jk50;_B4b($Z4bmy96Wh&Sn{>^>o zf0>fX@pblV_R~+FIjzj?bzJB&KW6jbP?f=hU`=K*03iI`X*b`_|K^lg_&v|cf@e+8 z!%q$;%ZwL|AK^DL;75GFUt8a2jG08ct45#wjIn#f7kZ;lXMX>(E!yE9VtoP1$DHb{ zoW`EvKKrN9>XO^ZF&p6fgG4KQ&HVk3`~Tm-QGx&b&%G;mYxfTRK~F`O2-kMixYY}Z z#|EF93S;uk{4WAAwpb=u#V$Qc%BPo`a>~-9)v|I3dWN{ z8^>vL4lWBclVeB1(@cWP#g{b#cI>`SzAyY_SThE@UVVM~<(FUn7F+~ixI=dL-OcG> z?kG-b$Ab`0xeYWjnTRQ`a8Bn!om`&(<;&~F4|AG%?HblSdXWy!++BmLg3?X^1;~v< zs!SB*#tnGZ93#BN8+s*1w;}!tAz|cHX1t&fcKrAuo9G8{{h-5$Zh0RrZioji@JQyr zJDgP|UrAvra8`3*~}Ibx>RDW8h$ z)baVJ2DO8z={xYX2X-u0&A+ffi}zy2($hH;#$C6%z}=){cqC1cdqdL!PZme2)oCf1 zz`Xiv@QjfHx=TdB2WkoyX6_ikZ(hB6#rEB+2Z4iWriV@uVMdPKy?+1d#cyL`yf9GY z5%cEi6jdko%X@Ccw_=7N*;it;>eqdt5}i8Y z4sW`%zI^?bot~IF`|*}E&`f+dLCn6AiGydweMvWuO{DB?=_?SvcdSR=(>Yz=$^7Qc z^H`nedC6?;^ter2wM>Y&+RxErZmBy+0 z6z%z~n33MGqQ+)D7*4s@Au*DsGHgs!K!T$Z4|s$W@X783Wmp#8rPH7XXaM;fEN(W!*<;0RQo9-otBs?c-fR77q4=f_K#YC!zKS%foZAUEGOt9qP zHtNw)F#qcFTV}U$Lrl3`XzHFn2lu4+IKXR8>nVgW1dT=gZR4%fY;BH3QVd3D2<{|8 zTWB@TW*~x0r$VK%P+pnZqT3F*o?ya-R9M|lkC5aVT**wAMF6NSYE<-)R9^Hl=CT0M z9e;HK6YSK~*6tX1#e5?Rp8o_$@X|`4BZU}C$CH-3?;gEn0QT6~Y75=HTuXg)V#?%R8s4?e?C$M~Ron*qI&p(IDr}{ZhQ;{CBAt)f==J!enSTMJbFlkpU2<_1;9%-AQ(juQ?F1R7W3!!W= zYk=p(9Z<-+N8%b%uxmX9#RS|DRSZK-j-t3vD7LOJn#?!`Pd|G=Z5=b~N1IgE zRmrQQLkxvOG`NTb5aTCIXMHS(@zK5Kex0XI1#Ts_^e$`f_-l_kwAJo_eGo(}t?AO{ zr8Ob^ZouZ}QM$6ZM^Rl`2{sniuvBF79)yipL-+Qv0sFd&EWt1nCskHrrTgEr>l+4^ zdLzdQbb|*VH>DBBY|s6ArL}eZ;BQ}I^wIhC)i?OZzrEZGDwh%uqB%b+@Kq9i(I}Y9 z>1II%DyEmerC`v&k<#;br>Bzg}1QDw%oxnhvU17~c6y>n09z!73IGk^!<{XMY^ zy@G$Vl#N@9E*}@qXvHrc;}stfpJGk{oi(KvxXBSUQJVXhPk~ zLMtl2`V6e>3k*`LF=>@WiqaZd-dD8hC_!7s{H-SFjT#ne+z3K~3QMu>`%HUg3YEsaZrB-7<} zK^{+}cqECU?heEagkZ*NpY`FM84AND`r&aPlmAeXjty}YP%|)8FhjlVkrUo5#4E!?x0c+o`XqGA~}PR@s^%oZkIf zvxhqNR9Qn4?O2W#KAPp(zA`W%^;YI@-G0MEeR84r$LWt{+bZ zZU#tW{4t=`*~N8_?ME>IlX5sn^z`VTT2Hh@?wKlcz)mjDu~LD9+#CQfJiJAzg0-q{ ztW!X_?}h!x48Zl5CfYo{j`ga3G}qBCTe}f|)$juZ6{L43W<9Th@CB-e!oGY#Tt2K+ z5gq_ab>v$uZ0$NJ>z2KK)TC97^~BGq@N%48>j*N4w;u(G7RKD@JE7Ji(-{4R?`-ns2A`xV52sq0aw>{5kY;z! zD(2wPv5VECV;3E}c&_YXA(`qf^y<-F=nQ|B)up+e6kM#%!Qt#D4g4L*qmp=h~eW9Mf(+*HS0I_9zrQlBt$iQLN@ zTuc?g1?he?AKUj~L-5mOIm~2on&~nG=lhZKIUxQ!x(|8`5*gT%&Ux%s}eW{^+-;!d+wRvx{i$|4C#r66A;M;%>`nbvOT$7hvBzBo8*HF1HE*!`>V%Nh z#1lTng8jYTV5tvPs|u+6TFI+TsoaM0E+M6qXGSw6M7n#rnmER>xsvyd*5gbtU_T#; zhL;z*)vW80*{vDtpKB9PuU?-^?1gN|DG*uK2mq442VZp##Fxb!@F5f*rS+<2&@c|?*RmF!o=mhhf^qo9I@Y?@_l%Bc=N1h%*QKd5hjtokr?Gy-8Y}RxQO1`S4p;&Y z&%sW^1V0P!S~Jojmu(W3R&2ZHHc6#EN`r)}bRvh1(y}U;4A_OX;+W#wxgr?d2{rd7 z<#8i=RaGvg!cKIG>0Q{M%djypp2F8|by%5;Vk)t*_sab44Z+MhlGl;EPlV(#Bm~>L zbHh~*NlvRML3b(Zqq~$FLFyB}lq-u)tpEv@pdye^-2EYzT@Shd*bk5kdk#M2V0o(U z?23wK8}BP|AiKuwniiL%p09Fo^7gfVH!dDHAvpm>EXuhlyajo}AtMdm_EHz_9~wS6 ztoIsjYiX?I8MgHo^x1`0?OelOYbWzO@@Oi@((+6vg17MFFO*}?aEf{R3_{@C~O#-ZeY|D1Grs7 zUi98Ul|<-J$re1h6U9IZJHiBeogC4Mjt-tVafLHkR?W6P*1OTw#2XM*ccITOLxoLU zfZ;q;P*I@-JC=}fe2lX9sY-#vXMePGkWgVBQk>iaq0y`R3z^Z+FYmt!-*k7HvVDb0 zGL2m`nfX`A3&DnP=GL%%F&MMIfAQkQ%j0zTo8#k`|2X)6|MzzcD>YVCKAY7)*P|2b z`k#y(w<_tk1o}M({+sLbZ&+2i5q+tQP)545KR9N$w|{x9K*L_h7lDDPXSBtECI7L9-@a0Z{d@p z;P&BrH&NLnTa~e+$`d|69YExJOpNA`YPS&RgsV=tS_Y|4Rk+fn6|cVBG==xC*X#;s z_y}~(LX6u4g=~`e`t4&CT&pq9jZdm6Lb)AiK3O;GM9nb_6h0M-?r38IL-pBxlU^IX zfy_o1@Lvj)hQdG(f{l2V^@lvUL$?stq`GJKyfBB%Ot{1ExPbCVX3(_H;lMdv{m=`e zQMksT{D4200iW`JYXx;VOnTQJ>7qZ@NB1v}sJM|R`r}fjB2=|;H^QWMgNdDHzp7$q z`q4_BgtWsv$rB>`58VB0Jfb5u184HkWf9wVthV~byDD7sVy>A`7?p*UX(P1C4rG?EyA zHOVS(q_I_~ocR+d1B~aAB|OWXMCC!zVG|2-_P}S{+>w53K zNfKLtQAY}zi-HfDnm!g>edeZs@p`G1)k2yqp_~>vHJq z502;3*F$!KNK}>8u>xw4j$L}BfjlR&$}(OIr7d_dWB+yi?gATzcmhI}{Ce|~st&!R zj21^c{>@3#jbC)%#sn8|ybS7m#xb|x3zCUAz(U#dwGxsMa3R2Bsd%i_kq3W)oBnHE zuvAzsGvgaE8D%>d}Wjj>Wy` zQ)B%;HW#jSjbAm!Tcm@5Me6dLvcbSYzaY00G3WB#^$lb)c?`PbvrO|eZdAMk^$5AD zSwrp|^(PJ~ZA~0{^Z=ej1E^@gE_@wy{K%J+5(CC34O);h*i%(+p>IO>F+Yh&aP>#{Gb$*n*I^m#4^Mvjed-h42qeV^_~+zT;mD{9|Y5r>Qq z-9qRFEpB=q&d8G={D?Hr4YiPP!!qppjWm}Xv7wmod#OvTMedGv96H{NN?W!GjDEs| z5Zw0$ROz%dR!=?LY6bE+b$_)w20A4Ry6aa@5S%zI(AQyiFZAR4&_dK(8$@pKAOu~m ztxBz%jmQh3oYKAtoO^$T#9smrSMv}$tiu!GZ2Y4D{XzW^Cny1Pb@Bn(VX8$gWY#ci zOtWa-?@Wm;gn(iAIV!oy(Ez%XPS%SQ`eiqn=dur-)Q{YgV75E@6}Of{JB7a0eR^{( zt$kJBg8F!*i@}Pc+Pn1oCz%n`{YMf{!x24df4^TM*ec*0;1!~d-A-V1oL8ib6I6pt z8Pv*X5`YqsKroZuNtHrY6uuiZc(xPv!``Vb#1DH_<5*Lj5l}X_R8qN};-=C{xImf( ztr_U;>Lyh^N*doahs>18#1Ya6cYaVoUL?5qxGHEC;=h<+@A}_Wf*nxUZ`~IC3sE1| z`did00^k+}SlIk!auk@g{yF@Ts{Giul3Sf$cH|49Hcf zqF!G=?9qiCRP3ED`wx5A6B;ce19qc>u{+U4tV-;A{6SsrhrMeQ8GhJ9T=xq&I3yGa zA$nvj&St)&@(+7I?6G}JRYw|r0JunDWlp>-L|eRIo7tEUwqWZzW=p}d#$-{yx-VXR zxyFmRlb8#2%>L&;{`Y_W&ins<(f{o~_W$0e|NGqk>EMt1KMYp>e(?E!wEnI;v(51k zuD&#Z%GEKjd6aBF(AfB=^O{oB55f8S@4(`H1GH2)jH=smCC>OawBwc z2Y=H4o^au$|9wCQ`u~GJw#SF{Y_3gw@C7fL3hZ*6_yA|R@r~T*WP-A?9TZXDi9m^e zXhG#G9vjRynND~%KTE~^FSHGPPDLTmm*nqZQD&rBUsj?i%Bs-7G@2MXQ}Y6Ppq;wk z`qgi-BwCPkMB)W32V0y)Tmn+eQBN^?D(r-`(K3rbo21VNAIc{Xcg*`ab`n9g(U^V7p- z_ss!&m}n#5G50UhchEwU5{ZKvv2dUiUg6FdBSs3rYUGbtk9!Wj93s;lC`1~*j0=YL zfH-}%EA(Z+Vb-M+!``0!x38NNsa{9~n#$H-?(HZW1786Jdy%5X-hOv|!;(z!BAW$Q z1e@rGBROIowIf`G)qYJO7I|4XSVNae%WY{6AteWtcbG?-ShqDLgcOX*%$-xF)!3Wb znkdW@aD*~xq0OIkdT*mcs;9OUI=hQi2uHmV-|FAnqQa_(S4|zcM|^VFd{|NX!#KwX z98WLZ_P$u~JzQkGU5GA;IA;R+Fzz#l6)QxL*R}o%GL9p`gj8os>2<#B@82VzO9g(R zT5zxj#O&j>w*~y5*zZYeIgV|ge^{TnI8++9$T6!1Hn@a0zx-l7t|X`B^q771>eZL8 z)?-WfVtb}!DoU|ABZ@O*=RZtxn-mea@;P_0;WBsMq9 z5X79pjvkq-Fy=-UqTzeqj^fG@Fy_~v9^}mcVl-V z7vqfs&H7%LcfO9M_f&!)?v&HWNlE@ddSpqS>V-}P&`DjeQ?yZ^Ujl|vo9#5}h3y~S zc~F8><3gAgmg6}OFTF`>$(;8(cD!Qm<|sgDIIH|f(6BnCVz%crX(finU9iEsQFUf_ zQ12ZL9z>S)A;1OO^M>yn{q4WM@45GS&1Lmiu2XVWQn1p!G*YVtZb|nQ;Rsa-Pw_=h zDXgGH5aJX1ZIg_*Ilcu)7(yVA5i1*h_VkU{6rTK`7*GSjOXa>ma0E2W#%JAQ6eLT3|430Retrf@Y*Zu zMw_qYCEm=D%i{g6^I9_$ID%RuP?M)=75wYT5a2tVU>(`p1Bq=4D7IzTPW|I+i^uDP zO8HR(_M0eHs)O^~Xzv4D86GPeoU#V*37_MYA-1YO9aSnL3hY^7HIud`iY_H#fuDwe z+LG!5C0hNnRF$!&;-y%;I77?(U08X4?6hCho2Oht?7U%|y*z%&raaHlCT<^uh|(&~ zFR_hTbNA1eS?4<41*6V&x*3xORp1xSp&jr#?!0n*JluH^({M=s>2eG|erC@6=>yqu zf(_}~Kh?2JFPq6tMRb;qsyDa;)?Q`K2F*ri?~XwQgJI$6Sqty6+{|Y0p5~T3TjKvPiTt zws2yH`7kKG88F;XIu$1y0fjXIa|Zty3Ime{P{)9&;*kh$>s@)a4UE`UaKg7K{rC)w zI@e)e=ZNw6=hyE=QrgzVDw<1)J(3C=Jo{7)4*bCJmcvV=D-HvzePF zWD;RV5@VSur*6^*A;%MI4w)Q?L1j#?rkx5Of8!mV5vpP2_*5us*nUuC$O3*CBFmd+ zxe#23OxR>_a0rI$(mI&9H4bM?T2c62QDGqb2)hqKiV9Tq}4JetBK3?RViNh{y53oXGLS!;+H_Uq6HO?;hJA6{I=VN)2g_ zhGa**(kiTq&fkh+8lVU$f|>p#GEb@g2_6G`!S(^kr0p3%_`c2$2JD2XGTgaUA5c2X zPZLqYz%bW_JKl=i;aES1qB;5m0imqjQ;=ls)+g|?ZChRJvdu2rwr$(&vb)q}+qP}n zwkF?m<~#FU%*0&G++^fa5g9vTsI@U=LCMX z+m;c3O&@tmygzgz5?FytZ~TctO3O9~(}X*r&mBkuI_#EBYs0>dm&9yAR8fR*iIYG$ zwX>W;j{&AYXKInS zeP-$N_|eNEOThbYNb3y77pMNVddIL`(;nsG3S+o~ogB~(YRCdM=yT8X)vm-PB#1_0 zXf{o84H6M4><`&pCH!PnpLlA_sel`|`vJ-t!?r^uAJADTyOb_CU5u${<$YQzw5=j! zX}ZU7r7CZ#pPWked=k`S%sJsnMObk?_&qJ?M(H)BR6~J>v3}Pmey+V74j17*dn?$E zK|S}ZDd2C|CiqOlE_Je$=M99}74xpnbyIb-qg=3Jl;?n_s{5Q{T#aMlN;%IK^Qv+t zb+;Yn!0YM6SJmbtHkw-klK03hE~r_FKfJTYaDT2VtjPjD4(dMXb-CAe2H1XXbR4a) zs7=S2dZkvkJFoMedkaS}q{cEUS+-knl%xFZ7zy zNPw~P3HDYC$!)3?)#aqMkV7HTlu4(htYj(IqO{=>>{!A-FoVfu42`+it~T{hgFOpr z(DIUBnHcte5k>vdbk4Wk&tDa~*ic=#nBJ^>{MhleWs-am zn=?omu3?miLu2lD7%w8_7D10AzV)h}5<4}_#Lm?&?Bwhk#wup!rv8(wz5nCn0wH_T zAoEtLhTEAhg}flqr3_HsOL5Bci8$G%Q{(J^J;)5#Kh|D24rv#2%b|?9w2_F1 z5l!bCS3%mZ7F(Qs1`7cR0w!ElBA`ZU6qasooNK^4vH}#=ROC4Sg!H#U+a*r}PcZ-8 zL>?Cz-`PXno2u)PXjFCtYt_pvIqIdD-xae|YBRwSkU_`g5+3-J>!w`K-i zZRLz*^h0RWd6FHFuN)@3Ub1>SrPfKTKr*8pQQ)iyBZ_X#Sl(+Xe}2~LWPYzy-6Dx3 zvE!ocYw{co5|+(_UwDq?y&uHI(tu)o3^>xm?z14*p$&H@T+-zXO%lO7Rovv5$s+Vf zsMpE1tY!)=6SKlQI%C^l%++*qGp=)I39olLHHO{w;pIc@f~+N6_S$(7ipj30Bh^MJ z8kb|Vp=Nn!-1uRzZ@kNop-TdtygYBSCEEi5mnY~W<-6@RDdV?p)))QO^thK}(KuNyYdM)bn`2?Yoo%M77D~=z3 z!E#?2)iuN9eAXgGD@L|nn~Q7GF$%D_6f4%CC(JfowMfB@maV^F8J7_+*5HdUZC!7< zRi7@{!HAckYi24n9216hcbdNP;t$;K>hjCSKMhTM|LNCI#61|n0neCo^!Ehz( zUxvJ3)%W449f&>S8Gq=1D)gpwuiu};LbOU9G8bucPG)Qy!_rb2@+Z7?yzUTzU<+;% z@WCtjYhr4mJJ_Pcp9R;Lge+RKbi3&jLzzgi)$fgjYUiINpF}myRCcvQz#tJ&Zrr>Z80xU*W?YHT1qpdh7dZQ8cH>?_kveKQ~maP-@hMpD#3N zBX{)r_SX2wx1lRGj-0vq-mZex7aF>r`_as~D;j@XncLt?Zol8X33}an8QDRHWn&r} ze%Zn;+Z8a6%axs->(Vx6*1~OEym)gmPPYBLp&g@O{)+3e>hh$aJ=@%^y}V~zw2)U& zWX+c90^#ILw#Gvo*<9yg^*)ww9YoDBJBNxU_wRt7_uBZ`$E_EgQc7w{S(Z!VSB_I< z_gaK2__rqdhZaPA)!Fv%TW`a?wlcr-SioX$R^?NR5wOx(?5<+#$0#VDtkHLF#AYMZ zu?bZLtY#KXOX8;58#hlZnXNgw;?&eK4aT9{0WZih`e!!+ww|qX#^KyQ8xybrt=Rz^ z^Yw&fT(*8g-#NpiSi=^uRqM*%p2wqWji(uf%gf)E2X$rnkUS9mR;?Lm))=p63!$>tr(TOTVa+L|T8R87-hpzp_%lsVC*j00?vvKmY z*mN&O$>RlCaU~foLaaZY`pDNRkPgq+Ow z;NEd{Yu6joJS)BF$(~2Xdh$fSv2;3w6n){}xrpS*IyY=>Xk!~27fUV66aPlF7{Mvo zASq%ynb3tf*olTHj@Z_cXu09m+#H9*C=Qr~phrC2?&0=ym6l!jLnWFv1}hnR(H!?@ z&UkltAGBH6g>2bFd@|FgkzJ6Dtzd&2xhuyUAZ*m?AU4YbUx229NLD$5QRHtJ$tQ*g zv#}bvg|PA%$R1EJhuWDV2xFar${L810JUFel`oaQC=ojja&?Z(E*7i8zx)I3kX!S* zhP}(X9m$)0pcas;iFb9)a4@1@>Kdvn`P^V%DzL=8n&9Ay4^JeUEvl1iV10wye@RBH zWhiG3IC(?M;>_?#RC?oVxr-@=l?Ca9K8HLrrVI1Tk~Qen%VH`tov5{zIs}Y1EpGA` z*xiPF{F09rBdPV%FS=ziN0JDV{POzt^LvqaVYRiiiv7g-YoJ)f1ib|wOzYd*i{Eny zuCp*5K4PYEX&@W`n3w(=NjsebtbGP8mjPjwTmpJgPk^~&2CKBY>0+QsrJAi_Xj$OR z#>7i}Wx@J-@v@@yq+H*N(log;iT3@EDxvDjxd&SYpPx8zr6*Ss41cK7dw=liXF>_u z7j*9H4AAz_fL_pBJ1g8SQp0k9W%welSYXKN%lIog;Ou5p_e3o zrwAd%tj?^+Bhl6B@gmOy3ne17Fsg5U2~xwLWEN>dkQu=Np1w)E8vaT3NHedI!pF#V z_24|a-mNF^cxsVo2@O3%X4$xIvNYJNFDwd4q0jzn7lMF#FM2STZYwV30Ol!hK z{R3xe#nH+UQ%Ee8-Fb61{*Otgx_s@5YTwSM_nOU0#v=y({?Y^mmU9+)QhVRdK2$Uh zUy}6{$~<6)uhW~TdZFmuuieX6pyoKT8Qf>p@@iHn!ZUoqco#j$vwIu)jI`NwVO3A# zo~m)4--`3e?QwBuBuCB@!k2~wsLLR-k(6ePyVh4rn%E0a=CP!;aSRH79AxEvG;ZTFi;xB znJW7*5ANB`Dg3P(k3}U(#T8=kBfBiGf+`Ek_4SfdkN{=5bT|c5Fl@yO{wR`FuW9TLVL|8`5I%A-`sNE z9hPdspmGX8Dm6Kw^p|~~ShdOwmv_vWvdwkPcXrMPY*>X z7Xv|Mh6(}m4IQ+Xv+Jk%N0c-LQc?A_--;vN`6cJu^UN_9o&1SDY!ZNOCe+MzeSXuTmZ@?OJvd4oF|5!(|AK?0>?1kMi8QsCsaNMpS~uH> zS3X!+R@yOJKtts=Edxb!MMtuu&qsaGDV)H~2&*M60BU9FtG2QjGjai8+)_}ui0^aZ z$;IXA>B=c!g@A*NWRZfCk^7{%$&LS^Q1KDvWl5^AH@zW!P)hHZv46Mj#C9;6Ee{L6 zNXN;?aA`a$$Eo**iqZ%FbKR#3og_EO6(z_npU|=oapE9;fmwf!8p*V!Yw@L45y{4= zYRAL3s6P3q=`H2vRg`8}MXlk4Y+A!T<=>(AQ-j87p~T#%a7S7jF}C)N667cH-IGq#iSD^R_vFjM>$vS>N# z`4Lh#2xm&%!+vEZ4?TJ?evfCzMPG(5t!1%td4K*!1j=`}!leL3_qp%n7{e{R{eijD zf)xq~!ztOe@?%D$I?3bLSY487nb4|5m^Y1O-=%x~uDFn2kjX=zdLbC>kLR1ECUK)9 zlwIE$Rq6d5EGitp8&T>|u_%Ydw@SfQg^pKt*l<*WT%a|k9LAjDgvxR5LWdWv;d4x- zrJtjF`0Ln~qV;2a1k?I?V*xn;|EGYyZ0fmVZ)VN)4IfVRFY(}R5GNOT>KFlEWM(MV zs@bUw*UML!=Piv%+qf$|g4X9aG0km)d|i1QV3yn8af1do3>ORkNOEc8H}B

Eh79GL>5j)t+xr4H?oxsoO1 zy^5LcW=4=_pZK>ov~`2}#b`vKA>C11C4FA#q5Q9YeWMvS*EAPZlYjG#N|Ijxvg6?G z-3&X|h2^{LW-5w$74MZ2B&NETl?a5_iN&)OhW6+t&cLEG`vM~%Ab_YroU`oX z46o@Zu*^4!_eC2k$J2ww)4g})1$oU9Z#%w9N-%O650)gJ>EZ79TmPN7J^Brrh}S9} z`?uaVk9pP&cMaFN1DmC*X&nKA`xRbgA?cQd4siK$%&5I;JAj{z;|~YY(+j&^hkNZV z!d1u~>8tw(5Kof=5OAlblgFs@?zamdMkboM1qTwDOO(G2t|M;*r4`KfG|eI$ z17VqTC_i!hUbes|b`JcUWo`Ib7enJb^A3Qarny5mpkAI!1eMRzI|D2LDbDyMM1MB? z&2DWC&x_f{bsjrR5O)vkgptgAmJ}U~HI)7Ki58c)t@`5Rgl`j-2f1zh6Aa@AMpDRk zY&iTspE_^u`-d2D+mq>RBh#-3Z9ix|twf7M4uNA!LZK@W&l;X^?ppR36TudkS@Qi` z%&HWj2;65x(IW{6CuGpkFE@c~NlXa^6xhQmjw#Q3t$F!372&gZwYF9nqcOB}hZqG6 z&`W<^2j-(Cl}YM8lk^dH2;+zsMy&9}J;sw}IkZyDS;om)+YM@TxoHb74O~enQ|(~Z zt?b$cRWa#PY8-uXgvxkm4$kf1wH{047-RHRvPFawhaLzJkd^&R5=5tF;i15%0|zlp zWNFru`w0b2iS#b}>#~6}cfIAk#T`Oi-dtM}$h?Hc1NY^lMsM+wz z%yuoEd26CG)LMVfQWFWN0r}6^rsHu87+`fdptIF{aNmfrQq&H<5c7Z&6*j zg`o57b32MJSbuwgdXLB%o$tYO>`<|c6*7Ow>nj%aWq2AUkuE?H1D1}?N#Nz`Y{RZi z`A*LSy#$HL07h?;d^b{1LWs_>7ejGFt-7esF&%s2H*lRp-6#P{x!g@m3|t+;A0Ne zLw6Q@+aov(RkgsPJCdrWngj#m{;YP{|xqa^Ino zJAok(o500Tj9y8iL@gBw3E1<=aZo`n!r}SjZ3F+^)YmYGa{jw<*<`tO>P7TW#o&RJ5ADV-{d1A^P4kf;DIjJj(cKg}jl~?{DuMcO zd}1sKFUCxcZN9gK<*=I}B~y!uIl6FMhQHkxMFQa^xo%8Xg1U5%h+@@d_-3UkK_-AgLZJrE6~z0yVf4 z;!?I5eB3JkmmiH(2WsxYUD14hAU;btS*qesxM~;xv`I4`*2g~~ZWn`;y^PkFN)NWZ zy*-RGbbrv@IKk})-N|onW+5EK?iIT7Ksiu)xf{z+7~(8`&9=%@rXKsf-nHEd&!_CU zM0pCXto*s6$7$J=;tuGKYZx%790p~VPg`V6)9T1kGjzsXgCm05q4Z;))amgQUX_LxtNLWllyX0kRp(#9& zzgh^3B2906jxn$*hvz&)(@#ztl;@^md#XvoiWWmSB?mi4Vx#e%Jrbk~(IeXDrl8s#8 zN)z->!QY|90}ZqNoy!;_uzvKDhLk|+XbrUr9Zqk z^#Kce0j~?A?#X4=ClI?1l5B4A8A{1c#t_F(QljROvY~LX-QoEddD*UpNP=2R5U4hx zpPchtt^47s<#lV6C*kq)tqP%WH9u)oNn{vxd1b*2Pva z?_AKHtQ+5Zbx>TVVN0bz55QS|k^V;E*z`%~3~;AnKJ5YPI{q0%n%5{7a3&(^Z&M=E zxx$UVM#Ni8^rDPYe4!LM2l&irsVv^25Nut}E25GyNn;-r*vUabWaZ{+M_tHlxDjQ+ zeE`xi&84y7k5eq#;PH(#_~qfc$GfISL6Wlt>q_qO(4LBiquiAWfg$Qbxj`Z(i^>le z((uPAIBv#u;P)ihdiZnBU;^_$WDM6b8@fw<9yV6ZT{Jed#YxSJI`2L8CIAQH*ZO>? z7YYRMDKcDcR_D682hC-esVr&4PR2MZW~2n9mBN99s|XfVOr~9I4*8bko_8~2xnWnz zLNAX9f;~MuJQ?WwK5{R8K%9~aL1XgE`*X0o8gCW%Ji7dL_MNmv{S7eTXnnAG$9)3t zG8P!)_JmpQ%hW{}_IaW_Rj-1p=)V3xEUZsn_KYszW7C)t{q*VdTxO$8s5XHy{XiiO%9gMr34%2UBc?@GV zlJDDF6X*T)*#1?S|JZ_hPS_sa_t6SU{9Y0&4UbSX<`^SnMf4`@Rtj!p;*(1*8)k#4 zxbjI>0wTyyUOh7_Q6`PvMpqLRhX8KngGW+L?5;$yOEoYigq0ROy#vyKbZMs3ph5w1 z+(G@nS*^b=5-*Nzy`b@#bKyo!$G)9&Yh1oJ%?!_Y7OULdU2hQIFU_f5f`^KGf-J{o z;9Zh^44R-$=^MnsVs$@VMPE2z|E;x#I0K?( zUfpix=W@q3|22 z;^8amXSc!o4D7{!2$ufmgZz-~J=w)d!#cvkG3Ii$Tz9)?=L(9X*yacqDzgz_MpFjQ z9~G86vdK>Hg;C@>=H>O6*43u{#>- ztC(1Ysz!6vQE05cEj{!~&y%9Lqgl97)BnL&0B!XxrUFdVklzQPJ~V4*?xZZ0#F97V zqpZ_&|r90N;$|($V4h4`U^KeC?I7h<}C5_?&?9SSdtz z_Uc$(&gAoca`G@LHTED&N4wjd(=9q~@QybhvRO$_$waq9yRvR^-<$f`YLeh=Macwy zP~Vis8)>W?S_yF3B&shj)$L+`sr;O2?4cjh?&c)^>0Ob^KU=W6i!3qH=M8Y|t$y}pT^}g;Y1S^jU4CXx!+y9(FroqD-|NJr3 zuBu>?J8tRofm-5PcEVE^Tw@05Qkzi;H5HLYC73;uuzH@}H74inJPXH4O9XZJP3CRc zlH?nihT*YRaQwC@SsYW~%%hxBOK>P5%vD~)$k2DV*xN!#hZSS)64jma%Ixev_^N?= zQ^^mxf9ob&W(&~jR@w4ZnMO%g)B$4d;!0x>kB7^(*3t_8CN;pWqxm`J0?dI+qc0|Rb? zuGh#Vt)LXFp;(~OYL{d;53I%C;YqiK#%#h?sP3xUwFPC}cI_rOlWSb(RB#D`MP`FN zG(T(B#7VIj0W+l2{yzY$6=uQ<@|m`>-)fnPt?-ZPLXtnw(v*b?+l$NPV|<;+uW>7f z)BpUwAq)!@?{smkD72YW|JKd~lGO>(He@gsmJJxG69bA@PJ&U%2E8?5g!G0Q6u0Q4 zzGD_j{w3`+jC$-uXEl2|Dq=;7rMJdm>RaJ4eG=|!JFD+hY0d3qUR>*nb1q&{3-XO) z;_b2PPkwWQcflfkQ-4#%N=oOrN?~9z*yLrKsC)+crgpBmZ3ttB_!GMZ)@HEwn&NMn zt(=pcA_058n#!Re^@T`r+ScLvSxmrfFa;?Bjgx|OQ7?7kB~lTo2mYuQe5qX0L9NSr zCmpbyOS16qd1}cSow2tQ`a`k!w_t|Dv3w;Ofk5&SD6%{&2<3aYHi3oSH7EOuYl6+y zuCA}Fl%&7LvDgKv6SEgywxWA6Hbkoix?y&q{LYz-<#qP&{UnFQ5T|4cM z9#2{?^Ru~{)@m2yvMMnO4x&=^d>esfI^NzEI!B+Lr%WT|O!~Tl`a*~H5yfN#)4g2b zJ!ZWiRZ#MT$xl?sBLmP}gQ1_eIP=l*@p+u~V9e?&GHiS*+zQGg*on%ngmU#lVKUo* z`aMei}-(-QB$V3}MiC4+=bf0>^W<&p6eaq-URNt2 zn}P`FnjKXBlB`EIS#v|oa_%KVM*_Ew-Utlzup z#Kxtg9}U=|liLDE(tLD#3E4(?m$I0ET9%iC{e-RE_s7%0!{uuE`Z^M)qrHS**Vp~% z`1<;Zo?ge>+0&AN^2<2j3(Ce;m5GezIhnZ^?iL6~sNEnODK$n8NXm`|h^dA8!t!%a z{(qBKR?b`CNUwj93OEbb=)kyk%TTLQ1kaSvtDG$c%Nf{Zc0bQq+xxKjKy>Pn_Spo9 zGs(Jq-_^mFEhn7z6D=d4+fCdR)!SjxV(53e z(Wu}<0tmoxZ)wjFJsiVqi7S4&Oka5zAf%_;hiy9mA0|u$8#N+8PMw~GQDDXNu_7Eo ze)PhUhrpc-4Nw)yqb&NYng~1{UD8?TK_t12zae^E^eop;tJq`&*`F(z0P#nQl zad{{lg^KT%%iR{+?zZ8BUC5_!^+gnlT6w+nxaE~d)&x`#i^hTpPRPRQL)-E;Ap^Ys zsMu~GRY{=7T#38W9-)rhMzuV@oMGY4$&R*sII~KKH(gX6b^(@9LS1eI?vaO|+C$Gf zCy;}xVS>#NI)~AbR{P!N>lOen%mYj;<=grCOC?NKQ7np-;QOfXbcmtN&L|L;Ll?q@ zZ$C}z27FWmPe0H4O%r+)ss(AMR+(q%Y$T|=N9MbcUj3iGPq*+*IGZK`P54|uX-xI3 zKOr>4W$)o&mV2=u;G|p>L;acyoEy6?XNGHAFh5baSD$Ms?JLdR%NVyv@Zo3`Q;(D2 zAu!&+TeHJCQ?IM+h!RJQX2^3*GKv-YcTGyfM^q+p9FI9t)Di#R?Ukj@{~vpW{U3X! z@qe*b*K7YoXXdxaAtYWbU8KOr9-^Fb7U&UzRR#&XO3;%hfqV91fa)s{5~WPlliKLQ0z1 z!Z&sVq1v>yk~P_J6EKB1i+oTyp1)usy1@vlj)l?Ad@t()f4r5z3_=%gbUS^PVf=vfhlKsDgUSr;fFI+iq><=tyJQV0 z%zNc}z8~>fzouW5rYO)xzP|Q%Oz)PanlJoYUDu---TO2J1`*2TI{(&y9T*=gyAU$} z3RjKJkUwxMwaUWzc^Ge7O;BX#yver#qL)yCiE)pu^G{dGws4gQpE&-K@y{B!7?OTO zukdqUV92mw5@Hd+H~rNKH{~yb>oDG?xY9F;encS59~Bc3sUhZ3nAXg=sC^jLI>XjK z+?Ud5&(xj)r{ZOIMmH3vyx7(6kKymH>1~hiZHBk*&*87__Xoe+cYgd2hwrf7v28@Y z@3^IH7qcc#g1*I63i(zp2)4N2mykHP-_E*9k#EEte&6`&9o0OiTrcq@0yLW!JKdVc z8^wyR&$_n3^jAA_Z`-K!Sr6TaCj#GoH^WaaH93=w5r|zoqDlcev8aSQ((GLcxY^S3 zs!c&8XWYnCw}^Bl-h)O#s}%Fl(?@2dE#DzcdDEp-U~j)acxlI?S*PQOM zPp{RFM0K_|oeLXbI}u4IV)RwAH=l=g#iZmCCY4M7$sV)V)r_mg{15{MlSaG#I34pw zm_tE#D0f+k3}m(M<@}3;wP{pz9ujKIUUcUn{cuoS;JK6NUdlu?0kiLPB=1xGsGeoC z>jJ?zz#G2!L#^VS>?qh5`-g$E!s$E0YTjkGFthOYvHfaheUMKuTMymR@4GsF>7xXs zrY1z*iCQ!3#$C|nc>68)(Ty)y(z#nrAMU2q%|ZO(>(jxyg*E{P<724Mn9t2h&#_$Ed>G=KCN6c<+3 z0VhcH-_W|o^*fRJsGs&^;u8g~!R0k;)jtME7+@i-P82O#=_ZK_5B%>C?j=yqoBjsJ z2P&kuI*m2pcqwV2hIoL)%2Uuaye7(C+c--+E;I?5xz)n#mZM`Q z^OKBqY02^3_Cof+u*2bx#y0Hz)bi{@jwI4Rwj&Pbk5NL2INDLA&Y4mmu2NXnNVc zDhFm-VATvgGTE3z*_XZ0KY8@x<8+vd7pKwTPq00T_mR@VN}~v#Vi|p z=uW#x@bX}Ge{`Mv2tjXv67m-6w)uU_Hr%`eB|KuHEC+c8@uMigs&3trmIVJ5sLNyh z26BpUbFOL=$gKn$D#0M8zw~*zYcLMKPD}iokuGz|;mkJxO}Dxp-1m*FmwR&h3hnG= z7+i}Ke#2|>xVNJX{?n)?6g|NhmF6I}`xH6Ku=s~70Y75#-Gci|4E(A|^P-7>03bPMGB*;Wc9=9J{ zsDJ5wjexVG-NVnULdZ3N>U@d6$Do!x1PV{YA-N|cBzh1jzi;t6Hx(bliYB}ZsU(dD z;(O2FI>xGVFm%04HqGxFI|ed29W^?(U39HNLB!>)z%1`rDg*s^u|d3xQ7cHjyohRL zfCro4)=T}oZEoGO<$v&7_V}U>O1jwR;9mOp)ASpe1Su`;@a{TwFb$}pmlQ~vhy?J` zRG7{!iOd#}(ca7>$!jU<{1GYlODSVoQtm*D!Zb7Y=!T`aj>~?E^1M%y3eRSbu1_6i zSWPEid?jyUw7U9wWjfa`Zr5LEm1Q@Y`vddQ$Jqd?JDjZ+8yCjSV2Fb- z%C8fY!-T6?H4n_49e#k|PjK;b`G&bm)Ux(F ze9;SV62kai@UjdeGqHM|L{3ETrR{pv3 zfa?-lLQ86V;ul7Q>JWzP#ZN+rB6iSFT*=}+W70o06#7#;gu({;b3kWUKUH{8dTlys zrketg*I{H|*op{q0~C(AF3#~73BEtxIwd66+tfv!CPojz`0S8|MEz?7`QrsuL05Qz zk5n^|Bk^{L2HrPxoTE*luIhIyQp`AME9Ec{dH?S47;Jf!GLJ+9X|(7o>3|gxXAl&e zlrI+=DA`zp3)-wM&sqe zn82?iM#5?IAPO6M+|yzPp2tIH!XY5^+UsaSqBalhM*pIL$z4!5ag63h#`bB+`EzZ| zH|(>JcDZ}>#AD?=Z}1cU3emg4cw5?80V>f7+^&zIy1em|z60!*gCGr+Gsj)s5oPb( ztmbRe2)8hhWmvP6cyc!WqC`zIL}m6>OFhO=Z)=kSy(vCi+D zAM|XwMpXj5+G?nGo1%=o#v54K67eqmAr;$(!GnL*dtHODt;Ow4c3!Fyi!MzwgB&T~ zgBus}bV%>|u|L+B3&*WNwVOaC)nY2mby-zObirW8a1f}Ve>r4W>u-qxJ;E|N<9KRw zu^kc8gE4tXiCS$4pX0!!#Ng-A#a{l>VtunM=T096bvhq)bzyg?Q0XvVOhZ-J?J^K< zkFe{zs6|9g&-QNPy{eg<#2pXiFQ@dMNcN{DiDl_EImhl$kL8jzmQ7d!8Oez2dWSVu zc@Z^e7;oh=#$0Rr?(k)ZK@Fd7_=&mJ#}9dd8} z!7>Xxq_3SNu(XK4VJsODoWcxd3i6kz#}H~l9ojx}3n*5zAuTE!`k;Ev#_hGv-ZVWH zWh{QJ93Ho@Es}fJP@MpOL51*9%fAHB6F>&7#mX*euyJXa^`89Wf>teg#5Ialu^qj>otQ$Zl{`RAxh2;0$f@D) zcL?KJu?COW8KR_mQ8_7nFH>GZMQsM#H4sgPo=3?{q*fJj>j%}Y4Db!z`8BWJIz_jR zp&qiMOBGdAJkTj^y+UZr_$x|w!A6@!D?EEWGUKzgy)lPyBkt0quhqp`)(+4-sQuwsHKZ0jT<6S5Zs5gVk)m3-DGDt0U}in+p@ zb|q%YQCL9_)>JH($1Zae+jk9svSTn zfHG}PJNMBg>pN60w2hxA;|Wn}Nzd4Gz*2aXotDn~IMoQA_@o*w9AbTfI7U-!?6kgd zL60lO=AZkKMxHXT?Vmv4^aJ+^0MEkwKyv-zktgb@5tb&G;G*9feWH!#Bf=!voE?@D zb|ap{sT?*Sp3SGcJvP;0UdvoNgwEe)oWFT8?PEH6yWI5swpUBzg1Mk@t+>&M52L&Z z$f2eQYWfxRiMoXP94`gq)(RE{zt##DjMCe{v_IL^QFLSy3XG>TgYRk3vOv(^XsC6a z;a4~|Q%@nFXN8=+&0<5ZR>6l@4-W}72?r6cSI)*?ZrG2wTJRXlkh!!7|NahZAg$ZP zZCjbTjHrOFum_JSW}@%oty;y*Wo~h^&;p38Lqezt;bvAQ0)ZqQD6~&~K!akv4lZDJ#<0eML~f z@~YwNUIzDRLZ2b0OtB6Wm_cGGYyG08wh3KTwe!DXLf6J_I<`6JEUDh!`csyOYP#`# zTO6cmlV#Zlur0g*+fojIE>ZwuWy=U)Et>%7a#%1=%RT8B^e+q3dJ6apm510(np!=kt*z-Mvy&%r^R$fvBocN z{+$pNag-@53fX4qzAHIN*I;(8+Bo|LmvdM#5~)>8Vrtk%oJgmd_ z5%xR<2&96fkboW)Cp;`a9nrkNg9HY@blr(f0e57}S_Zl}Id0}=0Buwg?Q^%qwf7Kh zUJ$LR?Ek=cqkq4)2y-^ajje|?+L_I|^LvqPF_gBA)IY@Wa98fn-$Xe7XuTXBj{p76 z{L0IhGqDl3w0v7@eB}BS(>TOHY~Wh_6+M+&Ym{zDIq_}bjWx|?ka@{PQWX{Y&RMVv zz*X~CX8<8|1Ykjxi~ut93b0nM02~CsLWa(3I>nof_}Q+_sF${G=r8Tz4V2*$>+m6) zz&e}IY}ZsEl~H_NTeD_bJCarNs3wQ=NiR`yUK-i_zSSmUcXNu#^;JvTPF0U;+_P0X zdgXzBqIyZ(RIfUYXvzUho97o4W%E-C;eH69b& z4ggs%FN12I<%2+9-bLR_dSd^DuFlWCT>zHq&F^yf{yB+<`x8*;SOC0Qi>neEm;+d= z6F^QZCssyGjo5H<)`Rt52n&F=Ffp4Q|5dcbe+*lUY`c~bKw#Yg%6b29lr;#bu57y% zH=yq2-_NJRe*uTVh^9xl^cc>w38!%!8{Vs%sIy8y>#K^WndB(8=OAV)H9O*~UBYDR zjKbaQT65hx2{ya-U(|_6pbC~08FUB<%&=B8;K(42QLUF&XPYM%rX4up{I-?!PW)wJ zOr1X$N)Wfq`$gAs%a&29bxn~}2(h^pvd*=M6_|iu(~KijCH9sENas)9&MFK>Kq?Xz zaWMkoz7ZppoO93lEvb9ECxV!6iZSn3Pfy4sM^Hds4>SiJ?AKFU`1Oe_MUe?b$OTLV zI5wa|POq2>9J_#?62FoO>JPf=wQ8zkkQ1r(yM=A+3= z&kmmm25@W5t^>F0-cZD=c;+uNO9aR`<%`Laq8Ma3%Q*jPz!`PW=<(s*8ycxSJLU?> z_ZC0Le;P`)!wkF*I6z&&C#qL~-~{)~5Uv(IiG_ zfY*IT{AA)nfuxfg$ShuO=P1b!sn&Jt-23YfyTPYGcEzE5-24yPmnfysg=o$co6-&| zy#Ams7G=n$8wO zmtJrrx+|@(p)xZrJKM5Z(S6XZuLmmXl7E{JE}Zql`0-AQ%^zP={ZWw%Q`{Z4xAaT< z+0KuW+{eGmOo+3yp7vrT@7EkE$e*@>k$Fe@{(0_~%!6CQ%(!P_3hetp>1jSC@uy|6wBpf zH-(jpVo$Fi5m}$AG}}S-?$W9p$72H%yoYr74QU}_UGqEimoyMpc$#=iI_Og|w$!aP zVtRuvpoJTOXGBUBJieMssdyXGbH4t2X{Z}f=AnL17iNgqeF~P*8ki2V#4&KGraxT} z?o;5U5lVEHtSEIW`sxKx0kt>I1A&geBNi zwVHb%$+VpD`W+by(G)+vuY+nh;|6~RFX7Hp0+{FimgQq3#IA5BDOBCnxqwd>yd)|! zm(z%39Ndaa`f*#0OjlN%KDr zx0_Vsr+^#fAkknrHqphh3pfF3sCE1$$U;5P{z^DxqQkyC^0w>W5GdY<3ENUF2QCAB~ribEqbi&Dt6-3 z3AA9VE(aHs0~}WZ4HjY?4`!h#Rf`f?dtln@alUybWj4}Na?YT~8_?Ijb5GN?-z24!Xjz0Hxkmxe>(&E}@g zP>y&hdI7i7l6w3f0IEP$zm!YG9v&VBjXBrCVgy@<#g9$e3X9i@c~~7y=sV&!!Ex)C&)wMI#+y* zPICA0KK{E{1o1~;ygI6~&kU^7F`xwxJjTM4B0G}~@20EZrl%RZ1z9jbrs1SfpqkHMT_K?H! z=mSp69txIBL~`eek5}z#Xy7RdpbFO0(4}3tA0w8EyN^?uM`+8V6h$W{zZQj~+pQ1g z-Rbou2>qf!^4U0%>J$KB(@-eD$C!L~^#%+RFcH;O5CuheH~#2B{;yILjz@Z?6djbY z+?HtRP#)mXVbo6vYMDeQy5OLg((xYggxJ@*@Z9mxeJF;)cUNy%;-&q)iM9Fv&)%DN zw{atnqVu;t1)eZI&~7FZWzUb|(783y-Ay=FaoUiNJ1Y>|pSWs_Q$llx!rd_#9y z?`vD!sLhFU7^{Hg5|5;Y7w$IDvuZ**E`e}155A`FoW$enXp&Tus-p6yi#Aa1JqZwc zyjMlQ!F2G zL-YM^tGU#{Te0%LWvP`!i61eaMi-Q&8piOaOEkj42a;3@s%vt5oQY}8rxe$IQiN~? z%$Ch{Lge#IOx@|5(mPN10Z0-_4FlF%g_<+fzh?Ys6hyn2D{z`#(YVpv<_}4guj;w# zbxw=U(-hgVXhwo5P9i?r&&SEAKIhUz*_AdOn_W@xZ0Ku8@w{7LP6PJjMdTPR!7gjR`dfaXLVCQjK1XymU)-ex) zZ{Wn!(Zu}7Id2LIQjWV@oz1GG*V_+dajVaXY|1asm>80T!Zrs^NfN;p07XxHVSiec zP@8;lnyQ_yI@N7XJ89V62EGn}r@lTFT&8jD6m z-ASx3jurGC-6y;F#&w#9ygQvas>A4Nnd)ePQp_!9z4qL}kf0wVWwXrFs61h5XX+#W zF7gH1JU5j@U;_*gnA31sE_h?dZR;!Jwbl{gEihL(OBpAMU$_h+nRv9*)2Z9M*8Sny%|MG$cu8Jw+&#T?4hRumeM#1qV~Y> zduHTL$eP`hr736{+kgG*M)A}!WlcEwGY3Mthi9XRBtq)`patWY?^&k~bL73{#9L1M zr{%<`$Z>SkMfGG}(jH7z?EB%TWN?g`IBjKDwcr z1sOQ|P#ffat5PAHpsVlCFA&QVEsnK`%Nj27Q3BvU2PIi)%eXtHK(xy!(P^?!R}=?m z*IgJ=!3WW;)+}@?*F?pgT06gCaj|7}yRK{p{_vIahR*b`3xa=T{GfI00scNq!g@*7 z8}Ij+lhczPNdg*@5RIUw9RKju8+37QEFeXiKgV$tnll#f#9)e}#OxwI zXri&E?LXe!46ot9h9-#)I8ZwgC!ulQ1p!c&G^HTA1-;2kF8Pl^obhO`^@fXcHF~?< zJ?9hi6(y9Fi^3^|c0nm0Lh8BE8^dD`Ql@1zj`g27N(giP1ib0Gd425+WuD7t4YoY(+*2ru|(B#kfVK_AN%sEGc%PKM4yW5 z8I8E>Na{5R+s&xCf}*_u_X8&O);Jnz=RuA9rwi|C;X7Y3&*{W(+IY=Z#AjN^YeU>S zQ50{ma1iUn$b&qo6NE&L5BHd00cVKX?yd;Lt3(i%G8OEBb)p5XKayk~F`B3xIf5F! zUm}#0@!w>zh-{4KGEN5l~?(XjTFb=KN*mWSb zdxIQM-AuR6a?`{_SgRD;OV8NtHoiGojF z7{4(rb1gE`1W1RT7X?d`Zp%LJnJ?lr@>R&S+z$${x;r8aFAIvSCYQi1B{Q|&!ghPO zSad$T+-kIsQMy1|jkbO48H+pbl%KC&8{RR(oWSpKfvEbCy_RXm>)K# z9&7EpNYidEfWx6fB5zp61sXCcG~*k_SV(>E(bG!j{H4kJrzaP54Kmthzt?iFp00VE(K?m$uJ`)JPq+XHBO23)NMjvg)V+%k*~L$2c(ps zWRW~?Dq@w;JcWelfy*XEry1A76A%)|`}#Rhn2zlkoG^r5SXVME}Wh4+R?n z!%P~N04E515j>X#!6GuxtA#}1DCR0W24ZE-R~bV<6BYkjxh*O~)%A|gYr-DYIk{B) zRvtN55zf>{KY@>h1pqx%0XMf-1c!^;`u$xOY|{{E?W2H7;c2ElGZ?{q&2iHaaK!MRB+>bVIu7h|Y(i9{QYx$dj0hZJf%_8+ zHc$VS?}=(KdaYQp9Mxk}Tm8hss?)m%n*xQV}Zo2;8fN-CyG`5ZdQt)6N=xcUrv zd7R^j3rObtfh=m)wU*120jZW}IMt=qjy;16%;uG#Oj2I;uGj1BBu5I&+J$EvM0N_gAfm>#3_5O9_}DC zSLmX__4Ja@4-JZCBYP`;j?$WRp+7$BS8jS2$a-=?<80G~yrPv7$Rv^3R%L}9|qVgq#eROH_U`{g3a3Tn?8QY1F zYT^{9DM@&5M8=d6tu^9+Q7%QA6ho>^m4;DSFfQT<p_p3{My94# z@)^(?btfUS2z*x&IG&BA)R~epQ?0`Ekimt*Yl-$Ard6vc4o_$@i|xt5t8zB!i?VXU zI}+*{vwDUdwM`R&6O%&&U7O@9$~YC!WKNhsj0AL&M88{TJXdJI5@Ay&=}cqF6t_T( z08(a&1Hs%FS7C0RM8a^qfX-%F5{bnCPDG+nCMe^BkjzOu;L)UqSul+R2}H)o0H;w8 zI-4joorO;<{VQ5!Yh?qV`HgceIe=X`((TF zO1VOX!N9QaPKBH73O!_bPQ^Dpu}heR<@C{{F1e3S>uu587fQZS`F*hCKx#6bzI=6s ztUvDcRljsqfT6tV$Du^=woS& zaFDWfaF;_{*2jy>p>Zet>!F6FXzkNyO6G^~auOARix=*ZlUz^GXU&O}GQj7vTFTEA zaW$hhfw!(0Qli9AqHb^1v#er&&1s`xke~ssS{C z#i2HIal`%4w6L9}J8nHD{iq3-94#OnUy{=WxMdJXi|3dGTHH8Fa2)+^WJszuQjrd6 z)lCx|RcKjq+u-n1ZQWe;z&sOE%A()v??);n8_SF5hfh*W%(u4{kKJ={NSTL4p+!4m z>bOBGqxNLfbEV3qijoZWu6dHzJ#>@f@P@wZ$Z_J2TVJG9KXyBCY=^Dk0Axito0FAo z;9RfHVX*z&&Za=_YKFhC6$)gTuXjv==5^|0QDH<4L4`5fS7Q0%8fivsNPIq*6`DwR^E`LiwrcR#duGd(lfF z1G&EVrg%G%c1qsZ1h+U~mgd*-!IXbmKZhJ~E`{0GWk()+GGQ%6I-n1{dF(P(PUZR| z&fSi>x)In4X3Un@D|@@)xdjz8;y&+Q^+XdookSwSF&xBn?Y2|JbX^+2m23B!h&_&8sl4KFpDGPA71Uu2 zw$HrG%CM1hgxb@llzrssTw|DDXq6G=Yh0v0 z>arzzOmLY%JEqd5j0Ir33}|Yj8i%jmSmU7^ zEM!HlcO{E}8AwXgELO|~th&}T0Jrmu2uTpn0=dJ+SsX8nsloO1b;#hpox2=H?mVUM z@0ByF2)CkPn>0eeM;yf&BXv%h-n9OZmsLqbb6-s7;v6*?ZT%|=tx+s>OR_|Kz|=xd~02V*7iVRScLIQXcX2TE>?OLv&p2}&gwKH?{- zuJE2T+tayR2r6>4r+1!~=rwy%BK;n$dsWy_3r5v*?k1ky#I2k8bSsB$=FQDqxt<@_ zaN-gVuGj`Z8nq~^B5dH2P~$K|yky@>inVStH?@MOA+#RfF%Yebi2|o*%n}=)WJj}E zCct@Bk!*EkZQkLU3yLc6qSeFjk3V`+G)133@BD51eBF<$_YaJ(B_D(&jp<@W6477a zS-f5w2L}fSrza=y|G~jQ;s3+a=SN3>J3Ki#IX-xP^!)JfZwH5`r>6&hLkF80WYd#z zf!W^<)^4jfxUb~#J%v-Adj`L>C{W=GT<@4G1;X4pcgIZyC2LvN+LAXfl(Dr+?!;YO zflcX3XW}&ggU_?JV~g~r&3TB z=P%A|BZB@L&*D1JnuZ%kC`Qsa3NTlr)Gg|l7Z3#pHT{d2E3U9ydjVEkg5?&WG__nX zEXZV`2dRH8IhDwcP|r@mDJ?GC^|>5oZ)kE?o!(`!yc4E>>%uQBN=ie0Wo)5T#cGrw z*)^#HUD`u1#^OHe)?{Uaa8-AhH_7OXh$+cbp*?J)=n^C^N;MmB4$| zyN{^UTR03djYo6Qj0X%$`B9lj>R?eO&d}aJP7aRts@r#6G5rkX zZ(ESNT-rl*Jxf!}eNa(-z4`cL~|G$C9(lZDX#mc`TyU4+D($WLc~{9$vw{#}pdJWY+0eqZ1T`urI=E$$Wu zR@mx#B*QF@RiY0Mnc-VMwIBVOm`%LXVlx*$2FG;rn#@V;*X^NRZ~mRrRAcGNju)B> z;*1NzO!%6OXezg>OZJSG-m@a7+o5jo?BXob%HN+3-@W|x?fIKm*TeIRSI@ePw2C;y z1c!vZGM27w6rYPVn|p-!!GR#w&-Sv+u8-6<8s3lm6zKW4z1Vup8p9( z@KS&D*WN^ac1e1DwJwd$@W%_9X#2e5^;c?thCfQdLYM629))gSIzj8wEu{}Qr+@G6 z66J(n>X<6pbIxpfR2J?rGXBmn@pf%GO!&d!l@t+qG*=u_g`I)BQ5jEU@g6hf6+2QD zdNhd_ox9-MLFziOR0m*bI!wM)U_7CxmrI;OP;R7d;~CeL>?LNU%}^-bl5nu!!!L&2@kzbALtE z#CkHC*BCg%{lIAHm<*0#DkEj!i;Cm6Vcb>#Yvp;blKDNdJc&WSri^|p?O8PuKi=F7 ze;r=E`#=9K&w|!DS8SEHHG94Jb`#pv=-YRjZ_!2z`{gQV#;LD$4PA%Eh=4^OiZ zq`%BfjP9SKn2rYD9-NMj4-Q`(1gD4i_}eiF!{;YK@DCD_lOQ}jdhr||;_!Gh2xcKv z#^G#$({zxDC?2Rg;8GgGPu`!ySpQ! zDWxC!di~YTa-{{=UqBa%l1VA8*ju-LoBx1m)OQ`|!PQL~_g3^HFb7KtNPd{>k%5{q zLlu9ASI__}p?Tycm5jFinA{(U?MM4*xShmRDzAT(t{dB}Bdn$~700(V(|B4#w-o`p zS)@v0?x9x-9llz6``DgN^k1*%svMw|{vVwd=>Or-(aDznKg6>v{eO4{ppEluasUlw zDHfpX+#?TIh6}982ky)3r@#xA;|5*$K^sT7)cCRG39CFc^gpFx7;%wg)qVZ-p&antQI;QRN|t#Z??LeN>1oX>F-L-Y%@HkAKE71SW z)V{xcQVt^C%`05}u(kj=5y5(8Hnm z@QMdGR#z?fB}jOIQ#^{|2(qxE_rD6u%ZYelpzL^Yls&Yk zvhKgZ>7M!xxoTcUO#e(7{SDFz>)-0Kd;KG%cgY=Q;rZ}VzL`=k^l4t+d-?Kmc;UYo zUcSs2eRvqT~!69vmEKOW_QEw7)rd9VOXE z4;TPCPZkmu&r*E>D2}yWypeXt{GWL`{q=x7N*Sew|AQ#GDFL9F|DT*5yf`fK|C7V5 z{qI4ZYEli$uM?y$uknF;u{z5?Q$d#|=CX7Y)EE$3jBx$S3lG|0C`prRGTaR)Wnq-K zE4cfMqGyf;aThwt3tu$HeeX?x6IZW`Yb)dX-3bx9`)F5PCMN$%u?VJ6Y6HEp%_xN3 z{bdL4K;3zQCJZOSOKpu(!e$YN=u|zoTQkF{P7S#8M3+FeNFK_w!v1J9 zg>*R~-R_b{U{6`ekWySZ-=>l`zpDoDc#JG&Tj{HmoLq)%sZ*e`Eou#epc7=IVv+e4 zpBt#Ye@Af@ZPl|V$+Z4+pf2$#lG`riynA!iEF+FM{C`*1LKR4*&2$5~j||)J7*t4N z0<5G{gJe#Ua(g*kGe3I&Xv}}C+en}LYbU0h{qD&6-g@0CQH^Ey@DubeVs4w#SB$|VORYy= z{DU%17|%2In;atg63?O4lwKuc$^tO10c0E+70TII`hehU#|`Q(m|1DS2lkfG0ftcF zp4@v0{QLXBLUuL_A3TACCHeKDxeiCXo~^eW1CukK)nbNIq8d9YkOBuY((lj}NJZa3 z;5511M~{SVH*JJX5O7F-{s>98TBj}1t|!_{V*EbRf2~eOeSO_MB zrwU-8y2E*aFbXm*=nQ;B;MP5LZzcg}7HB|SKJ)}7IM(Wat)#KA>_W0y3w|85~>%PpB*5Fsm z{kj;-H)nKQUs}0;7P%#fE6nUl>T11cQPGqx47lS687tz=N=+Bcw|Or%rmQcgS%1+e zsjXAQV+oq7SP9?X=fS;rYJG|x7v65Y5FQ!&78YcS#oL4auMdaa<=A5I_Pp~e!d<-( zTddul9DQY&>nz3=d$;ztuMU5`<@mE=@H(hFkI3B?PxBnWH35})GGSzb1#za`Cc~{? zEnBT8C`|Pk+7&D#yUWxgVI)?mheHTxb}Bky%=c^;{~``k)*Y?uiO!eYcyd*@8}C6j z-k)m&RUPZAzXCb=5SW!g>*q9h&x8;l_EM*K65UrC3?9p(^{TcDM5AVQP2R z`fENeZoA#XkDB2&EYD-*xsNi1-S%1I399q|X_VSb3c$Af?=KDu_dgw;9v*M=|31i5 zy#I;OnG{$vUYGILvMo&-*30d?3Uw;o+hgcoo+P&-Yp(26e$SlC2E0pw{!DCfEmLaM z4Exvj)m+`@K`Erl6^oQu26{Q%Q8(vnHkUFgXh7pLbaSC@!dKVo|KsG~$g;9CB!aY; zqiIifZAOzwPzL1$W)mWYP}#|Z=is(qsh?L+efRIqdfY3!CQELn|2npCO*YVa|KIV8 z;{8w054ZIHL7oEr_eIH?6tG1W;7W%c+0bV&(F{+>FpJ}B6)Pp-72lohG+(dkpgGP2 zoyi9M=5Oc+l6V`wFqo!#67DjLtU=^nP2jF&YNqpz%&naqZ<$HqO@>Hd!w&z~P{>HkAKn*QS?Y|;VRMW`BWL4S^tNk5>BP!2KMMF#Rl z3vX+v67_map`(eP`8cUen%uEEZ1k8{f?E3CqAgigGQXYrjw_OXxzwGmrSSDY;#D=9 z&Un|2fp_d~3E0woE+}Td(Ge(jODXT`TrphJM)J^i46jW$D(?8V7ic1+FVS3q7?31P zX_Oe{cx{~)p%ja0*FA;B)KrTSLD(F}9jlt+raBK8PRUI)BQ)#Ucdw2CG^Gh;8o#=M zacX>RV%dW|b@HFUOk^oeqhLyc553>$=-%%CKRrGz-v4=Ua&o+t{~qEg>i_<_*NJr_ zN#=4f?!V}$oT~S>zM=V)Jf61Na z(^z?`)m@AiG!f*Z$ZHlptbr*A68PXKAT70~&!~Vt{ z(A2-9P4v%tz4`TWXSj#!in`16jU;S^-nd^j)1lVRBs8>2eqPOeyB3*hZ6A-vQ4$Gf zH<>s0+peH7VMR9Xyu9Dm^>wy+4d;H_6=;c-F# zKR!4*+3Nof@pRY!SGXy@>@VBdHM->}sM($%(RFJbSk^kA)?;(_0UaXY``z!QfF*ms z?D&56dnpZ#>;pw2!l96MvX{^My^IsDrnR*9mO+o5En~3HYV?1k;uJcvfj0VodRRFB zKRP)+-O~RDdG3||-+Lg;mJNIbY~Y@vY&xvHA4apxMaoMV2iJ7`_@XSRJN>`6^S`4P zrw66;zZZvF`u`BmgVBE__YLNUBOKYdGM}P&K%&D3)<@o~Gw)mRsa-nqv7o?~25; zP6K0EBBB}byV40>^~fsW6)a#Ya^aPNZc-M_qnJ!cc!tI}=8~VnObpG3S3JNmv{NWS z!V8?LD}5qR(|$qdi9KTAL?Ajwfl~xB97MBJDsUoR7$`fQ{jP`hQbr|bCgGm?t!`0~ zKcYqJVSeLGA^#<7qtisClg~r>>17hC`D2YJ@z|9}{In9vh*37iw$zS5;eT~e7e3j=4WClF@8buju$ zb;k|m$E(ZN(d+eALCi+$L`R>uEqeY5&~r7@#;P_A4`sHtjqS66r=$G0Ry=r{{r|;D zQT{u6aj^COevoHn`LCIZEEzezvODBvL%f%ujGKz`ZW+&mMScI}goWi7W4(az2S|*t z3g4}v|950GrSwBDpvgGudd^$-wEF)X74tv6IDWA`|9z0BK-KlS_tmHp#B(Tmdg@A2W5{y)g$5;~lwJU4W`v`fDwclS;1Duhwf-Fye$L+ByH;w_*D?rN%+vV4)>Gl!C8yi8RmjvK_)uD!u(oVy;?JtwRT$8oYnh^ zUG=cM6Q(!EteOGbW2~yv!~?~swg#bE99$*%R9D`7(5dOwR~c-YPw4{$nbydsjuS&( z*7Ix-EF*P5my2B0ZQ7CzQ}UOMRP`pHbA)R3iFZsVugRRme%&7G_2zD%YGdizu*eo& z$8Al$|86kHY~V@p>cha%i@LP@ciqm}cXnZFda}D)tZh_u4MS}`0=b8JrGBfAy_7O9 zu-DtR#LZjOeaglJi;R(fN)@I&Vft%tB0sxL*H^2bEfMpJ(uA4u=O3%*iZb-noiAJd z8G4G7P=B;<`{5$NvnY60`Y?`S!WUeS*)#O4Vy<;Xb$pXddI9b!Pph7lyTAMU)8V_9 zzrH> zBkcyJ7N6T=v<={U2%Wb%!1K{6W;Ycg<<5Ilkt5uN9F40;kk^*OU2QV8_BxhzRV>&t zEp@N^hnHHi7Ut>6$#J<}af0o95*&+ZG3~+h`-g7nH4f$8KO-J$svir{z#mo(i>8R1hJ zhaCVGs}w(sXuL-LDT}wbuqO7ZUd-VfVdG{Ex>F> z)>z1^^7eqH&%CqqK?| zjji~4)%-dgwbOh>)Yrn?f<;NqY@PDj&g^SDR6jOa^o(cUxc_7W}Qe#1URC z&NuG9THAe1>18bXf59{Q`+Qo?|7V%NB1$I4%Dj>nKx_Qhi^IbC|KZ8@{QqGd@BBa1 z7invwe{}%N8;uhGQW&8uk(nyIs;9tT^US|=-dMcMI7)saf#?OeH)eFE?XJ+pltqac zquu|(2mgcj0=$c!w)G;0IGB+l^t?#ELyA!6hj}TQmL(fm=;G?t`OPcu)z8D1?yE|Lpoez9 z8GNI_+ij`tmbaZj?`02EXxkHXGmW^JDnyfbfp8FzRB$vu^bjj=u&=AW`cD?kaV*ya zd^+^m7)f}>2uEUy1xjern;+V`$Xrzn0pDC!6;y*0Zcm=#u^bVL`Whf zlsRF7gh)^{KYXUTjg*grh_pHS5)dWPQ8MCpY=9Fat*O>wrka; z$ey`I`xNA8RT1&fBKWQ5!KEH;wkmgWE5JRXr$zotX&6SFWvL`>qb!_|6&yj^{QsYq zGt6_9)^naba&*o{P{|7IM`JWGuw)tNk z=Bf5^`$AcrYy5{gr1AWdlJzf`Lb--1e>H?{`*eDiqyNk0eKt=U{XZ(+|9o_Gdc4j5 z`5?~<^#9(oI&axPkqz8a3gw20UOl}~r;wI4(+Yj*UGwL8+UftA$sanL{~a6^@Bcr2 zv8DeH^Q=k#Hbc8!39jLjy{qo?u|cZ!IB496WxdRD$|!sxXUMt^Cpw0$1xX_5cd%KL4e z7Wwb_;n86+|JT9s*8l54o@(Y@$m)F$xx1@HzOzD%8r7&VNr|92NDy=iB_x5A!r-=U>@uzbXl-$>XL-Rb%!`KNq`8y8q~$ z0{>6SyHInIJeXWRst47AdK*(n13_igJ;r@){mb{=0g0{_@SM{w&;V8_n|H^P{8VqW|~t!HXAL z`R^f~Cmt=_+3_f2#1VEgpWra0iKI-Rq05~n>6j`#lQa(}A&NN9q#%Xpc<1KzHF7Uv zM^cJGbIc-|aVQxj6J*Kn&ZR(iQ5sOV86?Pr8lBKdAatG>6uTg;Gc(D&3v5=%d z>^ymbhKxwM9`Q&JzO&Qwv%nl3 z{7)!#DJ91^mLrkV0hd|q?EH|$F^beDV1)34WdSh7eNA;_rH}|5#T?NDP3fH+R-mOR z*&D&|&Nxe;8jd5eKvClJuK*`#M9`RK38bo#op|rH9MXXQ^3)LElH5ZBK5)wqaw&K4 ztiQAKy_%WGA7>!PxsMh!Qx_|aBgTa(q)NEGyvVqqvn$Q>UJ^(?DLdz|U*Ophmn%A^ zY(`)>7;%zfCfA7z0?Q75(9p7=h+rPY3zQ|MX^8=hm|tEeq%cb}fkF}!DZNS<%=DRr zD1c77UD~<5y&Yjb-T6C`vl56{Votn3y|LwJlSoXnQ9q!wLM~JH2U8jaAB6NSi75^T z^TYn(QUAzakAQ_--Pv(_d%Lr9L;V?*bFM^kB|!j8;AiM|w&>{>w`b7rfF_*AMB?aq z7($KzB7zGXd_X^vSdP=&;Ii{~G&OJJHb=jif);CJ)4XrqOX=G|{_=VQIH+V`n}Ov0 zM7{hT3cq%*H`iCe=vlHI@}^5o(q)1u3#C*=(TGf<#8MV_-z!uF@{$lGU~wWOA;IRL z{*v>IupP>f`YRXjo{lL)1pccAWUwb zH)``pj8@x`sy!M;<1t|%DlSsO5gqGF98kDK3&fPnc8Z(*y+q)U1{t`POYkilJ#aNO z*xo--R_tD>ZEtaM2B3&8N*SF;aVKhczPX9gZ@xK0uOlub$uQf6qm0X@ZUHOfh>Kf5<)70D z!(oApU#BF9a4e@n3dSa-?F=vEG5~jABt&;plAz23@T1)TiYbXH;bcd0F*7nn-{b;H zITgLn>rb*MB+F22TNMWX%{N1}zu>9|G$qWkH%(e)BdQMJ|IatysPQLJB`3R~IdL5T zyjE4L$T;|CWBXGBP{MaN}6kS~syl8D)G zCuQj9@9ca+LjwVQLN5sqSOhtE;Fl`=Y3CE_nFl$4yT72+ZH*EC7JWij1n6x5uE#VX zh%z*zpsy=KKkyHfakHV*Ol<}L#?dEq`~H_(uNjHFG*QRrvI%{VE-55P2})RTRQ*rr z_K(j#49D4Cg18)`Tfs7NyN~p-8_2typsVlCFC0+Dl*bXzMm@!!pUDQGw%0`IdcE6E zXsF+zK)zHmgrwNs>dB^!=^~{e^nZ7^0K6_(w0Sh8DAD=>LCjWLqfU5Xp<&tDxsD7p z!H<$>jiXpHW;DDWR!>_@a7fszn1J-1n*=2McXPs66spaiOEG{vQnV0MvF!ZTaF|^M z5)lnkwkx-MPu=3x*gWj)mwJoJF33$9>fgClO1BW;Bm@fuS9I$RM*=D16CG(MXUT=3 zG@nMPQk8IwvP49&(V<}bQ-lLxbZ&7vhUy_2Wddah)Fz=BAdji&BO}lguqQ=xNpqFT z_%WJgvqJApZ;VF61x-`gfYv{R1jR@i<3PS$Ae>M!b;pW6p{5yxKE5P4jH85@8H^G^ z*c``bM8q8-i5WSUEy&K|0A^u^xlqE7(kZ>Ulf!lTDSOW0dz4J{G??4xV<`@aDV9Pj zBLB&7EMdxq6wkJE(l!>s7DG^dD{z5mgB?eFO0zgr%U>8FU_hYsTUkwUVTA;Gb?pF6 zXEblSxO&O;E|c44A6;C%bVw6m+=w`w5Oc{Wgkcf6aMYJDG{M-{a+E8GR0CG*F+>4Y zvK-Ep?Fuzc6ZV&{Ucb6|^|DA&)TNNQE(GI(n=+jnE53h zG*q2*?`D)8yVg`ivj}=UnjBf}R5h~z!VMOU$_wvbGG>$Ql)!YBiLik_#&3ba2 zw|bGhm+w+#1%mp#%&lDk1uDKu=3YIL%p*n7uTHw9i<9 zvlhTf3{cDJSIQ+ZpbrUE@$qtw>v|M%+aIi?nTVqaNr=J_MJME1m_vd5yuc8*dOsf7 zNB3A$C@YC3+~pxx!)x$sf(FcYbUwWFHk4c;*^rs0^UAr^NddeSH&<*luCaUhO+{^W zV^0eVySXKRi{a0M8JS7KSvnyb#L+Agz9d|@DN134dIkHGd=z#L@_h?b0&^Zs6h?nS zOiK82Ro)xT!Gh7C`7ckOXceSqTJ7m3q_7EuvF3KG%5Eu4h2#xIx{{NJ{6fQxb<({u zNOtTk4T<)mfT=3YO+MS^<{w+lUJle!(Ye3J)^#uXsjhBOr1s_=5McC2#NjI#T+H1) zEpYw&_Z;Qp75pjXEPb}G#4KH0@~#o7!mE6>qSBfUY1l{CgrM%mnGxZlmoh0w24o;t zJ|>tY2#;tcl*j{C^k^~_)=HsNY#nYMmQjGq1BO!qVI30QKTZygT7jilOl!bkF_mBjGa^_N@K#X9SWpelni4*t z+tY(~XtA{$o`+Uzjjai>u!5i3z^Pu`C{kJ7LqkVDq1)pQ9h}mHvfR31Mz<<23%16N28Cd!w(i^N**U-WYD0zZ!shvM8Kl`M;hh=)VT~N`2Wsea8N* zkG@|>VNIw5Aa{6D9n<=Q(^D`wq)KE{;|4iQN+lCXsNpQom5C`hagF0B5WJ5rltO6q zW_88^+jEzElt@g%UmZ^))q-=@*heK2LBsQ|dP!NOc7(b7#AY>kd3_IT!_5&I(K*6Z zmqg@(SbZyniDErW$2_Ay*tBKCQqxHUW($YlKE(HPzO z$^Sv=|MTzLO7oFbk;-#m zVe=;7*Xuw}XdURc@794ECj8*=N~+#Gnk&;NVROQib%V}kxmQw%xBxx~=h5KCijz=D zD7rJu08%2Gf>c-*Wv2g6MwGGnwxu8Mk{Aa40|imH=$`=&&r&k3ZyqQMVai)0qDh(6 zD6;LibiBJu2)iO<+3>%l+6vk!7&9ZD8}ZF?sxj94&LWBj>JwJ82D*V>c3zH3e2=Je8D)lmc~2J)W$j*efGtCE9C(%BR-j@AWEI6RU{?@A4A>g^qr(kWo0UVl z-cj_WqOs>>mB`IFlYM?{%N zAw)x)N(IB?aTL^XG{0S4Ngu*<3q(68jA9ymP&@yvThI67U5PXl#{Z?d(R^&S#Cmo) zMym%`B6DjUt1Zz^m7k2}G0H=7DSsIawXzNEG~)xWbe{3+`A#%`^>$~Pa$#03Gb`7xN;6}`|B{?YTIkl*#FyLMqVbA0TG3u~Npmnr+^54tOE6e#d; zmZ{$S%SQXcPf*1*?I<0ZR^+ELt1T93Zbr}yKzf!}SJHR|H&fvYAVV@Q^-{wz=t08aVw744bdZ*C}!`eCeGEp0vqLswfDzr3x>-Ji>tJ1TdpE22AqH)iyxYqWZYFKPOy z1m0x*XdmGQ3?TK5tZf6Sna$RA*S3T>4L5rad*pgWt4K#u+h!Xn(AFAt+5ydZ@7^ZrreinF>V{m& z^G>ariaJA9vLTkN&S{U5a}B+TKSiL6&W z!fCW;Qx8ZXa#piOBOH97mvh!!R;`)(aCASmrqb z71}FnMeDUUEb{QuE~%7a-L}{StnR!LkG(jv0_c@xkgCnnG=?@|q;>PvgKic})&Sf^ zKxqeGJNKR{T(zumm3q81_Ed41B9IjX6%jYKrx{IkLzUd78jjC$s=+PKcQ&TJ)sx^h zp0t!ASKmxy2xqrZZz80#xuv!Bx~6IF)`PV*ykZxc)w~)0ctMjOV~ix?Vk*UyWJ0XT z(HKFJ+(Ku|s3R~F;~Y#$nCZ~|y_C^_a1NQTuoMB5cZxE=?>u?(1l6*5m2AeGVW%|g z#bi!Y8aFE~=y$vzb?|*OA~C(&M>sSgFp&O^5iVF12oiSn5LSQ($|YD!8eHHc#4O~4 zCpo}plCb5&xHe_%v3q}K=VfU$1yS#8=l1p%67ax1)Ng5WMX5N0WC}YvMTx$kJilJ$ zN?lQ|fveO#rlmSrEpMppl5+RraoJ|bXsU8BpTB->@&r9W=fFbb##2dLb&9O<0>QD9 z%?9M`&f8v3?tB}g|9;>8`OB>p5g-7=O%!+A-`P2j;hajP4v|y<{Ffkc3m~IDW78Hq zb)IiG1|HG5m!C+ZuFhEyqPwZe08`Fu*hfD&3E!$RT2*DU8zaY~0QF(goJ3^*rWtg> z_~ja$fA7@F(X~^)r%_Bt1C^O%pp3UVQJ-JTY2cYj_as;}%0!e*c2wR)m8dD=Xoe$& vOQGD#@4NJ4$zBUaNPDlJ^}n)Qy4z>_Y@hA(H9h|~00960Igb)J0D1@jcSl10 literal 0 HcmV?d00001 diff --git a/charts/github-app-operator/templates/_helpers.tpl b/charts/github-app-operator/templates/_helpers.tpl new file mode 100644 index 00000000..213e37b5 --- /dev/null +++ b/charts/github-app-operator/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "github-app-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "github-app-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "github-app-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "github-app-operator.labels" -}} +helm.sh/chart: {{ include "github-app-operator.chart" . }} +{{ include "github-app-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "github-app-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "github-app-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "github-app-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "github-app-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/github-app-operator/templates/deployment.yaml b/charts/github-app-operator/templates/deployment.yaml new file mode 100644 index 00000000..7aa80e0f --- /dev/null +++ b/charts/github-app-operator/templates/deployment.yaml @@ -0,0 +1,111 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "github-app-operator.fullname" . }}-controller-manager + labels: + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + control-plane: controller-manager + {{- include "github-app-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.controllerManager.replicas }} + selector: + matchLabels: + control-plane: controller-manager + {{- include "github-app-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + control-plane: controller-manager + {{- include "github-app-operator.selectorLabels" . | nindent 8 }} + annotations: + kubectl.kubernetes.io/default-container: manager + spec: + containers: + - args: {{- toYaml .Values.controllerManager.manager.args | nindent 8 }} + command: + - /manager + env: + - name: CHECK_INTERVAL + value: {{ quote .Values.controllerManager.manager.env.checkInterval }} + - name: EXPIRY_THRESHOLD + value: {{ quote .Values.controllerManager.manager.env.expiryThreshold }} + - name: DEBUG_LOG + value: {{ quote .Values.controllerManager.manager.env.debugLog }} + - name: VAULT_ROLE + value: {{ quote .Values.controllerManager.manager.env.vaultRole }} + - name: VAULT_ROLE_AUDIENCE + value: {{ quote .Values.controllerManager.manager.env.vaultRoleAudience }} + - name: VAULT_ADDR + value: {{ quote .Values.controllerManager.manager.env.vaultAddr }} + - name: GITHUB_PROXY + value: {{ quote .Values.controllerManager.manager.env.githubProxy }} + - name: VAULT_NAMESPACE + value: {{ quote .Values.controllerManager.manager.env.vaultNamespace }} + - name: VAULT_PROXY_ADDR + value: {{ quote .Values.controllerManager.manager.env.vaultProxyAddr }} + - name: ENABLE_WEBHOOKS + value: {{ quote .Values.controllerManager.manager.env.enableWebhooks }} + - name: KUBERNETES_CLUSTER_DOMAIN + value: {{ quote .Values.kubernetesClusterDomain }} + image: {{ .Values.controllerManager.manager.image.repository }}:{{ .Values.controllerManager.manager.image.tag + | default .Chart.AppVersion }} + imagePullPolicy: {{ .Values.controllerManager.manager.imagePullPolicy }} + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: {{- toYaml .Values.controllerManager.manager.resources | nindent 10 + }} + securityContext: {{- toYaml .Values.controllerManager.manager.containerSecurityContext + | nindent 10 }} + volumeMounts: + {{- if .Values.webhook.enabled -}} + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + {{- end }} + - mountPath: /var/run/github-app-secrets + name: github-app-secrets + - args: {{- toYaml .Values.controllerManager.kubeRbacProxy.args | nindent 8 }} + env: + - name: KUBERNETES_CLUSTER_DOMAIN + value: {{ quote .Values.kubernetesClusterDomain }} + image: {{ .Values.controllerManager.kubeRbacProxy.image.repository }}:{{ .Values.controllerManager.kubeRbacProxy.image.tag + | default .Chart.AppVersion }} + name: kube-rbac-proxy + ports: + - containerPort: 8443 + name: https + protocol: TCP + resources: {{- toYaml .Values.controllerManager.kubeRbacProxy.resources | nindent + 10 }} + securityContext: {{- toYaml .Values.controllerManager.kubeRbacProxy.containerSecurityContext + | nindent 10 }} + securityContext: {{- toYaml .Values.controllerManager.podSecurityContext | nindent + 8 }} + serviceAccountName: {{ include "github-app-operator.fullname" . }}-controller-manager + terminationGracePeriodSeconds: 10 + volumes: + {{- if .Values.webhook.enabled -}} + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert + {{- end }} + - emptyDir: {} + name: github-app-secrets \ No newline at end of file diff --git a/charts/github-app-operator/templates/githubapp-crd.yaml b/charts/github-app-operator/templates/githubapp-crd.yaml new file mode 100644 index 00000000..dcfd9af2 --- /dev/null +++ b/charts/github-app-operator/templates/githubapp-crd.yaml @@ -0,0 +1,135 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: githubapps.githubapp.samir.io + annotations: + {{- if .Values.webhook.enabled -}} + cert-manager.io/inject-ca-from: '{{ .Release.Namespace }}/{{ include "github-app-operator.fullname" + . }}-serving-cert' + {{- end }} + controller-gen.kubebuilder.io/version: v0.14.0 + labels: + {{- include "github-app-operator.labels" . | nindent 4 }} +spec: + {{- if .Values.webhook.enabled -}} + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + name: '{{ include "github-app-operator.fullname" . }}-webhook-service' + namespace: '{{ .Release.Namespace }}' + path: /convert + conversionReviewVersions: + - v1 + {{- end }} + group: githubapp.samir.io + names: + kind: GithubApp + listKind: GithubAppList + plural: githubapps + singular: githubapp + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.appId + name: App ID + type: string + - jsonPath: .spec.accessTokenSecret + name: Access Token Secret + type: string + - jsonPath: .spec.installId + name: Install ID + type: string + - jsonPath: .status.expiresAt + name: Expires At + type: string + - jsonPath: .status.error + name: Error + type: string + name: v1 + schema: + openAPIV3Schema: + description: GithubApp is the Schema for the githubapps API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: GithubAppSpec defines the desired state of GithubApp + properties: + accessTokenSecret: + type: string + appId: + type: integer + googlePrivateKeySecret: + type: string + installId: + type: integer + privateKeySecret: + type: string + rolloutDeployment: + description: RolloutDeploymentSpec defines the specification for restarting + pods + properties: + labels: + additionalProperties: + type: string + type: object + type: object + vaultPrivateKey: + description: VaultPrivateKeySpec defines the spec for retrieving the + private key from Vault + properties: + mountPath: + type: string + secretKey: + type: string + secretPath: + type: string + required: + - mountPath + - secretKey + - secretPath + type: object + required: + - accessTokenSecret + - appId + - installId + type: object + status: + description: GithubAppStatus defines the observed state of GithubApp + properties: + error: + description: Error field to store error messages + type: string + expiresAt: + description: Expiry of access token + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] \ No newline at end of file diff --git a/charts/github-app-operator/templates/leader-election-rbac.yaml b/charts/github-app-operator/templates/leader-election-rbac.yaml new file mode 100644 index 00000000..aea639ac --- /dev/null +++ b/charts/github-app-operator/templates/leader-election-rbac.yaml @@ -0,0 +1,59 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "github-app-operator.fullname" . }}-leader-election-role + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "github-app-operator.fullname" . }}-leader-election-rolebinding + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: '{{ include "github-app-operator.fullname" . }}-leader-election-role' +subjects: +- kind: ServiceAccount + name: '{{ include "github-app-operator.fullname" . }}-controller-manager' + namespace: '{{ .Release.Namespace }}' \ No newline at end of file diff --git a/charts/github-app-operator/templates/manager-rbac.yaml b/charts/github-app-operator/templates/manager-rbac.yaml new file mode 100644 index 00000000..c9af6e79 --- /dev/null +++ b/charts/github-app-operator/templates/manager-rbac.yaml @@ -0,0 +1,94 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "github-app-operator.fullname" . }}-manager-role + labels: + {{- include "github-app-operator.labels" . | nindent 4 }} +rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - get +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create + - get +- apiGroups: + - githubapp.samir.io + resources: + - githubapps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - githubapp.samir.io + resources: + - githubapps/finalizers + verbs: + - update +- apiGroups: + - githubapp.samir.io + resources: + - githubapps/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "github-app-operator.fullname" . }}-manager-rolebinding + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: '{{ include "github-app-operator.fullname" . }}-manager-role' +subjects: +- kind: ServiceAccount + name: '{{ include "github-app-operator.fullname" . }}-controller-manager' + namespace: '{{ .Release.Namespace }}' \ No newline at end of file diff --git a/charts/github-app-operator/templates/metrics-reader-rbac.yaml b/charts/github-app-operator/templates/metrics-reader-rbac.yaml new file mode 100644 index 00000000..fa82930d --- /dev/null +++ b/charts/github-app-operator/templates/metrics-reader-rbac.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "github-app-operator.fullname" . }}-metrics-reader + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} +rules: +- nonResourceURLs: + - /metrics + verbs: + - get \ No newline at end of file diff --git a/charts/github-app-operator/templates/metrics-service.yaml b/charts/github-app-operator/templates/metrics-service.yaml new file mode 100644 index 00000000..62193b5c --- /dev/null +++ b/charts/github-app-operator/templates/metrics-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "github-app-operator.fullname" . }}-controller-manager-metrics-service + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + control-plane: controller-manager + {{- include "github-app-operator.labels" . | nindent 4 }} +spec: + type: {{ .Values.metricsService.type }} + selector: + control-plane: controller-manager + {{- include "github-app-operator.selectorLabels" . | nindent 4 }} + ports: + {{- .Values.metricsService.ports | toYaml | nindent 2 }} \ No newline at end of file diff --git a/charts/github-app-operator/templates/proxy-rbac.yaml b/charts/github-app-operator/templates/proxy-rbac.yaml new file mode 100644 index 00000000..dfeb4b7a --- /dev/null +++ b/charts/github-app-operator/templates/proxy-rbac.yaml @@ -0,0 +1,40 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "github-app-operator.fullname" . }}-proxy-role + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "github-app-operator.fullname" . }}-proxy-rolebinding + labels: + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: '{{ include "github-app-operator.fullname" . }}-proxy-role' +subjects: +- kind: ServiceAccount + name: '{{ include "github-app-operator.fullname" . }}-controller-manager' + namespace: '{{ .Release.Namespace }}' \ No newline at end of file diff --git a/charts/github-app-operator/templates/selfsigned-issuer.yaml b/charts/github-app-operator/templates/selfsigned-issuer.yaml new file mode 100644 index 00000000..193aa6d0 --- /dev/null +++ b/charts/github-app-operator/templates/selfsigned-issuer.yaml @@ -0,0 +1,13 @@ +{{- if .Values.webhook.enabled -}} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ include "github-app-operator.fullname" . }}-selfsigned-issuer + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "1" + labels: + {{- include "github-app-operator.labels" . | nindent 4 }} +spec: + selfSigned: {} +{{- end }} \ No newline at end of file diff --git a/charts/github-app-operator/templates/serviceaccount.yaml b/charts/github-app-operator/templates/serviceaccount.yaml new file mode 100644 index 00000000..3c90765a --- /dev/null +++ b/charts/github-app-operator/templates/serviceaccount.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "github-app-operator.fullname" . }}-controller-manager + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + {{- include "github-app-operator.labels" . | nindent 4 }} + annotations: + {{- toYaml .Values.controllerManager.serviceAccount.annotations | nindent 4 }} \ No newline at end of file diff --git a/charts/github-app-operator/templates/serving-cert.yaml b/charts/github-app-operator/templates/serving-cert.yaml new file mode 100644 index 00000000..2ba53d2e --- /dev/null +++ b/charts/github-app-operator/templates/serving-cert.yaml @@ -0,0 +1,21 @@ +{{- if .Values.webhook.enabled -}} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "github-app-operator.fullname" . }}-serving-cert + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "2" + labels: + {{- include "github-app-operator.labels" . | nindent 4 }} +spec: + dnsNames: + - '{{ include "github-app-operator.fullname" . }}-webhook-service.{{ .Release.Namespace + }}.svc' + - '{{ include "github-app-operator.fullname" . }}-webhook-service.{{ .Release.Namespace + }}.svc.{{ .Values.kubernetesClusterDomain }}' + issuerRef: + kind: Issuer + name: '{{ include "github-app-operator.fullname" . }}-selfsigned-issuer' + secretName: webhook-server-cert +{{- end }} \ No newline at end of file diff --git a/charts/github-app-operator/templates/validating-webhook-configuration.yaml b/charts/github-app-operator/templates/validating-webhook-configuration.yaml new file mode 100644 index 00000000..e072f085 --- /dev/null +++ b/charts/github-app-operator/templates/validating-webhook-configuration.yaml @@ -0,0 +1,31 @@ +{{- if .Values.webhook.enabled -}} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: {{ include "github-app-operator.fullname" . }}-validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "github-app-operator.fullname" . }}-serving-cert + labels: + {{- include "github-app-operator.labels" . | nindent 4 }} +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: '{{ include "github-app-operator.fullname" . }}-webhook-service' + namespace: '{{ .Release.Namespace }}' + path: /validate-githubapp-samir-io-v1-githubapp + failurePolicy: Fail + name: vgithubapp.kb.io + rules: + - apiGroups: + - githubapp.samir.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - githubapps + sideEffects: None +{{- end }} \ No newline at end of file diff --git a/charts/github-app-operator/templates/webhook-service.yaml b/charts/github-app-operator/templates/webhook-service.yaml new file mode 100644 index 00000000..33bafe77 --- /dev/null +++ b/charts/github-app-operator/templates/webhook-service.yaml @@ -0,0 +1,15 @@ +{{- if .Values.webhook.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "github-app-operator.fullname" . }}-webhook-service + labels: + {{- include "github-app-operator.labels" . | nindent 4 }} +spec: + type: {{ .Values.webhook.webhookService.type }} + selector: + control-plane: controller-manager + {{- include "github-app-operator.selectorLabels" . | nindent 4 }} + ports: + {{- .Values.webhook.webhookService.ports | toYaml | nindent 2 }} +{{- end }} \ No newline at end of file diff --git a/charts/github-app-operator/values.yaml b/charts/github-app-operator/values.yaml new file mode 100644 index 00000000..37b65e36 --- /dev/null +++ b/charts/github-app-operator/values.yaml @@ -0,0 +1,84 @@ +certmanager: + enabled: false + installCRDs: true + namespace: cert-manager +controllerManager: + kubeRbacProxy: + args: + - --secure-listen-address=0.0.0.0:8443 + - --upstream=http://127.0.0.1:8080/ + - --logtostderr=true + - --v=0 + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + image: + repository: gcr.io/kubebuilder/kube-rbac-proxy + tag: v0.15.0 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 5m + memory: 64Mi + manager: + args: + - --health-probe-bind-address=:8081 + - --metrics-bind-address=127.0.0.1:8080 + - --leader-elect + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + env: + checkInterval: 5m + debugLog: "false" + enableWebhooks: "false" + expiryThreshold: 15m + githubProxy: "" + vaultAddr: http://vault.default:8200 + vaultNamespace: "" + vaultProxyAddr: "" + vaultRole: githubapp + vaultRoleAudience: githubapp + image: + repository: samirtahir91076/github-app-operator + tag: latest + imagePullPolicy: Never + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + podSecurityContext: + fsGroup: 65532 + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + replicas: 1 + serviceAccount: + annotations: {} +kubernetesClusterDomain: cluster.local +metricsService: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + type: ClusterIP +webhook: + enabled: false + webhookService: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + type: ClusterIP diff --git a/cmd/main.go b/cmd/main.go index 8c44c59d..d0db1d0f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -187,6 +187,12 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "GithubApp") os.Exit(1) } + if os.Getenv("ENABLE_WEBHOOKS") == "true" { + if err = (&githubappv1.GithubApp{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "GithubApp") + os.Exit(1) + } + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 00000000..eafdd4c1 --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -0,0 +1,35 @@ +# The following manifests contain a self-signed issuer CR and a certificate CR. +# More document can be found at https://docs.cert-manager.io +# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + labels: + app.kubernetes.io/name: github-app-operator + app.kubernetes.io/managed-by: kustomize + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + app.kubernetes.io/name: certificate + app.kubernetes.io/instance: serving-cert + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + app.kubernetes.io/managed-by: kustomize + name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml + namespace: system +spec: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + dnsNames: + - SERVICE_NAME.SERVICE_NAMESPACE.svc + - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 00000000..bebea5a5 --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- certificate.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 00000000..cf6f89e8 --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -0,0 +1,8 @@ +# This configuration is for teaching kustomize how to update name ref substitution +nameReference: +- kind: Issuer + group: cert-manager.io + fieldSpecs: + - kind: Certificate + group: cert-manager.io + path: spec/issuerRef/name diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index e06a53f2..b78ce053 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -8,16 +8,16 @@ resources: patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD -#- path: patches/webhook_in_githubapps.yaml +- path: patches/webhook_in_githubapps.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD -#- path: patches/cainjection_in_githubapps.yaml +- path: patches/cainjection_in_githubapps.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # [WEBHOOK] To enable webhook, uncomment the following section # the following config is for teaching kustomize how to do kustomization for CRDs. -#configurations: -#- kustomizeconfig.yaml +configurations: +- kustomizeconfig.yaml diff --git a/config/crd/patches/cainjection_in_githubapps.yaml b/config/crd/patches/cainjection_in_githubapps.yaml new file mode 100644 index 00000000..f17d4bb7 --- /dev/null +++ b/config/crd/patches/cainjection_in_githubapps.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: githubapps.githubapp.samir.io diff --git a/config/crd/patches/webhook_in_githubapps.yaml b/config/crd/patches/webhook_in_githubapps.yaml new file mode 100644 index 00000000..3e089b8a --- /dev/null +++ b/config/crd/patches/webhook_in_githubapps.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: githubapps.githubapp.samir.io +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index f9fe748e..2efae3d0 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -20,9 +20,9 @@ resources: - ../manager # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- ../webhook +- ../webhook # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager +- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus @@ -34,7 +34,7 @@ patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- path: manager_webhook_patch.yaml +- path: manager_webhook_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. @@ -43,100 +43,100 @@ patches: # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. # Uncomment the following replacements to add the cert-manager CA injection annotations -#replacements: -# - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldPath: .metadata.namespace # namespace of the certificate CR -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - select: -# kind: CustomResourceDefinition -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldPath: .metadata.name -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - select: -# kind: CustomResourceDefinition -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - source: # Add cert-manager annotation to the webhook Service -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.name # namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 0 -# create: true -# - source: -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.namespace # namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 1 -# create: true +replacements: + - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldPath: .metadata.namespace # namespace of the certificate CR + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - select: + kind: CustomResourceDefinition + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldPath: .metadata.name + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - select: + kind: CustomResourceDefinition + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - source: # Add cert-manager annotation to the webhook Service + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.name # namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 0 + create: true + - source: + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.namespace # namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 1 + create: true diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 00000000..738de350 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml new file mode 100644 index 00000000..ad3838a7 --- /dev/null +++ b/config/default/webhookcainjection_patch.yaml @@ -0,0 +1,25 @@ +# This patch add annotation to admission webhook config and +# CERTIFICATE_NAMESPACE and CERTIFICATE_NAME will be substituted by kustomize +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: github-app-operator + app.kubernetes.io/managed-by: kustomize + name: mutating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: validatingwebhookconfiguration + app.kubernetes.io/instance: validating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: github-app-operator + app.kubernetes.io/part-of: github-app-operator + app.kubernetes.io/managed-by: kustomize + name: validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index ad13e96b..213f28b8 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: controller + newName: samirtahir91076/github-app-operator newTag: latest diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 5c63178d..dc5b0194 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -71,7 +71,7 @@ spec: - /manager args: - --leader-elect - image: github-app-operator:latest + image: controller imagePullPolicy: Never name: manager securityContext: @@ -122,6 +122,9 @@ spec: # Optional proxy for Vault - name: VAULT_PROXY_ADDR value: "" + # Optional enable webhook set to "true" + - name: ENABLE_WEBHOOKS + value: "true" # optional vault env vars - https://pkg.go.dev/github.com/hashicorp/vault/api#pkg-constants # volume to cache private keys volumeMounts: diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 00000000..9cf26134 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 00000000..206316e5 --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,22 @@ +# the following config is for teaching kustomize where to look at when substituting nameReference. +# It requires kustomize v2.1.0 or newer to work properly. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 00000000..8d44ba40 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-githubapp-samir-io-v1-githubapp + failurePolicy: Fail + name: vgithubapp.kb.io + rules: + - apiGroups: + - githubapp.samir.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - githubapps + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 00000000..c1bb2db3 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: github-app-operator + app.kubernetes.io/managed-by: kustomize + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager diff --git a/internal/controller/githubapp_controller.go b/internal/controller/githubapp_controller.go index 3a8be6f2..898a8684 100644 --- a/internal/controller/githubapp_controller.go +++ b/internal/controller/githubapp_controller.go @@ -20,7 +20,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/golang-jwt/jwt/v4" "math/rand" "net/http" "os" @@ -29,7 +28,10 @@ import ( "sync" "time" + "github.com/golang-jwt/jwt/v4" + githubappv1 "github-app-operator/api/v1" + vault "github.com/hashicorp/vault/api" // vault client appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" diff --git a/internal/controller/githubapp_controller_test.go b/internal/controller/githubapp_controller_test.go index 97134360..6dd331f4 100644 --- a/internal/controller/githubapp_controller_test.go +++ b/internal/controller/githubapp_controller_test.go @@ -29,6 +29,7 @@ import ( test_helpers "github-app-operator/internal/controller/test_helpers" githubappv1 "github-app-operator/api/v1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 44de1c9e..4f4a898f 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" // http client "os" + //"os/exec" "path/filepath" "runtime" @@ -37,6 +38,7 @@ import ( . "github.com/onsi/gomega" test_helpers "github-app-operator/internal/controller/test_helpers" + "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" @@ -155,7 +157,7 @@ var _ = BeforeSuite(func() { // Verify if reconciliation was successful Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("Token request failed: %v", err)) } else { - // Set a dummy token just to satisfy the SetupWithManager fucntion + // Set a dummy token just to satisfy the SetupWithManager function // which will read the token and get the service account and and namespace. // This token is for 'default' service account in the 'namespace0' namespace token = `eyJhbGciOiJSUzI1NiIsImtpZCI6Ik5ieTJyVUk2ZzlQZ0k0anNGclRvTkJDM0FsUjJjLUJDVUhzNU9mVG9lcEUifQ. diff --git a/internal/controller/test_helpers/test_helpers.go b/internal/controller/test_helpers/test_helpers.go index 67462f20..51c4203a 100644 --- a/internal/controller/test_helpers/test_helpers.go +++ b/internal/controller/test_helpers/test_helpers.go @@ -13,6 +13,7 @@ import ( gomega "github.com/onsi/gomega" githubappv1 "github-app-operator/api/v1" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" diff --git a/internal/controller/vault.go b/internal/controller/vault.go index 2e518bfe..61990d40 100644 --- a/internal/controller/vault.go +++ b/internal/controller/vault.go @@ -20,6 +20,7 @@ import ( "context" "encoding/base64" "fmt" + "k8s.io/utils/ptr" auth "github.com/hashicorp/vault/api/auth/kubernetes" // vault k8s auth diff --git a/scripts/delete_vault.sh b/scripts/delete_vault.sh index fc796888..cbf7750b 100644 --- a/scripts/delete_vault.sh +++ b/scripts/delete_vault.sh @@ -3,4 +3,4 @@ # Run this script to delete the vault setup in kubernetes helm delete vault -kubectl delete pvc data-vault-0 \ No newline at end of file +#kubectl delete pvc data-vault-0 \ No newline at end of file diff --git a/scripts/install_and_setup_vault_k8s.sh b/scripts/install_and_setup_vault_k8s.sh index 55fad4e8..55761de6 100644 --- a/scripts/install_and_setup_vault_k8s.sh +++ b/scripts/install_and_setup_vault_k8s.sh @@ -8,7 +8,8 @@ helm repo add hashicorp https://helm.releases.hashicorp.com helm repo update # install vault with single node -helm install vault hashicorp/vault --values helm-vault-raft-values.yml +helm install vault hashicorp/vault \ + --set server.dataStorage.enabled=false kubectl get pods # wait for vault to run diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 982aad57..2023f011 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -34,23 +34,37 @@ var _ = Describe("controller", Ordered, func() { By("installing prometheus operator") Expect(utils.InstallPrometheusOperator()).To(Succeed()) + By("install and setup Vault") + Expect(utils.InstallAndSetupVault()).To(Succeed()) + By("installing the cert-manager") Expect(utils.InstallCertManager()).To(Succeed()) By("creating manager namespace") cmd := exec.Command("kubectl", "create", "ns", namespace) _, _ = utils.Run(cmd) + + By("removing operator crds") + cmd = exec.Command("make", "uninstall") + _, _ = utils.Run(cmd) }) AfterAll(func() { By("uninstalling the Prometheus manager bundle") utils.UninstallPrometheusOperator() + By("removing manager namespace") + cmd := exec.Command("kubectl", "delete", "ns", namespace) + _, _ = utils.Run(cmd) + By("uninstalling the cert-manager bundle") utils.UninstallCertManager() - By("removing manager namespace") - cmd := exec.Command("kubectl", "delete", "ns", namespace) + By("uninstalling Vault") + utils.UninstallVault() + + By("removing operator crds") + cmd = exec.Command("make", "uninstall") _, _ = utils.Run(cmd) }) @@ -60,16 +74,23 @@ var _ = Describe("controller", Ordered, func() { var err error // projectimage stores the name of the image used in the example - var projectimage = "example.com/github-app-operator:v0.0.1" + var projectimage = "samirtahir91076/github-app-operator:e2e-test" + + By("setting the terminal to use the docker daemon inside minikube") + cmd := exec.Command("/bin/sh", "-c", "eval $(minikube docker-env)") + _, err = utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) By("building the manager(Operator) image") - cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) + cmd = exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred()) + /* Using minikube for now By("loading the the manager(Operator) image on Kind") err = utils.LoadImageToKindClusterWithName(projectimage) ExpectWithOffset(1, err).NotTo(HaveOccurred()) + */ By("installing CRDs") cmd = exec.Command("make", "install") diff --git a/test/utils/utils.go b/test/utils/utils.go index 545ab0fa..383320b8 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -21,6 +21,8 @@ import ( "os" "os/exec" "strings" + "time" + //lint:ignore ST1001 this is boilerplate code from kubebuilder . "github.com/onsi/ginkgo/v2" //nolint:golint,revive ) @@ -30,7 +32,7 @@ const ( prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + "releases/download/%s/bundle.yaml" - certmanagerVersion = "v1.5.3" + certmanagerVersion = "v1.12.2" certmanagerURLTmpl = "https://github.com/jetstack/cert-manager/releases/download/%s/cert-manager.yaml" ) @@ -99,8 +101,46 @@ func InstallCertManager() error { "--timeout", "5m", ) - _, err := Run(cmd) - return err + if _, err := Run(cmd); err != nil { + return err + } + + // check caBundle + retryInterval := time.Second * 5 + retryTimeout := time.Minute * 2 + start := time.Now() + + for time.Since(start) < retryTimeout { + cmd := exec.Command("kubectl", "get", "mutatingwebhookconfiguration", "cert-manager-webhook", + "-o", "jsonpath={.webhooks[*].clientConfig.caBundle}") + output, err := Run(cmd) + if err == nil && strings.TrimSpace(string(output)) != "" { + return nil + } + + time.Sleep(retryInterval) + } + return fmt.Errorf("failed to get caBundle from MutatingWebhookConfiguration") +} + +// UninstallVault uninstalls the Vault +func UninstallVault() { + installScript := "./scripts/delete_vault.sh" + cmd := exec.Command("/bin/sh", "-c", installScript) + if _, err := Run(cmd); err != nil { + warnError(err) + } +} + +// Install and setup Vault with github app private key +func InstallAndSetupVault() error { + installScript := "./scripts/install_and_setup_vault_k8s.sh" + cmd := exec.Command("/bin/sh", "-c", installScript) + if _, err := Run(cmd); err != nil { + return err + } + + return nil } // LoadImageToKindCluster loads a local docker image to the kind cluster