From 9d2a4ebee749807ccedec39f4be0147cd6de571e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 16 Mar 2026 14:39:22 +0000 Subject: [PATCH 01/30] chore: Install demo stuff and couple to public website --- .gitignore | 3 + Makefile | 5 +- test/e2e/cluster/start-cluster.sh | 33 +++++--- test/e2e/setup/flux/kustomization.yaml | 1 + test/e2e/setup/flux/releases/ingress.yaml | 75 +++++++++++++++++++ test/e2e/setup/manifests/README.md | 3 + .../manifests/cloudflared-public/README.md | 26 +++++++ .../cloudflared-deplyoment.yaml | 40 ++++++++++ .../cloudflared-public/kustomization.yaml | 7 ++ .../cloudflared-public/middleware.yaml | 9 +++ .../manifests/cloudflared-public/ns.yaml | 4 + .../tunnel-credentials.yaml.example | 8 ++ test/e2e/setup/manifests/kustomization.yaml | 9 +++ test/e2e/setup/manifests/vote/ingress.yaml | 21 ++++++ .../setup/manifests/vote/kustomization.yaml | 7 ++ test/e2e/setup/manifests/vote/ns.yaml | 4 + test/e2e/setup/manifests/vote/podinfo.yaml | 56 ++++++++++++++ 17 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 test/e2e/setup/flux/releases/ingress.yaml create mode 100644 test/e2e/setup/manifests/cloudflared-public/README.md create mode 100644 test/e2e/setup/manifests/cloudflared-public/cloudflared-deplyoment.yaml create mode 100644 test/e2e/setup/manifests/cloudflared-public/kustomization.yaml create mode 100644 test/e2e/setup/manifests/cloudflared-public/middleware.yaml create mode 100644 test/e2e/setup/manifests/cloudflared-public/ns.yaml create mode 100644 test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml.example create mode 100644 test/e2e/setup/manifests/kustomization.yaml create mode 100644 test/e2e/setup/manifests/vote/ingress.yaml create mode 100644 test/e2e/setup/manifests/vote/kustomization.yaml create mode 100644 test/e2e/setup/manifests/vote/ns.yaml create mode 100644 test/e2e/setup/manifests/vote/podinfo.yaml diff --git a/.gitignore b/.gitignore index 4f01e8feb..d05055795 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ charts/gitops-reverser/config/*.yaml *.o.yaml *.ignore.* .stamps/* +test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml + +*tunnel-credentials.yaml \ No newline at end of file diff --git a/Makefile b/Makefile index 503204790..c5e40e1c8 100644 --- a/Makefile +++ b/Makefile @@ -365,7 +365,10 @@ $(CS)/flux-setup.ready: $(FLUX_SETUP_READY_INPUTS) | $(CS) # Aggregate stamp for external E2E services required by tests. $(CS)/services.ready: $(SERVICES_READY_INPUTS) | $(CS) kubectl --context $(CTX) wait --for=condition=Established crd/prometheuses.monitoring.coreos.com crd/servicemonitors.monitoring.coreos.com --timeout=180s - kubectl --context $(CTX) apply -R -f $(LAST_STEP_MANIFESTS_DIR) + kubectl --context $(CTX) apply -k $(LAST_STEP_MANIFESTS_DIR) + if [ -f $(LAST_STEP_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml ]; then + kubectl --context $(CTX) apply -f $(LAST_STEP_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml + fi touch $@ # Step 1: Generate age key file β€” no cluster/namespace dependency; safe to run before installation. diff --git a/test/e2e/cluster/start-cluster.sh b/test/e2e/cluster/start-cluster.sh index 57489bd5d..ccdcc6aa9 100644 --- a/test/e2e/cluster/start-cluster.sh +++ b/test/e2e/cluster/start-cluster.sh @@ -6,6 +6,7 @@ set -euo pipefail CLUSTER_NAME="${CLUSTER_NAME:-gitops-reverser-test-e2e}" +DISABLE_K3S_TRAEFIK="${DISABLE_K3S_TRAEFIK:-true}" AUDIT_DIR_REL="test/e2e/cluster/audit" K3D_CREATE_LOG_FILE="${TMPDIR:-/tmp}/k3d-create-${CLUSTER_NAME}.log" REPO_PWD="$(pwd -P)" @@ -124,24 +125,38 @@ ensure_k3d_stat_compat_path() { create_cluster() { local host_project_path="$1" local audit_host_dir="${host_project_path}/${AUDIT_DIR_REL}" + local k3s_args=( + "--kube-apiserver-arg=audit-policy-file=/etc/kubernetes/audit/${AUDIT_POLICY_FILE}@server:0" + "--kube-apiserver-arg=audit-webhook-config-file=/etc/kubernetes/audit/${AUDIT_WEBHOOK_CONFIG_FILE}@server:0" + "--kube-apiserver-arg=audit-webhook-batch-max-wait=1s@server:0" + "--kube-apiserver-arg=audit-webhook-batch-max-size=10@server:0" + ) echo "πŸš€ Creating k3d cluster '${CLUSTER_NAME}' with audit webhook support..." echo "πŸ”§ Using HOST_PROJECT_PATH: ${host_project_path}" echo "πŸ”§ Mounting audit dir: ${audit_host_dir} -> /etc/kubernetes/audit" + if [ "${DISABLE_K3S_TRAEFIK}" = "true" ]; then + echo "πŸ”§ Disabling packaged k3s Traefik so Flux can install Traefik instead" + k3s_args+=("--disable=traefik@server:0") + fi + # NOTE: no --api-port: let k3d pick an available port. # # Mount audit dir into the server container and pass kube-apiserver audit flags # via k3s "--kube-apiserver-arg=..." options. - k3d cluster create "${CLUSTER_NAME}" \ - --kubeconfig-update-default \ - --kubeconfig-switch-context \ - -v "${audit_host_dir}:/etc/kubernetes/audit@server:0" \ - --k3s-arg "--kube-apiserver-arg=audit-policy-file=/etc/kubernetes/audit/${AUDIT_POLICY_FILE}@server:0" \ - --k3s-arg "--kube-apiserver-arg=audit-webhook-config-file=/etc/kubernetes/audit/${AUDIT_WEBHOOK_CONFIG_FILE}@server:0" \ - --k3s-arg "--kube-apiserver-arg=audit-webhook-batch-max-wait=1s@server:0" \ - --k3s-arg "--kube-apiserver-arg=audit-webhook-batch-max-size=10@server:0" \ - 2>&1 | tee "${K3D_CREATE_LOG_FILE}" + local create_cmd=( + k3d cluster create "${CLUSTER_NAME}" + --kubeconfig-update-default + --kubeconfig-switch-context + -v "${audit_host_dir}:/etc/kubernetes/audit@server:0" + ) + local k3s_arg + for k3s_arg in "${k3s_args[@]}"; do + create_cmd+=(--k3s-arg "${k3s_arg}") + done + + "${create_cmd[@]}" 2>&1 | tee "${K3D_CREATE_LOG_FILE}" } merge_kubeconfig() { diff --git a/test/e2e/setup/flux/kustomization.yaml b/test/e2e/setup/flux/kustomization.yaml index e5d63b5f7..ad704b597 100644 --- a/test/e2e/setup/flux/kustomization.yaml +++ b/test/e2e/setup/flux/kustomization.yaml @@ -10,6 +10,7 @@ resources: - repositories/jetstack.yaml - repositories/gitea-charts.yaml - repositories/valkey.yaml + - releases/ingress.yaml - releases/prometheus-operator.yaml - releases/cert-manager.yaml - releases/gitea.yaml diff --git a/test/e2e/setup/flux/releases/ingress.yaml b/test/e2e/setup/flux/releases/ingress.yaml new file mode 100644 index 000000000..ed720de52 --- /dev/null +++ b/test/e2e/setup/flux/releases/ingress.yaml @@ -0,0 +1,75 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: traefik + namespace: flux-system +spec: + interval: 24h + url: oci://ghcr.io/traefik/helm/traefik + layerSelector: + mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + operation: copy + ref: + semver: "39.0.1" +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: traefik + namespace: flux-system +spec: + interval: 60m + timeout: 10m + chartRef: + kind: OCIRepository + name: traefik + namespace: flux-system + targetNamespace: traefik-system + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + values: + fullnameOverride: traefik + global: + sendAnonymousUsage: false + + deployment: + kind: Deployment + + ingressClass: + enabled: true + isDefaultClass: true + + metrics: + prometheus: + service: + enabled: true + serviceMonitor: + enabled: true + + logs: + general: + level: INFO + access: + enabled: true + + ingressRoute: + dashboard: + enabled: false + + providers: + kubernetesCRD: + enabled: true + allowCrossNamespace: true + kubernetesIngress: + enabled: true + publishedService: + enabled: true + + rbac: + enabled: true + namespaced: false diff --git a/test/e2e/setup/manifests/README.md b/test/e2e/setup/manifests/README.md index bdca27073..b6592e122 100644 --- a/test/e2e/setup/manifests/README.md +++ b/test/e2e/setup/manifests/README.md @@ -5,3 +5,6 @@ Use this for small, cluster-local resources that are still easier to keep as a f as additional Flux sources. Organize related resources in subdirectories when that keeps the intent clearer. + +This directory is applied via the tracked [kustomization.yaml](/workspaces/gitops-reverser2/test/e2e/setup/manifests/kustomization.yaml). +Keep local-only secrets out of that file if they are ignored; apply those separately. diff --git a/test/e2e/setup/manifests/cloudflared-public/README.md b/test/e2e/setup/manifests/cloudflared-public/README.md new file mode 100644 index 000000000..b59cb43ca --- /dev/null +++ b/test/e2e/setup/manifests/cloudflared-public/README.md @@ -0,0 +1,26 @@ +This directory contains a local-only `cloudflared` scaffold for exposing one hostname through the k3d cluster. + +Files: + +- `kustomization.yaml`: tracked resources for the public tunnel namespace and Deployment. +- `cloudflared-deplyoment.yaml`: `cloudflared` Deployment that reads a tunnel token from a Secret. +- `tunnel-credentials.yaml.example`: plaintext example for the local Secret. Copy to `tunnel-credentials.yaml`. + +Setup steps: + +1. Copy `tunnel-credentials.yaml.example` to `tunnel-credentials.yaml`. +2. Paste the `eyJ...` tunnel token from Cloudflare's dashboard into `stringData.token`. +3. In the Cloudflare dashboard, add a public hostname for `vote.reversegitops.dev` and point it at `http://traefik.traefik-system.svc.cluster.local:80`. +4. Deploy the app resources in `../vote`. +5. Apply the manifests: + + ```bash + kubectl apply -k test/e2e/setup/manifests/cloudflared-public + kubectl apply -f test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml + ``` + +Quick checks: + +- `kubectl -n cloudflared-public get pods` +- `kubectl -n cloudflared-public logs deploy/cloudflared --tail=100` +- `kubectl get ingress -A | grep vote.reversegitops.dev` diff --git a/test/e2e/setup/manifests/cloudflared-public/cloudflared-deplyoment.yaml b/test/e2e/setup/manifests/cloudflared-public/cloudflared-deplyoment.yaml new file mode 100644 index 000000000..0b563c8bf --- /dev/null +++ b/test/e2e/setup/manifests/cloudflared-public/cloudflared-deplyoment.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloudflared + namespace: cloudflared-public + labels: + app: cloudflared +spec: + replicas: 2 # High availability + selector: + matchLabels: + app.kubernetes.io/name: cloudflared + template: + metadata: + labels: + app.kubernetes.io/name: cloudflared + spec: + containers: + - name: cloudflared + image: cloudflare/cloudflared:latest + args: + - tunnel + - --no-autoupdate + - --loglevel + - info + - --metrics + - 0.0.0.0:2000 + - run + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: tunnel-credentials + key: token + livenessProbe: + httpGet: + path: /ready + port: 2000 + initialDelaySeconds: 10 + periodSeconds: 10 diff --git a/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml b/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml new file mode 100644 index 000000000..c9e0fcd14 --- /dev/null +++ b/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ns.yaml + - middleware.yaml + - cloudflared-deplyoment.yaml diff --git a/test/e2e/setup/manifests/cloudflared-public/middleware.yaml b/test/e2e/setup/manifests/cloudflared-public/middleware.yaml new file mode 100644 index 000000000..8b08e71a3 --- /dev/null +++ b/test/e2e/setup/manifests/cloudflared-public/middleware.yaml @@ -0,0 +1,9 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ratelimit + namespace: cloudflared-public +spec: + rateLimit: + average: 10 + burst: 5 diff --git a/test/e2e/setup/manifests/cloudflared-public/ns.yaml b/test/e2e/setup/manifests/cloudflared-public/ns.yaml new file mode 100644 index 000000000..568456209 --- /dev/null +++ b/test/e2e/setup/manifests/cloudflared-public/ns.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cloudflared-public diff --git a/test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml.example b/test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml.example new file mode 100644 index 000000000..2c495126e --- /dev/null +++ b/test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml.example @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: tunnel-credentials + namespace: cloudflared-public +type: Opaque +stringData: + token: "eyJh..." diff --git a/test/e2e/setup/manifests/kustomization.yaml b/test/e2e/setup/manifests/kustomization.yaml new file mode 100644 index 000000000..2245fa98d --- /dev/null +++ b/test/e2e/setup/manifests/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - cloudflared-public + - vote + - prometheus/instance.yaml + - prometheus/prometheus-rbac.yaml + - prometheus/servicemonitor.yaml diff --git a/test/e2e/setup/manifests/vote/ingress.yaml b/test/e2e/setup/manifests/vote/ingress.yaml new file mode 100644 index 000000000..acb4c7ec8 --- /dev/null +++ b/test/e2e/setup/manifests/vote/ingress.yaml @@ -0,0 +1,21 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: vote-public + namespace: vote + annotations: + kubernetes.io/ingress.class: traefik + traefik.ingress.kubernetes.io/router.middlewares: cloudflared-public-ratelimit@kubernetescrd +spec: + ingressClassName: traefik + rules: + - host: vote.reversegitops.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: podinfo + port: + number: 80 diff --git a/test/e2e/setup/manifests/vote/kustomization.yaml b/test/e2e/setup/manifests/vote/kustomization.yaml new file mode 100644 index 000000000..ff20c9c1f --- /dev/null +++ b/test/e2e/setup/manifests/vote/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ns.yaml + - podinfo.yaml + - ingress.yaml diff --git a/test/e2e/setup/manifests/vote/ns.yaml b/test/e2e/setup/manifests/vote/ns.yaml new file mode 100644 index 000000000..eaae35e05 --- /dev/null +++ b/test/e2e/setup/manifests/vote/ns.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vote diff --git a/test/e2e/setup/manifests/vote/podinfo.yaml b/test/e2e/setup/manifests/vote/podinfo.yaml new file mode 100644 index 000000000..83c2d4dd9 --- /dev/null +++ b/test/e2e/setup/manifests/vote/podinfo.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: podinfo + namespace: vote + labels: + app.kubernetes.io/name: podinfo +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: podinfo + template: + metadata: + labels: + app.kubernetes.io/name: podinfo + spec: + containers: + - name: podinfo + image: ghcr.io/stefanprodan/podinfo:6.9.2 + ports: + - name: http + containerPort: 9898 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: podinfo + namespace: vote + labels: + app.kubernetes.io/name: podinfo +spec: + selector: + app.kubernetes.io/name: podinfo + ports: + - name: http + port: 80 + targetPort: http From faf356e6b6b7b2f114121f3b3b2f0cbd966e0596 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 16 Mar 2026 15:01:42 +0000 Subject: [PATCH 02/30] Copy raw objects: as we had them --- Makefile | 9 +- test/e2e/setup/manifests/README.md | 3 +- .../manifests/cloudflared-public/README.md | 1 - .../cloudflared-public/kustomization.yaml | 1 + test/e2e/setup/manifests/vote/ingress.yaml | 21 --- .../setup/manifests/vote/ingressroute.yaml | 17 +++ .../manifests/vote/k8s/auth-service-rbac.yaml | 63 +++++++++ .../manifests/vote/k8s/crds/quizsessions.yaml | 133 ++++++++++++++++++ .../vote/k8s/crds/quizsubmissions.yaml | 132 +++++++++++++++++ .../examples/quizsession-kubecon-2026.yaml | 40 ++++++ .../examples/quizsubmission-kubecon-2026.yaml | 22 +++ .../manifests/vote/k8s/ingress-auth.yaml | 29 ++++ .../manifests/vote/k8s/ingress-frontend.yaml | 17 +++ .../manifests/vote/k8s/ingress-kubeapi.yaml | 45 ++++++ .../setup/manifests/vote/k8s/quiz-rbac.yaml | 40 ++++++ .../setup/manifests/vote/k8s/test-deploy.yaml | 115 +++++++++++++++ .../setup/manifests/vote/kustomization.yaml | 2 +- 17 files changed, 663 insertions(+), 27 deletions(-) delete mode 100644 test/e2e/setup/manifests/vote/ingress.yaml create mode 100644 test/e2e/setup/manifests/vote/ingressroute.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/crds/quizsessions.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/crds/quizsubmissions.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml create mode 100644 test/e2e/setup/manifests/vote/k8s/test-deploy.yaml diff --git a/Makefile b/Makefile index c5e40e1c8..3eec5e5fa 100644 --- a/Makefile +++ b/Makefile @@ -321,6 +321,7 @@ $(CS)/ready: test/e2e/cluster/start-cluster.sh | $(CS) FLUX_SERVICES_INPUTS = $(shell find $(FLUX_SERVICES_DIR) -type f) LAST_STEP_MANIFESTS_DIR = test/e2e/setup/manifests +LOCAL_TUNNEL_CREDENTIALS = $(LAST_STEP_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml LAST_STEP_MANIFESTS = $(shell find $(LAST_STEP_MANIFESTS_DIR) -type f -name '*.yaml') FLUX_SETUP_READY_INPUTS = $(CS)/flux.installed $$(FLUX_SERVICES_INPUTS) SERVICES_READY_INPUTS = $(CS)/flux-setup.ready $$(LAST_STEP_MANIFESTS) @@ -365,10 +366,12 @@ $(CS)/flux-setup.ready: $(FLUX_SETUP_READY_INPUTS) | $(CS) # Aggregate stamp for external E2E services required by tests. $(CS)/services.ready: $(SERVICES_READY_INPUTS) | $(CS) kubectl --context $(CTX) wait --for=condition=Established crd/prometheuses.monitoring.coreos.com crd/servicemonitors.monitoring.coreos.com --timeout=180s + @[ -f "$(LOCAL_TUNNEL_CREDENTIALS)" ] || { \ + echo "ERROR: missing required local tunnel credentials file '$(LOCAL_TUNNEL_CREDENTIALS)'" >&2; \ + echo "Create it from '$(LOCAL_TUNNEL_CREDENTIALS).example' and set stringData.token before running this target." >&2; \ + exit 1; \ + } kubectl --context $(CTX) apply -k $(LAST_STEP_MANIFESTS_DIR) - if [ -f $(LAST_STEP_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml ]; then - kubectl --context $(CTX) apply -f $(LAST_STEP_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml - fi touch $@ # Step 1: Generate age key file β€” no cluster/namespace dependency; safe to run before installation. diff --git a/test/e2e/setup/manifests/README.md b/test/e2e/setup/manifests/README.md index b6592e122..e529422c2 100644 --- a/test/e2e/setup/manifests/README.md +++ b/test/e2e/setup/manifests/README.md @@ -7,4 +7,5 @@ as additional Flux sources. Organize related resources in subdirectories when that keeps the intent clearer. This directory is applied via the tracked [kustomization.yaml](/workspaces/gitops-reverser2/test/e2e/setup/manifests/kustomization.yaml). -Keep local-only secrets out of that file if they are ignored; apply those separately. +This branch expects the local tunnel credentials file at +`test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml` to exist before `make` applies the manifests. diff --git a/test/e2e/setup/manifests/cloudflared-public/README.md b/test/e2e/setup/manifests/cloudflared-public/README.md index b59cb43ca..b1fd613cf 100644 --- a/test/e2e/setup/manifests/cloudflared-public/README.md +++ b/test/e2e/setup/manifests/cloudflared-public/README.md @@ -16,7 +16,6 @@ Setup steps: ```bash kubectl apply -k test/e2e/setup/manifests/cloudflared-public - kubectl apply -f test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml ``` Quick checks: diff --git a/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml b/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml index c9e0fcd14..b645faf7a 100644 --- a/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml +++ b/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml @@ -3,5 +3,6 @@ kind: Kustomization resources: - ns.yaml + - tunnel-credentials.yaml - middleware.yaml - cloudflared-deplyoment.yaml diff --git a/test/e2e/setup/manifests/vote/ingress.yaml b/test/e2e/setup/manifests/vote/ingress.yaml deleted file mode 100644 index acb4c7ec8..000000000 --- a/test/e2e/setup/manifests/vote/ingress.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: vote-public - namespace: vote - annotations: - kubernetes.io/ingress.class: traefik - traefik.ingress.kubernetes.io/router.middlewares: cloudflared-public-ratelimit@kubernetescrd -spec: - ingressClassName: traefik - rules: - - host: vote.reversegitops.dev - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: podinfo - port: - number: 80 diff --git a/test/e2e/setup/manifests/vote/ingressroute.yaml b/test/e2e/setup/manifests/vote/ingressroute.yaml new file mode 100644 index 000000000..d28773dd8 --- /dev/null +++ b/test/e2e/setup/manifests/vote/ingressroute.yaml @@ -0,0 +1,17 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: vote-public + namespace: vote +spec: + entryPoints: + - web + routes: + - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/`) + kind: Rule + middlewares: + - name: ratelimit + namespace: cloudflared-public + services: + - name: podinfo + port: 80 diff --git a/test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml b/test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml new file mode 100644 index 000000000..0a0401b7d --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml @@ -0,0 +1,63 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-service + namespace: present +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: auth-service + namespace: present +rules: + # Access to manage session cookie secrets (namespaced) + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "update"] + # Access to create tokens for ServiceAccounts (for quiz-access SA) + - apiGroups: [""] + resources: ["serviceaccounts/token"] + verbs: ["create"] + resourceNames: # This is really cool: now we can limit it to what we absolutelty need + - quiz-access +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: auth-service + namespace: present +subjects: + - kind: ServiceAccount + name: auth-service + namespace: present +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: auth-service +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: auth-service +rules: + # Access to quizsessions (cluster-scoped CRD) + - apiGroups: ["examples.configbutler.ai"] + resources: ["quizsessions"] + verbs: ["get", "list", "watch"] + # Access to quizsubmissions (cluster-scoped CRD) + - apiGroups: ["examples.configbutler.ai"] + resources: ["quizsubmissions"] + verbs: ["get", "list", "watch", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: auth-service +subjects: + - kind: ServiceAccount + name: auth-service + namespace: present +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: auth-service diff --git a/test/e2e/setup/manifests/vote/k8s/crds/quizsessions.yaml b/test/e2e/setup/manifests/vote/k8s/crds/quizsessions.yaml new file mode 100644 index 000000000..3fe3d305a --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/crds/quizsessions.yaml @@ -0,0 +1,133 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: quizsessions.examples.configbutler.ai +spec: + group: examples.configbutler.ai + scope: Namespaced + names: + plural: quizsessions + singular: quizsession + kind: QuizSession + listKind: QuizSessionList + shortNames: + - qsession + - qs + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Title + type: string + jsonPath: .spec.title + - name: Created + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + description: QuizSession defines a quiz that attendees can answer. + required: + - spec + properties: + spec: + type: object + required: + - title + - questions + properties: + title: + type: string + minLength: 1 + state: + type: string + enum: + - draft + - live + - closed + questions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - id + items: + type: object + required: + - id + - type + - title + properties: + id: + type: string + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: + type: string + enum: + - singleChoice + - multiChoice + - scale0to10 + - number + - freeText + title: + type: string + minLength: 1 + required: + type: boolean + choices: + type: array + minItems: 1 + items: + type: string + minLength: 1 + min: + type: number + max: + type: number + placeholder: + type: string + x-kubernetes-validations: + - rule: "self.type in ['singleChoice','multiChoice'] ? has(self.choices) && size(self.choices) > 0 : !has(self.choices)" + message: "choices must be set for choice questions and unset otherwise" + - rule: "(has(self.min) || has(self.max)) ? (!has(self.min) || !has(self.max) || self.min <= self.max) : true" + message: "min must be <= max" + - rule: "self.type == 'scale0to10' ? (has(self.min) && has(self.max) && self.min == 0.0 && self.max == 10.0) : true" + message: "scale0to10 requires min=0 and max=10" + status: + type: object + properties: + observedGeneration: + type: integer + format: int64 + conditions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + items: + type: object + required: + - type + - status + - lastTransitionTime + - reason + - message + properties: + type: + type: string + status: + type: string + enum: + - "True" + - "False" + - "Unknown" + lastTransitionTime: + type: string + format: date-time + reason: + type: string + message: + type: string diff --git a/test/e2e/setup/manifests/vote/k8s/crds/quizsubmissions.yaml b/test/e2e/setup/manifests/vote/k8s/crds/quizsubmissions.yaml new file mode 100644 index 000000000..a8edcce81 --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/crds/quizsubmissions.yaml @@ -0,0 +1,132 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: quizsubmissions.examples.configbutler.ai +spec: + group: examples.configbutler.ai + scope: Namespaced + names: + plural: quizsubmissions + singular: quizsubmission + kind: QuizSubmission + listKind: QuizSubmissionList + shortNames: + - qsub + - qsubs + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Session + type: string + jsonPath: .spec.sessionRef.name + - name: SubmittedAt + type: date + jsonPath: .spec.submittedAt + - name: Created + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + description: QuizSubmission is a single attendee submission for a QuizSession. + required: + - spec + properties: + spec: + type: object + required: + - sessionRef + - submittedAt + - answers + properties: + sessionRef: + type: object + required: + - name + properties: + group: + type: string + default: examples.configbutler.ai + enum: + - examples.configbutler.ai + kind: + type: string + default: QuizSession + enum: + - QuizSession + name: + type: string + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + submittedAt: + type: string + format: date-time + answers: + type: array + maxItems: 100 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - questionId + items: + type: object + required: + - questionId + properties: + questionId: + type: string + maxLength: 63 + singleChoice: + type: string + maxLength: 256 + multiChoice: + type: array + maxItems: 20 + items: + type: string + maxLength: 256 + number: + type: number + freeText: + type: string + maxLength: 2000 + x-kubernetes-validations: + - rule: "(has(self.singleChoice) ? 1 : 0) + (has(self.multiChoice) ? 1 : 0) + (has(self.number) ? 1 : 0) + (has(self.freeText) ? 1 : 0) == 1" + message: "exactly one answer field must be set" + status: + type: object + properties: + observedGeneration: + type: integer + format: int64 + conditions: + type: array + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + items: + type: object + required: + - type + - status + - lastTransitionTime + - reason + - message + properties: + type: + type: string + status: + type: string + enum: + - "True" + - "False" + - "Unknown" + lastTransitionTime: + type: string + format: date-time + reason: + type: string + message: + type: string diff --git a/test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml b/test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml new file mode 100644 index 000000000..34706fc4c --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml @@ -0,0 +1,40 @@ +apiVersion: examples.configbutler.ai/v1alpha1 +kind: QuizSession +metadata: + name: kubecon-2026 + namespace: present +spec: + title: KubeCon 2026 + state: live + questions: + - id: q1 + type: singleChoice + title: Was the talk useful? + required: true + choices: + - "Yes" + - "Somewhat" + - "No" + - id: q2 + type: multiChoice + title: What do you use daily? + choices: + - Kubernetes + - Traefik + - Prometheus + - Terraform + - id: q3 + type: scale0to10 + title: How spicy was the YAML? + required: true + min: 0 + max: 10 + - id: q4 + type: number + title: How many clusters do you manage? + min: 0 + max: 500 + - id: q5 + type: freeText + title: One thing to improve + placeholder: Short and specific… diff --git a/test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml b/test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml new file mode 100644 index 000000000..73493d985 --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml @@ -0,0 +1,22 @@ +apiVersion: examples.configbutler.ai/v1alpha1 +kind: QuizSubmission +metadata: + name: kubecon-2026-sample + namespace: present +spec: + sessionRef: + name: kubecon-2026 + submittedAt: "2026-02-05T12:34:00Z" + answers: + - questionId: q1 + singleChoice: "Yes" + - questionId: q2 + multiChoice: + - Kubernetes + - Traefik + - questionId: q3 + number: 7 + - questionId: q4 + number: 42 + - questionId: q5 + freeText: Loved it. diff --git a/test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml b/test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml new file mode 100644 index 000000000..41d60225b --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml @@ -0,0 +1,29 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: auth-to-public + namespace: present +spec: + replacePathRegex: + regex: ^/auth(/.*)?$ + replacement: /public$1 +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: session-info + namespace: present +spec: + entryPoints: + - websecure + routes: + - match: Host(`present.z65.nl`) && PathPrefix(`/auth`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... + kind: Rule + middlewares: + - name: auth-to-public + - name: auth-forwarder + services: + - name: auth-service + port: http + scheme: http + tls: {} diff --git a/test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml b/test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml new file mode 100644 index 000000000..dac832cdc --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml @@ -0,0 +1,17 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: frontend-static + namespace: present +spec: + entryPoints: + - websecure + routes: + - match: Host(`present.z65.nl`) && PathPrefix(`/`) + kind: Rule + services: + - name: present-frontend + port: http + scheme: http + tls: {} + \ No newline at end of file diff --git a/test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml b/test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml new file mode 100644 index 000000000..2bd7487d6 --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml @@ -0,0 +1,45 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: auth-forwarder + namespace: present +spec: + forwardAuth: + address: http://auth-service.present.svc.cluster.local:8080/private/forward-auth-decision + trustForwardHeader: true + authResponseHeaders: + - Authorization + addAuthCookiesToResponse: + - auth_session +--- +apiVersion: traefik.io/v1alpha1 +kind: ServersTransport +metadata: + name: kube-apiserver-transport + namespace: default +spec: + rootCAs: + - configMap: kube-root-ca.crt + serverName: kubernetes.default.svc +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: kube-api-examples-only + namespace: present +spec: + entryPoints: + - websecure + routes: + - match: Host(`present.z65.nl`) && PathPrefix(`/apis/examples.configbutler.ai/`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... + kind: Rule + middlewares: + - name: auth-forwarder + services: + - name: kubernetes + namespace: default + port: 443 + scheme: https + serversTransport: default-kube-apiserver-transport@kubernetescrd + tls: {} +--- diff --git a/test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml b/test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml new file mode 100644 index 000000000..5d0f06fee --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml @@ -0,0 +1,40 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: quiz-access + namespace: present +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: quiz-access + namespace: present +rules: + - apiGroups: + - examples.configbutler.ai + resources: + - quizsessions + - quizsubmissions + verbs: + - get + - list + - watch + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: quiz-access + namespace: present +subjects: + - kind: ServiceAccount + name: quiz-access + namespace: present +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: quiz-access + + +# You can the token then with 'kubectl -n present create token quiz-access' +# These tokens are long lived and not advised anymore: already implemented the short lived version now. diff --git a/test/e2e/setup/manifests/vote/k8s/test-deploy.yaml b/test/e2e/setup/manifests/vote/k8s/test-deploy.yaml new file mode 100644 index 000000000..52fded898 --- /dev/null +++ b/test/e2e/setup/manifests/vote/k8s/test-deploy.yaml @@ -0,0 +1,115 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: present +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: present-frontend + namespace: present +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: present-frontend + template: + metadata: + labels: + app.kubernetes.io/name: present-frontend + spec: + imagePullSecrets: + - name: zot-pull + containers: + - name: present-frontend + image: zot.z65.nl/present/frontend:latest + imagePullPolicy: Always + #env: + #- name: KUBE_NAMESPACE # This does not work since it's a SPA + # value: present + ports: + - name: http + containerPort: 8080 + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: present-frontend + namespace: present +spec: + selector: + app.kubernetes.io/name: present-frontend + ports: + - name: http + port: 8080 + targetPort: http + +--- +# Ok this is working now, let's go to the next phase +apiVersion: apps/v1 +kind: Deployment +metadata: + name: present-auth-service + namespace: present +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: present-auth-service + template: + metadata: + labels: + app.kubernetes.io/name: present-auth-service + spec: + serviceAccountName: auth-service + imagePullSecrets: + - name: zot-pull + containers: + - name: auth-service + image: zot.z65.nl/present/auth-service:latest + imagePullPolicy: Always + env: + - name: FORWARD_SA + value: quiz-access + - name: FORWARD_SA_NAMESPACE + value: present + - name: COOKIE_SECURE + value: "true" + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /healthz + port: http + livenessProbe: + httpGet: + path: /healthz + port: http + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: auth-service + namespace: present +spec: + selector: + app.kubernetes.io/name: present-auth-service + ports: + - name: http + port: 8080 + targetPort: http diff --git a/test/e2e/setup/manifests/vote/kustomization.yaml b/test/e2e/setup/manifests/vote/kustomization.yaml index ff20c9c1f..99acd3bc3 100644 --- a/test/e2e/setup/manifests/vote/kustomization.yaml +++ b/test/e2e/setup/manifests/vote/kustomization.yaml @@ -4,4 +4,4 @@ kind: Kustomization resources: - ns.yaml - podinfo.yaml - - ingress.yaml + - ingressroute.yaml From af38093bfee47076c4d700568784eebd87c7e015 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 17 Mar 2026 09:00:26 +0000 Subject: [PATCH 03/30] chore: the voter demo is now running --- .gitignore | 4 +- Makefile | 6 +++ test/e2e/demo-todo.md | 5 +++ test/e2e/setup/flux/releases/ingress.yaml | 2 +- .../vote/{k8s => }/auth-service-rbac.yaml | 10 ++--- .../vote/{k8s => }/crds/quizsessions.yaml | 0 .../vote/{k8s => }/crds/quizsubmissions.yaml | 0 .../test-deploy.yaml => deployments.yaml} | 45 ++++++++----------- .../examples/quizsession-kubecon-2026.yaml | 2 +- .../examples/quizsubmission-kubecon-2026.yaml | 2 +- .../vote/{k8s => }/ingress-auth.yaml | 9 ++-- .../vote/{k8s => }/ingress-frontend.yaml | 11 +++-- .../vote/{k8s => }/ingress-kubeapi.yaml | 11 +++-- .../setup/manifests/vote/ingressroute.yaml | 32 ++++++++++++- .../setup/manifests/vote/kustomization.yaml | 11 +++++ test/e2e/setup/manifests/vote/podinfo.yaml | 5 ++- .../manifests/vote/pull-secret.yaml.example | 10 +++++ .../manifests/vote/{k8s => }/quiz-rbac.yaml | 13 +++--- 18 files changed, 116 insertions(+), 62 deletions(-) create mode 100644 test/e2e/demo-todo.md rename test/e2e/setup/manifests/vote/{k8s => }/auth-service-rbac.yaml (93%) rename test/e2e/setup/manifests/vote/{k8s => }/crds/quizsessions.yaml (100%) rename test/e2e/setup/manifests/vote/{k8s => }/crds/quizsubmissions.yaml (100%) rename test/e2e/setup/manifests/vote/{k8s/test-deploy.yaml => deployments.yaml} (66%) rename test/e2e/setup/manifests/vote/{k8s => }/examples/quizsession-kubecon-2026.yaml (97%) rename test/e2e/setup/manifests/vote/{k8s => }/examples/quizsubmission-kubecon-2026.yaml (95%) rename test/e2e/setup/manifests/vote/{k8s => }/ingress-auth.yaml (62%) rename test/e2e/setup/manifests/vote/{k8s => }/ingress-frontend.yaml (58%) rename test/e2e/setup/manifests/vote/{k8s => }/ingress-kubeapi.yaml (67%) create mode 100644 test/e2e/setup/manifests/vote/pull-secret.yaml.example rename test/e2e/setup/manifests/vote/{k8s => }/quiz-rbac.yaml (75%) diff --git a/.gitignore b/.gitignore index d05055795..d5bd4d16b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ charts/gitops-reverser/config/*.yaml .stamps/* test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml -*tunnel-credentials.yaml \ No newline at end of file +# For now we dont do sops (will add that later) +*tunnel-credentials.yaml +*pull-secret.yaml diff --git a/Makefile b/Makefile index 3eec5e5fa..aaec0a78c 100644 --- a/Makefile +++ b/Makefile @@ -371,6 +371,12 @@ $(CS)/services.ready: $(SERVICES_READY_INPUTS) | $(CS) echo "Create it from '$(LOCAL_TUNNEL_CREDENTIALS).example' and set stringData.token before running this target." >&2; \ exit 1; \ } + kubectl --context $(CTX) apply -f $(LAST_STEP_MANIFESTS_DIR)/vote/ns.yaml + kubectl --context $(CTX) apply -f $(LAST_STEP_MANIFESTS_DIR)/vote/crds + kubectl --context $(CTX) wait --for=condition=Established \ + crd/quizsessions.examples.configbutler.ai \ + crd/quizsubmissions.examples.configbutler.ai \ + --timeout=180s kubectl --context $(CTX) apply -k $(LAST_STEP_MANIFESTS_DIR) touch $@ diff --git a/test/e2e/demo-todo.md b/test/e2e/demo-todo.md new file mode 100644 index 000000000..1df51b18f --- /dev/null +++ b/test/e2e/demo-todo.md @@ -0,0 +1,5 @@ + + +* Get a 100 votes at the same time! +* move to a voter that is released by GH: https://github.com/sunib/voter +* https within cluster \ No newline at end of file diff --git a/test/e2e/setup/flux/releases/ingress.yaml b/test/e2e/setup/flux/releases/ingress.yaml index ed720de52..e4ebfa1d5 100644 --- a/test/e2e/setup/flux/releases/ingress.yaml +++ b/test/e2e/setup/flux/releases/ingress.yaml @@ -10,7 +10,7 @@ spec: mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip" operation: copy ref: - semver: "39.0.1" + semver: "39.0.5" # https://github.com/traefik/traefik-helm-chart/releases/tag/v39.0.5 --- apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease diff --git a/test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml b/test/e2e/setup/manifests/vote/auth-service-rbac.yaml similarity index 93% rename from test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml rename to test/e2e/setup/manifests/vote/auth-service-rbac.yaml index 0a0401b7d..325b3e142 100644 --- a/test/e2e/setup/manifests/vote/k8s/auth-service-rbac.yaml +++ b/test/e2e/setup/manifests/vote/auth-service-rbac.yaml @@ -2,13 +2,13 @@ apiVersion: v1 kind: ServiceAccount metadata: name: auth-service - namespace: present + namespace: vote --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: auth-service - namespace: present + namespace: vote rules: # Access to manage session cookie secrets (namespaced) - apiGroups: [""] @@ -25,11 +25,11 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: auth-service - namespace: present + namespace: vote subjects: - kind: ServiceAccount name: auth-service - namespace: present + namespace: vote roleRef: apiGroup: rbac.authorization.k8s.io kind: Role @@ -56,7 +56,7 @@ metadata: subjects: - kind: ServiceAccount name: auth-service - namespace: present + namespace: vote roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole diff --git a/test/e2e/setup/manifests/vote/k8s/crds/quizsessions.yaml b/test/e2e/setup/manifests/vote/crds/quizsessions.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/k8s/crds/quizsessions.yaml rename to test/e2e/setup/manifests/vote/crds/quizsessions.yaml diff --git a/test/e2e/setup/manifests/vote/k8s/crds/quizsubmissions.yaml b/test/e2e/setup/manifests/vote/crds/quizsubmissions.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/k8s/crds/quizsubmissions.yaml rename to test/e2e/setup/manifests/vote/crds/quizsubmissions.yaml diff --git a/test/e2e/setup/manifests/vote/k8s/test-deploy.yaml b/test/e2e/setup/manifests/vote/deployments.yaml similarity index 66% rename from test/e2e/setup/manifests/vote/k8s/test-deploy.yaml rename to test/e2e/setup/manifests/vote/deployments.yaml index 52fded898..6cc26e6c5 100644 --- a/test/e2e/setup/manifests/vote/k8s/test-deploy.yaml +++ b/test/e2e/setup/manifests/vote/deployments.yaml @@ -1,32 +1,24 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: present ---- apiVersion: apps/v1 kind: Deployment metadata: - name: present-frontend - namespace: present + name: vote-frontend + namespace: vote spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: present-frontend - template: + app.kubernetes.io/name: vote-frontend + template: metadata: labels: - app.kubernetes.io/name: present-frontend + app.kubernetes.io/name: vote-frontend spec: imagePullSecrets: - name: zot-pull containers: - - name: present-frontend - image: zot.z65.nl/present/frontend:latest + - name: vote-frontend + image: zot.z65.nl/voter/frontend:latest imagePullPolicy: Always - #env: - #- name: KUBE_NAMESPACE # This does not work since it's a SPA - # value: present ports: - name: http containerPort: 8080 @@ -41,45 +33,44 @@ spec: apiVersion: v1 kind: Service metadata: - name: present-frontend - namespace: present + name: vote-frontend + namespace: vote spec: selector: - app.kubernetes.io/name: present-frontend + app.kubernetes.io/name: vote-frontend ports: - name: http port: 8080 targetPort: http --- -# Ok this is working now, let's go to the next phase apiVersion: apps/v1 kind: Deployment metadata: - name: present-auth-service - namespace: present + name: vote-auth-service + namespace: vote spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: present-auth-service + app.kubernetes.io/name: vote-auth-service template: metadata: labels: - app.kubernetes.io/name: present-auth-service + app.kubernetes.io/name: vote-auth-service spec: serviceAccountName: auth-service imagePullSecrets: - name: zot-pull containers: - name: auth-service - image: zot.z65.nl/present/auth-service:latest + image: zot.z65.nl/voter/auth-service:latest imagePullPolicy: Always env: - name: FORWARD_SA value: quiz-access - name: FORWARD_SA_NAMESPACE - value: present + value: vote - name: COOKIE_SECURE value: "true" ports: @@ -105,10 +96,10 @@ apiVersion: v1 kind: Service metadata: name: auth-service - namespace: present + namespace: vote spec: selector: - app.kubernetes.io/name: present-auth-service + app.kubernetes.io/name: vote-auth-service ports: - name: http port: 8080 diff --git a/test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml b/test/e2e/setup/manifests/vote/examples/quizsession-kubecon-2026.yaml similarity index 97% rename from test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml rename to test/e2e/setup/manifests/vote/examples/quizsession-kubecon-2026.yaml index 34706fc4c..2824f6e61 100644 --- a/test/e2e/setup/manifests/vote/k8s/examples/quizsession-kubecon-2026.yaml +++ b/test/e2e/setup/manifests/vote/examples/quizsession-kubecon-2026.yaml @@ -2,7 +2,7 @@ apiVersion: examples.configbutler.ai/v1alpha1 kind: QuizSession metadata: name: kubecon-2026 - namespace: present + namespace: vote spec: title: KubeCon 2026 state: live diff --git a/test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml b/test/e2e/setup/manifests/vote/examples/quizsubmission-kubecon-2026.yaml similarity index 95% rename from test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml rename to test/e2e/setup/manifests/vote/examples/quizsubmission-kubecon-2026.yaml index 73493d985..d54a404df 100644 --- a/test/e2e/setup/manifests/vote/k8s/examples/quizsubmission-kubecon-2026.yaml +++ b/test/e2e/setup/manifests/vote/examples/quizsubmission-kubecon-2026.yaml @@ -2,7 +2,7 @@ apiVersion: examples.configbutler.ai/v1alpha1 kind: QuizSubmission metadata: name: kubecon-2026-sample - namespace: present + namespace: vote spec: sessionRef: name: kubecon-2026 diff --git a/test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml b/test/e2e/setup/manifests/vote/ingress-auth.yaml similarity index 62% rename from test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml rename to test/e2e/setup/manifests/vote/ingress-auth.yaml index 41d60225b..d8f5ae33a 100644 --- a/test/e2e/setup/manifests/vote/k8s/ingress-auth.yaml +++ b/test/e2e/setup/manifests/vote/ingress-auth.yaml @@ -2,7 +2,7 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: auth-to-public - namespace: present + namespace: vote spec: replacePathRegex: regex: ^/auth(/.*)?$ @@ -12,12 +12,12 @@ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: session-info - namespace: present + namespace: vote spec: entryPoints: - - websecure + - web routes: - - match: Host(`present.z65.nl`) && PathPrefix(`/auth`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... + - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/auth`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... kind: Rule middlewares: - name: auth-to-public @@ -26,4 +26,3 @@ spec: - name: auth-service port: http scheme: http - tls: {} diff --git a/test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml b/test/e2e/setup/manifests/vote/ingress-frontend.yaml similarity index 58% rename from test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml rename to test/e2e/setup/manifests/vote/ingress-frontend.yaml index dac832cdc..f160d7ddb 100644 --- a/test/e2e/setup/manifests/vote/k8s/ingress-frontend.yaml +++ b/test/e2e/setup/manifests/vote/ingress-frontend.yaml @@ -2,16 +2,15 @@ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: frontend-static - namespace: present + namespace: vote spec: entryPoints: - - websecure + - web routes: - - match: Host(`present.z65.nl`) && PathPrefix(`/`) + - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/`) kind: Rule services: - - name: present-frontend + - name: vote-frontend port: http scheme: http - tls: {} - \ No newline at end of file + diff --git a/test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml b/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml similarity index 67% rename from test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml rename to test/e2e/setup/manifests/vote/ingress-kubeapi.yaml index 2bd7487d6..1e6ae80c5 100644 --- a/test/e2e/setup/manifests/vote/k8s/ingress-kubeapi.yaml +++ b/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml @@ -2,10 +2,10 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: auth-forwarder - namespace: present + namespace: vote spec: forwardAuth: - address: http://auth-service.present.svc.cluster.local:8080/private/forward-auth-decision + address: http://auth-service.vote.svc.cluster.local:8080/private/forward-auth-decision trustForwardHeader: true authResponseHeaders: - Authorization @@ -26,12 +26,12 @@ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: kube-api-examples-only - namespace: present + namespace: vote spec: entryPoints: - - websecure + - web routes: - - match: Host(`present.z65.nl`) && PathPrefix(`/apis/examples.configbutler.ai/`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... + - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/apis/examples.configbutler.ai/`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... kind: Rule middlewares: - name: auth-forwarder @@ -41,5 +41,4 @@ spec: port: 443 scheme: https serversTransport: default-kube-apiserver-transport@kubernetescrd - tls: {} --- diff --git a/test/e2e/setup/manifests/vote/ingressroute.yaml b/test/e2e/setup/manifests/vote/ingressroute.yaml index d28773dd8..dd9423e0b 100644 --- a/test/e2e/setup/manifests/vote/ingressroute.yaml +++ b/test/e2e/setup/manifests/vote/ingressroute.yaml @@ -1,4 +1,24 @@ apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: podinfo-add-trailing-slash + namespace: vote +spec: + redirectRegex: + regex: ^https?://([^/]+)/podinfo$ + replacement: https://${1}/podinfo/ +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: podinfo-strip-prefix + namespace: vote +spec: + stripPrefix: + prefixes: + - /podinfo +--- +apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: vote-public @@ -7,11 +27,21 @@ spec: entryPoints: - web routes: - - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/`) + - match: Host(`vote.reversegitops.dev`) && Path(`/podinfo`) + kind: Rule + middlewares: + - name: ratelimit + namespace: cloudflared-public + - name: podinfo-add-trailing-slash + services: + - name: podinfo + port: 80 + - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/podinfo/`) kind: Rule middlewares: - name: ratelimit namespace: cloudflared-public + - name: podinfo-strip-prefix services: - name: podinfo port: 80 diff --git a/test/e2e/setup/manifests/vote/kustomization.yaml b/test/e2e/setup/manifests/vote/kustomization.yaml index 99acd3bc3..982908ae1 100644 --- a/test/e2e/setup/manifests/vote/kustomization.yaml +++ b/test/e2e/setup/manifests/vote/kustomization.yaml @@ -3,5 +3,16 @@ kind: Kustomization resources: - ns.yaml + - pull-secret.yaml + - deployments.yaml - podinfo.yaml + - auth-service-rbac.yaml + - quiz-rbac.yaml + - ingress-auth.yaml + - ingress-frontend.yaml + - ingress-kubeapi.yaml - ingressroute.yaml + - crds/quizsessions.yaml + - crds/quizsubmissions.yaml + - examples/quizsession-kubecon-2026.yaml + - examples/quizsubmission-kubecon-2026.yaml diff --git a/test/e2e/setup/manifests/vote/podinfo.yaml b/test/e2e/setup/manifests/vote/podinfo.yaml index 83c2d4dd9..dc4a893ed 100644 --- a/test/e2e/setup/manifests/vote/podinfo.yaml +++ b/test/e2e/setup/manifests/vote/podinfo.yaml @@ -17,7 +17,10 @@ spec: spec: containers: - name: podinfo - image: ghcr.io/stefanprodan/podinfo:6.9.2 + image: ghcr.io/stefanprodan/podinfo:6.11.1 + env: + - name: PODINFO_UI_MESSAGE + value: hello from simon ports: - name: http containerPort: 9898 diff --git a/test/e2e/setup/manifests/vote/pull-secret.yaml.example b/test/e2e/setup/manifests/vote/pull-secret.yaml.example new file mode 100644 index 000000000..e0505a6b2 --- /dev/null +++ b/test/e2e/setup/manifests/vote/pull-secret.yaml.example @@ -0,0 +1,10 @@ +#kubectl create secret docker-registry zot-pull --docker-server=zot.z65.nl --docker-username=admin --docker-password='...' -n vote +#kubectl get secret zot-pull -n vote -o yaml +apiVersion: v1 +data: + .dockerconfigjson: eyJh... +kind: Secret +metadata: + name: zot-pull + namespace: vote +type: kubernetes.io/dockerconfigjson diff --git a/test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml b/test/e2e/setup/manifests/vote/quiz-rbac.yaml similarity index 75% rename from test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml rename to test/e2e/setup/manifests/vote/quiz-rbac.yaml index 5d0f06fee..32ad83b11 100644 --- a/test/e2e/setup/manifests/vote/k8s/quiz-rbac.yaml +++ b/test/e2e/setup/manifests/vote/quiz-rbac.yaml @@ -2,13 +2,13 @@ apiVersion: v1 kind: ServiceAccount metadata: name: quiz-access - namespace: present + namespace: vote --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: quiz-access - namespace: present + namespace: vote rules: - apiGroups: - examples.configbutler.ai @@ -25,16 +25,15 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: quiz-access - namespace: present + namespace: vote subjects: - kind: ServiceAccount name: quiz-access - namespace: present + namespace: vote roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: quiz-access - -# You can the token then with 'kubectl -n present create token quiz-access' -# These tokens are long lived and not advised anymore: already implemented the short lived version now. +# You can the token then with 'kubectl -n vote create token quiz-access' +# These tokens are long lived and not advised anymore: already implemented the short lived version now (these are API calls) From c6e0b928c88b7b609401bc3d56e2114c3d9816e6 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 17 Mar 2026 12:40:50 +0000 Subject: [PATCH 04/30] chore: Tweaking ingress stuff --- test/e2e/cluster/README.md | 8 ++++++++ test/e2e/cluster/start-cluster.sh | 6 ++++++ test/e2e/setup/flux/releases/ingress.yaml | 7 +++++++ .../e2e/setup/manifests/cloudflared-public/README.md | 3 +++ .../manifests/cloudflared-public/kustomization.yaml | 1 - .../manifests/cloudflared-public/middleware.yaml | 9 --------- test/e2e/setup/manifests/vote/ingress-kubeapi.yaml | 5 ++++- .../vote/{ingressroute.yaml => ingress-podinfo.yaml} | 12 ++++++++++-- test/e2e/setup/manifests/vote/kustomization.yaml | 6 +++--- ...auth-service-rbac.yaml => rbac-auth-service.yaml} | 4 ++++ .../vote/{quiz-rbac.yaml => rbac-participants.yaml} | 0 11 files changed, 45 insertions(+), 16 deletions(-) delete mode 100644 test/e2e/setup/manifests/cloudflared-public/middleware.yaml rename test/e2e/setup/manifests/vote/{ingressroute.yaml => ingress-podinfo.yaml} (86%) rename test/e2e/setup/manifests/vote/{auth-service-rbac.yaml => rbac-auth-service.yaml} (90%) rename test/e2e/setup/manifests/vote/{quiz-rbac.yaml => rbac-participants.yaml} (100%) diff --git a/test/e2e/cluster/README.md b/test/e2e/cluster/README.md index 16d3c061f..78b06809e 100644 --- a/test/e2e/cluster/README.md +++ b/test/e2e/cluster/README.md @@ -2,6 +2,7 @@ This directory contains the remaining e2e cluster assets after the Kind-specific files were removed. The e2e environment now uses k3d with kube-apiserver audit webhook flags configured at cluster create time. +By default, the cluster bootstrap also disables packaged k3s Traefik and k3s ServiceLB. ## Current Files @@ -18,6 +19,11 @@ The e2e environment now uses k3d with kube-apiserver audit webhook flags configu - `audit-webhook-batch-max-wait=1s` - `audit-webhook-batch-max-size=10` +It also disables these packaged k3s components by default: + +- `traefik` +- `servicelb` + The webhook URL in [`audit/webhook-config.yaml`](audit/webhook-config.yaml) targets: `https://10.43.200.200:9444/audit-webhook/kind-e2e` @@ -49,6 +55,8 @@ Confirm cluster is up: kubectl get nodes ``` +If you change the k3s disable flags in `start-cluster.sh`, recreate the k3d cluster for them to take effect. + Confirm audit files are mounted in the k3d server node: ```bash diff --git a/test/e2e/cluster/start-cluster.sh b/test/e2e/cluster/start-cluster.sh index ccdcc6aa9..799daca22 100644 --- a/test/e2e/cluster/start-cluster.sh +++ b/test/e2e/cluster/start-cluster.sh @@ -7,6 +7,7 @@ set -euo pipefail CLUSTER_NAME="${CLUSTER_NAME:-gitops-reverser-test-e2e}" DISABLE_K3S_TRAEFIK="${DISABLE_K3S_TRAEFIK:-true}" +DISABLE_K3S_SERVICELB="${DISABLE_K3S_SERVICELB:-true}" AUDIT_DIR_REL="test/e2e/cluster/audit" K3D_CREATE_LOG_FILE="${TMPDIR:-/tmp}/k3d-create-${CLUSTER_NAME}.log" REPO_PWD="$(pwd -P)" @@ -141,6 +142,11 @@ create_cluster() { k3s_args+=("--disable=traefik@server:0") fi + if [ "${DISABLE_K3S_SERVICELB}" = "true" ]; then + echo "πŸ”§ Disabling packaged k3s ServiceLB to avoid svclb pods for local services" + k3s_args+=("--disable=servicelb@server:0") + fi + # NOTE: no --api-port: let k3d pick an available port. # # Mount audit dir into the server container and pass kube-apiserver audit flags diff --git a/test/e2e/setup/flux/releases/ingress.yaml b/test/e2e/setup/flux/releases/ingress.yaml index e4ebfa1d5..07de96869 100644 --- a/test/e2e/setup/flux/releases/ingress.yaml +++ b/test/e2e/setup/flux/releases/ingress.yaml @@ -70,6 +70,13 @@ spec: publishedService: enabled: true + ports: + web: + forwardedHeaders: + # Trust forwarded client IP headers from in-cluster cloudflared pods on the k3s pod network. + trustedIPs: + - 10.42.0.0/16 + rbac: enabled: true namespaced: false diff --git a/test/e2e/setup/manifests/cloudflared-public/README.md b/test/e2e/setup/manifests/cloudflared-public/README.md index b1fd613cf..99d55bdc1 100644 --- a/test/e2e/setup/manifests/cloudflared-public/README.md +++ b/test/e2e/setup/manifests/cloudflared-public/README.md @@ -23,3 +23,6 @@ Quick checks: - `kubectl -n cloudflared-public get pods` - `kubectl -n cloudflared-public logs deploy/cloudflared --tail=100` - `kubectl get ingress -A | grep vote.reversegitops.dev` + +Traefik is configured to trust forwarded client-IP headers from the default k3s pod network +(`10.42.0.0/16`) so requests proxied by `cloudflared` preserve the original visitor IP. diff --git a/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml b/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml index b645faf7a..1922326cd 100644 --- a/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml +++ b/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml @@ -4,5 +4,4 @@ kind: Kustomization resources: - ns.yaml - tunnel-credentials.yaml - - middleware.yaml - cloudflared-deplyoment.yaml diff --git a/test/e2e/setup/manifests/cloudflared-public/middleware.yaml b/test/e2e/setup/manifests/cloudflared-public/middleware.yaml deleted file mode 100644 index 8b08e71a3..000000000 --- a/test/e2e/setup/manifests/cloudflared-public/middleware.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: ratelimit - namespace: cloudflared-public -spec: - rateLimit: - average: 10 - burst: 5 diff --git a/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml b/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml index 1e6ae80c5..e7db19dac 100644 --- a/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml +++ b/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml @@ -7,6 +7,7 @@ spec: forwardAuth: address: http://auth-service.vote.svc.cluster.local:8080/private/forward-auth-decision trustForwardHeader: true + maxResponseBodySize: 65536 authResponseHeaders: - Authorization addAuthCookiesToResponse: @@ -31,7 +32,9 @@ spec: entryPoints: - web routes: - - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/apis/examples.configbutler.ai/`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... + # All Kubernetes API paths. auth-forwarder handles both browser (mints a token via + # session cookie / join code) and kubectl (validates and passes through bearer token). + - match: Host(`vote.reversegitops.dev`) && (PathPrefix(`/api`) || PathPrefix(`/apis`) || PathPrefix(`/openapi`)) kind: Rule middlewares: - name: auth-forwarder diff --git a/test/e2e/setup/manifests/vote/ingressroute.yaml b/test/e2e/setup/manifests/vote/ingress-podinfo.yaml similarity index 86% rename from test/e2e/setup/manifests/vote/ingressroute.yaml rename to test/e2e/setup/manifests/vote/ingress-podinfo.yaml index dd9423e0b..286b0e1cc 100644 --- a/test/e2e/setup/manifests/vote/ingressroute.yaml +++ b/test/e2e/setup/manifests/vote/ingress-podinfo.yaml @@ -19,6 +19,16 @@ spec: - /podinfo --- apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ratelimit + namespace: vote +spec: + rateLimit: + average: 10 + burst: 5 +--- +apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: vote-public @@ -31,7 +41,6 @@ spec: kind: Rule middlewares: - name: ratelimit - namespace: cloudflared-public - name: podinfo-add-trailing-slash services: - name: podinfo @@ -40,7 +49,6 @@ spec: kind: Rule middlewares: - name: ratelimit - namespace: cloudflared-public - name: podinfo-strip-prefix services: - name: podinfo diff --git a/test/e2e/setup/manifests/vote/kustomization.yaml b/test/e2e/setup/manifests/vote/kustomization.yaml index 982908ae1..fd6400fe8 100644 --- a/test/e2e/setup/manifests/vote/kustomization.yaml +++ b/test/e2e/setup/manifests/vote/kustomization.yaml @@ -6,12 +6,12 @@ resources: - pull-secret.yaml - deployments.yaml - podinfo.yaml - - auth-service-rbac.yaml - - quiz-rbac.yaml + - rbac-auth-service.yaml + - rbac-participants.yaml - ingress-auth.yaml - ingress-frontend.yaml - ingress-kubeapi.yaml - - ingressroute.yaml + - ingress-podinfo.yaml - crds/quizsessions.yaml - crds/quizsubmissions.yaml - examples/quizsession-kubecon-2026.yaml diff --git a/test/e2e/setup/manifests/vote/auth-service-rbac.yaml b/test/e2e/setup/manifests/vote/rbac-auth-service.yaml similarity index 90% rename from test/e2e/setup/manifests/vote/auth-service-rbac.yaml rename to test/e2e/setup/manifests/vote/rbac-auth-service.yaml index 325b3e142..f69932afc 100644 --- a/test/e2e/setup/manifests/vote/auth-service-rbac.yaml +++ b/test/e2e/setup/manifests/vote/rbac-auth-service.yaml @@ -40,6 +40,10 @@ kind: ClusterRole metadata: name: auth-service rules: + # Validate bearer tokens presented by kubectl / kubeconfig users + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] # Access to quizsessions (cluster-scoped CRD) - apiGroups: ["examples.configbutler.ai"] resources: ["quizsessions"] diff --git a/test/e2e/setup/manifests/vote/quiz-rbac.yaml b/test/e2e/setup/manifests/vote/rbac-participants.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/quiz-rbac.yaml rename to test/e2e/setup/manifests/vote/rbac-participants.yaml From 33f9a75917f71af13e9bd3a5e965774c38885811 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 17 Mar 2026 14:15:02 +0000 Subject: [PATCH 05/30] chore: Loadtesting and APF tuning (which is really cool stuff) --- .gitignore | 1 + .golangci.yml | 4 + Makefile | 27 ++ .../kubernetes-apf-and-inflight-tuning.md | 217 +++++++++ test/e2e/cluster/README.md | 5 +- test/e2e/cluster/start-cluster.sh | 4 + test/e2e/setup/manifests/vote/apf.yaml | 53 +++ .../setup/manifests/vote/ingress-kubeapi.yaml | 14 + .../setup/manifests/vote/kustomization.yaml | 1 + test/loadtest/main.go | 416 ++++++++++++++++++ 10 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 docs/design/kubernetes-apf-and-inflight-tuning.md create mode 100644 test/e2e/setup/manifests/vote/apf.yaml create mode 100644 test/loadtest/main.go diff --git a/.gitignore b/.gitignore index d5bd4d16b..b49f0c4b6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml # For now we dont do sops (will add that later) *tunnel-credentials.yaml *pull-secret.yaml +loadtest diff --git a/.golangci.yml b/.golangci.yml index c212c0a57..cddce5c4d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -373,6 +373,10 @@ linters: # Allow fmt.Print* in test/e2e (debugging outputs) - path: 'test/e2e/.*\.go' linters: [forbidigo] + # Allow the standalone load test CLI to use console output, pseudo-random + # traffic generation, and operator-supplied target URLs. + - path: 'test/loadtest/.*\.go' + linters: [depguard, forbidigo, gochecknoglobals, gosec, mnd] # Allow fmt.Println in metrics exporter (initialization logs) - path: 'internal/telemetry/.*\.go' linters: [forbidigo] diff --git a/Makefile b/Makefile index aaec0a78c..bf8ffe084 100644 --- a/Makefile +++ b/Makefile @@ -596,6 +596,33 @@ test-e2e-quickstart-manifest: ## Run quickstart smoke test (manifest install) +##@ Demo + +# Join code shown on the presenter screen. Auto-extracted from the auth-service log +# when not provided, so `make loadtest` just works from inside the devcontainer. +# Override at the command line: make loadtest LOADTEST_CODE=xxxx +LOADTEST_CODE ?= $(shell kubectl -n vote logs deploy/vote-auth-service --tail=5 2>/dev/null | grep 'join-code:' | tail -1 | awk '{print $$NF}' | cut -d= -f2) +LOADTEST_USERS ?= 250 +LOADTEST_RAMP ?= 10s +LOADTEST_BASE_URL ?= https://vote.reversegitops.dev +LOADTEST_SESSION ?= kubecon-2026 +LOADTEST_NS ?= vote + +.PHONY: loadtest +loadtest: ## Simulate LOADTEST_USERS participants against the live quiz (override: LOADTEST_CODE=xxxx LOADTEST_USERS=50 LOADTEST_RAMP=30s) + @[ -n "$(LOADTEST_CODE)" ] || { \ + echo "ERROR: could not determine join code automatically." >&2; \ + echo "Pass it explicitly: make loadtest LOADTEST_CODE=xxxx" >&2; \ + exit 1; \ + } + go run ./test/loadtest \ + --code $(LOADTEST_CODE) \ + --users $(LOADTEST_USERS) \ + --ramp-duration $(LOADTEST_RAMP) \ + --base-url $(LOADTEST_BASE_URL) \ + --session $(LOADTEST_SESSION) \ + --namespace $(LOADTEST_NS) + ##@ Cleanup .PHONY: clean clean: ## Remove build artifacts (binaries, coverage, generated dist files, build stamps) diff --git a/docs/design/kubernetes-apf-and-inflight-tuning.md b/docs/design/kubernetes-apf-and-inflight-tuning.md new file mode 100644 index 000000000..58e89aa54 --- /dev/null +++ b/docs/design/kubernetes-apf-and-inflight-tuning.md @@ -0,0 +1,217 @@ +# Kubernetes API Priority and Fairness β€” and How to Tune It for High Concurrency + +When you throw a sudden burst of requests at a Kubernetes API server β€” say, 300 clients all authenticating simultaneously β€” it doesn't just pass everything through. It applies a two-layer traffic control system: a hard concurrency ceiling called **max-requests-inflight**, and a sophisticated queuing layer called **API Priority and Fairness (APF)**. Understanding both is the key to making high-concurrency workloads reliable without adding more hardware. + +--- + +## The inflight ceiling + +Before APF even runs, the API server enforces a raw cap on simultaneous in-flight requests: + +| Flag | Default | Controls | +|---|---|---| +| `--max-requests-inflight` | 400 | Non-mutating requests (GET, LIST, WATCH) | +| `--max-mutating-requests-inflight` | 200 | Mutating requests (POST, PUT, PATCH, DELETE) | + +Any request that arrives when the ceiling is already hit gets an immediate `HTTP 429 Too Many Requests`. No queuing, no retry β€” just a rejection. The defaults are conservative: they were set for mid-2010s hardware and have barely moved since. + +For a k3d/k3s cluster you can raise them at cluster creation time: + +```bash +k3d cluster create my-cluster \ + --k3s-arg '--kube-apiserver-arg=--max-requests-inflight=800@server:0' \ + --k3s-arg '--kube-apiserver-arg=--max-mutating-requests-inflight=400@server:0' +``` + +Or, for an existing cluster started via a shell script, add those args to the `k3d cluster create` line in [test/e2e/cluster/start-cluster.sh](../../test/e2e/cluster/start-cluster.sh). + +Raising the ceiling is a blunt instrument. It lets more requests compete for the same CPU and memory simultaneously, which can actually make things worse under sustained load. It is most useful when you have headroom on the node and bursty (not sustained) traffic. + +--- + +## API Priority and Fairness (APF) + +APF is the sophisticated layer that sits *inside* the inflight ceiling. It was promoted to stable in Kubernetes 1.29 and is on by default in all modern clusters. The goal is to prevent one noisy caller from starving every other caller β€” and to give operators control over how the shared budget is allocated. + +APF is configured through two resource types: + +- **`PriorityLevelConfiguration`** β€” defines a traffic class (a bucket of capacity and a queue) +- **`FlowSchema`** β€” defines which requests match a priority level, and how to group them into flows + +### PriorityLevelConfiguration + +A priority level can be `Exempt` (bypasses all limits, for system-critical traffic like the kubelet) or `Limited` (gets a share of the inflight budget and a queue). + +For `Limited` levels the key fields are: + +```yaml +spec: + type: Limited + limited: + nominalConcurrencyShares: 100 # relative share of the inflight budget + limitResponse: + type: Queue + queuing: + queues: 64 # number of independent queues (more = better fairness) + handSize: 6 # how many queues each request is assigned to (shuffle sharding) + queueLengthLimit: 50 # max requests waiting per queue before rejection +``` + +**`nominalConcurrencyShares` (NCS)** is a proportional share, not an absolute count. If the total inflight ceiling is 400 and the sum of NCS across all active priority levels is 1000, a level with 100 NCS gets `400 Γ— (100/1000) = 40` concurrent slots. This is recalculated dynamically as levels become active and idle. + +**`queues` and `handSize`** together implement *shuffle sharding*: each incoming request is hashed into `handSize` candidate queues and placed in the shortest one. This probabilistically isolates a misbehaving caller β€” they are unlikely to land in the same queues as well-behaved callers. More queues mean better isolation but higher memory overhead. + +**`queueLengthLimit`** is the per-queue depth. Once a queue is full, new requests are rejected with `HTTP 429`. The total buffer capacity is `queues Γ— queueLengthLimit`. + +### FlowSchema + +A FlowSchema matches requests to a priority level and assigns a *flow distinguisher* β€” the key used to group requests within a priority level's queues. + +```yaml +spec: + matchingPrecedence: 800 # lower = evaluated first + priorityLevelConfiguration: + name: my-priority-level + distinguisherMethod: + type: ByUser # or ByNamespace, or None + rules: + - subjects: + - kind: ServiceAccount + serviceAccount: + name: my-sa + namespace: my-ns + resourceRules: + - verbs: ["get", "list"] + apiGroups: ["mygroup.example.com"] + resources: ["myresources"] + namespaces: ["*"] +``` + +**`matchingPrecedence`** controls evaluation order. Every request is matched against all FlowSchemas sorted by ascending `matchingPrecedence`; the first match wins. The built-in `exempt` schema has precedence 1; `catch-all` has 10000. + +**`distinguisherMethod`** determines how flows are separated within the priority level's queues: +- `ByUser`: one flow per authenticated user/SA β€” fair between callers but concentrates load from a single busy SA into one flow +- `ByNamespace`: one flow per source namespace +- `None`: all matched requests go into a single flow + +### The built-in priority levels + +Kubernetes ships with several built-in levels you cannot delete (but can reference): + +| Level | NCS | Purpose | +|---|---|---| +| `exempt` | β€” | Bypass all limits; for `system:masters` and health checks | +| `node-high` | 40 | High-priority node requests (e.g. kubelet status updates) | +| `system` | 30 | Other system components | +| `leader-election` | 10 | Controller leader election; small but never queued behind workload | +| `workload-high` | 40 | Workload controllers (Deployments, etc.) | +| `workload-low` | 100 | Generic service account requests | +| `global-default` | 20 | Catch-all for everything else | +| `catch-all` | 5 | Last resort; very small to force explicit classification | + +The `service-accounts` built-in FlowSchema (precedence 9000) sends all service account requests to `workload-low`. This is a large bucket (100 NCS) but uses `ByUser` distinguishing β€” so all requests from a *single* service account share one flow, and a burst from that SA competes only with itself within a few queues. + +--- + +## What "ByUser" means in practice under load + +This is the subtle behaviour that surprises most people. + +Suppose your application makes 300 concurrent GET requests from the same service account. With `ByUser` distinguishing, every single one of those 300 requests maps to the *same flow key*. Shuffle sharding spreads them across `handSize` (default 6) candidate queues, but they still all land in the same small region of the queue space. The queue depth limit kicks in quickly, and the rest get rejected. + +Compare this to 300 requests each from a *different* user. Those spread across all 64 queues, and each individual user's queue is much less likely to overflow. + +The practical implication: if one service account drives high concurrency, `ByUser` works against you. `ByNamespace` or `None` (with a dedicated priority level sized for that traffic) is usually better. + +--- + +## Creating a dedicated lane for a high-concurrency workload + +The right pattern for a workload that legitimately needs high concurrency is to pull it out of the shared buckets entirely and give it its own priority level with a matching FlowSchema: + +```yaml +apiVersion: flowcontrol.apiserver.k8s.io/v1 +kind: PriorityLevelConfiguration +metadata: + name: quiz-realtime +spec: + type: Limited + limited: + nominalConcurrencyShares: 400 + limitResponse: + type: Queue + queuing: + queues: 16 + handSize: 4 + queueLengthLimit: 200 +--- +apiVersion: flowcontrol.apiserver.k8s.io/v1 +kind: FlowSchema +metadata: + name: quiz-auth-service +spec: + matchingPrecedence: 800 + priorityLevelConfiguration: + name: quiz-realtime + distinguisherMethod: + type: ByResource # spread load by resource type, not by SA identity + rules: + - subjects: + - kind: ServiceAccount + serviceAccount: + name: quiz-access + namespace: vote + resourceRules: + - verbs: ["get", "list", "watch", "create"] + apiGroups: ["examples.configbutler.ai"] + resources: ["quizsessions", "quizsubmissions"] + namespaces: ["vote"] +``` + +Key decisions here: + +- **`matchingPrecedence: 800`** ensures this FlowSchema is evaluated before the generic `service-accounts` schema at 9000. Without this, the SA would still fall into `workload-low`. +- **`nominalConcurrencyShares: 400`** gives this level a large share. With a 400-request ceiling and all NCS summing to around 600 (400 + the ~200 from other active levels), this level gets roughly 260 concurrent slots β€” far more than the default 100 NCS of `workload-low`. +- **`ByResource` distinguishing** means the flow key is the resource type being accessed, not the SA. Concurrent reads and writes spread across different flows instead of piling onto one. +- **`queues: 16, queueLengthLimit: 200`** gives a total buffer of 3200 requests, with 16 isolated regions for shuffle sharding. + +--- + +## Would more nodes help? + +Adding **worker nodes** in k3d does not help at all. Worker nodes run workloads but do not run the API server. All of the APF logic lives in the API server process, which runs on the server node(s) only. + +Adding **server nodes** (k3d HA mode with multiple server nodes) gives you more API server instances behind the load balancer, which does increase total inflight capacity linearly. However: + +- k3d HA is significantly more complex to configure and maintain +- Writes still go through a single etcd leader +- The improvement for *read-heavy* burst traffic is real but rarely worth the operational overhead + +For a single-server k3d cluster, the right approach is always: raise the inflight ceiling if the node has headroom, then create a dedicated APF lane for the high-concurrency workload. These are both pure YAML/flag changes that take effect without restarting the cluster. + +--- + +## Observability + +APF exposes Prometheus metrics under the `apiserver_flowcontrol_` prefix: + +| Metric | What it tells you | +|---|---| +| `apiserver_flowcontrol_rejected_requests_total` | Requests dropped; labeled by `flow_schema`, `priority_level`, `reason` | +| `apiserver_flowcontrol_request_wait_duration_seconds` | Time spent in queue before execution | +| `apiserver_flowcontrol_request_execution_duration_seconds` | Time actually executing (in the API server) | +| `apiserver_flowcontrol_current_inqueue_requests` | Current queue depth per priority level | +| `apiserver_flowcontrol_current_executing_requests` | Current in-flight count per priority level | + +The fastest diagnostic: `kubectl get --raw /metrics | grep apiserver_flowcontrol_rejected`. Any non-zero `reason=queue-full` or `reason=concurrency-limit` on a priority level points directly at the bottleneck. + +You can also inspect the live configuration at any time: + +```bash +kubectl get prioritylevelconfiguration +kubectl get flowschema +kubectl get --raw /debug/api_priority_and_fairness/dump_priority_levels +kubectl get --raw /debug/api_priority_and_fairness/dump_queues +``` + +The `dump_queues` endpoint shows the current queue depths per priority level in real time β€” useful for catching thundering-herd events as they happen. diff --git a/test/e2e/cluster/README.md b/test/e2e/cluster/README.md index 78b06809e..5801b7333 100644 --- a/test/e2e/cluster/README.md +++ b/test/e2e/cluster/README.md @@ -18,6 +18,8 @@ By default, the cluster bootstrap also disables packaged k3s Traefik and k3s Ser - `audit-webhook-config-file=/etc/kubernetes/audit/webhook-config.yaml` - `audit-webhook-batch-max-wait=1s` - `audit-webhook-batch-max-size=10` +- `max-requests-inflight=800` (override with `KUBE_APISERVER_MAX_REQUESTS_INFLIGHT`) +- `max-mutating-requests-inflight=400` (override with `KUBE_APISERVER_MAX_MUTATING_REQUESTS_INFLIGHT`) It also disables these packaged k3s components by default: @@ -55,7 +57,8 @@ Confirm cluster is up: kubectl get nodes ``` -If you change the k3s disable flags in `start-cluster.sh`, recreate the k3d cluster for them to take effect. +If you change the k3s disable flags or kube-apiserver inflight env vars in `start-cluster.sh`, recreate the k3d +cluster for them to take effect. Confirm audit files are mounted in the k3d server node: diff --git a/test/e2e/cluster/start-cluster.sh b/test/e2e/cluster/start-cluster.sh index 799daca22..f61130689 100644 --- a/test/e2e/cluster/start-cluster.sh +++ b/test/e2e/cluster/start-cluster.sh @@ -8,6 +8,8 @@ set -euo pipefail CLUSTER_NAME="${CLUSTER_NAME:-gitops-reverser-test-e2e}" DISABLE_K3S_TRAEFIK="${DISABLE_K3S_TRAEFIK:-true}" DISABLE_K3S_SERVICELB="${DISABLE_K3S_SERVICELB:-true}" +KUBE_APISERVER_MAX_REQUESTS_INFLIGHT="${KUBE_APISERVER_MAX_REQUESTS_INFLIGHT:-800}" +KUBE_APISERVER_MAX_MUTATING_REQUESTS_INFLIGHT="${KUBE_APISERVER_MAX_MUTATING_REQUESTS_INFLIGHT:-400}" AUDIT_DIR_REL="test/e2e/cluster/audit" K3D_CREATE_LOG_FILE="${TMPDIR:-/tmp}/k3d-create-${CLUSTER_NAME}.log" REPO_PWD="$(pwd -P)" @@ -131,6 +133,8 @@ create_cluster() { "--kube-apiserver-arg=audit-webhook-config-file=/etc/kubernetes/audit/${AUDIT_WEBHOOK_CONFIG_FILE}@server:0" "--kube-apiserver-arg=audit-webhook-batch-max-wait=1s@server:0" "--kube-apiserver-arg=audit-webhook-batch-max-size=10@server:0" + "--kube-apiserver-arg=max-requests-inflight=${KUBE_APISERVER_MAX_REQUESTS_INFLIGHT}@server:0" + "--kube-apiserver-arg=max-mutating-requests-inflight=${KUBE_APISERVER_MAX_MUTATING_REQUESTS_INFLIGHT}@server:0" ) echo "πŸš€ Creating k3d cluster '${CLUSTER_NAME}' with audit webhook support..." diff --git a/test/e2e/setup/manifests/vote/apf.yaml b/test/e2e/setup/manifests/vote/apf.yaml new file mode 100644 index 000000000..3627c6834 --- /dev/null +++ b/test/e2e/setup/manifests/vote/apf.yaml @@ -0,0 +1,53 @@ +apiVersion: flowcontrol.apiserver.k8s.io/v1 +kind: PriorityLevelConfiguration +metadata: + name: quiz-realtime +spec: + type: Limited + limited: + nominalConcurrencyShares: 400 + limitResponse: + type: Queue + queuing: + queues: 16 + handSize: 4 + queueLengthLimit: 200 +--- +apiVersion: flowcontrol.apiserver.k8s.io/v1 +kind: FlowSchema +metadata: + name: quiz-realtime +spec: + matchingPrecedence: 800 + priorityLevelConfiguration: + name: quiz-realtime + # Kubernetes only supports ByUser and ByNamespace here. ByUser still keeps + # auth-service and quiz-access isolated from each other while giving them a + # dedicated, larger queue budget than the generic service-account bucket. + distinguisherMethod: + type: ByUser + rules: + - subjects: + - kind: ServiceAccount + serviceAccount: + name: auth-service + namespace: vote + - kind: ServiceAccount + serviceAccount: + name: quiz-access + namespace: vote + resourceRules: + - apiGroups: + - "*" + clusterScope: true + namespaces: + - "*" + resources: + - "*" + verbs: + - "*" + nonResourceRules: + - nonResourceURLs: + - "*" + verbs: + - "*" diff --git a/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml b/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml index e7db19dac..306d24c19 100644 --- a/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml +++ b/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml @@ -1,5 +1,18 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware +metadata: + name: api-ratelimit + namespace: vote +spec: + rateLimit: + # High ceiling for now β€” easy to tune down for production. + # At 10 000 avg/s per source IP, this is effectively unlimited for a + # 100-person audience while still giving us a known control point. + average: 10000 + burst: 1000 +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware metadata: name: auth-forwarder namespace: vote @@ -37,6 +50,7 @@ spec: - match: Host(`vote.reversegitops.dev`) && (PathPrefix(`/api`) || PathPrefix(`/apis`) || PathPrefix(`/openapi`)) kind: Rule middlewares: + - name: api-ratelimit - name: auth-forwarder services: - name: kubernetes diff --git a/test/e2e/setup/manifests/vote/kustomization.yaml b/test/e2e/setup/manifests/vote/kustomization.yaml index fd6400fe8..88e674259 100644 --- a/test/e2e/setup/manifests/vote/kustomization.yaml +++ b/test/e2e/setup/manifests/vote/kustomization.yaml @@ -5,6 +5,7 @@ resources: - ns.yaml - pull-secret.yaml - deployments.yaml + - apf.yaml - podinfo.yaml - rbac-auth-service.yaml - rbac-participants.yaml diff --git a/test/loadtest/main.go b/test/loadtest/main.go new file mode 100644 index 000000000..9f9adfdad --- /dev/null +++ b/test/loadtest/main.go @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 2025 ConfigButler +// +// 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 main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "math" + "math/rand" + "net/http" + "net/http/cookiejar" + "os" + "sort" + "sync" + "time" +) + +// ── Kubernetes API types ────────────────────────────────────────────────────── + +const ( + apiGroup = "examples.configbutler.ai" + apiVersion = "v1alpha1" +) + +type answer struct { + QuestionID string `json:"questionId"` + SingleChoice string `json:"singleChoice,omitempty"` + MultiChoice []string `json:"multiChoice,omitempty"` + Number *float64 `json:"number,omitempty"` + FreeText string `json:"freeText,omitempty"` +} + +type sessionRef struct { + Name string `json:"name"` +} + +type submissionSpec struct { + SessionRef sessionRef `json:"sessionRef"` + SubmittedAt string `json:"submittedAt"` + Answers []answer `json:"answers"` +} + +type objectMeta struct { + GenerateName string `json:"generateName"` + Namespace string `json:"namespace"` +} + +type quizSubmission struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata objectMeta `json:"metadata"` + Spec submissionSpec `json:"spec"` +} + +// ── Realistic answer pools ──────────────────────────────────────────────────── + +var freeTextPool = []string{ + "More live demos!", + "Loved the kubectl integration.", + "Could use more time on RBAC.", + "Great talk, very hands-on.", + "Would love a follow-up on APF.", + "Show the full Flux workflow end-to-end.", + "Brilliant use of CRDs.", + "More Prometheus integration examples please.", + "Very practical, loved it.", + "The QR code login was a great idea.", + "More depth on GitOps tooling.", + "Excellent speaker, clear explanations.", + "Would be great to see this in production.", + "Loved how simple the auth flow ended up being.", +} + +func floatPtr(f float64) *float64 { return &f } + +// clamp rounds and clamps v to [lo, hi]. +func clamp(v, lo, hi float64) float64 { + v = math.Round(v) + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func randomSubmission(rng *rand.Rand, session, namespace string) quizSubmission { + // q1 singleChoice β€” weighted 70% Yes / 20% Somewhat / 10% No + q1Pool := []string{ + "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", + "Somewhat", "Somewhat", + "No", + } + q1 := q1Pool[rng.Intn(len(q1Pool))] + + // q2 multiChoice β€” random non-empty subset of tools + tools := []string{"Kubernetes", "Traefik", "Prometheus", "Terraform"} + rng.Shuffle(len(tools), func(i, j int) { tools[i], tools[j] = tools[j], tools[i] }) + q2 := tools[:1+rng.Intn(len(tools))] + + // q3 scale0to10 β€” Gaussian centered on 7, clamped [0,10] + q3 := clamp(rng.NormFloat64()*1.5+7, 0, 10) + + // q4 number β€” realistic cluster count [1, 50] + q4 := float64(1 + rng.Intn(50)) + + // q5 freeText + q5 := freeTextPool[rng.Intn(len(freeTextPool))] + + return quizSubmission{ + APIVersion: apiGroup + "/" + apiVersion, + Kind: "QuizSubmission", + Metadata: objectMeta{ + GenerateName: session + "-", + Namespace: namespace, + }, + Spec: submissionSpec{ + SessionRef: sessionRef{Name: session}, + SubmittedAt: time.Now().UTC().Format(time.RFC3339), + Answers: []answer{ + {QuestionID: "q1", SingleChoice: q1}, + {QuestionID: "q2", MultiChoice: q2}, + {QuestionID: "q3", Number: floatPtr(q3)}, + {QuestionID: "q4", Number: floatPtr(q4)}, + {QuestionID: "q5", FreeText: q5}, + }, + }, + } +} + +// ── Per-user simulation ─────────────────────────────────────────────────────── + +type userResult struct { + userID int + loginStatus int + submitStatus int + loginLatency time.Duration + submitLatency time.Duration + err string + ok bool +} + +func simulateUser( + ctx context.Context, + userID int, + baseURL, code, session, namespace string, + rng *rand.Rand, +) userResult { + res := userResult{userID: userID} + + // Each user gets their own cookie jar so sessions are completely isolated. + jar, _ := cookiejar.New(nil) + client := &http.Client{ + Jar: jar, + Timeout: 15 * time.Second, + } + + // ── Step 1: Login β€” establish session cookie via join code ──────────────── + // Mirrors what the real browser does: GET /auth/session-info with the join + // code. The ForwardAuth middleware validates the code and sets the + // auth_session cookie; the handler also verifies the QuizSession is live. + loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/auth/session-info", nil) + if err != nil { + res.err = fmt.Sprintf("build login request: %v", err) + return res + } + loginReq.Header.Set("X-Join-Code", code) + + t0 := time.Now() + loginResp, err := client.Do(loginReq) + res.loginLatency = time.Since(t0) + if err != nil { + res.err = fmt.Sprintf("login: %v", err) + return res + } + io.Copy(io.Discard, loginResp.Body) //nolint:errcheck + loginResp.Body.Close() + res.loginStatus = loginResp.StatusCode + + if loginResp.StatusCode != http.StatusOK { + res.err = fmt.Sprintf("login HTTP %d", loginResp.StatusCode) + return res + } + + // ── Think time β€” realistic pause while reading the questions ───────────── + thinkMS := 1000 + rng.Intn(3000) + select { + case <-ctx.Done(): + res.err = "context cancelled during think time" + return res + case <-time.After(time.Duration(thinkMS) * time.Millisecond): + } + + // ── Step 2: Submit quiz answers ─────────────────────────────────────────── + sub := randomSubmission(rng, session, namespace) + body, err := json.Marshal(sub) + if err != nil { + res.err = fmt.Sprintf("marshal submission: %v", err) + return res + } + + submitURL := fmt.Sprintf("%s/apis/%s/%s/namespaces/%s/quizsubmissions", + baseURL, apiGroup, apiVersion, namespace) + submitReq, err := http.NewRequestWithContext(ctx, http.MethodPost, submitURL, bytes.NewReader(body)) + if err != nil { + res.err = fmt.Sprintf("build submit request: %v", err) + return res + } + submitReq.Header.Set("Content-Type", "application/json") + + t1 := time.Now() + submitResp, err := client.Do(submitReq) + res.submitLatency = time.Since(t1) + if err != nil { + res.err = fmt.Sprintf("submit: %v", err) + return res + } + io.Copy(io.Discard, submitResp.Body) //nolint:errcheck + submitResp.Body.Close() + res.submitStatus = submitResp.StatusCode + + if submitResp.StatusCode == http.StatusCreated { + res.ok = true + } else { + res.err = fmt.Sprintf("submit HTTP %d", submitResp.StatusCode) + } + return res +} + +// ── Reporting ───────────────────────────────────────────────────────────────── + +func pct(sorted []float64, p float64) float64 { + if len(sorted) == 0 { + return 0 + } + idx := int(math.Ceil(p/100*float64(len(sorted)))) - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func printReport(results []userResult, wall time.Duration) { + var succeeded, failed int + var loginMs, submitMs []float64 + statusCounts := map[int]int{} + var failures []userResult + + for _, r := range results { + if r.ok { + succeeded++ + } else { + failed++ + failures = append(failures, r) + } + if r.loginLatency > 0 { + loginMs = append(loginMs, float64(r.loginLatency.Milliseconds())) + } + if r.submitLatency > 0 { + submitMs = append(submitMs, float64(r.submitLatency.Milliseconds())) + statusCounts[r.submitStatus]++ + } + } + + sort.Float64s(loginMs) + sort.Float64s(submitMs) + + fmt.Println() + fmt.Println("═══════════════════════════════") + fmt.Println(" Load Test Results") + fmt.Println("═══════════════════════════════") + fmt.Printf(" Wall time: %s\n", wall.Round(time.Millisecond)) + fmt.Printf(" Total: %d\n", len(results)) + fmt.Printf(" Succeeded: %d\n", succeeded) + fmt.Printf(" Failed: %d\n", failed) + + if len(loginMs) > 0 { + fmt.Printf("\n Login latency (ms)\n") + fmt.Printf(" p50=%.0f p95=%.0f p99=%.0f max=%.0f\n", + pct(loginMs, 50), pct(loginMs, 95), pct(loginMs, 99), loginMs[len(loginMs)-1]) + } + if len(submitMs) > 0 { + fmt.Printf("\n Submit latency (ms)\n") + fmt.Printf(" p50=%.0f p95=%.0f p99=%.0f max=%.0f\n", + pct(submitMs, 50), pct(submitMs, 95), pct(submitMs, 99), submitMs[len(submitMs)-1]) + } + + if len(statusCounts) > 0 { + // Sort status codes for deterministic output. + codes := make([]int, 0, len(statusCounts)) + for c := range statusCounts { + codes = append(codes, c) + } + sort.Ints(codes) + fmt.Println("\n Submit HTTP status codes") + for _, c := range codes { + fmt.Printf(" %d: %d\n", c, statusCounts[c]) + } + } + + if len(failures) > 0 { + fmt.Printf("\n Failures (%d)\n", len(failures)) + for _, r := range failures { + fmt.Printf(" user %3d: %s\n", r.userID, r.err) + } + } + fmt.Println("═══════════════════════════════") +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +func main() { + baseURL := flag.String("base-url", "https://vote.reversegitops.dev", + "base URL of the voter app") + code := flag.String("code", "", + "join code shown on the presenter screen (required)") + session := flag.String("session", "kubecon-2026", + "quiz session name (QuizSession CR name)") + namespace := flag.String("namespace", "vote", + "Kubernetes namespace where the session lives") + users := flag.Int("users", 100, + "number of simulated participants") + rampDuration := flag.Duration("ramp-duration", 30*time.Second, + "window over which participant arrivals are spread") + timeout := flag.Duration("timeout", 5*time.Minute, + "hard deadline for the whole test") + flag.Parse() + + if *code == "" { + fmt.Fprintln( + os.Stderr, + "error: --code is required (grab it from the presenter screen or kubectl -n vote logs deploy/vote-auth-service | tail -1)", + ) + flag.Usage() + os.Exit(1) + } + + fmt.Printf("Starting load test\n") + fmt.Printf(" Base URL: %s\n", *baseURL) + fmt.Printf(" Session: %s/%s\n", *namespace, *session) + fmt.Printf(" Join code: %s\n", *code) + fmt.Printf(" Participants: %d\n", *users) + fmt.Printf(" Ramp duration: %s\n", *rampDuration) + fmt.Println() + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + resultCh := make(chan userResult, *users) + var wg sync.WaitGroup + + start := time.Now() + for i := range *users { + wg.Add(1) + go func(userID int) { + defer wg.Done() + // Seed per goroutine so answers are not identical across users. + rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(userID)*997)) + // Staggered arrival: uniform random delay within the ramp window. + delay := time.Duration(rng.Float64() * float64(*rampDuration)) + select { + case <-ctx.Done(): + resultCh <- userResult{userID: userID, err: "cancelled before start"} + return + case <-time.After(delay): + } + resultCh <- simulateUser(ctx, userID, *baseURL, *code, *session, *namespace, rng) + }(i) + } + + // Close the channel once all goroutines finish. + go func() { + wg.Wait() + close(resultCh) + }() + + // Collect and live-print results as they arrive. + results := make([]userResult, 0, *users) + done := 0 + for r := range resultCh { + done++ + results = append(results, r) + if r.ok { + fmt.Printf(" [%3d/%d] user %3d login=%4dms submit=%4dms OK\n", + done, *users, r.userID, + r.loginLatency.Milliseconds(), r.submitLatency.Milliseconds()) + } else { + fmt.Printf(" [%3d/%d] user %3d FAIL: %s\n", + done, *users, r.userID, r.err) + } + } + + printReport(results, time.Since(start)) +} From 46623fbb914dc3ccb9c565b82c15feff075cd0a9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 17 Mar 2026 15:17:42 +0000 Subject: [PATCH 06/30] chore: Prepare demo test (that bootstraps the gitea repo and needed reverse-gitops stuff) --- Makefile | 9 + TESTING.md | 9 + charts/gitops-reverser/values.yaml | 4 +- config/deployment.yaml | 4 +- .../talk-demo-reset-loop-investigation.md | 309 +++++++++++++++ test/e2e/bi_directional_e2e_test.go | 2 +- test/e2e/cluster/README.md | 11 + test/e2e/quickstart_framework_e2e_test.go | 12 +- test/e2e/talk_e2e_test.go | 373 ++++++++++++++++++ test/e2e/templates/clusterwatchrule-talk.tmpl | 19 + test/e2e/templates/watchrule-all.tmpl | 34 ++ voter | 1 + 12 files changed, 776 insertions(+), 11 deletions(-) create mode 100644 docs/design/talk-demo-reset-loop-investigation.md create mode 100644 test/e2e/talk_e2e_test.go create mode 100644 test/e2e/templates/clusterwatchrule-talk.tmpl create mode 100644 test/e2e/templates/watchrule-all.tmpl create mode 160000 voter diff --git a/Makefile b/Makefile index bf8ffe084..7a9668b89 100644 --- a/Makefile +++ b/Makefile @@ -566,6 +566,15 @@ test-e2e-quickstart-helm: ## Run quickstart smoke test (Helm install) export E2E_AGE_KEY_FILE=$(CS)/age-key.txt go test ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=quickstart-framework +.PHONY: test-e2e-talk +test-e2e-talk: ## Prepare a reusable talk/demo repo and leave demo resources in place + export CTX=$(CTX) + export INSTALL_MODE=$(INSTALL_MODE) + export NAMESPACE=$(NAMESPACE) + export E2E_ENABLE_TALK_FRAMEWORK=true + export E2E_AGE_KEY_FILE=$(CS)/age-key.txt + go test ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=talk-demo + .PHONY: test-e2e-audit-redis test-e2e-audit-redis: ## Run dedicated audit->Redis enqueue e2e scenario export CTX=$(CTX) diff --git a/TESTING.md b/TESTING.md index 1b8762b97..c8832cb0a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -57,6 +57,12 @@ E2E tests live under `./test/e2e/` (for example [`test/e2e/e2e_suite_test.go`](t make test-e2e ``` +To prepare the reusable talk/demo repo and intentionally leave it behind: + +```bash +make test-e2e-talk +``` + Important points: - E2E runs against a real Kind Kubernetes cluster. @@ -67,6 +73,9 @@ Important points: - makes debugging easier (you can inspect the live cluster state) - makes reruns faster (no full cluster rebuild) - tests are written to β€œappend” rather than require a clean-slate cluster +- `make test-e2e-talk` now follows the same repo-provisioning path as the normal e2e suite: + each run gets a fresh Gitea repo unless you explicitly provide `REPO_NAME`. + It still leaves the demo resources behind so the environment is ready to show immediately. Notes (see [`Makefile`](Makefile:83)): diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 67445970e..26521d2ea 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -162,10 +162,10 @@ rbac: resources: limits: cpu: 500m - memory: 256Mi + memory: 512Mi requests: cpu: 10m - memory: 128Mi + memory: 256Mi # Node selection nodeSelector: {} diff --git a/config/deployment.yaml b/config/deployment.yaml index 84dd58ae7..f5a49fd93 100644 --- a/config/deployment.yaml +++ b/config/deployment.yaml @@ -70,10 +70,10 @@ spec: resources: limits: cpu: 500m - memory: 256Mi + memory: 512Mi requests: cpu: 10m - memory: 128Mi + memory: 256Mi securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/docs/design/talk-demo-reset-loop-investigation.md b/docs/design/talk-demo-reset-loop-investigation.md new file mode 100644 index 000000000..19952dc3b --- /dev/null +++ b/docs/design/talk-demo-reset-loop-investigation.md @@ -0,0 +1,309 @@ +# Talk Demo Reset Loop Investigation + +## Summary + +The `test-e2e-talk` scenario currently shows repeated Git worktree resets and repeated initial snapshot reconciliations when the demo target is configured to watch the `vote` namespace plus cluster-scoped resources. + +The current cluster state contains about `3105` watched resources, and roughly `3000` of those are `quizsubmissions`. That large object count does not look like the root cause by itself, but it appears to amplify the problem significantly once the reset/reconciliation loop starts. + +## What We Observed + +- Controller logs repeatedly show: + - `Preparing branch for operations` + - `Switching worktree to match remote` + - `Reset hard to match remote` + - `Starting reconciliation` + - `Retrieved cluster state` +- These messages recur in tight loops, often within the same second. +- The live metrics endpoint showed: + - `gitopsreverser_git_commit_queue_size 5100` +- The talk demo repository ends up receiving many live `quizsubmission` updates, while some pre-existing objects are missing from the initial snapshot export. + +## Current High-Level Flow + +### Main Control Flow + +1. Audit requests enter the webhook handler. +2. Audit events can be enqueued into Redis/Valkey. +3. The watch manager seeds and watches matching Kubernetes resources. +4. Matching resources are routed into a `GitTargetEventStream`. +5. The `GitTargetEventStream` forwards events into a `BranchWorker`. +6. The `BranchWorker` batches events and serializes Git writes for one `(provider, branch)` pair. + +### Important Components + +- Audit ingestion: + - [internal/webhook/audit_handler.go](/workspaces/gitops-reverser2/internal/webhook/audit_handler.go) +- Redis/Valkey-backed audit queue: + - [internal/queue/redis_audit_queue.go](/workspaces/gitops-reverser2/internal/queue/redis_audit_queue.go) +- Watch manager: + - [internal/watch/manager.go](/workspaces/gitops-reverser2/internal/watch/manager.go) +- Event router: + - [internal/watch/event_router.go](/workspaces/gitops-reverser2/internal/watch/event_router.go) +- GitTarget startup event stream: + - [internal/reconcile/git_target_event_stream.go](/workspaces/gitops-reverser2/internal/reconcile/git_target_event_stream.go) +- Per-branch serialized Git writer: + - [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go) +- Worker lifecycle manager: + - [internal/git/worker_manager.go](/workspaces/gitops-reverser2/internal/git/worker_manager.go) +- GitTarget lifecycle controller: + - [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser2/internal/controller/gittarget_controller.go) + +## Queues and Buffers + +### 1. Audit Queue + +- Redis/Valkey stream-backed queue +- Used by the audit webhook path +- Source: + - [internal/queue/redis_audit_queue.go](/workspaces/gitops-reverser2/internal/queue/redis_audit_queue.go) + +### 2. GitTarget Startup Buffer + +- In-memory buffer inside `GitTargetEventStream` +- Holds events while startup reconciliation is still in progress +- Source: + - [internal/reconcile/git_target_event_stream.go](/workspaces/gitops-reverser2/internal/reconcile/git_target_event_stream.go) + +### 3. Branch Worker Event Queue + +- In-memory channel per `(provider, branch)` +- Queue size is currently `100` +- Source: + - [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go#L47) + - [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go#L71) + +### 4. Branch Worker Batch Buffer + +- In-memory slice used before commit/push +- Events are accumulated and flushed on interval or threshold +- Source: + - [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go#L401) + +## Main Findings + +### Finding 1: Initial Snapshot Reconciliation Restarts Repeatedly + +The `GitTarget` reconciler restarts initial snapshot reconciliation on every reconcile pass. + +- Source: + - [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser2/internal/controller/gittarget_controller.go#L477) + - [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser2/internal/controller/gittarget_controller.go#L499) + +What happens now: + +- `evaluateSnapshotGate()` creates or reuses a reconciler +- It immediately calls `StartReconciliation()` +- There is no guard for: + - snapshot already completed + - same target generation + - same provider/branch/path inputs + +Impact: + +- repeated `RequestClusterState` +- repeated `RequestRepoState` +- repeated `Retrieved cluster state` +- repeated Git prep/reset activity downstream + +### Finding 2: Bootstrap Git Preparation Also Re-Runs on Every Reconcile + +The bootstrap gate always re-enters branch preparation work. + +- Source: + - [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser2/internal/controller/gittarget_controller.go#L428) + - [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser2/internal/controller/gittarget_controller.go#L445) + - [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go#L207) + - [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go#L229) + +What happens now: + +- `EnsureWorker()` is called every reconcile +- `EnsureTargetBootstrapped()` is called every reconcile +- bootstrap prep calls `PrepareBranch()` + +Impact: + +- more `Preparing branch for operations` +- more `Switching worktree to match remote` +- more `Reset hard to match remote` + +### Finding 3: Initial Snapshot Events Are Not Hydrated With Objects + +The startup reconciliation path identifies resources, but the later event path expects actual object payloads. + +- Source: + - [internal/reconcile/git_target_event_stream.go](/workspaces/gitops-reverser2/internal/reconcile/git_target_event_stream.go#L160) + - [internal/watch/event_router.go](/workspaces/gitops-reverser2/internal/watch/event_router.go#L163) + +What happens now: + +- startup reconciliation emits identifier-only events +- the event stream drops non-delete events with `event.Object == nil` +- `ReconcileResource` currently only logs and returns + +Impact: + +- pre-existing resources from the initial snapshot do not reliably land in Git +- live watched updates can still show up later +- this matches the observed behavior where `quizsubmissions` arrive, but some initial objects are missing + +### Finding 4: The Large `quizsubmission` Set Likely Amplifies the Loop + +The current talk demo environment contains about `3000` `quizsubmission` resources out of about `3105` total watched resources. + +Observed effect: + +- every unnecessary snapshot restart rescans thousands of resources +- every unnecessary enqueue adds more pressure to the branch-worker path +- the queue-size metric climbed to `5100` + +Current interpretation: + +- the large `quizsubmission` set is probably not the original logic bug +- it does appear to make the bug much more visible and much more expensive +- it may also expose secondary issues such as queue pressure, dedupe gaps, and slow drain behavior + +### Finding 5: Single-Writer Design Appears Intact + +The evidence currently supports a single writer path, not multiple competing writers. + +Runtime observations: + +- one controller pod is running +- deployment replica count is `1` +- ready replica count is `1` + +Design evidence: + +- one `BranchWorker` exists per `(provider namespace, provider name, branch)` key +- writes are serialized by `repoMu` +- the worker-manager tests explicitly assert one worker for shared repo+branch + +Relevant sources: + +- [internal/git/worker_manager.go](/workspaces/gitops-reverser2/internal/git/worker_manager.go#L92) +- [internal/git/branch_worker.go](/workspaces/gitops-reverser2/internal/git/branch_worker.go#L84) +- [internal/git/worker_manager_test.go](/workspaces/gitops-reverser2/internal/git/worker_manager_test.go#L179) + +Important limitation: + +- the `RepoBranchActiveWorkers` metric is declared but does not appear to be wired up, so we do not currently have a runtime metric proving active worker count. + +### Finding 6: The Queue-Size Metric Needs Careful Interpretation + +The live metric reported: + +- `gitopsreverser_git_commit_queue_size 5100` + +However: + +- I found production increments for this metric in watch/seed code +- I did not find corresponding production decrements in the writer path + +Relevant sources: + +- [internal/watch/manager.go](/workspaces/gitops-reverser2/internal/watch/manager.go#L608) +- [internal/watch/informers.go](/workspaces/gitops-reverser2/internal/watch/informers.go) + +Interpretation: + +- the metric is still a strong sign of churn +- it should not currently be treated as an exact live queue depth without fixing its accounting + +## Why The Current Logs Make Sense + +The repeated log sequence: + +- `Reset hard to match remote` +- `Starting reconciliation` +- `Retrieved cluster state` +- `Preparing branch for operations` +- `Reset hard to match remote` + +is consistent with this loop: + +1. `GitTarget` reconciles again +2. snapshot gate starts reconciliation again +3. bootstrap/status path re-enters branch preparation again +4. snapshot asks for cluster state again +5. branch prep resets the local worktree again +6. the large `quizsubmission` population makes each loop expensive + +## Fix Plan + +### 1. Make Snapshot Startup One-Shot or Input-Aware + +Goal: + +- do not call `StartReconciliation()` on every reconcile + +Expected approach: + +- persist enough status to know whether snapshot already completed for the current target inputs +- re-run only when a relevant input changes: + - generation + - provider + - branch + - path + - possibly watch configuration generation + +### 2. Stop Re-Running Bootstrap Preparation on Status-Only Reconciles + +Goal: + +- avoid repeated `PrepareBranch()` calls when bootstrap is already satisfied + +Expected approach: + +- make bootstrap idempotence visible in status or in-memory state +- skip `EnsureTargetBootstrapped()` unless bootstrap inputs changed + +### 3. Hydrate Initial Snapshot Events With Real Objects + +Goal: + +- ensure pre-existing cluster resources are written into Git during startup sync + +Expected approach: + +- either return full objects from the cluster-state path +- or implement `ReconcileResource` so it fetches and routes the actual object + +### 4. Re-Test With the Large `quizsubmission` Set + +Goal: + +- validate behavior under the current realistic talk-demo scale + +Specific check: + +- keep the current `~3000` `quizsubmissions` +- verify the reset loop stops +- verify queue pressure falls +- verify initial objects and live `quizsubmissions` both land in Git + +### 5. Improve Observability + +Goal: + +- make the next investigation cheaper + +Expected follow-ups: + +- wire up active worker metrics +- fix queue-size accounting +- add targeted logs around: + - snapshot start + - snapshot completion + - bootstrap skip vs bootstrap execute + - worker creation / reuse + +## Recommended Validation After Fix + +1. Run `make lint` +2. Run the targeted talk flow +3. Check controller logs for disappearance of the reset loop +4. Check that the initial snapshot writes pre-existing objects +5. Check that live `quizsubmission` traffic still flows +6. Confirm queue metrics behave plausibly under the `3000`-resource load diff --git a/test/e2e/bi_directional_e2e_test.go b/test/e2e/bi_directional_e2e_test.go index 4ea0fd520..28141335b 100644 --- a/test/e2e/bi_directional_e2e_test.go +++ b/test/e2e/bi_directional_e2e_test.go @@ -167,7 +167,7 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { run.consistentlyExpectRemoteCommitCount(baselineCommitCount+1, biStableCountMediumWait) By("enabling gitops-reverser before Flux starts managing IceCreamOrder resources") - createGitProviderWithURLInNamespace(run.gitProviderName, namespace, "main", e2eGitSecretHTTP(), run.repoURL) + createGitProviderWithURLInNamespace(run.gitProviderName, namespace, e2eGitSecretHTTP(), run.repoURL) createGitTargetWithEncryptionOptions( run.gitTargetName, namespace, diff --git a/test/e2e/cluster/README.md b/test/e2e/cluster/README.md index 5801b7333..d12a156d5 100644 --- a/test/e2e/cluster/README.md +++ b/test/e2e/cluster/README.md @@ -49,6 +49,17 @@ make test-e2e-quickstart-manifest make test-e2e-quickstart-helm ``` +Prepare the talk/demo repo and leave it in place: + +```bash +make test-e2e-talk +``` + +This target now uses the same suite-driven repo setup as the normal e2e flow, so each run gets a fresh repo +unless you explicitly pass `REPO_NAME=...`. It seeds that repo from the `vote` namespace plus supporting +cluster-scoped objects, and intentionally keeps the resulting Kubernetes resources and repo state for a live +walkthrough. + ## Verification Confirm cluster is up: diff --git a/test/e2e/quickstart_framework_e2e_test.go b/test/e2e/quickstart_framework_e2e_test.go index 1bfcb9365..dd7a908e1 100644 --- a/test/e2e/quickstart_framework_e2e_test.go +++ b/test/e2e/quickstart_framework_e2e_test.go @@ -147,7 +147,7 @@ func (r *quickstartFrameworkRun) setupGiteaRepository() { func (r *quickstartFrameworkRun) applyQuickstartResources() { qsNamespace := quickstartSetupNamespace() - createGitProviderWithURLInNamespace(r.providerName, qsNamespace, "main", e2eGitSecretHTTP(), r.repoURL) + createGitProviderWithURLInNamespace(r.providerName, qsNamespace, e2eGitSecretHTTP(), r.repoURL) createGitTargetWithEncryptionOptions( r.targetName, @@ -172,7 +172,7 @@ func (r *quickstartFrameworkRun) applyQuickstartResources() { err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", watchRuleData, qsNamespace) Expect(err).NotTo(HaveOccurred(), "failed to apply quickstart watchrule") - createGitProviderWithURLInNamespace(r.invalidProvName, qsNamespace, "main", e2eGitSecretInvalid(), r.repoURL) + createGitProviderWithURLInNamespace(r.invalidProvName, qsNamespace, e2eGitSecretInvalid(), r.repoURL) } func (r *quickstartFrameworkRun) verifyQuickstartResourcesReady() { @@ -480,9 +480,9 @@ func quickstartTimeout() time.Duration { return time.Duration(seconds) * time.Second } -func createGitProviderWithURLInNamespace(name, ns, branch, secretName, repoURL string) { - By(fmt.Sprintf("creating GitProvider '%s' in ns '%s' with branch '%s', secret '%s' and URL '%s'", - name, ns, branch, secretName, repoURL)) +func createGitProviderWithURLInNamespace(name, ns, secretName, repoURL string) { + By(fmt.Sprintf("creating GitProvider '%s' in ns '%s' with branch 'main', secret '%s' and URL '%s'", + name, ns, secretName, repoURL)) data := struct { Name string @@ -494,7 +494,7 @@ func createGitProviderWithURLInNamespace(name, ns, branch, secretName, repoURL s Name: name, Namespace: ns, RepoURL: repoURL, - Branch: branch, + Branch: "main", SecretName: secretName, } diff --git a/test/e2e/talk_e2e_test.go b/test/e2e/talk_e2e_test.go new file mode 100644 index 000000000..1daa98d74 --- /dev/null +++ b/test/e2e/talk_e2e_test.go @@ -0,0 +1,373 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +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 e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + talkFrameworkEnabledEnv = "E2E_ENABLE_TALK_FRAMEWORK" + talkDemoNamespace = "vote" + talkDemoPath = "demo/vote-stack" +) + +type talkDemoRun struct { + repoName string + checkoutDir string + repoURL string + sourceNamespace string + gitSecretName string + providerName string + targetName string + watchRuleName string + clusterWatchRuleName string + encryptionName string +} + +var _ = Describe("Talk Demo", Label("talk-demo"), Ordered, func() { + var run talkDemoRun + + BeforeAll(func() { + if !talkFrameworkEnabled() { + Skip(fmt.Sprintf( + "talk demo is disabled; set %s=true to run", + talkFrameworkEnabledEnv, + )) + } + + run = newTalkDemoRun() + }) + + It("prepares a reusable demo repository without cleanup", func() { + By("asserting the talk demo repository checkout exists") + run.setupGiteaRepository() + + By("copying Git credentials into the vote namespace") + run.copyGitSecretToTalkNamespace() + + By("applying talk demo GitOps Reverser resources") + run.applyTalkResources() + + By("verifying the talk demo resources become Ready") + run.verifyTalkResourcesReady() + + By("seeding the demo repository with a few representative updates") + run.seedTalkRepository() + + By("waiting for the initial snapshot to seed the demo repository") + run.verifyTalkRepositorySeeded() + + By("printing demo artifacts for the presenter") + run.logArtifacts() + }) +}) + +func talkFrameworkEnabled() bool { + value := strings.ToLower(strings.TrimSpace(os.Getenv(talkFrameworkEnabledEnv))) + return value == "1" || value == "true" || value == "yes" +} + +func newTalkDemoRun() talkDemoRun { + repoName := strings.TrimSpace(os.Getenv("E2E_REPO_NAME")) + checkoutDir := strings.TrimSpace(os.Getenv("E2E_CHECKOUT_DIR")) + sourceNamespace := resolveE2ENamespace() + gitSecretName := e2eGitSecretHTTP() + + Expect(repoName).NotTo( + BeEmpty(), + "E2E_REPO_NAME must be set by the suite (make e2e-gitea-run-setup)", + ) + Expect(checkoutDir).NotTo( + BeEmpty(), + "E2E_CHECKOUT_DIR must be set by the suite (make e2e-gitea-run-setup)", + ) + + repoURL := fmt.Sprintf( + "http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/%s.git", + repoName, + ) + + return talkDemoRun{ + repoName: repoName, + checkoutDir: checkoutDir, + repoURL: repoURL, + + sourceNamespace: sourceNamespace, + gitSecretName: gitSecretName, + providerName: "talk-demo-provider", + targetName: "talk-demo-target", + watchRuleName: "talk-demo-watch-all", + clusterWatchRuleName: "talk-demo-cluster-resources", + encryptionName: "talk-demo-sops-age-key", + } +} + +func (r *talkDemoRun) setupGiteaRepository() { + _, err := os.Stat(filepath.Join(r.checkoutDir, ".git")) + Expect(err).NotTo(HaveOccurred(), "expected checkout to exist at E2E_CHECKOUT_DIR") +} + +func (r *talkDemoRun) copyGitSecretToTalkNamespace() { + output, err := kubectlRunInNamespace(r.sourceNamespace, "get", "secret", r.gitSecretName, "-o", "json") + Expect(err).NotTo(HaveOccurred(), "failed to fetch source Git Secret") + + var secretObj map[string]interface{} + Expect(json.Unmarshal([]byte(output), &secretObj)).To(Succeed()) + + metadata, found, err := unstructured.NestedMap(secretObj, "metadata") + Expect(err).NotTo(HaveOccurred(), "failed to read Secret metadata") + Expect(found).To(BeTrue(), "expected Secret metadata") + + metadata["namespace"] = talkDemoNamespace + delete(metadata, "creationTimestamp") + delete(metadata, "generateName") + delete(metadata, "managedFields") + delete(metadata, "resourceVersion") + delete(metadata, "selfLink") + delete(metadata, "uid") + + if annotations, ok := metadata["annotations"].(map[string]interface{}); ok { + delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") + if len(annotations) == 0 { + delete(metadata, "annotations") + } + } + + secretObj["metadata"] = metadata + delete(secretObj, "status") + + manifest, err := json.Marshal(secretObj) + Expect(err).NotTo(HaveOccurred(), "failed to marshal copied Git Secret") + + _, err = kubectlRunWithStdin(talkDemoNamespace, string(manifest), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred(), "failed to apply copied Git Secret into vote namespace") +} + +func (r *talkDemoRun) applyTalkResources() { + createGitProviderWithURLInNamespace(r.providerName, talkDemoNamespace, r.gitSecretName, r.repoURL) + + createGitTargetWithEncryptionOptions( + r.targetName, + talkDemoNamespace, + r.providerName, + talkDemoPath, + "main", + r.encryptionName, + true, + ) + + watchRuleData := struct { + Name string + Namespace string + DestinationName string + }{ + Name: r.watchRuleName, + Namespace: talkDemoNamespace, + DestinationName: r.targetName, + } + + err := applyFromTemplate("test/e2e/templates/watchrule-all.tmpl", watchRuleData, talkDemoNamespace) + Expect(err).NotTo(HaveOccurred(), "failed to apply talk demo WatchRule") + + clusterWatchRuleData := struct { + Name string + Namespace string + DestinationName string + }{ + Name: r.clusterWatchRuleName, + Namespace: talkDemoNamespace, + DestinationName: r.targetName, + } + + err = applyFromTemplate("test/e2e/templates/clusterwatchrule-talk.tmpl", clusterWatchRuleData, "") + Expect(err).NotTo(HaveOccurred(), "failed to apply talk demo ClusterWatchRule") +} + +func (r *talkDemoRun) verifyTalkResourcesReady() { + verifyResourceStatus("gitprovider", r.providerName, talkDemoNamespace, "True", "Ready", "") + verifyResourceStatus("gittarget", r.targetName, talkDemoNamespace, "True", "Ready", "") + verifyResourceStatus("watchrule", r.watchRuleName, talkDemoNamespace, "True", "Ready", "") + verifyResourceStatus("clusterwatchrule", r.clusterWatchRuleName, "", "True", "Ready", "") +} + +func (r *talkDemoRun) seedTalkRepository() { + value := strconv.FormatInt(time.Now().UnixNano(), 10) + + r.patchClusterResource( + "namespace", + talkDemoNamespace, + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + + r.patchNamespacedResource( + "gitprovider", + r.providerName, + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + r.patchNamespacedResource( + "gittarget", + r.targetName, + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + r.patchNamespacedResource( + "watchrule", + r.watchRuleName, + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + r.patchClusterResource( + "clusterwatchrule", + r.clusterWatchRuleName, + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + + r.patchNamespacedResource( + "deployment", + "vote-frontend", + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + r.patchNamespacedResource( + "service", + "vote-frontend", + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + r.patchNamespacedResource( + "ingressroute", + "frontend-static", + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) + r.patchNamespacedResource( + "quizsession", + "kubecon-2026", + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + ) +} + +func (r *talkDemoRun) patchClusterResource(resource, name, patch string) { + _, err := kubectlRun( + "patch", + resource, + name, + "--type", + "merge", + "--patch", + patch, + ) + Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("failed to patch cluster resource %s/%s", resource, name)) +} + +func (r *talkDemoRun) patchNamespacedResource(resource, name, patch string) { + _, err := kubectlRunInNamespace( + talkDemoNamespace, + "patch", + resource, + name, + "--type", + "merge", + "--patch", + patch, + ) + Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("failed to patch namespaced resource %s/%s", resource, name)) +} + +func (r *talkDemoRun) verifyTalkRepositorySeeded() { + expectedFiles := []string{ + filepath.Join(r.checkoutDir, talkDemoPath, ".sops.yaml"), + filepath.Join(r.checkoutDir, talkDemoPath, "v1/namespaces/vote.yaml"), + filepath.Join(r.checkoutDir, talkDemoPath, "apps/v1/deployments/vote/vote-frontend.yaml"), + filepath.Join(r.checkoutDir, talkDemoPath, "v1/services/vote/vote-frontend.yaml"), + filepath.Join(r.checkoutDir, talkDemoPath, "traefik.io/v1alpha1/ingressroutes/vote/frontend-static.yaml"), + filepath.Join( + r.checkoutDir, + talkDemoPath, + "configbutler.ai/v1alpha1/gitproviders/vote/talk-demo-provider.yaml", + ), + } + quizSubmissionPattern := filepath.Join( + r.checkoutDir, + talkDemoPath, + "examples.configbutler.ai/v1alpha1/quizsubmissions/vote/*.yaml", + ) + + Eventually(func(g Gomega) { + g.Expect(r.gitPull()).To(Succeed()) + + commitCount, err := r.gitCommitCount() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(commitCount).To(BeNumerically(">", 0), "expected the demo repo to contain commits") + + for _, expectedFile := range expectedFiles { + content, readErr := os.ReadFile(expectedFile) + g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("expected seeded file %s", expectedFile)) + g.Expect(content).NotTo(BeEmpty(), fmt.Sprintf("expected non-empty file %s", expectedFile)) + } + quizSubmissionFiles, globErr := filepath.Glob(quizSubmissionPattern) + g.Expect(globErr).NotTo(HaveOccurred()) + g.Expect(quizSubmissionFiles).NotTo(BeEmpty(), "expected at least one quizsubmission file in the demo repo") + }, 2*time.Minute, 2*time.Second).Should(Succeed()) +} + +func (r *talkDemoRun) logArtifacts() { + _, _ = fmt.Fprintf( + GinkgoWriter, + "Talk demo ready:\n repo=%s\n checkout=%s\n repoURL=%s\n namespace=%s\n path=%s\n", + r.repoName, + r.checkoutDir, + r.repoURL, + talkDemoNamespace, + talkDemoPath, + ) +} + +func (r *talkDemoRun) gitPull() error { + pullCmd := exec.Command("git", "pull", "--ff-only") + pullCmd.Dir = r.checkoutDir + output, err := pullCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git pull: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func (r *talkDemoRun) gitCommitCount() (int, error) { + cmd := exec.Command("git", "rev-list", "--count", "--all") + cmd.Dir = r.checkoutDir + output, err := cmd.CombinedOutput() + if err != nil { + return 0, fmt.Errorf("git rev-list --count: %w: %s", err, strings.TrimSpace(string(output))) + } + + var count int + _, scanErr := fmt.Sscanf(strings.TrimSpace(string(output)), "%d", &count) + if scanErr != nil { + return 0, fmt.Errorf("parse git commit count %q: %w", strings.TrimSpace(string(output)), scanErr) + } + return count, nil +} diff --git a/test/e2e/templates/clusterwatchrule-talk.tmpl b/test/e2e/templates/clusterwatchrule-talk.tmpl new file mode 100644 index 000000000..3a7d16d31 --- /dev/null +++ b/test/e2e/templates/clusterwatchrule-talk.tmpl @@ -0,0 +1,19 @@ +apiVersion: configbutler.ai/v1alpha1 +kind: ClusterWatchRule +metadata: + name: {{ .Name }} +spec: + targetRef: + name: {{ .DestinationName }} + namespace: {{ .Namespace }} + rules: + - scope: Cluster + operations: [CREATE, UPDATE, DELETE] + apiGroups: [""] + apiVersions: ["v1"] + resources: ["namespaces"] + - scope: Cluster + operations: [CREATE, UPDATE, DELETE] + apiGroups: ["configbutler.ai"] + apiVersions: ["v1alpha1"] + resources: ["clusterwatchrules"] diff --git a/test/e2e/templates/watchrule-all.tmpl b/test/e2e/templates/watchrule-all.tmpl new file mode 100644 index 000000000..7eeca94dd --- /dev/null +++ b/test/e2e/templates/watchrule-all.tmpl @@ -0,0 +1,34 @@ +apiVersion: configbutler.ai/v1alpha1 +kind: WatchRule +metadata: + name: {{ .Name }} + namespace: {{ .Namespace }} +spec: + targetRef: + kind: GitTarget + name: {{ .DestinationName }} + rules: + - operations: [CREATE, UPDATE, DELETE] + apiGroups: [""] + apiVersions: ["v1"] + resources: ["configmaps", "secrets", "services", "serviceaccounts"] + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["apps"] + apiVersions: ["v1"] + resources: ["deployments"] + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["rbac.authorization.k8s.io"] + apiVersions: ["v1"] + resources: ["roles", "rolebindings"] + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["traefik.io"] + apiVersions: ["v1alpha1"] + resources: ["ingressroutes"] + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["examples.configbutler.ai"] + apiVersions: ["v1alpha1"] + resources: ["quizsessions", "quizsubmissions"] + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["configbutler.ai"] + apiVersions: ["v1alpha1"] + resources: ["gitproviders", "gittargets", "watchrules"] diff --git a/voter b/voter new file mode 160000 index 000000000..92ab78b73 --- /dev/null +++ b/voter @@ -0,0 +1 @@ +Subproject commit 92ab78b73697259d41cc023bcb5b0d281121b0af From 1584bf1c21c9b7adc668a52ab2ddbbb8972feab2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 17 Mar 2026 16:48:33 +0000 Subject: [PATCH 07/30] chore: This needs more testing, and probably get it together in a single commit --- internal/events/events.go | 7 ++++++ internal/reconcile/folder_reconciler.go | 25 +++++++++++++++++-- internal/reconcile/folder_reconciler_test.go | 8 ++++-- internal/reconcile/git_target_event_stream.go | 22 +++++++++++----- internal/watch/event_router.go | 3 ++- internal/watch/manager.go | 17 +++++++++---- 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/internal/events/events.go b/internal/events/events.go index c17b58e09..3cea4874e 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -20,6 +20,8 @@ limitations under the License. package events import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -30,6 +32,11 @@ type ClusterStateEvent struct { // Resources currently in cluster for this GitDestination Resources []types.ResourceIdentifier + + // Objects maps ResourceIdentifier.Key() to the full sanitized cluster object. + // Populated during initial snapshot reconciliation so that CREATE and + // RECONCILE_RESOURCE events can be hydrated with real payloads. + Objects map[string]unstructured.Unstructured } // RepoStateEvent reports what Kubernetes resources exist in a Git repository for a GitDestination. diff --git a/internal/reconcile/folder_reconciler.go b/internal/reconcile/folder_reconciler.go index 82422e9bb..07ec090a3 100644 --- a/internal/reconcile/folder_reconciler.go +++ b/internal/reconcile/folder_reconciler.go @@ -24,6 +24,7 @@ import ( "fmt" "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/ConfigButler/gitops-reverser/internal/events" "github.com/ConfigButler/gitops-reverser/internal/types" @@ -41,6 +42,10 @@ type FolderReconciler struct { clusterStateSeen bool gitStateSeen bool + // Full cluster objects keyed by ResourceIdentifier.Key(), populated alongside + // clusterResources so that write events can be hydrated with real payloads. + clusterObjects map[string]unstructured.Unstructured + // Dependencies for event emission eventEmitter EventEmitter controlEmitter events.ControlEventEmitter @@ -99,6 +104,7 @@ func (r *FolderReconciler) OnClusterState(event events.ClusterStateEvent) { return } r.clusterResources = event.Resources + r.clusterObjects = event.Objects r.clusterStateSeen = true r.logger.V(1).Info("Received cluster state", "resourceCount", len(event.Resources)) r.reconcile() @@ -137,7 +143,8 @@ func (r *FolderReconciler) reconcile() { // Emit reconciliation events (time filtering happens in WatchManager) for _, resource := range toCreate { - if err := r.eventEmitter.EmitCreateEvent(resource); err != nil { + obj := r.objectForResource(resource) + if err := r.eventEmitter.EmitCreateEvent(resource, obj); err != nil { r.logger.Error(err, "Failed to emit create event", "resource", resource.String()) } } @@ -150,7 +157,8 @@ func (r *FolderReconciler) reconcile() { // For resources that exist in both places, emit reconcile event immediately for _, resource := range existingInBoth { - if err := r.eventEmitter.EmitReconcileResourceEvent(resource); err != nil { + obj := r.objectForResource(resource) + if err := r.eventEmitter.EmitReconcileResourceEvent(resource, obj); err != nil { r.logger.Error(err, "Failed to emit reconcile resource event", "resource", resource.String()) } } @@ -161,6 +169,19 @@ func (r *FolderReconciler) GetLastSnapshotStats() SnapshotStats { return r.lastSnapshotStats } +// objectForResource returns a pointer to the cached cluster object for the given resource, +// or nil if the object is not available (e.g. ClusterStateEvent carried no objects). +func (r *FolderReconciler) objectForResource(resource types.ResourceIdentifier) *unstructured.Unstructured { + if r.clusterObjects == nil { + return nil + } + obj, ok := r.clusterObjects[resource.Key()] + if !ok { + return nil + } + return &obj +} + // findDifferences computes what needs to be created, deleted, and resources that exist in both. func (r *FolderReconciler) findDifferences( clusterResources, gitResources []types.ResourceIdentifier, diff --git a/internal/reconcile/folder_reconciler_test.go b/internal/reconcile/folder_reconciler_test.go index 4bf39864c..a9ddbd708 100644 --- a/internal/reconcile/folder_reconciler_test.go +++ b/internal/reconcile/folder_reconciler_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/controller-runtime/pkg/log" "github.com/ConfigButler/gitops-reverser/internal/events" @@ -329,7 +330,7 @@ type MockEventEmitter struct { reconcileEvents []types.ResourceIdentifier } -func (m *MockEventEmitter) EmitCreateEvent(resource types.ResourceIdentifier) error { +func (m *MockEventEmitter) EmitCreateEvent(resource types.ResourceIdentifier, _ *unstructured.Unstructured) error { m.createEvents = append(m.createEvents, resource) return nil } @@ -339,7 +340,10 @@ func (m *MockEventEmitter) EmitDeleteEvent(resource types.ResourceIdentifier) er return nil } -func (m *MockEventEmitter) EmitReconcileResourceEvent(resource types.ResourceIdentifier) error { +func (m *MockEventEmitter) EmitReconcileResourceEvent( + resource types.ResourceIdentifier, + _ *unstructured.Unstructured, +) error { m.reconcileEvents = append(m.reconcileEvents, resource) return nil } diff --git a/internal/reconcile/git_target_event_stream.go b/internal/reconcile/git_target_event_stream.go index 7c0b3987d..29e9ea492 100644 --- a/internal/reconcile/git_target_event_stream.go +++ b/internal/reconcile/git_target_event_stream.go @@ -24,6 +24,7 @@ import ( "sync" "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/ConfigButler/gitops-reverser/internal/events" "github.com/ConfigButler/gitops-reverser/internal/git" @@ -70,9 +71,9 @@ type EventEnqueuer interface { // EventEmitter interface for emitting reconciliation events. type EventEmitter interface { - EmitCreateEvent(resource types.ResourceIdentifier) error + EmitCreateEvent(resource types.ResourceIdentifier, obj *unstructured.Unstructured) error EmitDeleteEvent(resource types.ResourceIdentifier) error - EmitReconcileResourceEvent(resource types.ResourceIdentifier) error + EmitReconcileResourceEvent(resource types.ResourceIdentifier, obj *unstructured.Unstructured) error } // NewGitTargetEventStream creates a new event stream for a GitTarget. @@ -234,11 +235,15 @@ func (s *GitTargetEventStream) String() string { } // EmitCreateEvent emits a CREATE event for reconciliation. -func (s *GitTargetEventStream) EmitCreateEvent(resource types.ResourceIdentifier) error { +// obj carries the full sanitized cluster object so the branch worker can write it to Git. +func (s *GitTargetEventStream) EmitCreateEvent( + resource types.ResourceIdentifier, + obj *unstructured.Unstructured, +) error { event := git.Event{ Operation: "CREATE", Identifier: resource, - // BaseFolder will be set by the reconciler + Object: obj, } s.OnWatchEvent(event) return nil @@ -256,11 +261,16 @@ func (s *GitTargetEventStream) EmitDeleteEvent(resource types.ResourceIdentifier } // EmitReconcileResourceEvent emits a RECONCILE_RESOURCE event for reconciliation. -func (s *GitTargetEventStream) EmitReconcileResourceEvent(resource types.ResourceIdentifier) error { +// obj carries the full sanitized cluster object so the branch worker can overwrite the +// existing Git file with the current cluster state. +func (s *GitTargetEventStream) EmitReconcileResourceEvent( + resource types.ResourceIdentifier, + obj *unstructured.Unstructured, +) error { event := git.Event{ Operation: string(events.ReconcileResource), Identifier: resource, - // BaseFolder will be set by the reconciler + Object: obj, } s.OnWatchEvent(event) return nil diff --git a/internal/watch/event_router.go b/internal/watch/event_router.go index 1c06721c0..0630005cd 100644 --- a/internal/watch/event_router.go +++ b/internal/watch/event_router.go @@ -114,7 +114,7 @@ func (r *EventRouter) ProcessControlEvent(ctx context.Context, event events.Cont // handleRequestClusterState processes RequestClusterState control events. func (r *EventRouter) handleRequestClusterState(ctx context.Context, event events.ControlEvent) error { // Call WatchManager service (synchronous) - resources, err := r.WatchManager.GetClusterStateForGitDest(ctx, event.GitDest) + resources, objects, err := r.WatchManager.GetClusterStateForGitDest(ctx, event.GitDest) if err != nil { return fmt.Errorf("failed to get cluster state: %w", err) } @@ -123,6 +123,7 @@ func (r *EventRouter) handleRequestClusterState(ctx context.Context, event event return r.RouteClusterStateEvent(events.ClusterStateEvent{ GitDest: event.GitDest, Resources: resources, + Objects: objects, }) } diff --git a/internal/watch/manager.go b/internal/watch/manager.go index c49b72181..ce2a7d4f4 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -612,10 +612,12 @@ func (m *Manager) processListedObject( // GetClusterStateForGitDest returns cluster resources for a GitTarget. // This is a synchronous service method called by EventRouter. +// It returns both resource identifiers (for diff logic) and sanitized full objects +// (keyed by ResourceIdentifier.Key()) for hydrating initial snapshot write events. func (m *Manager) GetClusterStateForGitDest( ctx context.Context, gitDest types.ResourceReference, -) ([]types.ResourceIdentifier, error) { +) ([]types.ResourceIdentifier, map[string]unstructured.Unstructured, error) { log := m.Log.WithValues("gitDest", gitDest.String()) // Look up GitTarget to get path @@ -624,7 +626,7 @@ func (m *Manager) GetClusterStateForGitDest( Name: gitDest.Name, Namespace: gitDest.Namespace, }, &gitTargetObj); err != nil { - return nil, fmt.Errorf("failed to get GitTarget: %w", err) + return nil, nil, fmt.Errorf("failed to get GitTarget: %w", err) } path := gitTargetObj.Spec.Path @@ -658,12 +660,13 @@ func (m *Manager) GetClusterStateForGitDest( // Query cluster for these GVRs dc := m.dynamicClientFromConfig(log) if dc == nil { - return nil, errors.New("no dynamic client available") + return nil, nil, errors.New("no dynamic client available") } var resources []types.ResourceIdentifier + objects := make(map[string]unstructured.Unstructured) for gvr := range gvrSet { - gvrResources, err := m.listResourcesForGVR(ctx, dc, gvr, &gitTargetObj) + gvrResources, err := m.listResourcesForGVR(ctx, dc, gvr, &gitTargetObj, objects) if err != nil { log.Error(err, "Failed to list GVR", "gvr", gvr) continue @@ -672,7 +675,7 @@ func (m *Manager) GetClusterStateForGitDest( } log.Info("Retrieved cluster state", "resourceCount", len(resources)) - return resources, nil + return resources, objects, nil } // addGVRsFromResourceRule adds GVRs from a CompiledResourceRule to the set. @@ -752,11 +755,14 @@ func (m *Manager) addGVRsFromClusterResourceRule( } // listResourcesForGVR lists all resources for a specific GVR that match the GitTarget criteria. +// Identifiers are returned; sanitized full objects are written into the provided objects map +// (keyed by ResourceIdentifier.Key()) for hydrating initial snapshot write events. func (m *Manager) listResourcesForGVR( ctx context.Context, dc dynamic.Interface, gvr schema.GroupVersionResource, gitTarget *configv1alpha1.GitTarget, + objects map[string]unstructured.Unstructured, ) ([]types.ResourceIdentifier, error) { if shouldIgnoreResource(gvr.Group, gvr.Resource) { return nil, nil @@ -787,6 +793,7 @@ func (m *Manager) listResourcesForGVR( obj.GetName(), ) resources = append(resources, id) + objects[id.Key()] = *sanitize.Sanitize(obj) } return resources, nil From a249c965ab2624c7f628ae0e04d56e71353db3f4 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Mar 2026 09:02:43 +0000 Subject: [PATCH 08/30] chore: Move demo stuff out of 'production' tests so that we could merge if we wanted --- Makefile | 48 +++++++++++++------ TESTING.md | 7 ++- .../talk-demo-reset-loop-investigation.md | 2 +- test/e2e/cluster/README.md | 6 ++- .../cloudflared-public/README.md | 0 .../cloudflared-deplyoment.yaml | 0 .../cloudflared-public/kustomization.yaml | 0 .../cloudflared-public/ns.yaml | 0 .../tunnel-credentials.yaml.example | 0 test/e2e/setup/demo-only/kustomization.yaml | 6 +++ .../{manifests => demo-only}/vote/apf.yaml | 0 .../vote/crds/quizsessions.yaml | 0 .../vote/crds/quizsubmissions.yaml | 0 .../vote/deployments.yaml | 0 .../examples/quizsession-kubecon-2026.yaml | 0 .../examples/quizsubmission-kubecon-2026.yaml | 0 .../vote/ingress-auth.yaml | 0 .../vote/ingress-frontend.yaml | 0 .../vote/ingress-kubeapi.yaml | 0 .../vote/ingress-podinfo.yaml | 0 .../vote/kustomization.yaml | 0 .../{manifests => demo-only}/vote/ns.yaml | 0 .../vote/podinfo.yaml | 0 .../vote/pull-secret.yaml.example | 0 .../vote/rbac-auth-service.yaml | 0 .../vote/rbac-participants.yaml | 0 test/e2e/setup/manifests/README.md | 5 +- test/e2e/setup/manifests/kustomization.yaml | 2 - 28 files changed, 54 insertions(+), 22 deletions(-) rename test/e2e/setup/{manifests => demo-only}/cloudflared-public/README.md (100%) rename test/e2e/setup/{manifests => demo-only}/cloudflared-public/cloudflared-deplyoment.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/cloudflared-public/kustomization.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/cloudflared-public/ns.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/cloudflared-public/tunnel-credentials.yaml.example (100%) create mode 100644 test/e2e/setup/demo-only/kustomization.yaml rename test/e2e/setup/{manifests => demo-only}/vote/apf.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/crds/quizsessions.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/crds/quizsubmissions.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/deployments.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/examples/quizsession-kubecon-2026.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/examples/quizsubmission-kubecon-2026.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/ingress-auth.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/ingress-frontend.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/ingress-kubeapi.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/ingress-podinfo.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/kustomization.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/ns.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/podinfo.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/pull-secret.yaml.example (100%) rename test/e2e/setup/{manifests => demo-only}/vote/rbac-auth-service.yaml (100%) rename test/e2e/setup/{manifests => demo-only}/vote/rbac-participants.yaml (100%) diff --git a/Makefile b/Makefile index 7a9668b89..68ad5987e 100644 --- a/Makefile +++ b/Makefile @@ -320,11 +320,14 @@ $(CS)/ready: test/e2e/cluster/start-cluster.sh | $(CS) touch $@ FLUX_SERVICES_INPUTS = $(shell find $(FLUX_SERVICES_DIR) -type f) -LAST_STEP_MANIFESTS_DIR = test/e2e/setup/manifests -LOCAL_TUNNEL_CREDENTIALS = $(LAST_STEP_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml -LAST_STEP_MANIFESTS = $(shell find $(LAST_STEP_MANIFESTS_DIR) -type f -name '*.yaml') +SHARED_MANIFESTS_DIR = test/e2e/setup/manifests +SHARED_MANIFESTS = $(shell find $(SHARED_MANIFESTS_DIR) -type f -name '*.yaml') +DEMO_MANIFESTS_DIR = test/e2e/setup/demo-only +DEMO_MANIFESTS = $(shell find $(DEMO_MANIFESTS_DIR) -type f -name '*.yaml') +DEMO_TUNNEL_CREDENTIALS = $(DEMO_MANIFESTS_DIR)/cloudflared-public/tunnel-credentials.yaml +DEMO_PULL_SECRET = $(DEMO_MANIFESTS_DIR)/vote/pull-secret.yaml FLUX_SETUP_READY_INPUTS = $(CS)/flux.installed $$(FLUX_SERVICES_INPUTS) -SERVICES_READY_INPUTS = $(CS)/flux-setup.ready $$(LAST_STEP_MANIFESTS) +SERVICES_READY_INPUTS = $(CS)/flux-setup.ready $$(SHARED_MANIFESTS) $(CS)/flux.installed: $(CS)/ready | $(CS) $(FLUX) install --context $(CTX) --namespace $(FLUX_NAMESPACE) kubectl --context $(CTX) -n $(FLUX_NAMESPACE) wait \ @@ -366,18 +369,27 @@ $(CS)/flux-setup.ready: $(FLUX_SETUP_READY_INPUTS) | $(CS) # Aggregate stamp for external E2E services required by tests. $(CS)/services.ready: $(SERVICES_READY_INPUTS) | $(CS) kubectl --context $(CTX) wait --for=condition=Established crd/prometheuses.monitoring.coreos.com crd/servicemonitors.monitoring.coreos.com --timeout=180s - @[ -f "$(LOCAL_TUNNEL_CREDENTIALS)" ] || { \ - echo "ERROR: missing required local tunnel credentials file '$(LOCAL_TUNNEL_CREDENTIALS)'" >&2; \ - echo "Create it from '$(LOCAL_TUNNEL_CREDENTIALS).example' and set stringData.token before running this target." >&2; \ - exit 1; \ - } - kubectl --context $(CTX) apply -f $(LAST_STEP_MANIFESTS_DIR)/vote/ns.yaml - kubectl --context $(CTX) apply -f $(LAST_STEP_MANIFESTS_DIR)/vote/crds + kubectl --context $(CTX) apply -k $(SHARED_MANIFESTS_DIR) + touch $@ + +.PHONY: prepare-e2e-demo +prepare-e2e-demo: $(CS)/demo.ready ## Prepare demo-only prerequisites for talk/demo e2e runs + +$(CS)/demo.ready: $(CS)/services.ready $$(DEMO_MANIFESTS) | $(CS) + $(call REQUIRE_FILE,$(DEMO_TUNNEL_CREDENTIALS),demo tunnel credentials,Create it from '$(DEMO_TUNNEL_CREDENTIALS).example' and set stringData.token before running this target.) + $(call REQUIRE_FILE,$(DEMO_PULL_SECRET),demo image pull secret,Create it from '$(DEMO_PULL_SECRET).example' before running this target.) + kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/vote/ns.yaml + kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/cloudflared-public/ns.yaml + kubectl --context $(CTX) wait --for=jsonpath='{.status.phase}'=Active \ + namespace/vote \ + namespace/cloudflared-public \ + --timeout=120s + kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/vote/crds kubectl --context $(CTX) wait --for=condition=Established \ crd/quizsessions.examples.configbutler.ai \ crd/quizsubmissions.examples.configbutler.ai \ --timeout=180s - kubectl --context $(CTX) apply -k $(LAST_STEP_MANIFESTS_DIR) + kubectl --context $(CTX) apply -k $(DEMO_MANIFESTS_DIR) touch $@ # Step 1: Generate age key file β€” no cluster/namespace dependency; safe to run before installation. @@ -502,6 +514,14 @@ define UPDATE_IF_CHANGED fi endef +define REQUIRE_FILE + @[ -f "$(1)" ] || { \ + echo "ERROR: missing required $(2) file '$(1)'" >&2; \ + echo "$(3)" >&2; \ + exit 1; \ + } +endef + ##@ Deployments of all manifests needed to run $(CS)/$(NAMESPACE)/helm/install.yaml: $(CS)/services.ready $(HELM_SYNC_OUTPUTS) $$(CHART_INPUTS) | $(CS)/$(NAMESPACE)/helm $(DO_CLEANUP_INSTALLS) @@ -566,8 +586,8 @@ test-e2e-quickstart-helm: ## Run quickstart smoke test (Helm install) export E2E_AGE_KEY_FILE=$(CS)/age-key.txt go test ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=quickstart-framework -.PHONY: test-e2e-talk -test-e2e-talk: ## Prepare a reusable talk/demo repo and leave demo resources in place +.PHONY: test-e2e-demo +test-e2e-demo: prepare-e2e-demo ## Prepare a reusable talk/demo repo and leave demo resources in place export CTX=$(CTX) export INSTALL_MODE=$(INSTALL_MODE) export NAMESPACE=$(NAMESPACE) diff --git a/TESTING.md b/TESTING.md index c8832cb0a..3c9f12440 100644 --- a/TESTING.md +++ b/TESTING.md @@ -60,7 +60,7 @@ make test-e2e To prepare the reusable talk/demo repo and intentionally leave it behind: ```bash -make test-e2e-talk +make test-e2e-demo ``` Important points: @@ -73,9 +73,12 @@ Important points: - makes debugging easier (you can inspect the live cluster state) - makes reruns faster (no full cluster rebuild) - tests are written to β€œappend” rather than require a clean-slate cluster -- `make test-e2e-talk` now follows the same repo-provisioning path as the normal e2e suite: +- `make test-e2e-demo` now follows the same repo-provisioning path as the normal e2e suite: each run gets a fresh Gitea repo unless you explicitly provide `REPO_NAME`. It still leaves the demo resources behind so the environment is ready to show immediately. +- `make test-e2e-demo` runs an extra demo-only prep step before the suite: + it validates the local cloudflared and pull-secret manifests, installs the demo quiz CRDs, waits for them, + and only then applies `test/e2e/setup/demo-only`. Notes (see [`Makefile`](Makefile:83)): diff --git a/docs/design/talk-demo-reset-loop-investigation.md b/docs/design/talk-demo-reset-loop-investigation.md index 19952dc3b..f4e64a8d8 100644 --- a/docs/design/talk-demo-reset-loop-investigation.md +++ b/docs/design/talk-demo-reset-loop-investigation.md @@ -2,7 +2,7 @@ ## Summary -The `test-e2e-talk` scenario currently shows repeated Git worktree resets and repeated initial snapshot reconciliations when the demo target is configured to watch the `vote` namespace plus cluster-scoped resources. +The `test-e2e-demo` scenario currently shows repeated Git worktree resets and repeated initial snapshot reconciliations when the demo target is configured to watch the `vote` namespace plus cluster-scoped resources. The current cluster state contains about `3105` watched resources, and roughly `3000` of those are `quizsubmissions`. That large object count does not look like the root cause by itself, but it appears to amplify the problem significantly once the reset/reconciliation loop starts. diff --git a/test/e2e/cluster/README.md b/test/e2e/cluster/README.md index d12a156d5..575465f4e 100644 --- a/test/e2e/cluster/README.md +++ b/test/e2e/cluster/README.md @@ -52,7 +52,7 @@ make test-e2e-quickstart-helm Prepare the talk/demo repo and leave it in place: ```bash -make test-e2e-talk +make test-e2e-demo ``` This target now uses the same suite-driven repo setup as the normal e2e flow, so each run gets a fresh repo @@ -60,6 +60,10 @@ unless you explicitly pass `REPO_NAME=...`. It seeds that repo from the `vote` n cluster-scoped objects, and intentionally keeps the resulting Kubernetes resources and repo state for a live walkthrough. +Before the suite runs, `make test-e2e-demo` also executes a demo-only prep step that validates the local +cloudflared and pull-secret manifests, installs the quiz CRDs, waits for them, and then applies +`test/e2e/setup/demo-only`. + ## Verification Confirm cluster is up: diff --git a/test/e2e/setup/manifests/cloudflared-public/README.md b/test/e2e/setup/demo-only/cloudflared-public/README.md similarity index 100% rename from test/e2e/setup/manifests/cloudflared-public/README.md rename to test/e2e/setup/demo-only/cloudflared-public/README.md diff --git a/test/e2e/setup/manifests/cloudflared-public/cloudflared-deplyoment.yaml b/test/e2e/setup/demo-only/cloudflared-public/cloudflared-deplyoment.yaml similarity index 100% rename from test/e2e/setup/manifests/cloudflared-public/cloudflared-deplyoment.yaml rename to test/e2e/setup/demo-only/cloudflared-public/cloudflared-deplyoment.yaml diff --git a/test/e2e/setup/manifests/cloudflared-public/kustomization.yaml b/test/e2e/setup/demo-only/cloudflared-public/kustomization.yaml similarity index 100% rename from test/e2e/setup/manifests/cloudflared-public/kustomization.yaml rename to test/e2e/setup/demo-only/cloudflared-public/kustomization.yaml diff --git a/test/e2e/setup/manifests/cloudflared-public/ns.yaml b/test/e2e/setup/demo-only/cloudflared-public/ns.yaml similarity index 100% rename from test/e2e/setup/manifests/cloudflared-public/ns.yaml rename to test/e2e/setup/demo-only/cloudflared-public/ns.yaml diff --git a/test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml.example b/test/e2e/setup/demo-only/cloudflared-public/tunnel-credentials.yaml.example similarity index 100% rename from test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml.example rename to test/e2e/setup/demo-only/cloudflared-public/tunnel-credentials.yaml.example diff --git a/test/e2e/setup/demo-only/kustomization.yaml b/test/e2e/setup/demo-only/kustomization.yaml new file mode 100644 index 000000000..e1fa59188 --- /dev/null +++ b/test/e2e/setup/demo-only/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - cloudflared-public + - vote diff --git a/test/e2e/setup/manifests/vote/apf.yaml b/test/e2e/setup/demo-only/vote/apf.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/apf.yaml rename to test/e2e/setup/demo-only/vote/apf.yaml diff --git a/test/e2e/setup/manifests/vote/crds/quizsessions.yaml b/test/e2e/setup/demo-only/vote/crds/quizsessions.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/crds/quizsessions.yaml rename to test/e2e/setup/demo-only/vote/crds/quizsessions.yaml diff --git a/test/e2e/setup/manifests/vote/crds/quizsubmissions.yaml b/test/e2e/setup/demo-only/vote/crds/quizsubmissions.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/crds/quizsubmissions.yaml rename to test/e2e/setup/demo-only/vote/crds/quizsubmissions.yaml diff --git a/test/e2e/setup/manifests/vote/deployments.yaml b/test/e2e/setup/demo-only/vote/deployments.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/deployments.yaml rename to test/e2e/setup/demo-only/vote/deployments.yaml diff --git a/test/e2e/setup/manifests/vote/examples/quizsession-kubecon-2026.yaml b/test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/examples/quizsession-kubecon-2026.yaml rename to test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml diff --git a/test/e2e/setup/manifests/vote/examples/quizsubmission-kubecon-2026.yaml b/test/e2e/setup/demo-only/vote/examples/quizsubmission-kubecon-2026.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/examples/quizsubmission-kubecon-2026.yaml rename to test/e2e/setup/demo-only/vote/examples/quizsubmission-kubecon-2026.yaml diff --git a/test/e2e/setup/manifests/vote/ingress-auth.yaml b/test/e2e/setup/demo-only/vote/ingress-auth.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/ingress-auth.yaml rename to test/e2e/setup/demo-only/vote/ingress-auth.yaml diff --git a/test/e2e/setup/manifests/vote/ingress-frontend.yaml b/test/e2e/setup/demo-only/vote/ingress-frontend.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/ingress-frontend.yaml rename to test/e2e/setup/demo-only/vote/ingress-frontend.yaml diff --git a/test/e2e/setup/manifests/vote/ingress-kubeapi.yaml b/test/e2e/setup/demo-only/vote/ingress-kubeapi.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/ingress-kubeapi.yaml rename to test/e2e/setup/demo-only/vote/ingress-kubeapi.yaml diff --git a/test/e2e/setup/manifests/vote/ingress-podinfo.yaml b/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/ingress-podinfo.yaml rename to test/e2e/setup/demo-only/vote/ingress-podinfo.yaml diff --git a/test/e2e/setup/manifests/vote/kustomization.yaml b/test/e2e/setup/demo-only/vote/kustomization.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/kustomization.yaml rename to test/e2e/setup/demo-only/vote/kustomization.yaml diff --git a/test/e2e/setup/manifests/vote/ns.yaml b/test/e2e/setup/demo-only/vote/ns.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/ns.yaml rename to test/e2e/setup/demo-only/vote/ns.yaml diff --git a/test/e2e/setup/manifests/vote/podinfo.yaml b/test/e2e/setup/demo-only/vote/podinfo.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/podinfo.yaml rename to test/e2e/setup/demo-only/vote/podinfo.yaml diff --git a/test/e2e/setup/manifests/vote/pull-secret.yaml.example b/test/e2e/setup/demo-only/vote/pull-secret.yaml.example similarity index 100% rename from test/e2e/setup/manifests/vote/pull-secret.yaml.example rename to test/e2e/setup/demo-only/vote/pull-secret.yaml.example diff --git a/test/e2e/setup/manifests/vote/rbac-auth-service.yaml b/test/e2e/setup/demo-only/vote/rbac-auth-service.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/rbac-auth-service.yaml rename to test/e2e/setup/demo-only/vote/rbac-auth-service.yaml diff --git a/test/e2e/setup/manifests/vote/rbac-participants.yaml b/test/e2e/setup/demo-only/vote/rbac-participants.yaml similarity index 100% rename from test/e2e/setup/manifests/vote/rbac-participants.yaml rename to test/e2e/setup/demo-only/vote/rbac-participants.yaml diff --git a/test/e2e/setup/manifests/README.md b/test/e2e/setup/manifests/README.md index e529422c2..1bebaada1 100644 --- a/test/e2e/setup/manifests/README.md +++ b/test/e2e/setup/manifests/README.md @@ -7,5 +7,6 @@ as additional Flux sources. Organize related resources in subdirectories when that keeps the intent clearer. This directory is applied via the tracked [kustomization.yaml](/workspaces/gitops-reverser2/test/e2e/setup/manifests/kustomization.yaml). -This branch expects the local tunnel credentials file at -`test/e2e/setup/manifests/cloudflared-public/tunnel-credentials.yaml` to exist before `make` applies the manifests. + +Keep this directory CI-safe. Demo-only resources that require local secrets or extra CRD ordering belong under +`test/e2e/setup/demo-only` and are applied only by `make test-e2e-demo` / `make prepare-e2e-demo`. diff --git a/test/e2e/setup/manifests/kustomization.yaml b/test/e2e/setup/manifests/kustomization.yaml index 2245fa98d..faab7ef3a 100644 --- a/test/e2e/setup/manifests/kustomization.yaml +++ b/test/e2e/setup/manifests/kustomization.yaml @@ -2,8 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - cloudflared-public - - vote - prometheus/instance.yaml - prometheus/prometheus-rbac.yaml - prometheus/servicemonitor.yaml From 951f8745cf9042af6ce1be289df51aa9b01bcc54 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Mar 2026 12:09:35 +0000 Subject: [PATCH 09/30] chore: Group reconcile in one commit and less opinions on git signing and identity --- .claude/settings.local.json | 9 + .devcontainer/devcontainer.json | 7 +- .devcontainer/post-create.sh | 91 +-- docs/event-pipeline-design.md | 527 ++++++++++++++++++ internal/controller/gittarget_controller.go | 4 + internal/git/branch_worker.go | 136 ++++- internal/git/git.go | 143 +++++ internal/git/types.go | 16 + internal/reconcile/folder_reconciler.go | 69 ++- internal/reconcile/folder_reconciler_test.go | 102 ++-- internal/reconcile/git_target_event_stream.go | 92 ++- .../reconcile/git_target_event_stream_test.go | 56 +- .../gittarget_lifecycle_integration_test.go | 11 +- internal/reconcile/integration_test.go | 97 ++-- internal/reconcile/reconciler_manager.go | 2 +- 15 files changed, 1129 insertions(+), 233 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/event-pipeline-design.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..b2eb7bce7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(go test:*)", + "Bash(make lint:*)", + "Bash(make test-e2e:*)" + ] + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e5213ca68..2b8a01799 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -74,15 +74,14 @@ "postCreateCommand": "bash .devcontainer/post-create.sh '${containerWorkspaceFolder}'", "remoteUser": "vscode", "mounts": [ - "source=ghconfig,target=/home/vscode/.config/gh,type=volume", - "source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig-host,type=bind,readonly,consistency=cached", - "source=devcontainer-ssh,target=/home/vscode/.ssh,type=volume", "source=gomodcache,target=/go/pkg/mod,type=volume", "source=gobuildcache,target=/home/vscode/.cache/go-build,type=volume", "source=codexconfig,target=/home/vscode/.codex,type=volume" ], "containerEnv": { "HOST_PROJECT_PATH": "${localWorkspaceFolder}", - "PROJECT_PATH": "/workspaces/${localWorkspaceFolderBasename}" + "PROJECT_PATH": "/workspaces/${localWorkspaceFolderBasename}", + "GIT_USER_NAME": "${localEnv:GIT_USER_NAME}", + "GIT_USER_EMAIL": "${localEnv:GIT_USER_EMAIL}" } } diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 952768dbf..b12359457 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -11,74 +11,79 @@ fail() { exit 1 } -# Resolve workspace path in a way that works both inside and outside -# VS Code-specific shell variable injection. workspace_dir="${1:-${containerWorkspaceFolder:-${WORKSPACE_FOLDER:-$(pwd)}}}" log "Using workspace directory: ${workspace_dir}" -# Keep ~/.gitconfig writable inside the container while still importing host settings. -if [ -f /home/vscode/.gitconfig-host ]; then - log "Configuring git to include /home/vscode/.gitconfig-host" - touch /home/vscode/.gitconfig - if git config --global --get-all include.path | grep -Fxq "/home/vscode/.gitconfig-host"; then - log "Host gitconfig include already present" - else - git config --global --add include.path /home/vscode/.gitconfig-host - log "Added host gitconfig include" - fi +# Resolve Git identity from effective config first, then fallback env vars. +git_name="$(git config --get user.name || true)" +git_email="$(git config --get user.email || true)" + +if [ -z "${git_name}" ] && [ -n "${GIT_USER_NAME:-}" ]; then + git_name="${GIT_USER_NAME}" +fi + +if [ -z "${git_email}" ] && [ -n "${GIT_USER_EMAIL:-}" ]; then + git_email="${GIT_USER_EMAIL}" fi -# Require basic Git identity information. -git_name="$(git config --global --includes --get user.name || true)" -git_email="$(git config --global --includes --get user.email || true)" if [ -z "${git_name}" ] || [ -z "${git_email}" ]; then - fail "Missing Git identity. Configure both user.name and user.email in your host ~/.gitconfig." + fail "Missing Git identity. Set user.name and user.email in Git, or provide GIT_USER_NAME and GIT_USER_EMAIL to the devcontainer environment." fi -# Respect existing signing settings (OpenPGP/SSH). Fallback to SSH signing only when missing. -if git config --global --includes --get commit.gpgsign >/dev/null 2>&1 \ - || git config --global --includes --get gpg.format >/dev/null 2>&1 \ - || git config --global --includes --get user.signingkey >/dev/null 2>&1; then - log "Detected existing Git signing configuration; leaving signing settings unchanged" -else - log "No Git signing configuration detected; configuring SSH signing fallback" +# Persist identity globally in the container if it is not already configured there. +if ! git config --global --get user.name >/dev/null 2>&1; then + git config --global user.name "${git_name}" +fi - mkdir -p /home/vscode/.ssh +if ! git config --global --get user.email >/dev/null 2>&1; then + git config --global user.email "${git_email}" +fi - if ! ssh-add -L >/tmp/ssh-agent-keys.out 2>/dev/null || ! grep -qE '^ssh-' /tmp/ssh-agent-keys.out; then - log "No SSH keys found in agent; creating fallback signing key" - if [ ! -f /home/vscode/.ssh/signing_key ]; then - ssh-keygen -t ed25519 -f /home/vscode/.ssh/signing_key -N "" -C "${git_email}" >/dev/null - fi - cp /home/vscode/.ssh/signing_key.pub /tmp/ssh-agent-keys.out - fi +# Require SSH agent forwarding for signing. +if [ -z "${SSH_AUTH_SOCK:-}" ]; then + fail "SSH agent not available in the devcontainer. Start ssh-agent on your machine, load your key with ssh-add, then reopen the devcontainer." +fi - first_pubkey="$(head -n 1 /tmp/ssh-agent-keys.out)" - printf "%s %s\n" "${git_email}" "${first_pubkey}" > /home/vscode/.ssh/allowed_signers - printf "%s\n" "${first_pubkey}" > /home/vscode/.ssh/signing_key.pub +agent_keys_file="$(mktemp)" +trap 'rm -f "${agent_keys_file}"' EXIT - git config --global gpg.format ssh - git config --global commit.gpgsign true - git config --global gpg.ssh.allowedSignersFile /home/vscode/.ssh/allowed_signers - git config --global user.signingkey /home/vscode/.ssh/signing_key.pub +if ! ssh-add -L >"${agent_keys_file}" 2>/dev/null; then + fail "Could not read SSH keys from agent. Make sure your key is loaded on your machine with ssh-add, then reopen the devcontainer." +fi - rm -f /tmp/ssh-agent-keys.out +if ! grep -qE '^ssh-' "${agent_keys_file}"; then + fail "SSH agent is running but has no keys loaded. Run ssh-add ~/.ssh/id_ed25519 on your machine, then reopen the devcontainer." fi +first_pubkey="$(head -n 1 "${agent_keys_file}")" + +# Enforce SSH commit signing. +git config --global gpg.format ssh +git config --global commit.gpgsign true + +mkdir -p /home/vscode/.config/git /home/vscode/.ssh +printf '%s\n' "${first_pubkey}" > /home/vscode/.ssh/devcontainer_signing_key.pub +chmod 600 /home/vscode/.ssh/devcontainer_signing_key.pub +git config --global user.signingkey /home/vscode/.ssh/devcontainer_signing_key.pub + +# Useful for local verification output. +printf '%s <%s> %s\n' "${git_name}" "${git_email}" "${first_pubkey}" > /home/vscode/.config/git/allowed_signers +chmod 600 /home/vscode/.config/git/allowed_signers +git config --global gpg.ssh.allowedSignersFile /home/vscode/.config/git/allowed_signers + +log "Git identity and SSH signing configured" -# Ensure Go-related caches exist and are writable by vscode log "Ensuring Go cache directories exist" sudo mkdir -p \ /home/vscode/.cache/go-build \ /home/vscode/.cache/goimports \ /home/vscode/.cache/golangci-lint -# Fix ownership for workspace and cache roots used by tooling if [ -d "${workspace_dir}" ]; then - log "Fixing ownership for workspace and cache directories" + log "Fixing ownership for workspace and home directories" sudo chown -R vscode:vscode "${workspace_dir}" /home/vscode || true else - log "Workspace directory not found; fixing ownership for cache only" + log "Workspace directory not found; fixing ownership for home only" sudo chown -R vscode:vscode /home/vscode || true fi diff --git a/docs/event-pipeline-design.md b/docs/event-pipeline-design.md new file mode 100644 index 000000000..99723eda1 --- /dev/null +++ b/docs/event-pipeline-design.md @@ -0,0 +1,527 @@ +# Event Pipeline Design: From Cluster to Git Commit + +## Overview + +This document analyses the current event flow from Kubernetes cluster through to +Git commits, identifies the problem with reconcile events producing many individual +commits, and proposes a design that: + +- produces a **single Git commit** for an entire reconcile run +- **buffers live events** while a reconcile is running (per GitTarget, not globally) +- keeps the existing per-event commit behaviour for live cluster changes unchanged +- allows the state machine to re-enter `RECONCILING` for planned re-reconciles + +--- + +## 1. Current `git.Event` Interface + +**File:** `internal/git/types.go` + +```go +type Event struct { + Object *unstructured.Unstructured // nil for DELETE + Identifier types.ResourceIdentifier + Operation string // CREATE | UPDATE | DELETE | RECONCILE_RESOURCE + UserInfo UserInfo + Path string // folder prefix from owning GitTarget + GitTargetName string // set by GitTargetEventStream + GitTargetNamespace string +} +``` + +### What is missing today + +There is **no way for the BranchWorker or `generateCommitsFromEvents` to tell a +reconcile event apart from a live event**. `generateCommitsFromEvents` +([internal/git/git.go:715](../internal/git/git.go#L715)) creates **one commit per +event**. A cluster with 500 resources on first install produces 500 commits. + +A second gap: the state machine in `GitTargetEventStream` enters `StartupReconcile` +exactly once (at construction). After `OnReconciliationComplete()` fires it never +returns to the buffering state. A planned re-reconcile (provider URL change, +selector change, etc.) would therefore not buffer live events and could interleave +live commits with reconcile writes. + +--- + +## 2. Full Event Pipeline (current) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Event producers β”‚ +β”‚ β”‚ +β”‚ A) WatchManager (live) B) FolderReconciler β”‚ +β”‚ cluster change detected reconcile() computes diff β”‚ +β”‚ β†’ dedup by content hash β†’ N Γ— EmitCreateEvent / β”‚ +β”‚ β†’ git.Event{Op:…} EmitDeleteEvent / β”‚ +β”‚ EmitReconcileResource β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ (live path) β”‚ (reconcile path, N events) + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ GitTargetEventStream (per GitTarget) β”‚ + β”‚ β”‚ + β”‚ STARTUP_RECONCILE: buffer live events β”‚ + β”‚ forward reconcile events β”‚ + β”‚ LIVE_PROCESSING: dedup + forward live β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Enqueue(event) ← N separate calls + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BranchWorker β”‚ + β”‚ chan Event (cap 100) β”‚ + β”‚ β”‚ + β”‚ flush triggers: β”‚ + β”‚ β‰₯20 events / β‰₯1 MiB β”‚ ← batch may be SPLIT here + β”‚ 1 min ticker β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ generateCommitsFromEvents() β”‚ + β”‚ for each event: β”‚ + β”‚ applyEventToWorktree() β”‚ + β”‚ createCommitForEvent() ← one commit each + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 3. Proposed Design + +### 3.1 Replace N individual reconcile events with one `ReconcileBatch` + +`FolderReconciler.reconcile()` already has **all events in memory at the same time** +when the diff is computed. Instead of emitting N individual events one by one, it +emits a single `ReconcileBatch` value that carries all of them. + +```go +// ReconcileBatch is a complete set of file changes from one reconcile run. +// It arrives at the BranchWorker as a single unit and is always committed together. +type ReconcileBatch struct { + // Events is the complete ordered list of file changes for this reconcile. + Events []Event + + // CommitMessage is the summary commit message, e.g.: + // "reconcile: sync 312 resources from cluster snapshot" + CommitMessage string + + // GitTargetName / GitTargetNamespace identify the owning GitTarget. + GitTargetName string + GitTargetNamespace string +} +``` + +The `EventEmitter` interface used by `FolderReconciler` changes from three +per-resource methods to a single batch method: + +```go +// Before (three separate calls, each producing one Event): +type EventEmitter interface { + EmitCreateEvent(resource, obj) error + EmitDeleteEvent(resource) error + EmitReconcileResourceEvent(resource, obj) error +} + +// After (one call with the complete diff): +type ReconcileEmitter interface { + EmitReconcileBatch(batch ReconcileBatch) error +} +``` + +`FolderReconciler.reconcile()` builds the full event slice from the diff, wraps it +in `ReconcileBatch`, and calls `EmitReconcileBatch` once. + +### 3.2 BranchWorker queue accepts both singles and batches + +The queue type changes from `chan Event` to `chan WorkItem`, where `WorkItem` is a +small discriminated-union struct: + +```go +// WorkItem is either a single live event or a complete reconcile batch. +// Exactly one field is non-nil. +type WorkItem struct { + Single *Event + Batch *ReconcileBatch +} +``` + +This does not touch the `Event` type at all and keeps the channel strongly typed. +The BranchWorker processes items in arrival order. When it encounters a `Batch`, it +writes all files without committing, then creates a single commit. When it +encounters a `Single`, it writes the file and commits immediately (existing +behaviour). + +``` +BranchWorker receives WorkItem: + + if item.Single != nil: + applyEventToWorktree(event) + createCommitForEvent(event) ← unchanged, one commit per live event + + if item.Batch != nil: + for each event in batch.Events: + applyEventToWorktree(event) ← write file only, no commit + createCommitForBatch(batch) ← ONE commit for the whole batch +``` + +Because the entire batch arrives as one `WorkItem`, **there is no flush-tick +splitting problem**. The BranchWorker does not need to know anything about +reconcile group IDs or counts; it simply processes the item it received. + +### 3.3 State machine: `RECONCILING` and `LIVE_PROCESSING` + +Rename `StartupReconcile` β†’ `RECONCILING`. The key addition is that the state +machine can **re-enter `RECONCILING`** from `LIVE_PROCESSING` whenever a new +reconcile is triggered. + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + (created) β”‚ β”‚ BeginReconciliation() +────────────► β”‚ RECONCILING β”‚ ◄──────────────────────── + β”‚ β”‚ (from LIVE_PROCESSING) + β”‚ live events: buffered β”‚ + β”‚ ReconcileBatch: forwarded β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ OnReconciliationComplete() + β”‚ flush buffered live events + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ LIVE_PROCESSING β”‚ + β”‚ β”‚ + β”‚ live events: dedup+forward β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**`BeginReconciliation()`** is a new method on `GitTargetEventStream`. It is +called by `GitTargetController` whenever it decides a reconcile is needed (new +target, selector change, provider URL change, etc.). If the stream is already in +`RECONCILING` the call is a no-op. + +**`OnReconciliationComplete()`** works as today: transitions to `LIVE_PROCESSING` +and flushes buffered live events. + +Live events buffered during `RECONCILING` still pass through the normal dedup path +when they are flushed β€” so if the reconcile batch already wrote the same content, +the live event will produce no further commit (the "no-op file write" check in +`handleCreateOrUpdateOperation` takes care of this). + +### 3.4 What happens to other GitTargets on the same branch + +Each `GitTargetEventStream` has its own independent state machine. Only the stream +whose reconcile is running enters `RECONCILING`. Other GitTargets sharing the same +`BranchWorker` continue to enqueue `WorkItem{Single: &event}` items normally. + +The BranchWorker serialises all work regardless of source, so a live commit from +GitTarget B may land before or after the reconcile batch from GitTarget A β€” this is +fine and already the correct semantics. + +--- + +## 4. Updated Event and Batch Interfaces + +### `internal/git/types.go` additions + +```go +// ReconcileBatch is a complete set of file changes from one reconcile run. +// It is enqueued as a single WorkItem so the BranchWorker always commits it atomically. +type ReconcileBatch struct { + Events []Event + CommitMessage string + GitTargetName string + GitTargetNamespace string +} + +// WorkItem is the unit of work in the BranchWorker queue. +// Exactly one of Single or Batch is non-nil. +type WorkItem struct { + Single *Event + Batch *ReconcileBatch +} +``` + +The `Event` struct itself is **unchanged**. No batch-related fields are added to it. + +### `internal/reconcile/git_target_event_stream.go` changes + +```go +// Interface used by FolderReconciler β€” replaces the three per-resource methods. +type ReconcileEmitter interface { + EmitReconcileBatch(batch git.ReconcileBatch) error +} + +// GitTargetEventStream new/changed public methods: + +// BeginReconciliation transitions to RECONCILING (buffering live events). +// Safe to call when already RECONCILING (no-op). +func (s *GitTargetEventStream) BeginReconciliation() + +// EmitReconcileBatch forwards a complete reconcile batch to the BranchWorker +// as a single WorkItem. Called while in RECONCILING state. +func (s *GitTargetEventStream) EmitReconcileBatch(batch git.ReconcileBatch) error + +// OnReconciliationComplete transitions to LIVE_PROCESSING and flushes buffered live events. +func (s *GitTargetEventStream) OnReconciliationComplete() + +// OnWatchEvent handles incoming live events from WatchManager (unchanged signature). +func (s *GitTargetEventStream) OnWatchEvent(event git.Event) +``` + +### `internal/reconcile/folder_reconciler.go` changes + +`FolderReconciler` depends on `ReconcileEmitter` instead of the old `EventEmitter`. +`reconcile()` assembles the full event list and calls `EmitReconcileBatch` once: + +```go +func (r *FolderReconciler) reconcile() { + if !r.clusterStateSeen || !r.gitStateSeen { return } + + toCreate, toDelete, existingInBoth := r.findDifferences(...) + + var events []git.Event + for _, res := range toCreate { + events = append(events, git.Event{Op: "CREATE", Identifier: res, Object: r.objectFor(res), + UserInfo: git.UserInfo{Username: "gitops-reverser"}, Path: r.path}) + } + for _, res := range toDelete { + events = append(events, git.Event{Op: "DELETE", Identifier: res, + UserInfo: git.UserInfo{Username: "gitops-reverser"}, Path: r.path}) + } + for _, res := range existingInBoth { + events = append(events, git.Event{Op: "RECONCILE_RESOURCE", Identifier: res, + Object: r.objectFor(res), UserInfo: git.UserInfo{Username: "gitops-reverser"}, + Path: r.path}) + } + + total := len(events) + if total == 0 { return } // nothing to do; cluster and git already match + + batch := git.ReconcileBatch{ + Events: events, + CommitMessage: fmt.Sprintf("reconcile: sync %d resources from cluster snapshot", total), + } + r.reconcileEmitter.EmitReconcileBatch(batch) +} +``` + +### `internal/git/branch_worker.go` changes + +```go +eventQueue chan WorkItem // was: chan Event +``` + +`processEvents` loop handles `WorkItem` instead of `Event`: + +```go +case item := <-w.eventQueue: + if item.Single != nil { + w.eventBuffer = append(w.eventBuffer, *item.Single) + // existing count/size flush logic unchanged + } + if item.Batch != nil { + // Flush any buffered live events first (preserve ordering) + if len(w.eventBuffer) > 0 { + w.commitAndPush(w.eventBuffer) + w.eventBuffer = nil + } + // Process the entire batch as a unit + w.commitAndPushBatch(item.Batch) + } +``` + +### `internal/git/git.go` changes + +Add `WriteEventsAsBatch` (or a flag parameter) that writes all files and creates +exactly one commit: + +```go +func generateBatchCommit( + ctx context.Context, + writer eventContentWriter, + repo *git.Repository, + batch *ReconcileBatch, +) (plumbing.Hash, error) { + worktree, _ := repo.Worktree() + for _, event := range batch.Events { + applyEventToWorktree(ctx, writer, worktree, event) // write files, no commit + } + return worktree.Commit(batch.CommitMessage, &git.CommitOptions{ + Author: &object.Signature{ + Name: "gitops-reverser", + Email: "noreply@configbutler.ai", + When: time.Now(), + }, + Committer: &object.Signature{ + Name: "gitops-reverser", + Email: "noreply@configbutler.ai", + When: time.Now(), + }, + }) +} +``` + +--- + +## 5. Full Pipeline (proposed) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Event producers β”‚ +β”‚ β”‚ +β”‚ A) WatchManager (live) B) FolderReconciler β”‚ +β”‚ cluster change detected reconcile() computes diff β”‚ +β”‚ β†’ git.Event β†’ ONE ReconcileBatch β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ OnWatchEvent(event) β”‚ EmitReconcileBatch(batch) + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ GitTargetEventStream (per GitTarget) β”‚ + β”‚ β”‚ + β”‚ RECONCILING: β”‚ + β”‚ live events β†’ buffered []Event β”‚ + β”‚ ReconcileBatch β†’ WorkItem{Batch} β†’ worker β”‚ + β”‚ β”‚ + β”‚ LIVE_PROCESSING: β”‚ + β”‚ live events β†’ dedup β†’ WorkItem{Single} β†’ workerβ”‚ + β”‚ β”‚ + β”‚ BeginReconciliation() β†’ RECONCILING β”‚ + β”‚ OnReconciliationComplete() β†’ LIVE_PROCESSING β”‚ + β”‚ + flush buffered live events as WorkItem{Single} β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ chan WorkItem + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ BranchWorker β”‚ + β”‚ chan WorkItem (cap 100) β”‚ + β”‚ β”‚ + β”‚ WorkItem{Single}: β”‚ + β”‚ buffer β†’ flush on tick β”‚ + β”‚ β†’ one commit per event β”‚ + β”‚ β”‚ + β”‚ WorkItem{Batch}: β”‚ + β”‚ flush live buffer first β”‚ + β”‚ β†’ generateBatchCommit() β”‚ + β”‚ β†’ ONE commit β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ PushAtomic (3 retries) + β–Ό + remote Git +``` + +--- + +## 6. Desired Git History + +### Today (500 resources, fresh install) + +``` +abc1234 [CREATE] apps/v1/deployments/default/frontend +abc1235 [CREATE] apps/v1/deployments/default/backend +... (498 more) +``` + +### After proposed change + +``` +abc1234 reconcile: sync 500 resources from cluster snapshot +``` + +Subsequent live changes look identical to today: + +``` +abc1235 [UPDATE] apps/v1/deployments/default/frontend (alice) +``` + +A second planned reconcile (e.g. GitTarget selector updated): + +``` +abc1236 reconcile: sync 498 resources from cluster snapshot +``` + +--- + +## 7. Who Creates and Synchronises Changes + +| Actor | Role | Emits | +|---|---|---| +| `WatchManager` | Watches all cluster resources; deduplicates by content hash | `WorkItem{Single: &event}` | +| `FolderReconciler` | Diffs cluster vs Git; assembles full batch | `ReconcileBatch` via `ReconcileEmitter` | +| `GitTargetEventStream` | State machine per GitTarget; routes and buffers | `WorkItem{Single}` or `WorkItem{Batch}` into `BranchWorker` queue | +| `GitTargetController` | Drives lifecycle; calls `BeginReconciliation()` / `OnReconciliationComplete()` | state transitions only | +| `BranchWorker` | One per (provider Γ— branch); serialises all writes | commits + push | + +--- + +## 8. Implementation Actions + +Changes are ordered by dependency β€” each step compiles and tests cleanly on its own. + +### Step 1 β€” `internal/git/types.go`: add `ReconcileBatch` and `WorkItem` + +Add the two new types. `Event` is unchanged. + +```go +type ReconcileBatch struct { + Events []Event + CommitMessage string + GitTargetName string + GitTargetNamespace string +} + +type WorkItem struct { + Single *Event + Batch *ReconcileBatch +} +``` + +### Step 2 β€” `internal/git/git.go`: add `generateBatchCommit` + +Add a new function alongside the existing `generateCommitsFromEvents`. It applies +all file changes in a `ReconcileBatch` without committing after each one, then +creates a single commit with the batch message. The existing function is unchanged. + +### Step 3 β€” `internal/git/branch_worker.go`: switch queue to `chan WorkItem` + +- Change `eventQueue chan Event` β†’ `eventQueue chan WorkItem`. +- Update `Enqueue(event Event)` β†’ wraps the event in `WorkItem{Single: &event}`. +- Add `EnqueueBatch(batch *ReconcileBatch)` β†’ sends `WorkItem{Batch: batch}`. +- Update `processEvents`: when `item.Batch != nil`, flush the live buffer first, + then call `commitAndPushBatch` which drives `generateBatchCommit`. + +### Step 4 β€” `internal/reconcile/git_target_event_stream.go`: state machine + batch + +- Rename constant `StartupReconcile` β†’ `Reconciling`. +- Add `BeginReconciliation()` method: transitions `LiveProcessing` β†’ `Reconciling` + (no-op if already `Reconciling`). +- Replace `EventEnqueuer` interface (`Enqueue`) with one that also exposes + `EnqueueBatch`. +- Add `EmitReconcileBatch(batch git.ReconcileBatch) error`: stamps + `GitTargetName`/`GitTargetNamespace` onto the batch, calls `EnqueueBatch`. +- Remove `EmitCreateEvent`, `EmitDeleteEvent`, `EmitReconcileResourceEvent` β€” these + are replaced by `EmitReconcileBatch`. + +### Step 5 β€” `internal/reconcile/folder_reconciler.go`: emit one batch + +- Replace dependency on `EventEmitter` with `ReconcileEmitter` + (`EmitReconcileBatch`). +- Rewrite `reconcile()` to collect all `toCreate`, `toDelete`, `existingInBoth` + events into a single `[]git.Event`, set `UserInfo{Username: "gitops-reverser"}` + on each, and call `EmitReconcileBatch` once. Return early (no emit) when the + slice is empty. + +### Step 6 β€” `internal/controller/gittarget_controller.go`: drive state transitions + +- Call `stream.BeginReconciliation()` at the start of every reconcile pass that + determines a snapshot sync is needed (i.e. before starting `FolderReconciler`). +- Ensure `stream.OnReconciliationComplete()` is still called after + `FolderReconciler` emits its batch β€” this flushes buffered live events and + transitions to `LiveProcessing`. + +### Step 7 β€” Update tests + +- Unit tests for `FolderReconciler`: expect one `EmitReconcileBatch` call instead of + N individual emit calls. +- Unit tests for `GitTargetEventStream`: add coverage for `BeginReconciliation()` + re-entry from `LiveProcessing`. +- Integration tests: assert that a reconcile run with N resources produces exactly + one Git commit with message matching `reconcile: sync N resources…`. diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index cdb7621b3..14f0970a9 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -494,6 +494,10 @@ func (r *GitTargetReconciler) evaluateSnapshotGate( return nil, metav1.ConditionFalse, "", 0, err } + // Enter buffering state before starting reconciliation so that live events + // arriving during the snapshot sync are queued and not interleaved. + stream.BeginReconciliation() + gitDest := types.NewResourceReference(target.Name, target.Namespace) reconciler := r.EventRouter.ReconcilerManager.CreateReconciler(gitDest, stream) if err := reconciler.StartReconciliation(ctx); err != nil { diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 0308d80eb..150440507 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -68,7 +68,7 @@ type BranchWorker struct { contentWriter *contentWriter // Event processing - eventQueue chan Event + eventQueue chan WorkItem ctx context.Context cancelFunc context.CancelFunc wg sync.WaitGroup @@ -107,7 +107,7 @@ func NewBranchWorker( "branch", branch, ), contentWriter: writer, - eventQueue: make(chan Event, branchWorkerQueueSize), + eventQueue: make(chan WorkItem, branchWorkerQueueSize), } } @@ -168,10 +168,11 @@ func (w *BranchWorker) Stop() { w.Log.Info("Branch worker stopped") } -// Enqueue adds an event to this worker's queue. +// Enqueue adds a single live event to this worker's queue. func (w *BranchWorker) Enqueue(event Event) { + item := WorkItem{Single: &event} select { - case w.eventQueue <- event: + case w.eventQueue <- item: w.Log.V(1).Info("Event enqueued", "operation", event.Operation, "path", event.Path) @@ -182,6 +183,21 @@ func (w *BranchWorker) Enqueue(event Event) { } } +// EnqueueBatch adds a reconcile batch to this worker's queue as a single atomic unit. +func (w *BranchWorker) EnqueueBatch(batch *ReconcileBatch) { + item := WorkItem{Batch: batch} + select { + case w.eventQueue <- item: + w.Log.V(1).Info("Batch enqueued", + "events", len(batch.Events), + "gitTarget", batch.GitTargetName) + default: + w.Log.Error(nil, "Event queue full, batch dropped", + "events", len(batch.Events), + "gitTarget", batch.GitTargetName) + } +} + // ListResourcesInPath returns resource identifiers found in a Git folder. // This is a synchronous service method called by EventRouter. func (w *BranchWorker) ListResourcesInPath(path string) ([]itypes.ResourceIdentifier, error) { @@ -420,16 +436,27 @@ func (w *BranchWorker) processEvents() { w.handleShutdown(eventBuffer) return - case event := <-w.eventQueue: - // Buffer event - eventBuffer = append(eventBuffer, event) - bufferByteCount += w.estimateEventSize(event) - - // Check limits - if len(eventBuffer) >= maxCommits || bufferByteCount >= maxBytesBytes { - w.commitAndPush(eventBuffer) - eventBuffer = nil - bufferByteCount = 0 + case item := <-w.eventQueue: + if item.Single != nil { + // Buffer single live event + eventBuffer = append(eventBuffer, *item.Single) + bufferByteCount += w.estimateEventSize(*item.Single) + + // Check limits + if len(eventBuffer) >= maxCommits || bufferByteCount >= maxBytesBytes { + w.commitAndPush(eventBuffer) + eventBuffer = nil + bufferByteCount = 0 + } + } else if item.Batch != nil { + // Flush any buffered live events first to preserve ordering + if len(eventBuffer) > 0 { + w.commitAndPush(eventBuffer) + eventBuffer = nil + bufferByteCount = 0 + } + // Process the entire batch as a single atomic commit + w.commitAndPushBatch(item.Batch) } case <-pushTicker.C: @@ -526,6 +553,87 @@ func (w *BranchWorker) commitAndPush(events []Event) { telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(events))) } +// commitAndPushBatch processes a reconcile batch as a single atomic commit. +func (w *BranchWorker) commitAndPushBatch(batch *ReconcileBatch) { + w.repoMu.Lock() + defer w.repoMu.Unlock() + + log := w.Log.WithValues("batchEvents", len(batch.Events), "gitTarget", batch.GitTargetName) + log.Info("Starting git batch commit and push", "branch", w.Branch) + + provider, err := w.getGitProvider(w.ctx) + if err != nil { + log.Error(err, "Failed to get GitProvider") + return + } + + auth, err := getAuthFromSecret(w.ctx, w.Client, provider) + if err != nil { + log.Error(err, "Failed to get auth") + return + } + + repoPath := w.repoPathForRemote(provider.Spec.URL) + + // Resolve encryption and path from the GitTarget + var encryptionConfig *ResolvedEncryptionConfig + if batch.GitTargetName != "" && batch.GitTargetNamespace != "" { + targetKey := types.NamespacedName{ + Name: batch.GitTargetName, + Namespace: batch.GitTargetNamespace, + } + var target configv1alpha1.GitTarget + if err := w.Client.Get(w.ctx, targetKey, &target); err != nil { + log.Error(err, "Failed to resolve GitTarget for batch") + return + } + + encryptionConfig, err = ResolveTargetEncryption(w.ctx, w.Client, &target) + if err != nil { + log.Error(err, "Failed to resolve target encryption configuration") + return + } + + // Stamp path and GitTarget info onto each event in the batch + for i := range batch.Events { + if batch.Events[i].Path == "" { + batch.Events[i].Path = target.Spec.Path + } + batch.Events[i].GitTargetName = batch.GitTargetName + batch.Events[i].GitTargetNamespace = batch.GitTargetNamespace + } + } + + // Configure encryption writer for the batch path + if len(batch.Events) > 0 { + encryptionWorkDir := filepath.Join(repoPath, batch.Events[0].Path) + if err := configureSecretEncryptionWriter(w.contentWriter, encryptionWorkDir, encryptionConfig); err != nil { + log.Error(err, "Failed to configure secret encryptor") + return + } + } + + result, err := WriteBatchWithContentWriter(w.ctx, w.contentWriter, repoPath, batch, w.Branch, auth) + if err != nil { + log.Error(err, "Failed to write batch") + return + } + + log.Info("Successfully pushed batch commit", + "commitsCreated", result.CommitsCreated, + "conflictPulls", len(result.ConflictPulls), + "failures", result.Failures) + + if len(result.ConflictPulls) > 0 { + lastPull := result.ConflictPulls[len(result.ConflictPulls)-1] + w.updateBranchMetadataFromPullReport(lastPull) + } + + telemetry.GitOperationsTotal.Add(w.ctx, int64(len(batch.Events))) + telemetry.CommitsTotal.Add(w.ctx, 1) + telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(batch.Events))) +} + // handleShutdown finalizes processing when context is canceled. func (w *BranchWorker) handleShutdown( eventBuffer []Event, diff --git a/internal/git/git.go b/internal/git/git.go index 0ae5880a4..589897497 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -711,6 +711,149 @@ func setHead(r *git.Repository, branchName string) error { return r.Storer.SetReference(newHeadRef) } +// WriteBatchWithContentWriter handles writing a ReconcileBatch as a single atomic commit. +func WriteBatchWithContentWriter( + ctx context.Context, + writer eventContentWriter, + repoPath string, + batch *ReconcileBatch, + targetBranchName string, + auth transport.AuthMethod, +) (*WriteEventsResult, error) { + logger := log.FromContext(ctx) + logger.Info("Starting writeBatch operation", "path", repoPath, "events", len(batch.Events)) + if writer == nil { + return nil, errors.New("content writer is required") + } + + repo, err := git.PlainOpen(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + + targetBranch := plumbing.NewBranchReferenceName(targetBranchName) + + result := &WriteEventsResult{ + CommitsCreated: 0, + ConflictPulls: []*PullReport{}, + Failures: 0, + } + + const maxRetries = 3 + for ; result.Failures < maxRetries; result.Failures++ { + if result.Failures > 0 { + if err := tryResolve(ctx, logger, result, repo, targetBranch, auth); err != nil { + continue + } + } + + done, err := tryWriteBatchAttempt( + ctx, logger, writer, result, repo, batch, targetBranch, targetBranchName, auth, + ) + if err != nil { + return nil, err + } + if done { + break + } + } + + return result, nil +} + +func tryWriteBatchAttempt( + ctx context.Context, + logger logr.Logger, + writer eventContentWriter, + result *WriteEventsResult, + repo *git.Repository, + batch *ReconcileBatch, + targetBranch plumbing.ReferenceName, + targetBranchName string, + auth transport.AuthMethod, +) (bool, error) { + baseBranch, baseHash, err := GetCurrentBranch(repo) + if err != nil { + return false, err + } + + if baseBranch != targetBranch { + if err := switchOrCreateBranch(repo, targetBranch, logger, targetBranchName, baseHash); err != nil { + return false, err + } + } + + committed, lastHash, err := generateBatchCommit(ctx, writer, repo, batch) + if err != nil { + return false, fmt.Errorf("failed to generate batch commit: %w", err) + } + + result.LastHash = printSha(lastHash) + if !committed { + logger.Info("No changes in batch, no need to push") + result.CommitsCreated = 0 + return true, nil + } + result.CommitsCreated = 1 + + if err := PushAtomic(ctx, repo, baseHash, baseBranch, auth); err == nil { + logger.Info("Batch pushed to remote", "failureCount", result.Failures) + return true, nil + } + + return false, nil +} + +// generateBatchCommit applies all events in a ReconcileBatch without committing after each, +// then creates a single commit with the batch message. +// Returns (committed bool, lastHash, error). +func generateBatchCommit( + ctx context.Context, + writer eventContentWriter, + repo *git.Repository, + batch *ReconcileBatch, +) (bool, plumbing.Hash, error) { + logger := log.FromContext(ctx) + worktree, err := repo.Worktree() + if err != nil { + return false, plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err) + } + + anyChanges := false + for _, event := range batch.Events { + changesApplied, err := applyEventToWorktree(ctx, writer, worktree, event) + if err != nil { + return false, plumbing.ZeroHash, err + } + if changesApplied { + anyChanges = true + } + } + + if !anyChanges { + return false, plumbing.ZeroHash, nil + } + + hash, err := worktree.Commit(batch.CommitMessage, &git.CommitOptions{ + Author: &object.Signature{ + Name: "gitops-reverser", + Email: "noreply@configbutler.ai", + When: time.Now(), + }, + Committer: &object.Signature{ + Name: "gitops-reverser", + Email: "noreply@configbutler.ai", + When: time.Now(), + }, + }) + if err != nil { + return false, plumbing.ZeroHash, fmt.Errorf("failed to create batch commit: %w", err) + } + + logger.Info("Created batch commit", "message", batch.CommitMessage, "events", len(batch.Events)) + return true, hash, nil +} + // generateCommitsFromEvents creates commits from the provided events. func generateCommitsFromEvents( ctx context.Context, diff --git a/internal/git/types.go b/internal/git/types.go index a490022d0..8bf759283 100644 --- a/internal/git/types.go +++ b/internal/git/types.go @@ -85,6 +85,22 @@ type UserInfo struct { UID string } +// ReconcileBatch is a complete set of file changes from one reconcile run. +// It is enqueued as a single WorkItem so the BranchWorker always commits it atomically. +type ReconcileBatch struct { + Events []Event + CommitMessage string + GitTargetName string + GitTargetNamespace string +} + +// WorkItem is the unit of work in the BranchWorker queue. +// Exactly one of Single or Batch is non-nil. +type WorkItem struct { + Single *Event + Batch *ReconcileBatch +} + // Event represents a resource change event to be processed by a branch worker. // Branch comes from the worker context (not stored in event). // Path comes from the GitTarget that created this event. diff --git a/internal/reconcile/folder_reconciler.go b/internal/reconcile/folder_reconciler.go index 07ec090a3..830390a14 100644 --- a/internal/reconcile/folder_reconciler.go +++ b/internal/reconcile/folder_reconciler.go @@ -27,9 +27,15 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/ConfigButler/gitops-reverser/internal/events" + "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/types" ) +// BatchEmitter emits a complete reconcile batch as a single unit. +type BatchEmitter interface { + EmitReconcileBatch(batch git.ReconcileBatch) error +} + // FolderReconciler reconciles Git base folder to match cluster state. // It operates without time concerns (delegated to WatchManager) and focuses purely // on reconciliation logic. @@ -47,9 +53,9 @@ type FolderReconciler struct { clusterObjects map[string]unstructured.Unstructured // Dependencies for event emission - eventEmitter EventEmitter - controlEmitter events.ControlEventEmitter - logger logr.Logger + reconcileEmitter BatchEmitter + controlEmitter events.ControlEventEmitter + logger logr.Logger lastSnapshotStats SnapshotStats } @@ -64,15 +70,15 @@ type SnapshotStats struct { // NewFolderReconciler creates a new FolderReconciler. func NewFolderReconciler( gitDest types.ResourceReference, - eventEmitter EventEmitter, + reconcileEmitter BatchEmitter, controlEmitter events.ControlEventEmitter, logger logr.Logger, ) *FolderReconciler { return &FolderReconciler{ - gitDest: gitDest, - eventEmitter: eventEmitter, - controlEmitter: controlEmitter, - logger: logger.WithValues("gitDest", gitDest.String()), + gitDest: gitDest, + reconcileEmitter: reconcileEmitter, + controlEmitter: controlEmitter, + logger: logger.WithValues("gitDest", gitDest.String()), } } @@ -122,6 +128,7 @@ func (r *FolderReconciler) OnRepoState(event events.RepoStateEvent) { } // reconcile performs the reconciliation logic when both states are available. +// It collects all changes into a single ReconcileBatch and emits it atomically. func (r *FolderReconciler) reconcile() { // Only reconcile when we have both cluster and Git state if !r.clusterStateSeen || !r.gitStateSeen { @@ -141,26 +148,50 @@ func (r *FolderReconciler) reconcile() { "toDelete", len(toDelete), "existingInBoth", len(existingInBoth)) - // Emit reconciliation events (time filtering happens in WatchManager) + total := len(toCreate) + len(toDelete) + len(existingInBoth) + if total == 0 { + r.logger.V(1).Info("No differences found, skipping batch emission") + return + } + + // Build the complete event list for this reconcile run + var batchEvents []git.Event + for _, resource := range toCreate { obj := r.objectForResource(resource) - if err := r.eventEmitter.EmitCreateEvent(resource, obj); err != nil { - r.logger.Error(err, "Failed to emit create event", "resource", resource.String()) - } + batchEvents = append(batchEvents, git.Event{ + Operation: "CREATE", + Identifier: resource, + Object: obj, + UserInfo: git.UserInfo{Username: "gitops-reverser"}, + }) } for _, resource := range toDelete { - if err := r.eventEmitter.EmitDeleteEvent(resource); err != nil { - r.logger.Error(err, "Failed to emit delete event", "resource", resource.String()) - } + batchEvents = append(batchEvents, git.Event{ + Operation: "DELETE", + Identifier: resource, + UserInfo: git.UserInfo{Username: "gitops-reverser"}, + }) } - // For resources that exist in both places, emit reconcile event immediately for _, resource := range existingInBoth { obj := r.objectForResource(resource) - if err := r.eventEmitter.EmitReconcileResourceEvent(resource, obj); err != nil { - r.logger.Error(err, "Failed to emit reconcile resource event", "resource", resource.String()) - } + batchEvents = append(batchEvents, git.Event{ + Operation: string(events.ReconcileResource), + Identifier: resource, + Object: obj, + UserInfo: git.UserInfo{Username: "gitops-reverser"}, + }) + } + + batch := git.ReconcileBatch{ + Events: batchEvents, + CommitMessage: fmt.Sprintf("reconcile: sync %d resources from cluster snapshot", total), + } + + if err := r.reconcileEmitter.EmitReconcileBatch(batch); err != nil { + r.logger.Error(err, "Failed to emit reconcile batch") } } diff --git a/internal/reconcile/folder_reconciler_test.go b/internal/reconcile/folder_reconciler_test.go index a9ddbd708..02824e1dc 100644 --- a/internal/reconcile/folder_reconciler_test.go +++ b/internal/reconcile/folder_reconciler_test.go @@ -22,10 +22,10 @@ import ( "testing" "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/controller-runtime/pkg/log" "github.com/ConfigButler/gitops-reverser/internal/events" + "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -139,11 +139,9 @@ func TestFolderReconciler_FindDifferences(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create mock emitters - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} - // Create reconciler with new API gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -170,7 +168,7 @@ func TestFolderReconciler_FindDifferences(t *testing.T) { } func TestFolderReconciler_OnClusterState(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -200,7 +198,7 @@ func TestFolderReconciler_OnClusterState(t *testing.T) { } func TestFolderReconciler_OnRepoState(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -230,7 +228,7 @@ func TestFolderReconciler_OnRepoState(t *testing.T) { } func TestFolderReconciler_NoDeleteForIdenticalCoreNamespacedResource(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -252,16 +250,19 @@ func TestFolderReconciler_NoDeleteForIdenticalCoreNamespacedResource(t *testing. Resources: []types.ResourceIdentifier{resource}, }) - assert.Empty(t, mockEmitter.GetCreateEvents()) - assert.Empty(t, mockEmitter.GetDeleteEvents()) - assert.Equal(t, []types.ResourceIdentifier{resource}, mockEmitter.GetReconcileEvents()) + assert.Empty(t, mockEmitter.GetEventsByOperation("CREATE")) + assert.Empty(t, mockEmitter.GetEventsByOperation("DELETE")) + assert.Equal(t, + []types.ResourceIdentifier{resource}, + mockEmitter.GetIdentifiersByOperation(string(events.ReconcileResource)), + ) stats := reconciler.GetLastSnapshotStats() assert.Equal(t, SnapshotStats{Created: 0, Updated: 1, Deleted: 0}, stats) } func TestFolderReconciler_HasBothStates(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -289,7 +290,7 @@ func TestFolderReconciler_HasBothStates(t *testing.T) { } func TestFolderReconciler_HasBothStates_WithEmptyStates(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -308,7 +309,7 @@ func TestFolderReconciler_HasBothStates_WithEmptyStates(t *testing.T) { } func TestFolderReconciler_GetGitDest(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -323,41 +324,66 @@ func TestFolderReconciler_GetGitDest(t *testing.T) { assert.Contains(t, reconciler.String(), "default/test-gitdest", "String should contain gitDest reference") } -// MockEventEmitter is a mock implementation of EventEmitter for testing. -type MockEventEmitter struct { - createEvents []types.ResourceIdentifier - deleteEvents []types.ResourceIdentifier - reconcileEvents []types.ResourceIdentifier -} +func TestFolderReconciler_EmitsSingleBatch(t *testing.T) { + mockEmitter := &MockReconcileEmitter{} + mockControlEmitter := &MockControlEventEmitter{} + gitDest := types.NewResourceReference("test-gitdest", "default") + reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) -func (m *MockEventEmitter) EmitCreateEvent(resource types.ResourceIdentifier, _ *unstructured.Unstructured) error { - m.createEvents = append(m.createEvents, resource) - return nil -} + reconciler.OnClusterState(events.ClusterStateEvent{ + GitDest: gitDest, + Resources: []types.ResourceIdentifier{ + {Group: "", Version: "v1", Resource: "pods", Name: "new-pod"}, + {Group: "", Version: "v1", Resource: "pods", Name: "existing-pod"}, + }, + }) + reconciler.OnRepoState(events.RepoStateEvent{ + GitDest: gitDest, + Resources: []types.ResourceIdentifier{ + {Group: "", Version: "v1", Resource: "pods", Name: "existing-pod"}, + {Group: "", Version: "v1", Resource: "pods", Name: "old-pod"}, + }, + }) -func (m *MockEventEmitter) EmitDeleteEvent(resource types.ResourceIdentifier) error { - m.deleteEvents = append(m.deleteEvents, resource) - return nil + // Exactly one batch should be emitted + assert.Len(t, mockEmitter.Batches, 1, "Should emit exactly one batch") + batch := mockEmitter.Batches[0] + + // Batch should contain all events + assert.Len(t, batch.Events, 3, "Batch should have 3 events (1 create, 1 delete, 1 reconcile)") + assert.Contains(t, batch.CommitMessage, "reconcile: sync 3 resources") } -func (m *MockEventEmitter) EmitReconcileResourceEvent( - resource types.ResourceIdentifier, - _ *unstructured.Unstructured, -) error { - m.reconcileEvents = append(m.reconcileEvents, resource) - return nil +// MockReconcileEmitter is a mock implementation of ReconcileEmitter for testing. +type MockReconcileEmitter struct { + Batches []git.ReconcileBatch } -func (m *MockEventEmitter) GetCreateEvents() []types.ResourceIdentifier { - return m.createEvents +func (m *MockReconcileEmitter) EmitReconcileBatch(batch git.ReconcileBatch) error { + m.Batches = append(m.Batches, batch) + return nil } -func (m *MockEventEmitter) GetDeleteEvents() []types.ResourceIdentifier { - return m.deleteEvents +// GetEventsByOperation returns all events from all batches matching the given operation. +func (m *MockReconcileEmitter) GetEventsByOperation(op string) []git.Event { + var result []git.Event + for _, batch := range m.Batches { + for _, ev := range batch.Events { + if ev.Operation == op { + result = append(result, ev) + } + } + } + return result } -func (m *MockEventEmitter) GetReconcileEvents() []types.ResourceIdentifier { - return m.reconcileEvents +// GetIdentifiersByOperation returns resource identifiers from all batch events with the given operation. +func (m *MockReconcileEmitter) GetIdentifiersByOperation(op string) []types.ResourceIdentifier { + var result []types.ResourceIdentifier + for _, ev := range m.GetEventsByOperation(op) { + result = append(result, ev.Identifier) + } + return result } // MockControlEventEmitter is a mock implementation of ControlEventEmitter for testing. diff --git a/internal/reconcile/git_target_event_stream.go b/internal/reconcile/git_target_event_stream.go index 29e9ea492..34f6cbd56 100644 --- a/internal/reconcile/git_target_event_stream.go +++ b/internal/reconcile/git_target_event_stream.go @@ -24,20 +24,17 @@ import ( "sync" "github.com/go-logr/logr" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/ConfigButler/gitops-reverser/internal/events" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/sanitize" - "github.com/ConfigButler/gitops-reverser/internal/types" ) // EventStreamState represents the state of the event stream processing. type EventStreamState string const ( - // StartupReconcile buffers events while initial reconciliation runs. - StartupReconcile EventStreamState = "STARTUP_RECONCILE" + // Reconciling buffers live events while a reconcile batch is in flight. + Reconciling EventStreamState = "RECONCILING" // LiveProcessing processes all events normally. LiveProcessing EventStreamState = "LIVE_PROCESSING" ) @@ -64,16 +61,10 @@ type GitTargetEventStream struct { mu sync.RWMutex } -// EventEnqueuer interface for enqueuing events (allows mocking). +// EventEnqueuer interface for enqueuing events and batches (allows mocking). type EventEnqueuer interface { Enqueue(event git.Event) -} - -// EventEmitter interface for emitting reconciliation events. -type EventEmitter interface { - EmitCreateEvent(resource types.ResourceIdentifier, obj *unstructured.Unstructured) error - EmitDeleteEvent(resource types.ResourceIdentifier) error - EmitReconcileResourceEvent(resource types.ResourceIdentifier, obj *unstructured.Unstructured) error + EnqueueBatch(batch *git.ReconcileBatch) } // NewGitTargetEventStream creates a new event stream for a GitTarget. @@ -85,7 +76,7 @@ func NewGitTargetEventStream( return &GitTargetEventStream{ gitTargetName: gitTargetName, gitTargetNamespace: gitTargetNamespace, - state: StartupReconcile, + state: Reconciling, bufferedEvents: make([]git.Event, 0), processedEventHashes: make(map[string]string), branchWorker: branchWorker, @@ -93,11 +84,24 @@ func NewGitTargetEventStream( } } +// BeginReconciliation transitions the stream to RECONCILING state (buffering live events). +// Safe to call when already in RECONCILING state (no-op). +func (s *GitTargetEventStream) BeginReconciliation() { + s.mu.Lock() + defer s.mu.Unlock() + if s.state == Reconciling { + return + } + s.state = Reconciling + s.bufferedEvents = make([]git.Event, 0) + s.logger.Info("Transitioned to RECONCILING state") +} + // OnWatchEvent processes incoming watch events from the cluster. func (s *GitTargetEventStream) OnWatchEvent(event git.Event) { s.mu.Lock() switch s.state { - case StartupReconcile: + case Reconciling: // Buffer all events during reconciliation (no deduplication) s.bufferedEvents = append(s.bufferedEvents, event) bufferSize := len(s.bufferedEvents) @@ -125,14 +129,24 @@ func (s *GitTargetEventStream) OnWatchEvent(event git.Event) { } } -// OnReconciliationComplete signals that initial reconciliation has finished. +// EmitReconcileBatch forwards a complete reconcile batch to the BranchWorker as a single WorkItem. +// Called while in RECONCILING state by FolderReconciler. +func (s *GitTargetEventStream) EmitReconcileBatch(batch git.ReconcileBatch) error { + batch.GitTargetName = s.gitTargetName + batch.GitTargetNamespace = s.gitTargetNamespace + s.branchWorker.EnqueueBatch(&batch) + return nil +} + +// OnReconciliationComplete signals that reconciliation has finished. +// Transitions to LIVE_PROCESSING and flushes buffered live events. func (s *GitTargetEventStream) OnReconciliationComplete() { s.mu.Lock() - if s.state != StartupReconcile { + if s.state != Reconciling { currentState := s.state s.mu.Unlock() s.logger.Info( - "Reconciliation complete signal received but not in STARTUP_RECONCILE state", + "Reconciliation complete signal received but not in RECONCILING state", "currentState", currentState, ) @@ -233,45 +247,3 @@ func (s *GitTargetEventStream) String() string { return fmt.Sprintf("GitTargetEventStream(gitTarget=%s/%s, state=%s, buffered=%d, processed=%d)", s.gitTargetNamespace, s.gitTargetName, s.state, len(s.bufferedEvents), len(s.processedEventHashes)) } - -// EmitCreateEvent emits a CREATE event for reconciliation. -// obj carries the full sanitized cluster object so the branch worker can write it to Git. -func (s *GitTargetEventStream) EmitCreateEvent( - resource types.ResourceIdentifier, - obj *unstructured.Unstructured, -) error { - event := git.Event{ - Operation: "CREATE", - Identifier: resource, - Object: obj, - } - s.OnWatchEvent(event) - return nil -} - -// EmitDeleteEvent emits a DELETE event for reconciliation. -func (s *GitTargetEventStream) EmitDeleteEvent(resource types.ResourceIdentifier) error { - event := git.Event{ - Operation: "DELETE", - Identifier: resource, - // BaseFolder will be set by the reconciler - } - s.OnWatchEvent(event) - return nil -} - -// EmitReconcileResourceEvent emits a RECONCILE_RESOURCE event for reconciliation. -// obj carries the full sanitized cluster object so the branch worker can overwrite the -// existing Git file with the current cluster state. -func (s *GitTargetEventStream) EmitReconcileResourceEvent( - resource types.ResourceIdentifier, - obj *unstructured.Unstructured, -) error { - event := git.Event{ - Operation: string(events.ReconcileResource), - Identifier: resource, - Object: obj, - } - s.OnWatchEvent(event) - return nil -} diff --git a/internal/reconcile/git_target_event_stream_test.go b/internal/reconcile/git_target_event_stream_test.go index 2c0be5387..47a61aa66 100644 --- a/internal/reconcile/git_target_event_stream_test.go +++ b/internal/reconcile/git_target_event_stream_test.go @@ -45,14 +45,14 @@ var _ = Describe("GitTargetEventStream", func() { ) BeforeEach(func() { - mockWorker = &mockBranchWorker{events: make([]git.Event, 0)} + mockWorker = &mockBranchWorker{events: make([]git.Event, 0), batches: make([]*git.ReconcileBatch, 0)} logger = logr.Discard() stream = NewGitTargetEventStream(gitTargetName, gitTargetNS, mockWorker, logger) }) Describe("Initial State", func() { - It("should start in STARTUP_RECONCILE state", func() { - Expect(stream.GetState()).To(Equal(StartupReconcile)) + It("should start in RECONCILING state", func() { + Expect(stream.GetState()).To(Equal(Reconciling)) }) It("should have empty buffers initially", func() { @@ -62,7 +62,7 @@ var _ = Describe("GitTargetEventStream", func() { }) Describe("Event Buffering During Reconciliation", func() { - It("should buffer events during STARTUP_RECONCILE", func() { + It("should buffer events during RECONCILING", func() { event := createTestEvent("pod", "test-pod", "CREATE") stream.OnWatchEvent(event) @@ -104,7 +104,7 @@ var _ = Describe("GitTargetEventStream", func() { }) It("should transition state correctly", func() { - Expect(stream.GetState()).To(Equal(StartupReconcile)) + Expect(stream.GetState()).To(Equal(Reconciling)) stream.OnReconciliationComplete() @@ -138,6 +138,45 @@ var _ = Describe("GitTargetEventStream", func() { }) }) + Describe("BeginReconciliation", func() { + It("should be a no-op when already in RECONCILING", func() { + Expect(stream.GetState()).To(Equal(Reconciling)) + stream.BeginReconciliation() + Expect(stream.GetState()).To(Equal(Reconciling)) + }) + + It("should re-enter RECONCILING from LIVE_PROCESSING and buffer new events", func() { + stream.OnReconciliationComplete() // β†’ LiveProcessing + Expect(stream.GetState()).To(Equal(LiveProcessing)) + + stream.BeginReconciliation() // β†’ Reconciling again + Expect(stream.GetState()).To(Equal(Reconciling)) + + event := createTestEvent("pod", "test-pod", "UPDATE") + stream.OnWatchEvent(event) + + Expect(stream.GetBufferedEventCount()).To(Equal(1)) + Expect(mockWorker.events).To(BeEmpty()) // buffered, not forwarded + }) + }) + + Describe("EmitReconcileBatch", func() { + It("should enqueue batch to worker and stamp GitTarget info", func() { + batch := git.ReconcileBatch{ + Events: []git.Event{ + createTestEvent("pod", "pod1", "CREATE"), + }, + CommitMessage: "reconcile: sync 1 resources", + } + + err := stream.EmitReconcileBatch(batch) + Expect(err).NotTo(HaveOccurred()) + Expect(mockWorker.batches).To(HaveLen(1)) + Expect(mockWorker.batches[0].GitTargetName).To(Equal(gitTargetName)) + Expect(mockWorker.batches[0].GitTargetNamespace).To(Equal(gitTargetNS)) + }) + }) + Describe("Event Hash Deduplication", func() { It("should treat different operations as different events", func() { event1 := createTestEvent("pod", "test-pod", "CREATE") @@ -167,13 +206,18 @@ var _ = Describe("GitTargetEventStream", func() { // mockBranchWorker implements EventEnqueuer interface for testing. type mockBranchWorker struct { - events []git.Event + events []git.Event + batches []*git.ReconcileBatch } func (m *mockBranchWorker) Enqueue(event git.Event) { m.events = append(m.events, event) } +func (m *mockBranchWorker) EnqueueBatch(batch *git.ReconcileBatch) { + m.batches = append(m.batches, batch) +} + // createTestEvent creates a test event with minimal required fields. func createTestEvent(resourceType, name, operation string) git.Event { obj := &unstructured.Unstructured{} diff --git a/internal/reconcile/gittarget_lifecycle_integration_test.go b/internal/reconcile/gittarget_lifecycle_integration_test.go index 08836ec23..2ed022613 100644 --- a/internal/reconcile/gittarget_lifecycle_integration_test.go +++ b/internal/reconcile/gittarget_lifecycle_integration_test.go @@ -272,8 +272,9 @@ func TestGitTargetEventStream_DeduplicationPerStream(t *testing.T) { // mockEventEnqueuer implements EventEnqueuer interface for testing. type mockEventEnqueuer struct { - mu sync.Mutex - events []git.Event + mu sync.Mutex + events []git.Event + batches []*git.ReconcileBatch } func (m *mockEventEnqueuer) Enqueue(event git.Event) { @@ -282,6 +283,12 @@ func (m *mockEventEnqueuer) Enqueue(event git.Event) { m.events = append(m.events, event) } +func (m *mockEventEnqueuer) EnqueueBatch(batch *git.ReconcileBatch) { + m.mu.Lock() + defer m.mu.Unlock() + m.batches = append(m.batches, batch) +} + // createTestEventWithPath creates a test event with a specific path. func createTestEventWithPath(resourceType, name, operation, path string) git.Event { obj := &unstructured.Unstructured{} diff --git a/internal/reconcile/integration_test.go b/internal/reconcile/integration_test.go index 04caa4045..07afb1cee 100644 --- a/internal/reconcile/integration_test.go +++ b/internal/reconcile/integration_test.go @@ -31,7 +31,7 @@ import ( // TestFolderReconciler_FullReconciliationCycle tests the complete reconciliation cycle // with both cluster and Git state events. func TestFolderReconciler_FullReconciliationCycle(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -46,7 +46,7 @@ func TestFolderReconciler_FullReconciliationCycle(t *testing.T) { // Should not have reconciled yet (missing Git state) assert.False(t, reconciler.HasBothStates(), "Should not have both states") - assert.Empty(t, mockEmitter.GetCreateEvents(), "Should not emit events without both states") + assert.Empty(t, mockEmitter.Batches, "Should not emit batch without both states") // Provide Git state - should now reconcile reconciler.OnRepoState(events.RepoStateEvent{ @@ -57,18 +57,23 @@ func TestFolderReconciler_FullReconciliationCycle(t *testing.T) { }, }) - // Should now have both states and should have reconciled + // Should now have both states and a single batch assert.True(t, reconciler.HasBothStates(), "Should have both states") - assert.Empty(t, mockEmitter.GetCreateEvents(), "No resources should be created (pod exists in both)") - // The service is an orphan in Git (exists in Git but not in cluster), so it should be deleted - assert.Len(t, mockEmitter.GetDeleteEvents(), 1, "Should delete orphaned service") - assert.Equal(t, "old-service", mockEmitter.GetDeleteEvents()[0].Name, "Should delete orphaned service") - assert.Len(t, mockEmitter.GetReconcileEvents(), 1, "Should emit reconcile event for existing resource") + assert.Len(t, mockEmitter.Batches, 1, "Should emit exactly one batch") + + createEvents := mockEmitter.GetEventsByOperation("CREATE") + deleteEvents := mockEmitter.GetEventsByOperation("DELETE") + reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource)) + + assert.Empty(t, createEvents, "No resources should be created (pod exists in both)") + assert.Len(t, deleteEvents, 1, "Should delete orphaned service") + assert.Equal(t, "old-service", deleteEvents[0].Identifier.Name, "Should delete orphaned service") + assert.Len(t, reconcileEvents, 1, "Should emit reconcile event for existing resource") } // TestFolderReconciler_MissingInGit tests reconciliation when cluster has resources not in Git. func TestFolderReconciler_MissingInGit(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -89,20 +94,19 @@ func TestFolderReconciler_MissingInGit(t *testing.T) { }, }) - // Should emit create event for missing service - createEvents := mockEmitter.GetCreateEvents() + createEvents := mockEmitter.GetEventsByOperation("CREATE") + reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource)) + assert.Len(t, createEvents, 1, "Should emit one create event") - assert.Equal(t, "app-svc", createEvents[0].Name, "Should create missing service") + assert.Equal(t, "app-svc", createEvents[0].Identifier.Name, "Should create missing service") - // Should emit reconcile event for existing resource - reconcileEvents := mockEmitter.GetReconcileEvents() assert.Len(t, reconcileEvents, 1, "Should emit one reconcile event") - assert.Equal(t, "app-pod", reconcileEvents[0].Name, "Should reconcile existing pod") + assert.Equal(t, "app-pod", reconcileEvents[0].Identifier.Name, "Should reconcile existing pod") } // TestFolderReconciler_OrphansInGit tests reconciliation when Git has orphaned resources. func TestFolderReconciler_OrphansInGit(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("test-gitdest", "default") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -123,21 +127,20 @@ func TestFolderReconciler_OrphansInGit(t *testing.T) { }, }) - // Should emit delete event for orphan - deleteEvents := mockEmitter.GetDeleteEvents() + deleteEvents := mockEmitter.GetEventsByOperation("DELETE") + reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource)) + assert.Len(t, deleteEvents, 1, "Should emit one delete event") - assert.Equal(t, "old-config", deleteEvents[0].Name, "Should delete orphan configmap") + assert.Equal(t, "old-config", deleteEvents[0].Identifier.Name, "Should delete orphan configmap") - // Should emit reconcile event for existing resource - reconcileEvents := mockEmitter.GetReconcileEvents() assert.Len(t, reconcileEvents, 1, "Should emit one reconcile event") - assert.Equal(t, "app-pod", reconcileEvents[0].Name, "Should reconcile existing pod") + assert.Equal(t, "app-pod", reconcileEvents[0].Identifier.Name, "Should reconcile existing pod") } // TestFolderReconciler_OrderIndependence tests that event order doesn't matter. func TestFolderReconciler_OrderIndependence(t *testing.T) { // Test 1: Git state first, then cluster state - mockEmitter1 := &MockEventEmitter{} + mockEmitter1 := &MockReconcileEmitter{} mockControlEmitter1 := &MockControlEventEmitter{} gitDest1 := types.NewResourceReference("test-gitdest", "default") reconciler1 := NewFolderReconciler(gitDest1, mockEmitter1, mockControlEmitter1, log.Log) @@ -158,7 +161,7 @@ func TestFolderReconciler_OrderIndependence(t *testing.T) { }) // Test 2: Cluster state first, then Git state - mockEmitter2 := &MockEventEmitter{} + mockEmitter2 := &MockReconcileEmitter{} mockControlEmitter2 := &MockControlEventEmitter{} gitDest2 := types.NewResourceReference("test-gitdest", "default") reconciler2 := NewFolderReconciler(gitDest2, mockEmitter2, mockControlEmitter2, log.Log) @@ -179,25 +182,29 @@ func TestFolderReconciler_OrderIndependence(t *testing.T) { }) // Both should produce the same results regardless of order - createEvents1 := mockEmitter1.GetCreateEvents() - createEvents2 := mockEmitter2.GetCreateEvents() + createEvents1 := mockEmitter1.GetEventsByOperation("CREATE") + createEvents2 := mockEmitter2.GetEventsByOperation("CREATE") assert.Len(t, createEvents2, len(createEvents1), "Both orders should produce same number of create events") assert.Len(t, createEvents1, 1, "Should have one create event") if len(createEvents1) > 0 && len(createEvents2) > 0 { - assert.Equal(t, createEvents1[0], createEvents2[0], "Both orders should produce same create event") + assert.Equal(t, + createEvents1[0].Identifier, + createEvents2[0].Identifier, + "Both orders should produce same create event", + ) } } // TestFolderReconciler_ScopeIsolation tests that different scopes don't interfere. func TestFolderReconciler_ScopeIsolation(t *testing.T) { - mockEmitter1 := &MockEventEmitter{} + mockEmitter1 := &MockReconcileEmitter{} mockControlEmitter1 := &MockControlEventEmitter{} gitDest1 := types.NewResourceReference("gitdest-apps", "default") reconciler1 := NewFolderReconciler(gitDest1, mockEmitter1, mockControlEmitter1, log.Log) - mockEmitter2 := &MockEventEmitter{} + mockEmitter2 := &MockReconcileEmitter{} mockControlEmitter2 := &MockControlEventEmitter{} gitDest2 := types.NewResourceReference("gitdest-infra", "default") reconciler2 := NewFolderReconciler(gitDest2, mockEmitter2, mockControlEmitter2, log.Log) @@ -237,27 +244,25 @@ func TestFolderReconciler_ScopeIsolation(t *testing.T) { assert.True(t, reconciler1.HasBothStates(), "First reconciler should have both states") assert.True(t, reconciler2.HasBothStates(), "Second reconciler should have both states") - // First reconciler should have reconcile event for deployment - reconcileEvents1 := mockEmitter1.GetReconcileEvents() + reconcileEvents1 := mockEmitter1.GetEventsByOperation(string(events.ReconcileResource)) assert.Len(t, reconcileEvents1, 1, "First reconciler should have one reconcile event") - assert.Equal(t, "app-deployment", reconcileEvents1[0].Name, "Should reconcile deployment") + assert.Equal(t, "app-deployment", reconcileEvents1[0].Identifier.Name, "Should reconcile deployment") - // Second reconciler should have delete event for orphan configmap - deleteEvents2 := mockEmitter2.GetDeleteEvents() + deleteEvents2 := mockEmitter2.GetEventsByOperation("DELETE") assert.Len(t, deleteEvents2, 1, "Second reconciler should have one delete event") - assert.Equal(t, "orphan-cm", deleteEvents2[0].Name, "Should delete orphan configmap") + assert.Equal(t, "orphan-cm", deleteEvents2[0].Identifier.Name, "Should delete orphan configmap") // Cross-contamination check - assert.Empty(t, mockEmitter1.GetDeleteEvents(), "First reconciler should not have delete events") - // Second reconciler should have reconcile event for worker-node (exists in both places) - reconcileEvents2 := mockEmitter2.GetReconcileEvents() + assert.Empty(t, mockEmitter1.GetEventsByOperation("DELETE"), "First reconciler should not have delete events") + + reconcileEvents2 := mockEmitter2.GetEventsByOperation(string(events.ReconcileResource)) assert.Len(t, reconcileEvents2, 1, "Second reconciler should have one reconcile event") - assert.Equal(t, "worker-node", reconcileEvents2[0].Name, "Should reconcile worker-node") + assert.Equal(t, "worker-node", reconcileEvents2[0].Identifier.Name, "Should reconcile worker-node") } // TestFolderReconciler_ComplexScenario tests a complex real-world scenario. func TestFolderReconciler_ComplexScenario(t *testing.T) { - mockEmitter := &MockEventEmitter{} + mockEmitter := &MockReconcileEmitter{} mockControlEmitter := &MockControlEventEmitter{} gitDest := types.NewResourceReference("my-app-gitdest", "production") reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log) @@ -287,16 +292,16 @@ func TestFolderReconciler_ComplexScenario(t *testing.T) { }) // Verify complex reconciliation results - createEvents := mockEmitter.GetCreateEvents() - deleteEvents := mockEmitter.GetDeleteEvents() - reconcileEvents := mockEmitter.GetReconcileEvents() + createEvents := mockEmitter.GetEventsByOperation("CREATE") + deleteEvents := mockEmitter.GetEventsByOperation("DELETE") + reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource)) // Should create missing resources (backend, backend-svc, app-config) assert.Len(t, createEvents, 3, "Should create 3 missing resources") createNames := make(map[string]bool) for _, event := range createEvents { - createNames[event.Name] = true + createNames[event.Identifier.Name] = true } assert.True(t, createNames["backend"], "Should create missing backend deployment") assert.True(t, createNames["backend-svc"], "Should create missing backend service") @@ -307,7 +312,7 @@ func TestFolderReconciler_ComplexScenario(t *testing.T) { deleteNames := make(map[string]bool) for _, event := range deleteEvents { - deleteNames[event.Name] = true + deleteNames[event.Identifier.Name] = true } assert.True(t, deleteNames["old-config"], "Should delete old configmap") assert.True(t, deleteNames["legacy-secret"], "Should delete legacy secret") @@ -318,7 +323,7 @@ func TestFolderReconciler_ComplexScenario(t *testing.T) { reconcileNames := make(map[string]bool) for _, event := range reconcileEvents { - reconcileNames[event.Name] = true + reconcileNames[event.Identifier.Name] = true } assert.True(t, reconcileNames["frontend"], "Should reconcile frontend deployment") assert.True(t, reconcileNames["frontend-svc"], "Should reconcile frontend service") diff --git a/internal/reconcile/reconciler_manager.go b/internal/reconcile/reconciler_manager.go index e8972acee..4a4374373 100644 --- a/internal/reconcile/reconciler_manager.go +++ b/internal/reconcile/reconciler_manager.go @@ -65,7 +65,7 @@ func (m *ReconcilerManager) SetEventRouter( // CreateReconciler creates or retrieves a FolderReconciler for the given GitDestination. func (m *ReconcilerManager) CreateReconciler( gitDest types.ResourceReference, - eventEmitter EventEmitter, + eventEmitter BatchEmitter, ) *FolderReconciler { key := gitDest.Key() From afd488b728b0bc7d6f39e9737df2a79831c67eda Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Mar 2026 12:21:37 +0000 Subject: [PATCH 10/30] chore: Also allow claude to stay loged in over devcontainer rebuilds --- .devcontainer/devcontainer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2b8a01799..1063f3f5c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -67,7 +67,8 @@ "golang.go", "ms-kubernetes-tools.vscode-kubernetes-tools", "ms-azuretools.vscode-docker", - "openai.chatgpt" + "openai.chatgpt", + "anthropic.claude-code" ] } }, @@ -76,7 +77,8 @@ "mounts": [ "source=gomodcache,target=/go/pkg/mod,type=volume", "source=gobuildcache,target=/home/vscode/.cache/go-build,type=volume", - "source=codexconfig,target=/home/vscode/.codex,type=volume" + "source=codexconfig,target=/home/vscode/.codex,type=volume", + "source=claudeconfig,target=/home/vscode/.claude,type=volume" ], "containerEnv": { "HOST_PROJECT_PATH": "${localWorkspaceFolder}", From ab9b01ee7e871f0c83cd36aef2a7f428786afcca Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 18 Mar 2026 23:29:15 +0000 Subject: [PATCH 11/30] chore: have e2e resources in a seperate ns (so that we can keep the gitops-reverser installaton between runs). --- Makefile | 18 +- docs/bug-snapshot-namespace-scoping.md | 386 ++++++++++++++++++ docs/e2e-test-design.md | 221 ++++++++++ hack/e2e/gitea-run-setup.sh | 16 +- test/e2e/audit_redis_e2e_test.go | 25 +- test/e2e/bi_directional_e2e_test.go | 95 +++-- test/e2e/e2e_suite_test.go | 6 +- test/e2e/e2e_test.go | 239 ++++++----- test/e2e/helpers.go | 17 + test/e2e/namespace.go | 13 +- test/e2e/quickstart_framework_e2e_test.go | 34 +- test/e2e/talk_e2e_test.go | 15 +- .../flux-gitrepository-http.tmpl | 0 .../flux-kustomization.tmpl | 0 .../watchrule-secret.tmpl | 0 .../{ => manager}/clusterwatchrule-crd.tmpl | 0 .../templates/{ => manager}/configmap.tmpl | 0 .../{ => manager}/watchrule-configmap.tmpl | 0 .../clusterwatchrule-talk.tmpl | 0 .../{ => talk-demo}/watchrule-all.tmpl | 0 20 files changed, 884 insertions(+), 201 deletions(-) create mode 100644 docs/bug-snapshot-namespace-scoping.md create mode 100644 docs/e2e-test-design.md rename test/e2e/templates/{ => bi-directional}/flux-gitrepository-http.tmpl (100%) rename test/e2e/templates/{ => bi-directional}/flux-kustomization.tmpl (100%) rename test/e2e/templates/{ => bi-directional}/watchrule-secret.tmpl (100%) rename test/e2e/templates/{ => manager}/clusterwatchrule-crd.tmpl (100%) rename test/e2e/templates/{ => manager}/configmap.tmpl (100%) rename test/e2e/templates/{ => manager}/watchrule-configmap.tmpl (100%) rename test/e2e/templates/{ => talk-demo}/clusterwatchrule-talk.tmpl (100%) rename test/e2e/templates/{ => talk-demo}/watchrule-all.tmpl (100%) diff --git a/Makefile b/Makefile index 68ad5987e..0e554a63b 100644 --- a/Makefile +++ b/Makefile @@ -197,9 +197,13 @@ $(CS)/$(NAMESPACE)/prepare-e2e.ready: $(CS)/$(NAMESPACE)/$(INSTALL_MODE)/install | $(CS)/$(NAMESPACE) touch $@ -# Keep `make test-e2e` as the classic entrypoint. .PHONY: test-e2e -test-e2e: $(CS)/e2e.passed +test-e2e: ## Run the full e2e test suite + export CTX=$(CTX) + export INSTALL_MODE=$(INSTALL_MODE) + export NAMESPACE=$(NAMESPACE) + export E2E_AGE_KEY_FILE=$(CS)/age-key.txt + go test ./test/e2e/ -v -ginkgo.v .PHONY: lint lint: ## Run golangci-lint linter @@ -477,16 +481,6 @@ portforward-ensure: $(CS)/services.ready ## Ensure port-forwards are running (al export VALKEY_PORT=$(VALKEY_PORT) bash hack/e2e/setup-port-forwards.sh -E2E_TEST_INPUTS = $(CS)/age-key.txt \ - $(shell find test/e2e -type f \( -name '*.go' -o -name '*.sh' -o -name '*.yaml' -o -name '*.tmpl' \)) \ - $(wildcard hack/e2e/*.sh) -$(CS)/e2e.passed: $$(E2E_TEST_INPUTS) Makefile | $(CS) - export CTX=$(CTX) - export INSTALL_MODE=$(INSTALL_MODE) - export NAMESPACE=$(NAMESPACE) - export E2E_AGE_KEY_FILE=$(CS)/age-key.txt - go test ./test/e2e/ -v -ginkgo.v - touch $@ .PHONY: install install-helm install-plain-manifests-file install-config-dir install: $(CS)/$(NAMESPACE)/$(INSTALL_MODE)/install.yaml diff --git a/docs/bug-snapshot-namespace-scoping.md b/docs/bug-snapshot-namespace-scoping.md new file mode 100644 index 000000000..238a91d00 --- /dev/null +++ b/docs/bug-snapshot-namespace-scoping.md @@ -0,0 +1,386 @@ +# Bug Report: Snapshot Reconciliation Ignores Namespace Scope + +**Status:** Confirmed +**Severity:** High β€” causes spurious commits that break the commit-loop prevention guarantee +**Discovered via:** `make test-e2e-bi` (bi-directional e2e test) + +--- + +## Summary + +When the gitops-reverser controller performs a **full cluster snapshot** (the `reconcile: sync N resources from cluster snapshot` code path), it lists resources **cluster-wide** with no namespace filter. This means a `WatchRule` scoped to a single test namespace inadvertently pulls secrets from `cert-manager`, `flux-system`, `kube-system`, `gitea-e2e`, and every other namespace on the cluster into the live Git path. + +The consequence is that after Flux applies committed resources back to the cluster, a snapshot is triggered, which produces a commit containing secrets it was never supposed to touch β€” breaking the commit-loop prevention that is the core feature under test. + +--- + +## Observed Failure + +Running `make test-e2e-bi`, the bi-directional e2e test failed at: + +``` +test/e2e/bi_directional_e2e_test.go:326 +Consistently expected commit count to stay at but it grew by 1 +``` + +Inspecting the Git log of the test repo (`.stamps/repos//bi-directional//`): + +``` +97d53b1 reconcile: sync 27 resources from cluster snapshot ← SPURIOUS +6cc837e [UPDATE] v1/secrets/bi-secret- +9c4d294 [CREATE] v1/secrets/bi-secret- +c2722b7 [CREATE] v1/secrets/sops-age-key +3179204 [CREATE] v1/secrets/git-creds-ssh- +4dca51e [CREATE] v1/secrets/git-creds-invalid- +f6da0a0 [CREATE] v1/secrets/git-creds- +3743e9b [CREATE] v1/secrets/bi-controller-sops- +d6cc2bf [UPDATE] shop.example.com/v1/icecreamorders/bi-alice-order- +fb396c1 bi-directional: add two icecream orders +0e059d3 chore(bootstrap): initialize path bi-directional//live +b1760a3 bi-directional: add icecreamorder crd +``` + +The spurious commit adds 19 files across namespaces the test never owned: + +``` +live/v1/secrets/cert-manager/cert-manager-webhook-ca.sops.yaml +live/v1/secrets/flux-system/bi-flux-auth-.sops.yaml +live/v1/secrets/flux-system/bi-sops-.sops.yaml +live/v1/secrets/gitea-e2e/gitea-init.sops.yaml +live/v1/secrets/gitea-e2e/gitea.sops.yaml +live/v1/secrets/gitops-reverser/admission-server-cert.sops.yaml +live/v1/secrets/kube-system/k3s-serving.sops.yaml +live/v1/secrets/prometheus-operator/prometheus-prometheus-shared-e2e.sops.yaml +live/v1/secrets/valkey-e2e/sh.helm.release.v1.valkey.v1.sops.yaml +... (19 files total) +``` + +--- + +## Root Cause + +There are two compounding bugs. Either one alone would prevent the spurious commit; together they explain both *why* the snapshot is wrong and *why* it fires at all after the initial setup. + +### Bug 1 β€” Snapshot lists resources cluster-wide (namespace scoping missing) + +### The broken path: `GetClusterStateForGitDest` β†’ `listResourcesForGVR` + +**`internal/watch/manager.go:773`** + +```go +// List resources (cluster-wide for now, namespace filtering would go here) +list, err := dc.Resource(gvr).List(ctx, metav1.ListOptions{}) +``` + +The call uses `dc.Resource(gvr)` (cluster-wide) instead of `dc.Resource(gvr).Namespace(ns)`. The comment acknowledges this is a placeholder. + +**`internal/watch/manager.go:802`** + +```go +func (m *Manager) objectMatchesGitTarget( + _ *unstructured.Unstructured, + _ *configv1alpha1.GitTarget, +) bool { + // For now, simple match - in the future could filter by namespace, labels, etc. + return true +} +``` + +The post-list filter is a stub that accepts every object unconditionally. + +### Bug 2 β€” Snapshot re-runs when triggered by the wrong events + +Re-running the snapshot is not inherently wrong. There are legitimate reasons to do it: a new `WatchRule` is added (the watch scope has grown), a `WatchRule` is deleted (resources may need to be removed from Git), or the controller restarts (the informer cache is gone). In those cases a re-snapshot is correct and necessary β€” the whole point of reconciliation loops is to converge to the right state no matter how many times they run. + +The problem here is that the snapshot re-runs **for the wrong reason, via an accidental path**. + +`evaluateSnapshotGate` in `gittarget_controller.go` is called unconditionally on every reconcile loop iteration. Inside it, `StartReconciliation` fires `RequestClusterState` on the reused `FolderReconciler` with no guard: + +```go +// gittarget_controller.go:477 β€” called every reconcile loop +func (r *GitTargetReconciler) evaluateSnapshotGate(...) { + stream.BeginReconciliation() + reconciler := r.EventRouter.ReconcilerManager.CreateReconciler(gitDest, stream) // reuses existing + reconciler.StartReconciliation(ctx) // fires RequestClusterState unconditionally + ... +} +``` + +`ReconcilerManager.CreateReconciler` returns the cached reconciler after the first call β€” so subsequent `StartReconciliation` calls overwrite its internal cluster/repo state with whatever the next snapshot returns. + +The correct triggers for a re-snapshot are events that imply the **watch scope changed** (a WatchRule was added, updated, or deleted) or that **incremental state was lost** (controller restart). An encryption secret being touched by Flux says neither of those things β€” it is an entirely unrelated event that happens to go through the same reconcile loop. + +### Why the snapshot fires late β€” the encryption secret re-trigger + +The GitTarget controller watches **all `Secret` objects** in the cluster via `encryptionSecretToGitTargets` (see `SetupWithManager`). It maps a Secret event to a GitTarget reconcile if the secret is the GitTarget's `Encryption.SecretRef` β€” i.e. the controller-generated SOPS age key. + +In the test, the GitTarget's encryption secret is `bi-controller-sops-`. The Secret WatchRule commits this secret to Git (commit #5 in the log). When Flux reconciles the live kustomization it applies `bi-controller-sops-` back to the cluster, firing a `Secret UPDATE` event. That event maps to the GitTarget β†’ GitTarget re-reconciles β†’ `evaluateSnapshotGate` runs a second time β†’ `StartReconciliation` fires again β†’ cluster-wide snapshot. + +This is why the spurious commit is **last** rather than first: the initial snapshot (Phase 1 below) found zero matching resources and emitted nothing. The second snapshot is triggered specifically by Flux touching the encryption secret. + +### The full event sequence + +``` +Phase 1 β€” GitTarget initial setup + GitTarget created + β†’ evaluateSnapshotGate (1st run) + β†’ StartReconciliation β†’ RequestClusterState + β†’ cluster-wide list: secrets GVR, but NO Secret WatchRule exists yet + β†’ diff = 0 β†’ no commit emitted + Bootstrap gate: "chore(bootstrap): initialize path" ← commit 2 + +Phase 2 β€” IceCreamOrders flow normally via informers + Two orders committed β†’ Flux applies β†’ informers fire UPDATE + β†’ "[UPDATE] icecreamorders/bi-alice-order-" ← commit 4 + +Phase 3 β€” Secret WatchRule applied + WatchRule controller starts namespace-scoped informers for secrets + β†’ ADDED events fire for every existing secret in test-ns + β†’ "[CREATE] v1/secrets/bi-controller-sops-" ← commit 5 + β†’ "[CREATE] v1/secrets/git-creds-*" ← commits 6–9 + Test creates and patches bi-secret + β†’ "[CREATE/UPDATE] v1/secrets/bi-secret-" ← commits 10–11 + +Phase 4 β€” Flux reconciles live kustomization (THE TRIGGER) + Flux applies committed secrets back to cluster + β†’ bi-controller-sops- receives a Secret UPDATE event + β†’ encryptionSecretToGitTargets maps it to the GitTarget + β†’ GitTarget re-reconciles + β†’ evaluateSnapshotGate (2nd run, no SnapshotSynced guard) + β†’ StartReconciliation β†’ RequestClusterState (Bug 2) + β†’ cluster-wide list: all secrets on cluster, 27 resources (Bug 1) + β†’ diff vs Git (only test-ns secrets present): 19 files "missing" + β†’ "reconcile: sync 27 resources from cluster snapshot" ← commit 12 (SPURIOUS) +``` + +Either fix alone breaks the symptom in this specific case: +- Fix Bug 1 (namespace scope): the re-snapshot only sees test-ns secrets β†’ diff = 0 β†’ no spurious commit, even though the re-snapshot fires. +- Fix Bug 2 (wrong trigger): the re-snapshot never fires for this reason, so Bug 1 is never exercised by this path. + +**Bug 1 is the more fundamental correctness problem** β€” a namespace-unscoped snapshot is wrong regardless of what triggered it. Bug 2 is a design problem: the snapshot should only re-run when the watch scope changes or state is lost, not on every arbitrary GitTarget reconcile. Both should be fixed, but in priority order: fix Bug 1 first because a correctly-scoped snapshot is safe to run repeatedly. + +### Why the normal (incremental) event path is correct + +The live-event informers in `watch/manager.go` already use `getNamespacesForGVR` / `collectWatchRuleNamespaces` to scope dynamic informers to the WatchRule's own namespace. Individual `[CREATE]`/`[UPDATE]` commits in the log above are correctly scoped to the test namespace. The bug only exists in the **snapshot reconciliation path**, which runs a separate cluster list instead of using the informer cache. + +### What data is already available + +The rule store already records each WatchRule's source namespace in `CompiledRule.Source.Namespace`. Inside `GetClusterStateForGitDest`, the loop at line 642 already iterates over the matching `wrRules` β€” the namespace is right there: + +```go +for _, rule := range wrRules { + if rule.GitTargetRef == gitTargetObj.Name && + rule.GitTargetNamespace == gitTargetObj.Namespace { + // rule.Source.Namespace <-- THIS IS THE NAMESPACE TO LIST IN + for _, rr := range rule.ResourceRules { + m.addGVRsFromResourceRule(rr, gvrSet) + } + } +} +``` + +--- + +## Affected Code + +| File | Line | Bug | Issue | +|------|------|-----|-------| +| `internal/watch/manager.go` | 773–774 | 1 | Cluster-wide `List` β€” namespace not passed | +| `internal/watch/manager.go` | 802–810 | 1 | `objectMatchesGitTarget` always returns `true` | +| `internal/watch/manager.go` | 640–658 | 1 | GVR set built without collecting associated namespaces | +| `internal/controller/gittarget_controller.go` | 477–527 | 2 | `evaluateSnapshotGate` called unconditionally every reconcile loop β€” no early-exit when `SnapshotSynced=True` | +| `internal/reconcile/folder_reconciler.go` | 86–105 | 2 | `StartReconciliation` has no idempotency guard β€” re-fires `RequestClusterState` even if snapshot already completed | + +--- + +## Fix Plan + +### Step 1 β€” Thread namespace sets through `GetClusterStateForGitDest` + +Change the GVR accumulation loop to also collect the namespaces from each matching WatchRule, building a `map[GVR][]string` (GVR β†’ namespaces to list): + +```go +type gvrNamespaces struct { + namespaces map[string]struct{} // empty means cluster-wide + clusterWide bool +} +gvrMap := make(map[schema.GroupVersionResource]*gvrNamespaces) + +for _, rule := range wrRules { + if rule.GitTargetRef == gitTargetObj.Name && + rule.GitTargetNamespace == gitTargetObj.Namespace { + ns := rule.Source.Namespace + for _, rr := range rule.ResourceRules { + for _, gvr := range gvrsFromRule(rr) { + entry := gvrMap[gvr] + if entry == nil { + entry = &gvrNamespaces{namespaces: make(map[string]struct{})} + gvrMap[gvr] = entry + } + entry.namespaces[ns] = struct{}{} + } + } + } +} + +// ClusterWatchRules always list cluster-wide +for _, cwrRule := range cwrRules { + if cwrRule.GitTargetRef == gitTargetObj.Name && + cwrRule.GitTargetNamespace == gitTargetObj.Namespace { + for _, gvr := range gvrsFromClusterRule(cwrRule) { + entry := gvrMap[gvr] + if entry == nil { + entry = &gvrNamespaces{} + gvrMap[gvr] = entry + } + entry.clusterWide = true + } + } +} +``` + +### Step 2 β€” Namespace-scope the `List` call in `listResourcesForGVR` + +Pass the namespace set to `listResourcesForGVR` and use `Namespace(ns)` when listing: + +```go +func (m *Manager) listResourcesForGVR( + ctx context.Context, + dc dynamic.Interface, + gvr schema.GroupVersionResource, + namespaces []string, // NEW: empty = cluster-wide (for ClusterWatchRules) + objects map[string]unstructured.Unstructured, +) ([]types.ResourceIdentifier, error) { + if shouldIgnoreResource(gvr.Group, gvr.Resource) { + return nil, nil + } + + var allItems []unstructured.Unstructured + + if len(namespaces) == 0 { + // ClusterWatchRule or cluster-scoped resource: list cluster-wide + list, err := dc.Resource(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list %v: %w", gvr, err) + } + allItems = list.Items + } else { + // WatchRule: list only in the namespaces that have a matching rule + for _, ns := range namespaces { + list, err := dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list %v in namespace %s: %w", gvr, ns, err) + } + allItems = append(allItems, list.Items...) + } + } + + var resources []types.ResourceIdentifier + for i := range allItems { + obj := &allItems[i] + id := types.NewResourceIdentifier( + gvr.Group, gvr.Version, gvr.Resource, + obj.GetNamespace(), obj.GetName(), + ) + resources = append(resources, id) + objects[id.Key()] = *sanitize.Sanitize(obj) + } + return resources, nil +} +``` + +### Step 3 β€” Remove `objectMatchesGitTarget` + +The stub is dead code once namespace scoping is in the `List` call. Delete it to avoid future confusion. + +### Step 4 β€” Trigger re-snapshots from rule-change events, not the generic reconcile loop (Bug 2) + +The snapshot should re-run when the watch scope changes β€” i.e. when a `WatchRule` referencing this `GitTarget` is created, updated, or deleted. It should **not** re-run just because the GitTarget's encryption secret was touched. + +The right approach is to move the re-snapshot trigger out of `evaluateSnapshotGate` (which runs on every reconcile loop) and into the `WatchRule` controller, which already calls `ReconcileForRuleChange` when rules change. `ReconcileForRuleChange` can emit a `RequestClusterState` event directly, without going through the GitTarget reconcile loop. + +`evaluateSnapshotGate` should then be limited to the **initial bootstrap only** β€” bail out immediately once `SnapshotSynced=True` is set: + +```go +func (r *GitTargetReconciler) evaluateSnapshotGate(...) { + // Initial snapshot only. Re-snapshots on rule changes are triggered by + // the WatchRule controller via ReconcileForRuleChange, not here. + if meta.IsStatusConditionTrue(target.Status.Conditions, GitTargetConditionSnapshotSynced) { + return nil, metav1.ConditionTrue, "Initial snapshot reconciliation completed", 0, nil + } + // ... rest of the gate (runs once, on first setup) +} +``` + +Note: this does not prevent re-snapshotting when needed. It moves the responsibility to the correct trigger point (rule changes) rather than letting it happen accidentally via unrelated events. + +--- + +## Unit Test Plan + +### Test location: `internal/watch/manager_snapshot_test.go` (new file) + +These tests use a fake dynamic client to assert which namespaces are listed during snapshot reconciliation. + +#### Test 1 β€” WatchRule scopes snapshot to its own namespace + +**Setup:** One WatchRule in namespace `ns-a` watching `secrets`. One existing secret in `ns-a`, one in `ns-b`. +**Assert:** `GetClusterStateForGitDest` returns only the secret from `ns-a`. + +#### Test 2 β€” Two WatchRules in different namespaces targeting the same GitTarget + +**Setup:** WatchRule-1 in `ns-a` and WatchRule-2 in `ns-b`, both pointing to the same GitTarget, both watching `configmaps`. Configmaps exist in `ns-a`, `ns-b`, and `ns-c`. +**Assert:** Resources from `ns-a` and `ns-b` are returned; `ns-c` is excluded. + +#### Test 3 β€” ClusterWatchRule lists cluster-wide + +**Setup:** One ClusterWatchRule watching `nodes`. +**Assert:** All nodes are returned (cluster-wide list, not namespace-scoped). + +#### Test 4 β€” Mixed WatchRule + ClusterWatchRule for same GitTarget + +**Setup:** WatchRule in `ns-a` watching `secrets`; ClusterWatchRule watching `nodes`. Resources exist in `ns-a`, `ns-b`, and as cluster-scoped nodes. +**Assert:** Secrets only from `ns-a`; all nodes returned. + +#### Test 5 β€” WatchRule with no existing resources returns empty, not error + +**Setup:** WatchRule in `ns-empty` watching `configmaps`. No configmaps exist in that namespace. +**Assert:** Returns empty slice, no error, no resources from other namespaces. + +### Test location: `internal/watch/manager_test.go` (extend existing) + +#### Test 6 β€” Regression: secrets from `flux-system` not included when WatchRule is in test namespace + +Directly reproduces the observed failure. Creates a WatchRule in `test-ns`, populates secrets in `test-ns` and `flux-system`, runs `GetClusterStateForGitDest`, and asserts none of the `flux-system` secrets appear. + +### Test location: `internal/reconcile/folder_reconciler_test.go` (extend existing) + +#### Test 7 β€” Snapshot reconcile commit contains only namespace-scoped resources + +Integration-level: stand up a FolderReconciler with a fake cluster state containing resources across multiple namespaces; assert the resulting `ReconcileBatch` only includes resources from the WatchRule's namespace. + +#### Test 8 β€” `StartReconciliation` is idempotent (Bug 2) + +Call `StartReconciliation` twice on the same `FolderReconciler`. Assert that `RequestClusterState` is emitted exactly once, not twice. The second call must be a no-op. + +### Test location: `internal/controller/gittarget_controller_test.go` (extend existing) + +#### Test 9 β€” `evaluateSnapshotGate` skips re-run when `SnapshotSynced=True` (Bug 2) + +Set up a GitTarget with `SnapshotSynced=True` already in its status conditions. Call `evaluateSnapshotGate`. Assert that `StartReconciliation` is never called (i.e. no `RequestClusterState` event is emitted). This directly tests the re-trigger path that Flux's encryption secret update exploits. + +#### Test 10 β€” Encryption secret update does not trigger a second snapshot (Bug 2, regression) + +Simulates the exact trigger: create a GitTarget in `SnapshotSynced=True` state, then fire a `Secret` UPDATE event for its encryption secret. Assert the reconcile loop completes without calling `StartReconciliation`. + +#### Test 11 β€” Adding a new WatchRule does trigger a re-snapshot (Bug 2, positive case) + +Verifies that the fix for Bug 2 does not prevent legitimate re-snapshots. Add a second `WatchRule` referencing an already-Ready GitTarget. Assert that a `RequestClusterState` event is emitted by the `WatchRule` controller path (via `ReconcileForRuleChange`), not via `evaluateSnapshotGate`. + +--- + +## Invariant for Future Tests + +After this fix, the following invariant should hold and can be checked at the e2e level (already exercised by the bi-directional test once the fix is in): + +> After Flux applies resources from a gitops-reverser commit back to the cluster, the commit count in the repository **must not increase** β€” the controller must detect that the cluster state already matches Git and produce no new commit. diff --git a/docs/e2e-test-design.md b/docs/e2e-test-design.md new file mode 100644 index 000000000..790c13e86 --- /dev/null +++ b/docs/e2e-test-design.md @@ -0,0 +1,221 @@ +# E2E Test Design + +## Overview + +The e2e tests validate the gitops-reverser operator end-to-end against a real Kubernetes cluster (k3d), a real Gitea instance, and real Flux reconciliation. This document describes how the tests are structured, what is shared vs per-run, and the design principles that keep things predictable. + +## Entry Points + +| Command | What it does | +|---|---| +| `make test-e2e` | Classic entrypoint β€” always runs the full suite | +| `go test ./test/e2e/ -v -ginkgo.v` | Direct Go invocation β€” BeforeSuite handles all setup | +| IDE Go test plugin | Same as above β€” BeforeSuite handles all setup | + +All three paths are equivalent: `make test-e2e` is fully `.PHONY` and always runs `go test`, which triggers `BeforeSuite` to call `make prepare-e2e` and `make e2e-gitea-run-setup` before any test executes. + +## Infrastructure Layers + +### Layer 1 β€” Long-lived cluster resources (created once, persist across runs) + +These are created the first time and reused on every subsequent run: + +- **k3d cluster** β€” stamp: `$(CS)/ready` +- **Flux** β€” stamp: `$(CS)/flux.installed` +- **CRDs** β€” stamp: `$(CS)/crds.applied` +- **External services via Flux** (Gitea, Prometheus, Valkey/Redis, Cert-Manager, Traefik) β€” stamp: `$(CS)/flux-setup.ready` +- **Gitea organisation** (`testorg`) β€” stamp: `$(CS)/gitea/bootstrap/org-{ORG_NAME}.ready` +- **Age encryption key** β€” stamp: `$(CS)/age-key.txt` +- **Port-forwards** (Gitea :13000, Prometheus :19090, Valkey :16379) β€” managed by `portforward-ensure` (idempotent, fast-path reuses healthy forwards) + +### Layer 2 β€” Per-run resources (created fresh each `go test` invocation) + +These are re-created on each test run, keyed by `REPO_NAME` and `NAMESPACE`: + +- **Controller namespace** (`sut`) β€” cleaned and recreated +- **Operator deployment** β€” redeployed with current image +- **Gitea repository** β€” new repo per run, named `e2e-test-{GinkgoRandomSeed}` (or `REPO_NAME` env var) +- **Git credentials** β€” HTTP access token + SSH keypair, registered with Gitea, applied as Kubernetes Secrets +- **Local git checkout** β€” at `.stamps/repos/{REPO_NAME}/` +- **SOPS secret** β€” applied to namespace + +### Layer 3 β€” Per-test resources (created and cleaned up within each `It` block) + +- GitProvider, GitTarget, WatchRule, ClusterWatchRule resources +- Applied via `kubectl apply` from Go templates (see `test/e2e/templates/`) +- Cleaned up in `AfterEach` or test-specific `DeferCleanup` + +## Repo Lifecycle: One Repo Per `go test` Invocation + +Because all test files live in the same Go package (`package e2e`), `go test ./test/e2e/` compiles them together and `BeforeSuite` runs **exactly once** per invocation. This means: + +- All test files (`e2e_test.go`, `quickstart_framework_e2e_test.go`, `audit_redis_e2e_test.go`, `bi_directional_e2e_test.go`, `talk_e2e_test.go`) **share one repo** per run. +- Whether you run all suites or filter with `-ginkgo.label-filter=audit-redis`, the same repo is created once. +- The repo name is `e2e-test-{GinkgoRandomSeed}` β€” the Ginkgo random seed is stable for a given `go test` run (reproducible with `-ginkgo.seed`). + +## BeforeSuite: The Setup Contract + +`BeforeSuite` in [test/e2e/e2e_suite_test.go](test/e2e/e2e_suite_test.go) calls `ensureE2EPrepared()`, which: + +1. Calls `make prepare-e2e` β€” idempotent, stamp-based, handles cluster/operator/port-forwards +2. Calls `make e2e-gitea-run-setup` β€” creates or reuses the repo for this run +3. Exports env vars for all test suites to consume: + - `E2E_REPO_NAME` β€” active repo name + - `E2E_CHECKOUT_DIR` β€” local checkout path + - `E2E_GIT_SECRET_HTTP` β€” Kubernetes Secret name for HTTP credentials + - `E2E_GIT_SECRET_SSH` β€” Kubernetes Secret name for SSH credentials + - `E2E_GIT_SECRET_INVALID` β€” Kubernetes Secret name for error-path testing + - `SUT_NAMESPACE` β€” namespace where operator and secrets live + - `E2E_AGE_KEY_FILE` β€” path to age key for SOPS tests +4. Sets the current `kubectl` context + +**Every test file must read its repo/secret/checkout info exclusively from these env vars**, set by BeforeSuite. No test file should re-derive the repo name or re-run setup independently. + +This design means running via a Go IDE plugin works identically to `make test-e2e` β€” the test itself drives all prerequisite setup through `make prepare-e2e`. + +## Specialized Make Targets + +In addition to `make test-e2e`, there are focused targets for individual suites: + +| Target | Suite label | Notes | +|---|---|---| +| `make test-e2e-quickstart-helm` | `quickstart-framework` | Forces `INSTALL_MODE=helm` | +| `make test-e2e-quickstart-manifest` | `quickstart-framework` | Forces `INSTALL_MODE=plain-manifests-file` | +| `make test-e2e-audit-redis` | `audit-redis` | Uses default `INSTALL_MODE` | +| `make test-e2e-bi` | `bi-directional` | Uses default `INSTALL_MODE` | +| `make test-e2e-demo` | `talk-demo` | Depends on `prepare-e2e-demo`; leaves resources in place | + +All targets are fully `.PHONY` and always run `go test` when invoked. Setup is handled by `BeforeSuite` on every invocation. + +## Optional Test Suites + +Some suites are gated behind environment variables to avoid running expensive or environment-specific tests by default: + +| Suite file | Label | Enable flag | +|---|---|---| +| `e2e_test.go` | *(none, always runs)* | β€” | +| `quickstart_framework_e2e_test.go` | `quickstart-framework` | `E2E_ENABLE_QUICKSTART_FRAMEWORK=true` | +| `audit_redis_e2e_test.go` | `audit-redis` | *(always runs when label matches)* | +| `bi_directional_e2e_test.go` | `bi-directional` | `E2E_ENABLE_BI_DIRECTIONAL=true` | +| `talk_e2e_test.go` | `talk-demo` | `E2E_ENABLE_TALK_FRAMEWORK=true` | + +Specialized make targets (`test-e2e-quickstart-helm`, `test-e2e-audit-redis`, `test-e2e-bi`, `test-e2e-demo`) set these flags and run the relevant label filter. + +## Design Principles + +### Consistency: every test file initialises the same way + +All suites read repo/secret/checkout info from the env vars exported by `ensureE2EPrepared()`. No suite should: +- Derive `REPO_NAME` independently +- Call `make` setup targets itself +- Hard-code paths or secret names + +### Infrastructure stays up + +Cluster, Flux services, port-forwards, and the operator are left running after a test run β€” even on failure. This allows you to `kubectl` into the cluster and inspect state. `AfterEach` collects diagnostic logs on failure via `dumpFailureDiagnostics()`. + +To tear down: `make clean-cluster` (cluster + all stamps), `make clean-port-forwards` (port-forwards only). + +### Idempotent setup via stamps + +Every infrastructure step is guarded by a stamp file. Running `make prepare-e2e` or `make test-e2e` repeatedly is safe β€” already-done steps are skipped. The Go `BeforeSuite` leverages this by calling `make prepare-e2e` unconditionally; if everything is current, it completes in seconds. + +### One gitops-reverser instance per cluster + +The operator creates a cluster-scoped Service and registers a single audit webhook listener. This means only one active gitops-reverser deployment can exist in a cluster at a time β€” a second instance would conflict on the Service or the webhook endpoint. The e2e setup reflects this: there is one operator namespace (`sut`) and one install, and `prepare-e2e` tears down and recreates that namespace on each run. Running multiple test suites concurrently against the same cluster is not supported. + +## Known Issues / Improvement Areas + +### `INSTALL_MODE` must be set + +`INSTALL_MODE` is required by `ensureE2EPrepared()` but has no default. Running `go test ./test/e2e/` directly without setting it will fail fast with a clear error. `make test-e2e` always sets it. + +### `quickstart_framework_e2e_test.go` uses a nanosecond timestamp for resource names + +This test creates GitProvider/GitTarget resources with names based on `time.Now().UnixNano()`. This is fine but inconsistent with the seed-based naming used elsewhere. A future improvement would be to use Ginkgo's `CurrentSpecReport().ContainerHierarchyTexts` or a shared counter for deterministic naming. + +### Demo suite (`talk_e2e_test.go`) intentionally skips cleanup + +The demo suite leaves its namespace and resources in place so a live demo can be run after the test. This is intentional but means the cluster state after a demo run differs from a regular test run. + +## Namespace Separation + +### Why + +Mixing the controller and test resources in one namespace makes teardown destructive: deleting the namespace to clean up test resources also kills the operator. The operator is expensive to redeploy (image build, load into k3d, rollout), so it should survive a test resource cleanup. + +### Two namespace roles + +| Role | Owner | Default name | Lifecycle | +|---|---|---|---| +| **Controller namespace** | `NAMESPACE` variable | `gitops-reverser` | Persists across runs; managed by `prepare-e2e` | +| **Test namespace** | derived per suite | `{seed}-test-{suite}` | Created in `BeforeAll`, deleted in `AfterAll` | + +The controller namespace contains the operator Deployment, Service, ServiceAccount, TLS certificates, and the SOPS age key. It is never touched by individual test suites. + +The test namespace contains GitProvider, GitTarget, WatchRule, git credential Secrets, ConfigMaps, and any other resources created during a test run. + +### Test namespace naming + +``` +{GinkgoRandomSeed}-test-{suite-label} +``` + +| Suite | Suite label | Example | +|---|---|---| +| `e2e_test.go` | `manager` | `8675309-test-manager` | +| `audit_redis_e2e_test.go` | `audit-redis` | `8675309-test-audit-redis` | +| `bi_directional_e2e_test.go` | `bi-directional` | `8675309-test-bi-directional` | +| `quickstart_framework_e2e_test.go` | `quickstart-framework` | `8675309-test-quickstart-framework` | +| `talk_e2e_test.go` | `talk-demo` | `8675309-test-talk-demo` | + +The seed ties test namespaces to the repo name (also seed-based), making a run traceable as a whole. Each suite gets its own namespace so suites are fully isolated from each other and can be cleaned up independently. + +Names are DNS-compliant and stay well under the 63-character Kubernetes limit. + +### Per-suite lifecycle + +Each suite's `BeforeAll`: +1. Derives the test namespace name from `testNamespaceFor("")` +2. Creates the namespace via `kubectl create namespace` +3. Applies git credential secrets from `E2E_SECRETS_YAML` (written by `gitea-run-setup.sh` but not applied β€” each suite applies them to its own namespace) + +Each suite's `AfterAll` deletes the test namespace with `--ignore-not-found`. + +### RBAC + +No RBAC changes are needed. The controller uses a `ClusterRole` bound cluster-wide via `ClusterRoleBinding`, so it already has permission to watch and manage resources in any namespace β€” including dynamically created test namespaces. + +### Secrets + +`hack/e2e/gitea-run-setup.sh` generates `secrets.yaml` into the stamp directory but does **not** apply it to Kubernetes. The path is exported as `E2E_SECRETS_YAML` by `BeforeSuite`. Each suite applies it to its own test namespace in `BeforeAll`. + +This means secrets are scoped to the test namespace they belong to and disappear with the namespace on cleanup. + +### Template organisation + +Templates are organised by ownership to make it clear which suite a template belongs to: + +``` +test/e2e/templates/ # shared β€” used by β‰₯2 suites + gitprovider.tmpl # manager, quickstart-framework + gittarget.tmpl # all suites (via helpers.go) + watchrule.tmpl # manager, quickstart-framework + watchrule-crd.tmpl # manager, bi-directional + icecreamorder-crd.yaml # manager, bi-directional + icecreamorder-instance.tmpl # manager, bi-directional + +test/e2e/templates/manager/ # manager suite only + clusterwatchrule-crd.tmpl + watchrule-configmap.tmpl + configmap.tmpl + +test/e2e/templates/bi-directional/ # bi-directional suite only + watchrule-secret.tmpl + flux-gitrepository-http.tmpl + flux-kustomization.tmpl + +test/e2e/templates/talk-demo/ # talk-demo suite only + watchrule-all.tmpl + clusterwatchrule-talk.tmpl +``` diff --git a/hack/e2e/gitea-run-setup.sh b/hack/e2e/gitea-run-setup.sh index 20fc0a5b2..a247b7adb 100755 --- a/hack/e2e/gitea-run-setup.sh +++ b/hack/e2e/gitea-run-setup.sh @@ -25,7 +25,6 @@ set -euo pipefail # - $(CS)/$(NAMESPACE)/git-$(REPO_NAME)/ssh/id_rsa{,.pub} # - $(CS)/$(NAMESPACE)/git-$(REPO_NAME)/ssh/known_hosts # - $(CS)/$(NAMESPACE)/git-$(REPO_NAME)/secrets.yaml -# - $(CS)/$(NAMESPACE)/git-$(REPO_NAME)/secrets.applied # - $(CS)/$(NAMESPACE)/git-$(REPO_NAME)/repo.ready # - $(CS)/$(NAMESPACE)/git-$(REPO_NAME)/checkout.ready # - .stamps/repos//.git/HEAD (or CHECKOUT_DIR override) @@ -72,7 +71,6 @@ active_repo_file="${run_dir}/active-repo.txt" checkout_path_file="${run_dir}/checkout-path.txt" token_file="${run_dir}/token.txt" secrets_yaml="${run_dir}/secrets.yaml" -secrets_applied="${run_dir}/secrets.applied" repo_ready="${run_dir}/repo.ready" checkout_ready="${run_dir}/checkout.ready" @@ -283,18 +281,20 @@ write_secrets_manifest() { ssh_args+=(--from-file=known_hosts="${ssh_dir}/known_hosts") fi + # Generate namespace-free manifests so each test suite can apply them + # to its own dynamically-named test namespace. { - kubectl --context "${CTX}" -n "${NAMESPACE}" create secret generic "${SECRET_HTTP_NAME}" \ + kubectl create secret generic "${SECRET_HTTP_NAME}" \ --from-literal=username="${GITEA_ADMIN_USER}" \ --from-literal=password="${token}" \ --dry-run=client -o yaml printf '\n---\n' - kubectl --context "${CTX}" -n "${NAMESPACE}" create secret generic "${SECRET_SSH_NAME}" \ + kubectl create secret generic "${SECRET_SSH_NAME}" \ --from-file=ssh-privatekey="${ssh_dir}/id_rsa" \ "${ssh_args[@]}" \ --dry-run=client -o yaml printf '\n---\n' - kubectl --context "${CTX}" -n "${NAMESPACE}" create secret generic "${SECRET_INVALID_NAME}" \ + kubectl create secret generic "${SECRET_INVALID_NAME}" \ --from-literal=username="invaliduser" \ --from-literal=password="invalidpassword" \ --dry-run=client -o yaml @@ -303,10 +303,6 @@ write_secrets_manifest() { mv "${secrets_tmp}" "${secrets_yaml}" } -apply_secrets() { - kubectl --context "${CTX}" apply -f "${secrets_yaml}" >/dev/null - touch "${secrets_applied}" -} ensure_checkout() { local checkout_dir repo_url repos_dir @@ -341,6 +337,7 @@ ensure_checkout() { git -C "${checkout_dir}" config user.name "E2E Test" >/dev/null 2>&1 || true git -C "${checkout_dir}" config user.email "e2e-test@gitops-reverser.local" >/dev/null 2>&1 || true + git -C "${checkout_dir}" config commit.gpgsign false >/dev/null 2>&1 || true if [[ ! -f "${checkout_dir}/.git/HEAD" ]]; then echo "ERROR: expected checkout to contain .git/HEAD at ${checkout_dir}" >&2 @@ -362,5 +359,4 @@ generate_known_hosts configure_ssh_key_in_gitea ensure_repo write_secrets_manifest -apply_secrets ensure_checkout diff --git a/test/e2e/audit_redis_e2e_test.go b/test/e2e/audit_redis_e2e_test.go index 721e04fb1..d24279188 100644 --- a/test/e2e/audit_redis_e2e_test.go +++ b/test/e2e/audit_redis_e2e_test.go @@ -35,6 +35,23 @@ const ( ) var _ = Describe("Audit Redis Queue", Label("audit-redis"), Ordered, func() { + var testNs string + + BeforeAll(func() { + By("creating test namespace and applying git secrets") + testNs = testNamespaceFor("audit-redis") + _, _ = kubectlRun("create", "namespace", testNs) // idempotent; ignore AlreadyExists + secretsYaml := strings.TrimSpace(os.Getenv("E2E_SECRETS_YAML")) + Expect(secretsYaml).NotTo(BeEmpty(), "E2E_SECRETS_YAML must be set by BeforeSuite") + _, err := kubectlRunInNamespace(testNs, "apply", "-f", secretsYaml) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace") + }) + + AfterAll(func() { + By("deleting test namespace") + _, _ = kubectlRun("delete", "namespace", testNs, "--ignore-not-found=true") + }) + It("should enqueue incoming audit webhook events into a Redis stream", func() { streamName := defaultAuditRedisStream testConfigMapName := fmt.Sprintf("audit-redis-test-cm-%d", GinkgoRandomSeed()) @@ -53,7 +70,7 @@ var _ = Describe("Audit Redis Queue", Label("audit-redis"), Ordered, func() { By("triggering kube-apiserver audit events with a ConfigMap create/update") _, err = kubectlRunInNamespace( - namespace, + testNs, "delete", "configmap", testConfigMapName, @@ -62,7 +79,7 @@ var _ = Describe("Audit Redis Queue", Label("audit-redis"), Ordered, func() { Expect(err).NotTo(HaveOccurred()) _, err = kubectlRunInNamespace( - namespace, + testNs, "create", "configmap", testConfigMapName, @@ -71,7 +88,7 @@ var _ = Describe("Audit Redis Queue", Label("audit-redis"), Ordered, func() { Expect(err).NotTo(HaveOccurred()) _, err = kubectlRunInNamespace( - namespace, + testNs, "patch", "configmap", testConfigMapName, @@ -103,7 +120,7 @@ var _ = Describe("Audit Redis Queue", Label("audit-redis"), Ordered, func() { By("cleaning up test ConfigMap") _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "configmap", testConfigMapName, diff --git a/test/e2e/bi_directional_e2e_test.go b/test/e2e/bi_directional_e2e_test.go index 28141335b..5dbeb4eb7 100644 --- a/test/e2e/bi_directional_e2e_test.go +++ b/test/e2e/bi_directional_e2e_test.go @@ -49,6 +49,7 @@ const ( type biDirectionalRun struct { testID string + testNs string repoName string checkoutDir string repoURL string @@ -95,6 +96,7 @@ type iceCreamScoop struct { var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { var run biDirectionalRun + var testNs string BeforeAll(func() { if !biDirectionalEnabled() { @@ -102,48 +104,61 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { } run = newBiDirectionalRun() + + By("creating test namespace and applying git secrets") + testNs = testNamespaceFor("bi-directional") + run.testNs = testNs + _, _ = kubectlRun("create", "namespace", testNs) // idempotent; ignore AlreadyExists + secretsYaml := strings.TrimSpace(os.Getenv("E2E_SECRETS_YAML")) + Expect(secretsYaml).NotTo(BeEmpty(), "E2E_SECRETS_YAML must be set by BeforeSuite") + _, err := kubectlRunInNamespace(testNs, "apply", "-f", secretsYaml) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace") + applySOPSAgeKeyToNamespace(testNs) run.assertCheckoutReady() }) AfterAll(func() { run.cleanupFluxResources() - run.cleanupReverseResources() + run.cleanupReverseResources(testNs) _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "secret", run.controllerEncryptionSecret, "--ignore-not-found=true", ) _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "secret", run.reverseSecretName, "--ignore-not-found=true", ) _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "icecreamorder", run.firstOrderName, "--ignore-not-found=true", ) _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "icecreamorder", run.secondOrderName, "--ignore-not-found=true", ) _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "icecreamorder", run.revertedOrderName, "--ignore-not-found=true", ) _, _ = kubectlRun("delete", "crd", "icecreamorders.shop.example.com", "--ignore-not-found=true") + + By("deleting test namespace") + _, _ = kubectlRun("delete", "namespace", testNs, "--ignore-not-found=true") }) It("should avoid a commit loop while Flux and gitops-reverser share IceCreamOrder resources", func() { @@ -167,10 +182,10 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { run.consistentlyExpectRemoteCommitCount(baselineCommitCount+1, biStableCountMediumWait) By("enabling gitops-reverser before Flux starts managing IceCreamOrder resources") - createGitProviderWithURLInNamespace(run.gitProviderName, namespace, e2eGitSecretHTTP(), run.repoURL) + createGitProviderWithURLInNamespace(run.gitProviderName, testNs, e2eGitSecretHTTP(), run.repoURL) createGitTargetWithEncryptionOptions( run.gitTargetName, - namespace, + testNs, run.gitProviderName, run.livePath, "main", @@ -184,23 +199,23 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { DestinationName string }{ Name: run.watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: run.gitTargetName, - }, namespace) + }, testNs) Expect(err).NotTo(HaveOccurred(), "failed to apply bi-directional WatchRule") - verifyResourceStatus("gitprovider", run.gitProviderName, namespace, "True", "Ready", "") + verifyResourceStatus("gitprovider", run.gitProviderName, testNs, "True", "Ready", "") run.waitForControllerEncryptionSecret() run.applyFluxDecryptionSecretFromControllerSecret() run.applyFluxLiveKustomization() run.waitForGitTargetReady() - verifyResourceStatus("watchrule", run.watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", run.watchRuleName, testNs, "True", "Ready", "") sharedBaselineCommitCount := run.waitForStableRemoteCommitCount(biStableCountShortWait) By("committing two IceCreamOrders through normal GitOps") run.writeLiveOrder(iceCreamOrderFile{ Name: run.firstOrderName, - Namespace: namespace, + Namespace: testNs, CustomerName: "Alice", Container: "Cone", Scoops: []iceCreamScoop{ @@ -210,7 +225,7 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { }) run.writeLiveOrder(iceCreamOrderFile{ Name: run.secondOrderName, - Namespace: namespace, + Namespace: testNs, CustomerName: "Bob", Container: "Cup", Scoops: []iceCreamScoop{ @@ -230,7 +245,7 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { By("changing one IceCreamOrder through the Kubernetes API") _, err = kubectlRunInNamespace( - namespace, + testNs, "patch", "icecreamorder", run.firstOrderName, @@ -261,21 +276,21 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { run.consistentlyExpectRemoteCommitCount(sharedBaselineCommitCount+2, biStableCountLongWait) By("adding a Secret to the shared live path with SOPS encryption") - err = applyFromTemplate("test/e2e/templates/watchrule-secret.tmpl", struct { + err = applyFromTemplate("test/e2e/templates/bi-directional/watchrule-secret.tmpl", struct { Name string Namespace string DestinationName string }{ Name: run.secretWatchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: run.gitTargetName, - }, namespace) + }, testNs) Expect(err).NotTo(HaveOccurred(), "failed to apply Secret WatchRule") - verifyResourceStatus("watchrule", run.secretWatchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", run.secretWatchRuleName, testNs, "True", "Ready", "") - _, _ = kubectlRunInNamespace(namespace, "delete", "secret", run.reverseSecretName, "--ignore-not-found=true") + _, _ = kubectlRunInNamespace(testNs, "delete", "secret", run.reverseSecretName, "--ignore-not-found=true") _, err = kubectlRunInNamespace( - namespace, + testNs, "create", "secret", "generic", @@ -286,7 +301,7 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { Expect(err).NotTo(HaveOccurred(), "failed to create Secret through the API") _, err = kubectlRunInNamespace( - namespace, + testNs, "patch", "secret", run.reverseSecretName, @@ -316,7 +331,7 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { run.writeLiveOrder(iceCreamOrderFile{ Name: run.revertedOrderName, - Namespace: namespace, + Namespace: testNs, CustomerName: "Charlie", Container: "Cone", Scoops: []iceCreamScoop{ @@ -423,7 +438,7 @@ func (r biDirectionalRun) liveOrderPath(name string) string { "shop.example.com", "v1", "icecreamorders", - namespace, + r.testNs, name+".yaml", ) } @@ -433,7 +448,7 @@ func (r biDirectionalRun) liveSecretPath(name string) string { r.livePath, "v1", "secrets", - namespace, + r.testNs, name+".sops.yaml", ) } @@ -607,7 +622,7 @@ func (r biDirectionalRun) consistentlyExpectRemoteCommitCount(expected int, dura func (r biDirectionalRun) applyFluxGitRepository() { username, password := r.readGitCredentialSecretDataBase64() - err := applyFromTemplate("test/e2e/templates/flux-gitrepository-http.tmpl", struct { + err := applyFromTemplate("test/e2e/templates/bi-directional/flux-gitrepository-http.tmpl", struct { Namespace string SecretName string Name string @@ -699,7 +714,7 @@ func (r biDirectionalRun) reconcileFluxKustomization(name string) { } func (r biDirectionalRun) applyFluxCRDKustomization() { - err := applyFromTemplate("test/e2e/templates/flux-kustomization.tmpl", struct { + err := applyFromTemplate("test/e2e/templates/bi-directional/flux-kustomization.tmpl", struct { Namespace string Name string Path string @@ -725,7 +740,7 @@ func (r biDirectionalRun) applyFluxCRDKustomization() { } func (r biDirectionalRun) applyFluxLiveKustomization() { - err := applyFromTemplate("test/e2e/templates/flux-kustomization.tmpl", struct { + err := applyFromTemplate("test/e2e/templates/bi-directional/flux-kustomization.tmpl", struct { Namespace string Name string Path string @@ -754,7 +769,7 @@ func (r biDirectionalRun) applyFluxLiveKustomization() { } func (r biDirectionalRun) readGitCredentialSecretDataBase64() (string, string) { - output, err := kubectlRunInNamespace(namespace, "get", "secret", e2eGitSecretHTTP(), "-o", "json") + output, err := kubectlRunInNamespace(r.testNs, "get", "secret", e2eGitSecretHTTP(), "-o", "json") Expect(err).NotTo(HaveOccurred(), "failed to read git credential Secret for Flux") var obj unstructured.Unstructured @@ -855,7 +870,7 @@ func (r biDirectionalRun) waitForFluxKustomizationRevision(name, head string) { func (r biDirectionalRun) waitForOrderSpec(name, container, flavor, topping string) { Eventually(func(g Gomega) { output, err := kubectlRunInNamespace( - namespace, + r.testNs, "get", "icecreamorder", name, @@ -894,7 +909,7 @@ func (r biDirectionalRun) waitForOrderSpec(name, container, flavor, topping stri func (r biDirectionalRun) waitForGitTargetReady() { Eventually(func(g Gomega) { output, err := kubectlRunInNamespace( - namespace, + r.testNs, "get", "gittarget", r.gitTargetName, @@ -931,7 +946,7 @@ func (r biDirectionalRun) waitForGitTargetReady() { func (r biDirectionalRun) waitForControllerEncryptionSecret() { Eventually(func(g Gomega) { output, err := kubectlRunInNamespace( - namespace, + r.testNs, "get", "secret", r.controllerEncryptionSecret, @@ -952,7 +967,7 @@ func (r biDirectionalRun) waitForControllerEncryptionSecret() { func (r biDirectionalRun) readControllerEncryptionAgeKey() string { output, err := kubectlRunInNamespace( - namespace, + r.testNs, "get", "secret", r.controllerEncryptionSecret, @@ -1050,7 +1065,7 @@ func (r biDirectionalRun) waitForSecretValue(name, key, expectedValue string) { expectedValueB64 := base64.StdEncoding.EncodeToString([]byte(expectedValue)) Eventually(func(g Gomega) { - output, err := kubectlRunInNamespace(namespace, "get", "secret", name, "-o", "json") + output, err := kubectlRunInNamespace(r.testNs, "get", "secret", name, "-o", "json") g.Expect(err).NotTo(HaveOccurred()) var obj unstructured.Unstructured @@ -1065,7 +1080,7 @@ func (r biDirectionalRun) waitForSecretValue(name, key, expectedValue string) { func (r biDirectionalRun) waitForOrderDeleted(name string) { Eventually(func(g Gomega) { - _, err := kubectlRunInNamespace(namespace, "get", "icecreamorder", name, "-o", "json") + _, err := kubectlRunInNamespace(r.testNs, "get", "icecreamorder", name, "-o", "json") g.Expect(err).To(HaveOccurred()) g.Expect(strings.ToLower(err.Error())).To(ContainSubstring("not found")) }, biEventuallyTimeout, biPollInterval).Should(Succeed()) @@ -1109,12 +1124,12 @@ func (r biDirectionalRun) cleanupFluxResources() { ) } -func (r biDirectionalRun) cleanupReverseResources() { - cleanupWatchRule(r.watchRuleName, namespace) - cleanupWatchRule(r.secretWatchRuleName, namespace) - cleanupGitTarget(r.gitTargetName, namespace) +func (r biDirectionalRun) cleanupReverseResources(ns string) { + cleanupWatchRule(r.watchRuleName, ns) + cleanupWatchRule(r.secretWatchRuleName, ns) + cleanupGitTarget(r.gitTargetName, ns) _, _ = kubectlRunInNamespace( - namespace, + ns, "delete", "gitprovider", r.gitProviderName, diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 340039230..aaf6caf00 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -157,9 +157,9 @@ func exportGiteaArtifacts(ctx, namespace, repoName string) { Expect(os.Setenv("E2E_GIT_SECRET_HTTP", resolveE2EHTTPSecretName(activeRepo))).To(Succeed()) Expect(os.Setenv("E2E_GIT_SECRET_SSH", resolveE2ESSHSecretName(activeRepo))).To(Succeed()) Expect(os.Setenv("E2E_GIT_SECRET_INVALID", resolveE2EInvalidSecretName(activeRepo))).To(Succeed()) - // Propagate the resolved namespace so quickstartSetupNamespace() uses the same - // namespace as the install (where the operator and secrets live). - Expect(os.Setenv("SUT_NAMESPACE", namespace)).To(Succeed()) + // Path to the secrets manifest generated by gitea-run-setup.sh. + // Each suite applies this to its own test namespace in BeforeAll. + Expect(os.Setenv("E2E_SECRETS_YAML", filepath.Join(base, "secrets.yaml"))).To(Succeed()) } func resolveE2EHTTPSecretName(repoName string) string { diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 63aed55f3..241d9fc1f 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -65,6 +65,7 @@ func getRepoURLSSH() string { var _ = Describe("Manager", Ordered, func() { var controllerPodName string // Name of first controller pod for logging + var testNs string // Before running the tests, set up per-run test fixtures. BeforeAll(func() { @@ -91,10 +92,22 @@ var _ = Describe("Manager", Ordered, func() { By("setting up Prometheus client for metrics testing") setupPrometheusClient() verifyPrometheusAvailable() + + By("creating test namespace and applying git secrets") + testNs = testNamespaceFor("manager") + _, _ = kubectlRun("create", "namespace", testNs) // idempotent; ignore AlreadyExists + secretsYaml := strings.TrimSpace(os.Getenv("E2E_SECRETS_YAML")) + Expect(secretsYaml).NotTo(BeEmpty(), "E2E_SECRETS_YAML must be set by BeforeSuite") + _, err := kubectlRunInNamespace(testNs, "apply", "-f", secretsYaml) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace") + applySOPSAgeKeyToNamespace(testNs) }) - // After all tests have been executed, infrastructure remains running for debugging + // After all tests have been executed, clean up the test namespace AfterAll(func() { + By("deleting test namespace") + _, _ = kubectlRun("delete", "namespace", testNs, "--ignore-not-found=true") + By("test infrastructure still running for debugging") fmt.Printf("\n") fmt.Printf("═══════════════════════════════════════════════════════════\n") @@ -394,42 +407,42 @@ var _ = Describe("Manager", Ordered, func() { By("showing initial controller logs") showControllerLogs("before creating GitProvider") - createGitProvider(gitProviderName, "main", gitSecretHTTP) + createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretHTTP, getRepoURLHTTP()) By("showing controller logs after GitProvider creation") showControllerLogs("after creating GitProvider") - verifyGitProviderStatus(gitProviderName, "True", "Ready", "Repository connectivity validated") + verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") By("showing final controller logs") showControllerLogs("after status verification") - cleanupGitProvider(gitProviderName) + _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", gitProviderName, "--ignore-not-found=true") }) It("should handle GitProvider with invalid credentials", func() { gitProviderName := "gitprovider-invalid-test" - createGitProvider(gitProviderName, "main", gitSecretInvalid) - verifyGitProviderStatus(gitProviderName, "False", "ConnectionFailed", "") - cleanupGitProvider(gitProviderName) + createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretInvalid, getRepoURLHTTP()) + verifyResourceStatus("gitprovider", gitProviderName, testNs, "False", "ConnectionFailed", "") + _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", gitProviderName, "--ignore-not-found=true") }) It("should handle GitTarget with nonexistent branch pattern", func() { gitProviderName := "gitprovider-branch-test" // GitProvider should be Ready=True (validates connectivity, not branch existence) - createGitProvider(gitProviderName, "nonexistent-branch", gitSecretHTTP) - verifyGitProviderStatus(gitProviderName, "True", "Ready", "Repository connectivity validated") + createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretHTTP, getRepoURLHTTP()) + verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") // GitTarget with branch not matching any pattern should fail destName := "dest-invalid-branch" - createGitTarget(destName, namespace, gitProviderName, "test/invalid", "different-branch") + createGitTarget(destName, testNs, gitProviderName, "test/invalid", "different-branch") By("verifying GitTarget fails branch validation") - verifyResourceStatus("gittarget", destName, namespace, "False", "ValidationFailed", "BranchNotAllowed") + verifyResourceStatus("gittarget", destName, testNs, "False", "ValidationFailed", "BranchNotAllowed") - cleanupGitTarget(destName, namespace) - cleanupGitProvider(gitProviderName) + cleanupGitTarget(destName, testNs) + _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", gitProviderName, "--ignore-not-found=true") }) It("should validate GitProvider with SSH authentication", func() { @@ -439,7 +452,7 @@ var _ = Describe("Manager", Ordered, func() { showControllerLogs("before SSH test") By("πŸ“‹ Checking SSH secret exists") - secretOutput, err := kubectlRunInNamespace(namespace, "get", "secret", gitSecretSSH, "-o", "yaml") + secretOutput, err := kubectlRunInNamespace(testNs, "get", "secret", gitSecretSSH, "-o", "yaml") if err != nil { fmt.Printf("❌ SSH secret not found: %v\n", err) } else { @@ -451,23 +464,23 @@ var _ = Describe("Manager", Ordered, func() { ) } - createSSHGitProvider(gitProviderName, "main", gitSecretSSH) + createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretSSH, getRepoURLSSH()) By("πŸ” Controller logs after SSH GitProvider creation") showControllerLogs("after SSH GitProvider creation") - verifyGitProviderStatus(gitProviderName, "True", "Ready", "Repository connectivity validated") + verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") By("βœ… Final SSH test logs") showControllerLogs("SSH test completion") - cleanupGitProvider(gitProviderName) + _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", gitProviderName, "--ignore-not-found=true") }) It("should handle a normal and healthy GitProvider", func() { gitProviderName := "gitprovider-normal" - createGitProvider(gitProviderName, "main", gitSecretHTTP) - verifyGitProviderStatus(gitProviderName, "True", "Ready", "Repository connectivity validated") + createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretHTTP, getRepoURLHTTP()) + verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") }) It("should reconcile a WatchRule CR", func() { @@ -476,7 +489,7 @@ var _ = Describe("Manager", Ordered, func() { By("creating a WatchRule that references the working GitProvider") destName := watchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, getBaseFolder(), "main") + createGitTarget(destName, testNs, gitProviderName, getBaseFolder(), "main") data := struct { Name string @@ -484,19 +497,19 @@ var _ = Describe("Manager", Ordered, func() { DestinationName string }{ Name: watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: destName, } - err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, namespace) + err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, testNs) Expect(err).NotTo(HaveOccurred(), "Failed to apply WatchRule") By("verifying the WatchRule is reconciled") - verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") By("cleaning up test resources") - cleanupWatchRule(watchRuleName, namespace) - cleanupGitTarget(destName, namespace) + cleanupWatchRule(watchRuleName, testNs) + cleanupGitTarget(destName, testNs) }) It("should commit encrypted Secret manifests when WatchRule includes secrets", func() { @@ -506,7 +519,7 @@ var _ = Describe("Manager", Ordered, func() { By("creating WatchRule that includes secrets") destName := watchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/secret-encryption-test", "main") + createGitTarget(destName, testNs, gitProviderName, "e2e/secret-encryption-test", "main") data := struct { Name string @@ -514,21 +527,21 @@ var _ = Describe("Manager", Ordered, func() { DestinationName string }{ Name: watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: destName, } - err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, namespace) + err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, testNs) Expect(err).NotTo(HaveOccurred(), "Failed to apply WatchRule") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") - verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") By("creating Secret in watched namespace") - _, _ = kubectlRunInNamespace(namespace, "delete", "secret", secretName, "--ignore-not-found=true") + _, _ = kubectlRunInNamespace(testNs, "delete", "secret", secretName, "--ignore-not-found=true") _, err = kubectlRunInNamespace( - namespace, + testNs, "create", "secret", "generic", @@ -539,7 +552,7 @@ var _ = Describe("Manager", Ordered, func() { By("patching Secret once to avoid informer start race and force an update event") _, err = kubectlRunInNamespace( - namespace, + testNs, "patch", "secret", secretName, @@ -561,7 +574,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/secret-encryption-test", - fmt.Sprintf("v1/secrets/%s/%s.sops.yaml", namespace, secretName)) + fmt.Sprintf("v1/secrets/%s/%s.sops.yaml", testNs, secretName)) content, readErr := os.ReadFile(expectedFile) g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("Secret file must exist at %s", expectedFile)) g.Expect(string(content)).To(ContainSubstring("sops:")) @@ -586,9 +599,9 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyEncryptedSecretCommitted, "30s", "2s").Should(Succeed()) By("cleaning up test resources") - _, _ = kubectlRunInNamespace(namespace, "delete", "secret", secretName, "--ignore-not-found=true") - cleanupWatchRule(watchRuleName, namespace) - cleanupGitTarget(destName, namespace) + _, _ = kubectlRunInNamespace(testNs, "delete", "secret", secretName, "--ignore-not-found=true") + cleanupWatchRule(watchRuleName, testNs) + cleanupGitTarget(destName, testNs) }) It("should generate missing SOPS age secret when age.recipients.generateWhenMissing is enabled", func() { @@ -599,7 +612,7 @@ var _ = Describe("Manager", Ordered, func() { By("ensuring generated encryption secret does not exist before test") _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "secret", generatedSecretName, @@ -610,7 +623,7 @@ var _ = Describe("Manager", Ordered, func() { destName := watchRuleName + "-dest" createGitTargetWithEncryptionOptions( destName, - namespace, + testNs, gitProviderName, "e2e/secret-autogen-test", "main", @@ -624,20 +637,20 @@ var _ = Describe("Manager", Ordered, func() { DestinationName string }{ Name: watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: destName, } - err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, namespace) + err := applyFromTemplate("test/e2e/templates/watchrule.tmpl", data, testNs) Expect(err).NotTo(HaveOccurred(), "Failed to apply WatchRule") - verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") By("validating generated encryption secret has recipient and warning annotations") var generatedAgeKey string var generatedRecipient string Eventually(func(g Gomega) { output, getErr := kubectlRunInNamespace( - namespace, + testNs, "get", "secret", generatedSecretName, @@ -693,9 +706,9 @@ var _ = Describe("Manager", Ordered, func() { }, "30s", "2s").Should(Succeed()) By("creating Secret in watched namespace") - _, _ = kubectlRunInNamespace(namespace, "delete", "secret", secretName, "--ignore-not-found=true") + _, _ = kubectlRunInNamespace(testNs, "delete", "secret", secretName, "--ignore-not-found=true") _, err = kubectlRunInNamespace( - namespace, + testNs, "create", "secret", "generic", @@ -706,7 +719,7 @@ var _ = Describe("Manager", Ordered, func() { By("patching Secret once to avoid informer start race and force an update event") _, err = kubectlRunInNamespace( - namespace, + testNs, "patch", "secret", secretName, @@ -728,7 +741,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/secret-autogen-test", - fmt.Sprintf("v1/secrets/%s/%s.sops.yaml", namespace, secretName)) + fmt.Sprintf("v1/secrets/%s/%s.sops.yaml", testNs, secretName)) content, readErr := os.ReadFile(expectedFile) g.Expect(readErr).NotTo(HaveOccurred(), fmt.Sprintf("Secret file must exist at %s", expectedFile)) g.Expect(string(content)).To(ContainSubstring("sops:")) @@ -749,16 +762,16 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyEncryptedSecretCommitted, "30s", "2s").Should(Succeed()) By("cleaning up test resources") - _, _ = kubectlRunInNamespace(namespace, "delete", "secret", secretName, "--ignore-not-found=true") + _, _ = kubectlRunInNamespace(testNs, "delete", "secret", secretName, "--ignore-not-found=true") _, _ = kubectlRunInNamespace( - namespace, + testNs, "delete", "secret", generatedSecretName, "--ignore-not-found=true", ) - cleanupWatchRule(watchRuleName, namespace) - cleanupGitTarget(destName, namespace) + cleanupWatchRule(watchRuleName, testNs) + cleanupGitTarget(destName, testNs) }) It("should create Git commit when ConfigMap is added via WatchRule", func() { @@ -769,7 +782,7 @@ var _ = Describe("Manager", Ordered, func() { By("creating WatchRule that monitors ConfigMaps") destName := watchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/configmap-test", "main") + createGitTarget(destName, testNs, gitProviderName, "e2e/configmap-test", "main") data := struct { Name string @@ -777,16 +790,16 @@ var _ = Describe("Manager", Ordered, func() { DestinationName string }{ Name: watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: destName, } - err2 := applyFromTemplate("test/e2e/templates/watchrule-configmap.tmpl", data, namespace) + err2 := applyFromTemplate("test/e2e/templates/manager/watchrule-configmap.tmpl", data, testNs) Expect(err2).NotTo(HaveOccurred(), "Failed to apply WatchRule") By("verifying WatchRule is ready") - verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") By("creating test ConfigMap to trigger Git commit") configMapData := struct { @@ -794,13 +807,13 @@ var _ = Describe("Manager", Ordered, func() { Namespace string }{ Name: configMapName, - Namespace: namespace, + Namespace: testNs, } err3 := applyFromTemplate( - "test/e2e/templates/configmap.tmpl", + "test/e2e/templates/manager/configmap.tmpl", configMapData, - namespace, + testNs, "--as=jane@acme.com", // Important: we validate later if the user is included in the git commit! ) Expect(err3).NotTo(HaveOccurred(), "Failed to apply ConfigMap") @@ -843,7 +856,7 @@ var _ = Describe("Manager", Ordered, func() { // Check for the expected ConfigMap file (new API-aligned path) expectedFile := filepath.Join(checkoutDir, "e2e/configmap-test", - fmt.Sprintf("v1/configmaps/%s/%s.yaml", namespace, configMapName)) + fmt.Sprintf("v1/configmaps/%s/%s.yaml", testNs, configMapName)) fileInfo, statErr := os.Stat(expectedFile) g.Expect(statErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file should exist at %s", expectedFile)) g.Expect(fileInfo.Size()).To(BeNumerically(">", 0), "ConfigMap file should not be empty") @@ -884,9 +897,9 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyGitCommit).Should(Succeed()) By("cleaning up test resources") - _, _ = kubectlRunInNamespace(namespace, "delete", "configmap", configMapName, "--ignore-not-found=true") - cleanupWatchRule(watchRuleName, namespace) - cleanupGitTarget(destName, namespace) + _, _ = kubectlRunInNamespace(testNs, "delete", "configmap", configMapName, "--ignore-not-found=true") + cleanupWatchRule(watchRuleName, testNs) + cleanupGitTarget(destName, testNs) By("βœ… ConfigMap to Git commit E2E test passed - verified actual file creation and commit") fmt.Printf("βœ… ConfigMap '%s' successfully triggered Git commit with YAML file in repo '%s'\n", @@ -901,23 +914,23 @@ var _ = Describe("Manager", Ordered, func() { By("creating WatchRule that monitors ConfigMaps") destName := watchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/delete-test", "main") + createGitTarget(destName, testNs, gitProviderName, "e2e/delete-test", "main") data := struct { Name string Namespace string DestinationName string }{ Name: watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: destName, } - err2 := applyFromTemplate("test/e2e/templates/watchrule-configmap.tmpl", data, namespace) + err2 := applyFromTemplate("test/e2e/templates/manager/watchrule-configmap.tmpl", data, testNs) Expect(err2).NotTo(HaveOccurred(), "Failed to apply WatchRule") By("verifying WatchRule is ready") - verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") By("creating test ConfigMap to trigger Git commit") configMapData := struct { @@ -925,10 +938,10 @@ var _ = Describe("Manager", Ordered, func() { Namespace string }{ Name: configMapName, - Namespace: namespace, + Namespace: testNs, } - err3 := applyFromTemplate("test/e2e/templates/configmap.tmpl", configMapData, namespace) + err3 := applyFromTemplate("test/e2e/templates/manager/configmap.tmpl", configMapData, testNs) Expect(err3).NotTo(HaveOccurred(), "Failed to apply ConfigMap") By("waiting for ConfigMap file to appear in Git repository") @@ -945,7 +958,7 @@ var _ = Describe("Manager", Ordered, func() { // Check for the expected ConfigMap file (new API-aligned path) expectedFile := filepath.Join(checkoutDir, "e2e/delete-test", - fmt.Sprintf("v1/configmaps/%s/%s.yaml", namespace, configMapName)) + fmt.Sprintf("v1/configmaps/%s/%s.yaml", testNs, configMapName)) fileInfo, statErr := os.Stat(expectedFile) g.Expect(statErr).NotTo(HaveOccurred(), fmt.Sprintf("ConfigMap file should exist at %s", expectedFile)) g.Expect(fileInfo.Size()).To(BeNumerically(">", 0), "ConfigMap file should not be empty") @@ -953,7 +966,7 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyFileCreated).Should(Succeed()) By("deleting the ConfigMap to trigger DELETE operation") - _, err := kubectlRunInNamespace(namespace, "delete", "configmap", configMapName) + _, err := kubectlRunInNamespace(testNs, "delete", "configmap", configMapName) Expect(err).NotTo(HaveOccurred(), "ConfigMap deletion should succeed") By("verifying ConfigMap file is deleted from Git repository") @@ -971,7 +984,7 @@ var _ = Describe("Manager", Ordered, func() { // Check that the ConfigMap file no longer exists (new API-aligned path) expectedFile := filepath.Join(checkoutDir, getBaseFolder(), - fmt.Sprintf("v1/configmaps/%s/%s.yaml", namespace, configMapName)) + fmt.Sprintf("v1/configmaps/%s/%s.yaml", testNs, configMapName)) _, statErr := os.Stat(expectedFile) g.Expect(statErr).To(HaveOccurred(), fmt.Sprintf("ConfigMap file should NOT exist at %s", expectedFile)) g.Expect(os.IsNotExist(statErr)).To(BeTrue(), "Error should be 'file does not exist'") @@ -988,8 +1001,8 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyFileDeleted).Should(Succeed()) By("cleaning up test resources") - cleanupWatchRule(watchRuleName, namespace) - cleanupGitTarget(destName, namespace) + cleanupWatchRule(watchRuleName, testNs) + cleanupGitTarget(destName, testNs) By("βœ… ConfigMap deletion E2E test passed - verified file removal from Git") fmt.Printf("βœ… ConfigMap '%s' deletion successfully triggered Git commit removing file from repo '%s'\n", @@ -1003,7 +1016,7 @@ var _ = Describe("Manager", Ordered, func() { By("creating ClusterWatchRule with Cluster scope for CRDs") destName := clusterWatchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/crd-install-test", "main") + createGitTarget(destName, testNs, gitProviderName, "e2e/crd-install-test", "main") clusterWatchRuleData := struct { Name string @@ -1012,15 +1025,15 @@ var _ = Describe("Manager", Ordered, func() { }{ Name: clusterWatchRuleName, DestinationName: destName, - Namespace: namespace, + Namespace: testNs, } - err := applyFromTemplate("test/e2e/templates/clusterwatchrule-crd.tmpl", clusterWatchRuleData, "") + err := applyFromTemplate("test/e2e/templates/manager/clusterwatchrule-crd.tmpl", clusterWatchRuleData, "") Expect(err).NotTo(HaveOccurred(), "Failed to apply ClusterWatchRule for CRDs") By("verifying ClusterWatchRule is ready") verifyResourceStatus("clusterwatchrule", clusterWatchRuleName, "", "True", "Ready", "") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") By("installing the IceCreamOrder CRD to trigger Git commit") _, err = kubectlRun("apply", "-f", "test/e2e/templates/icecreamorder-crd.yaml") @@ -1073,7 +1086,7 @@ var _ = Describe("Manager", Ordered, func() { By("cleaning up test resources") cleanupClusterWatchRule(clusterWatchRuleName) - cleanupGitTarget(destName, namespace) + cleanupGitTarget(destName, testNs) // Keep CRD installed for subsequent tests By("βœ… CRD installation via ClusterWatchRule E2E test passed") @@ -1106,7 +1119,7 @@ var _ = Describe("Manager", Ordered, func() { By("creating WatchRule that monitors IceCreamOrder resources") destName := watchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/icecream-test", "main") + createGitTarget(destName, testNs, gitProviderName, "e2e/icecream-test", "main") data := struct { Name string @@ -1114,16 +1127,16 @@ var _ = Describe("Manager", Ordered, func() { DestinationName string }{ Name: watchRuleName, - Namespace: namespace, + Namespace: testNs, DestinationName: destName, } - err2 := applyFromTemplate("test/e2e/templates/watchrule-crd.tmpl", data, namespace) + err2 := applyFromTemplate("test/e2e/templates/watchrule-crd.tmpl", data, testNs) Expect(err2).NotTo(HaveOccurred(), "Failed to apply WatchRule for CRDs") By("verifying WatchRule is ready") - verifyResourceStatus("watchrule", watchRuleName, namespace, "True", "Ready", "") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") By("creating CR with labels and annotations to trigger Git commit") crdInstanceData := struct { @@ -1140,7 +1153,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings []string }{ Name: crdInstanceName, - Namespace: namespace, + Namespace: testNs, Labels: map[string]string{ "environment": "test", "team": "engineering", @@ -1163,7 +1176,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings: []string{"Sprinkles", "HotFudge"}, } - err3 := applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", crdInstanceData, namespace) + err3 := applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", crdInstanceData, testNs) Expect(err3).NotTo(HaveOccurred(), "Failed to apply CRD instance") By("waiting for controller reconciliation of CRD instance event") @@ -1194,7 +1207,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/icecream-test", - fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", namespace, crdInstanceName)) + fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", testNs, crdInstanceName)) fileInfo, statErr := os.Stat(expectedFile) g.Expect(statErr). NotTo(HaveOccurred(), fmt.Sprintf("CRD instance file should exist at %s", expectedFile)) @@ -1241,7 +1254,7 @@ var _ = Describe("Manager", Ordered, func() { By("verifying the IceCreamOrder still exists before status update") verifyCRDInstanceExists := func(g Gomega) { - _, err := kubectlRunInNamespace(namespace, "get", "icecreamorder", crdInstanceName) + _, err := kubectlRunInNamespace(testNs, "get", "icecreamorder", crdInstanceName) g.Expect(err).NotTo(HaveOccurred(), "IceCreamOrder should exist before status patch") } Eventually(verifyCRDInstanceExists, 15*time.Second, time.Second).Should(Succeed()) @@ -1249,7 +1262,7 @@ var _ = Describe("Manager", Ordered, func() { By("applying status update to the IceCreamOrder CR") statusPatch := `{"status":{"phase":"Preparing","cost":12.5,"message":"Queued for pickup"}}` statusOutput, statusErr := kubectlRunInNamespace( - namespace, + testNs, "patch", "icecreamorder", crdInstanceName, @@ -1287,7 +1300,7 @@ var _ = Describe("Manager", Ordered, func() { // Read the file again to ensure status is still not present expectedFile := filepath.Join(checkoutDir, "e2e/icecream-test", - fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", namespace, crdInstanceName)) + fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", testNs, crdInstanceName)) content, readErr := os.ReadFile(expectedFile) g.Expect(readErr).NotTo(HaveOccurred()) g.Expect(string(content)).NotTo(ContainSubstring("status:"), @@ -1302,7 +1315,7 @@ var _ = Describe("Manager", Ordered, func() { By("βœ… Status update verified - no Git commit created and status not in file") By("cleaning up IceCreamOrder instance") - _, _ = kubectlRunInNamespace(namespace, "delete", "icecreamorder", crdInstanceName) + _, _ = kubectlRunInNamespace(testNs, "delete", "icecreamorder", crdInstanceName) By("Note: GitTarget, WatchRule, GitProvider, and CRD kept for subsequent tests") @@ -1330,7 +1343,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings []string }{ Name: crdInstanceName, - Namespace: namespace, + Namespace: testNs, Labels: nil, Annotations: nil, CustomerName: "Bob", @@ -1344,7 +1357,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings: []string{"WhippedCream"}, } - err := applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", crdInstanceData, namespace) + err := applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", crdInstanceData, testNs) Expect(err).NotTo(HaveOccurred(), "Failed to apply initial CRD instance") By("waiting for initial CRD instance file to appear in Git") @@ -1355,7 +1368,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/icecream-test", - fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", namespace, crdInstanceName)) + fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", testNs, crdInstanceName)) content, readErr := os.ReadFile(expectedFile) g.Expect(readErr).NotTo(HaveOccurred()) g.Expect(string(content)).To(ContainSubstring("customerName: Bob")) @@ -1378,7 +1391,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings []string }{ Name: crdInstanceName, - Namespace: namespace, + Namespace: testNs, Labels: nil, Annotations: nil, CustomerName: "Bob", @@ -1393,7 +1406,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings: []string{"HotFudge", "Caramel", "Sprinkles"}, } - err = applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", updatedCRDData, namespace) + err = applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", updatedCRDData, testNs) Expect(err).NotTo(HaveOccurred(), "Failed to update CRD instance") By("verifying updated CRD instance content in Git") @@ -1404,7 +1417,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/icecream-test", - fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", namespace, crdInstanceName)) + fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", testNs, crdInstanceName)) content, readErr := os.ReadFile(expectedFile) g.Expect(readErr).NotTo(HaveOccurred()) g.Expect(string(content)).To(ContainSubstring("container: WaffleBowl"), @@ -1417,7 +1430,7 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyUpdatedFile).Should(Succeed()) By("cleaning up IceCreamOrder instance") - _, _ = kubectlRunInNamespace(namespace, "delete", "icecreamorder", crdInstanceName) + _, _ = kubectlRunInNamespace(testNs, "delete", "icecreamorder", crdInstanceName) By("Note: GitTarget, WatchRule, GitProvider, and CRD kept for subsequent tests") @@ -1445,7 +1458,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings []string }{ Name: crdInstanceName, - Namespace: namespace, + Namespace: testNs, Labels: nil, Annotations: nil, CustomerName: "Charlie", @@ -1459,7 +1472,7 @@ var _ = Describe("Manager", Ordered, func() { Toppings: []string{"Sprinkles"}, } - err := applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", crdInstanceData, namespace) + err := applyFromTemplate("test/e2e/templates/icecreamorder-instance.tmpl", crdInstanceData, testNs) Expect(err).NotTo(HaveOccurred(), "Failed to apply CR") By("waiting for CR file to appear in Git repository") @@ -1470,7 +1483,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/icecream-test", - fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", namespace, crdInstanceName)) + fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", testNs, crdInstanceName)) fileInfo, statErr := os.Stat(expectedFile) g.Expect(statErr). NotTo(HaveOccurred(), fmt.Sprintf("CRD instance file should exist at %s", expectedFile)) @@ -1479,7 +1492,7 @@ var _ = Describe("Manager", Ordered, func() { Eventually(verifyFileCreated).Should(Succeed()) By("deleting the CR to trigger DELETE operation") - _, err = kubectlRunInNamespace(namespace, "delete", "icecreamorder", crdInstanceName) + _, err = kubectlRunInNamespace(testNs, "delete", "icecreamorder", crdInstanceName) Expect(err).NotTo(HaveOccurred(), "CRD instance deletion should succeed") By("verifying CRD instance file is deleted from Git repository") @@ -1490,7 +1503,7 @@ var _ = Describe("Manager", Ordered, func() { expectedFile := filepath.Join(checkoutDir, "e2e/icecream-test", - fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", namespace, crdInstanceName)) + fmt.Sprintf("shop.example.com/v1/icecreamorders/%s/%s.yaml", testNs, crdInstanceName)) _, statErr := os.Stat(expectedFile) g.Expect(statErr). To(HaveOccurred(), fmt.Sprintf("CRD instance file should NOT exist at %s", expectedFile)) @@ -1518,8 +1531,8 @@ var _ = Describe("Manager", Ordered, func() { By("creating ClusterWatchRule with Cluster scope for CRDs") destName := clusterWatchRuleName + "-dest" - createGitTarget(destName, namespace, gitProviderName, "e2e/crd-delete-test", "main") - verifyResourceStatus("gittarget", destName, namespace, "True", "Ready", "") + createGitTarget(destName, testNs, gitProviderName, "e2e/crd-delete-test", "main") + verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "") clusterWatchRuleData := struct { Name string @@ -1528,10 +1541,10 @@ var _ = Describe("Manager", Ordered, func() { }{ Name: clusterWatchRuleName, DestinationName: destName, - Namespace: namespace, + Namespace: testNs, } - err := applyFromTemplate("test/e2e/templates/clusterwatchrule-crd.tmpl", clusterWatchRuleData, "") + err := applyFromTemplate("test/e2e/templates/manager/clusterwatchrule-crd.tmpl", clusterWatchRuleData, "") Expect(err).NotTo(HaveOccurred(), "Failed to apply ClusterWatchRule for CRDs") By("verifying ClusterWatchRule is ready") @@ -1580,7 +1593,7 @@ var _ = Describe("Manager", Ordered, func() { By("cleaning up test resources") cleanupClusterWatchRule(clusterWatchRuleName) - cleanupGitTarget(destName, namespace) + cleanupGitTarget(destName, testNs) By("βœ… CRD deletion via ClusterWatchRule E2E test passed") }) @@ -1589,13 +1602,13 @@ var _ = Describe("Manager", Ordered, func() { By("cleaning up shared test resources") // Clean up WatchRule from IceCreamOrder tests - cleanupWatchRule("watchrule-icecream-orders", namespace) + cleanupWatchRule("watchrule-icecream-orders", testNs) // Clean up GitTarget from IceCreamOrder tests - cleanupGitTarget("watchrule-icecream-orders-dest", namespace) + cleanupGitTarget("watchrule-icecream-orders-dest", testNs) // Clean up GitProvider from IceCreamOrder tests - cleanupGitProvider("gitprovider-normal") + _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", "gitprovider-normal", "--ignore-not-found=true") // Clean up IceCreamOrder CRD _, _ = kubectlRun( diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 6e9a2800c..3562d055c 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -26,7 +26,9 @@ import ( "bytes" "context" "fmt" + "os" "sort" + "strings" "text/template" "time" @@ -206,6 +208,21 @@ func createGitTargetWithEncryptionOptions( Expect(err).NotTo(HaveOccurred(), "Failed to apply GitTarget") } +// applySOPSAgeKeyToNamespace copies the sops-age-key secret into the given namespace +// so that GitTarget resources created there can reference it for encryption. +// The key data is read directly from E2E_AGE_KEY_FILE to avoid cross-namespace copy issues. +func applySOPSAgeKeyToNamespace(ns string) { + By(fmt.Sprintf("applying sops-age-key to test namespace '%s'", ns)) + ageKeyFile := strings.TrimSpace(os.Getenv("E2E_AGE_KEY_FILE")) + Expect(ageKeyFile).NotTo(BeEmpty(), "E2E_AGE_KEY_FILE must be set by BeforeSuite") + out, err := kubectlRunInNamespace(ns, "create", "secret", "generic", e2eEncryptionRefName, + "--from-file=identity.agekey="+ageKeyFile, + "--dry-run=client", "-o", "yaml") + Expect(err).NotTo(HaveOccurred(), "failed to generate sops-age-key manifest") + _, err = kubectlRunWithStdin(ns, out, "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred(), "failed to apply sops-age-key to test namespace") +} + // cleanupGitTarget deletes a GitTarget resource. func cleanupGitTarget(name, namespace string) { By(fmt.Sprintf("cleaning up GitTarget '%s' in ns '%s'", name, namespace)) diff --git a/test/e2e/namespace.go b/test/e2e/namespace.go index 145e0b347..2441f7efb 100644 --- a/test/e2e/namespace.go +++ b/test/e2e/namespace.go @@ -19,11 +19,14 @@ limitations under the License. package e2e import ( + "fmt" "os" "strings" + + . "github.com/onsi/ginkgo/v2" ) -const defaultE2ENamespace = "sut" +const defaultE2ENamespace = "gitops-reverser" func resolveE2ENamespace() string { if value := strings.TrimSpace(os.Getenv("NAMESPACE")); value != "" { @@ -31,3 +34,11 @@ func resolveE2ENamespace() string { } return defaultE2ENamespace } + +// testNamespaceFor returns the test namespace for a given suite label. +// The name is scoped to the current Ginkgo run via the random seed, +// tying it to the repo and other per-run resources. +// Example: "8675309-test-manager" +func testNamespaceFor(suite string) string { + return fmt.Sprintf("%d-test-%s", GinkgoRandomSeed(), suite) +} diff --git a/test/e2e/quickstart_framework_e2e_test.go b/test/e2e/quickstart_framework_e2e_test.go index dd7a908e1..2c0407af2 100644 --- a/test/e2e/quickstart_framework_e2e_test.go +++ b/test/e2e/quickstart_framework_e2e_test.go @@ -37,7 +37,6 @@ import ( const ( quickstartFrameworkEnabledEnv = "E2E_ENABLE_QUICKSTART_FRAMEWORK" quickstartFrameworkModeEnv = "E2E_QUICKSTART_MODE" - quickstartSetupNamespaceEnv = "QUICKSTART_NAMESPACE" quickstartTimeoutSecondsEnv = "QUICKSTART_TIMEOUT_SECONDS" ) @@ -66,6 +65,18 @@ var _ = Describe("Quickstart Framework", Label("quickstart-framework"), Ordered, } run = newQuickstartFrameworkRun() + + qsNs := testNamespaceFor("quickstart-framework") + _, _ = kubectlRun("create", "namespace", qsNs) + secretsYaml := strings.TrimSpace(os.Getenv("E2E_SECRETS_YAML")) + Expect(secretsYaml).NotTo(BeEmpty(), "E2E_SECRETS_YAML must be set by BeforeSuite") + _, err := kubectlRunInNamespace(qsNs, "apply", "-f", secretsYaml) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to quickstart namespace") + applySOPSAgeKeyToNamespace(qsNs) + }) + + AfterAll(func() { + _, _ = kubectlRun("delete", "namespace", testNamespaceFor("quickstart-framework"), "--ignore-not-found=true") }) It("sets up quickstart flow via Go framework", func() { @@ -146,7 +157,7 @@ func (r *quickstartFrameworkRun) setupGiteaRepository() { } func (r *quickstartFrameworkRun) applyQuickstartResources() { - qsNamespace := quickstartSetupNamespace() + qsNamespace := testNamespaceFor("quickstart-framework") createGitProviderWithURLInNamespace(r.providerName, qsNamespace, e2eGitSecretHTTP(), r.repoURL) createGitTargetWithEncryptionOptions( @@ -176,7 +187,7 @@ func (r *quickstartFrameworkRun) applyQuickstartResources() { } func (r *quickstartFrameworkRun) verifyQuickstartResourcesReady() { - ns := quickstartSetupNamespace() + ns := testNamespaceFor("quickstart-framework") verifyResourceStatus("gitprovider", r.providerName, ns, "True", "Ready", "") verifyResourceStatus("gittarget", r.targetName, ns, "True", "Ready", "") verifyResourceStatus("watchrule", r.watchRuleName, ns, "True", "Ready", "") @@ -197,7 +208,7 @@ func e2eGitSecretInvalid() string { } func (r *quickstartFrameworkRun) verifyGeneratedEncryptionSecret() string { - ns := quickstartSetupNamespace() + ns := testNamespaceFor("quickstart-framework") var generatedAgeKey string Eventually(func(g Gomega) { @@ -236,7 +247,7 @@ func (r *quickstartFrameworkRun) verifyGeneratedEncryptionSecret() string { } func (r *quickstartFrameworkRun) verifyQuickstartConfigMapCommits() { - ns := quickstartSetupNamespace() + ns := testNamespaceFor("quickstart-framework") configMapName := fmt.Sprintf("quickstart-config-%s", r.testID) expectedFile := filepath.Join( r.checkoutDir, @@ -322,7 +333,7 @@ func (r *quickstartFrameworkRun) verifyQuickstartConfigMapCommits() { } func (r *quickstartFrameworkRun) verifyQuickstartSecretEncryption(generatedAgeKey string) { - ns := quickstartSetupNamespace() + ns := testNamespaceFor("quickstart-framework") secretName := fmt.Sprintf("quickstart-secret-%s", r.testID) secretValueOne := fmt.Sprintf("quickstart-plaintext-one-%s", r.testID) secretValueTwo := fmt.Sprintf("quickstart-plaintext-two-%s", r.testID) @@ -397,7 +408,7 @@ func (r *quickstartFrameworkRun) verifyQuickstartSecretEncryption(generatedAgeKe } func (r *quickstartFrameworkRun) verifyInvalidProviderActionableMessage() { - ns := quickstartSetupNamespace() + ns := testNamespaceFor("quickstart-framework") Eventually(func(g Gomega) { output, err := kubectlRunInNamespace(ns, "get", "gitprovider", r.invalidProvName, "-o", "json") g.Expect(err).NotTo(HaveOccurred()) @@ -502,12 +513,3 @@ func createGitProviderWithURLInNamespace(name, ns, secretName, repoURL string) { Expect(err).NotTo(HaveOccurred(), "failed to apply GitProvider") } -func quickstartSetupNamespace() string { - if value := strings.TrimSpace(os.Getenv(quickstartSetupNamespaceEnv)); value != "" { - return value - } - if value := strings.TrimSpace(os.Getenv("SUT_NAMESPACE")); value != "" { - return value - } - return "sut" -} diff --git a/test/e2e/talk_e2e_test.go b/test/e2e/talk_e2e_test.go index 1daa98d74..eb3adc312 100644 --- a/test/e2e/talk_e2e_test.go +++ b/test/e2e/talk_e2e_test.go @@ -54,6 +54,7 @@ type talkDemoRun struct { var _ = Describe("Talk Demo", Label("talk-demo"), Ordered, func() { var run talkDemoRun + var testNs string BeforeAll(func() { if !talkFrameworkEnabled() { @@ -64,6 +65,16 @@ var _ = Describe("Talk Demo", Label("talk-demo"), Ordered, func() { } run = newTalkDemoRun() + + By("creating test namespace and applying git secrets") + testNs = testNamespaceFor("talk-demo") + run.sourceNamespace = testNs + _, _ = kubectlRun("create", "namespace", testNs) // idempotent; ignore AlreadyExists + secretsYaml := strings.TrimSpace(os.Getenv("E2E_SECRETS_YAML")) + Expect(secretsYaml).NotTo(BeEmpty(), "E2E_SECRETS_YAML must be set by BeforeSuite") + _, err := kubectlRunInNamespace(testNs, "apply", "-f", secretsYaml) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace") + applySOPSAgeKeyToNamespace(testNs) }) It("prepares a reusable demo repository without cleanup", func() { @@ -194,7 +205,7 @@ func (r *talkDemoRun) applyTalkResources() { DestinationName: r.targetName, } - err := applyFromTemplate("test/e2e/templates/watchrule-all.tmpl", watchRuleData, talkDemoNamespace) + err := applyFromTemplate("test/e2e/templates/talk-demo/watchrule-all.tmpl", watchRuleData, talkDemoNamespace) Expect(err).NotTo(HaveOccurred(), "failed to apply talk demo WatchRule") clusterWatchRuleData := struct { @@ -207,7 +218,7 @@ func (r *talkDemoRun) applyTalkResources() { DestinationName: r.targetName, } - err = applyFromTemplate("test/e2e/templates/clusterwatchrule-talk.tmpl", clusterWatchRuleData, "") + err = applyFromTemplate("test/e2e/templates/talk-demo/clusterwatchrule-talk.tmpl", clusterWatchRuleData, "") Expect(err).NotTo(HaveOccurred(), "failed to apply talk demo ClusterWatchRule") } diff --git a/test/e2e/templates/flux-gitrepository-http.tmpl b/test/e2e/templates/bi-directional/flux-gitrepository-http.tmpl similarity index 100% rename from test/e2e/templates/flux-gitrepository-http.tmpl rename to test/e2e/templates/bi-directional/flux-gitrepository-http.tmpl diff --git a/test/e2e/templates/flux-kustomization.tmpl b/test/e2e/templates/bi-directional/flux-kustomization.tmpl similarity index 100% rename from test/e2e/templates/flux-kustomization.tmpl rename to test/e2e/templates/bi-directional/flux-kustomization.tmpl diff --git a/test/e2e/templates/watchrule-secret.tmpl b/test/e2e/templates/bi-directional/watchrule-secret.tmpl similarity index 100% rename from test/e2e/templates/watchrule-secret.tmpl rename to test/e2e/templates/bi-directional/watchrule-secret.tmpl diff --git a/test/e2e/templates/clusterwatchrule-crd.tmpl b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl similarity index 100% rename from test/e2e/templates/clusterwatchrule-crd.tmpl rename to test/e2e/templates/manager/clusterwatchrule-crd.tmpl diff --git a/test/e2e/templates/configmap.tmpl b/test/e2e/templates/manager/configmap.tmpl similarity index 100% rename from test/e2e/templates/configmap.tmpl rename to test/e2e/templates/manager/configmap.tmpl diff --git a/test/e2e/templates/watchrule-configmap.tmpl b/test/e2e/templates/manager/watchrule-configmap.tmpl similarity index 100% rename from test/e2e/templates/watchrule-configmap.tmpl rename to test/e2e/templates/manager/watchrule-configmap.tmpl diff --git a/test/e2e/templates/clusterwatchrule-talk.tmpl b/test/e2e/templates/talk-demo/clusterwatchrule-talk.tmpl similarity index 100% rename from test/e2e/templates/clusterwatchrule-talk.tmpl rename to test/e2e/templates/talk-demo/clusterwatchrule-talk.tmpl diff --git a/test/e2e/templates/watchrule-all.tmpl b/test/e2e/templates/talk-demo/watchrule-all.tmpl similarity index 100% rename from test/e2e/templates/watchrule-all.tmpl rename to test/e2e/templates/talk-demo/watchrule-all.tmpl From 5c53e02b97027b7236c3e108748dbfb19cbe3dad Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 19 Mar 2026 00:03:51 +0000 Subject: [PATCH 12/30] fix: Preventing reconcile code from ignoring ns boundary --- .claude/settings.local.json | 3 +- docs/bug-snapshot-namespace-scoping.md | 4 +- internal/controller/gittarget_controller.go | 22 +++ internal/watch/manager.go | 167 +++++++++++--------- 4 files changed, 120 insertions(+), 76 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b2eb7bce7..e9296a141 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(go test:*)", "Bash(make lint:*)", - "Bash(make test-e2e:*)" + "Bash(make test-e2e:*)", + "Bash(go build:*)" ] } } diff --git a/docs/bug-snapshot-namespace-scoping.md b/docs/bug-snapshot-namespace-scoping.md index 238a91d00..8f0ba8085 100644 --- a/docs/bug-snapshot-namespace-scoping.md +++ b/docs/bug-snapshot-namespace-scoping.md @@ -1,6 +1,6 @@ # Bug Report: Snapshot Reconciliation Ignores Namespace Scope -**Status:** Confirmed +**Status:** Confirmed -> Resolved! **Severity:** High β€” causes spurious commits that break the commit-loop prevention guarantee **Discovered via:** `make test-e2e-bi` (bi-directional e2e test) @@ -29,7 +29,7 @@ Inspecting the Git log of the test repo (`.stamps/repos//bi-directional/ 9c4d294 [CREATE] v1/secrets/bi-secret- -c2722b7 [CREATE] v1/secrets/sops-age-key +c2722b7 [CREATE] v1/secret1s/sops-age-key 3179204 [CREATE] v1/secrets/git-creds-ssh- 4dca51e [CREATE] v1/secrets/git-creds-invalid- f6da0a0 [CREATE] v1/secrets/git-creds- diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 14f0970a9..968c4a9c2 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -489,6 +489,18 @@ func (r *GitTargetReconciler) evaluateSnapshotGate( return nil, metav1.ConditionTrue, "Initial snapshot reconciliation completed", 0, nil } + // Initial snapshot only. Re-snapshots on rule changes are triggered by the + // WatchRule controller via ReconcileForRuleChange, not via this gate. + // Without this guard, unrelated events (e.g. Flux touching the encryption + // secret) can re-trigger a cluster-wide snapshot and produce spurious commits. + if isConditionTrue(target.Status.Conditions, GitTargetConditionSnapshotSynced) { + stream, err := r.ensureEventStream(target, providerNS, log) + if err != nil { + return nil, metav1.ConditionFalse, "", 0, err + } + return stream, metav1.ConditionTrue, "Initial snapshot reconciliation completed", 0, nil + } + stream, err := r.ensureEventStream(target, providerNS, log) if err != nil { return nil, metav1.ConditionFalse, "", 0, err @@ -620,6 +632,16 @@ func (r *GitTargetReconciler) setReadyCondition( r.setCondition(target, GitTargetConditionReady, status, reason, message) } +// isConditionTrue returns true if the named condition is present with Status=True. +func isConditionTrue(conditions []metav1.Condition, conditionType string) bool { + for _, c := range conditions { + if c.Type == conditionType { + return c.Status == metav1.ConditionTrue + } + } + return false +} + func (r *GitTargetReconciler) setCondition( target *configbutleraiv1alpha1.GitTarget, conditionType string, diff --git a/internal/watch/manager.go b/internal/watch/manager.go index ce2a7d4f4..836103421 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -636,14 +636,30 @@ func (m *Manager) GetClusterStateForGitDest( wrRules := m.RuleStore.SnapshotWatchRules() cwrRules := m.RuleStore.SnapshotClusterWatchRules() - // Build GVR set from rules that reference this GitTarget - gvrSet := make(map[schema.GroupVersionResource]struct{}) + // Build a map from GVR to the namespaces that should be listed for it. + // WatchRules are namespace-scoped: only list within rule.Source.Namespace. + // ClusterWatchRules are cluster-wide: clusterWide=true overrides any namespace set. + type gvrEntry struct { + namespaces map[string]struct{} + clusterWide bool + } + gvrMap := make(map[schema.GroupVersionResource]*gvrEntry) for _, rule := range wrRules { if rule.GitTargetRef == gitTargetObj.Name && rule.GitTargetNamespace == gitTargetObj.Namespace { + ns := rule.Source.Namespace for _, rr := range rule.ResourceRules { - m.addGVRsFromResourceRule(rr, gvrSet) + for _, gvr := range m.gvrsFromResourceRule(rr) { + entry := gvrMap[gvr] + if entry == nil { + entry = &gvrEntry{namespaces: make(map[string]struct{})} + gvrMap[gvr] = entry + } + if !entry.clusterWide { + entry.namespaces[ns] = struct{}{} + } + } } } } @@ -651,8 +667,13 @@ func (m *Manager) GetClusterStateForGitDest( for _, cwrRule := range cwrRules { if cwrRule.GitTargetRef == gitTargetObj.Name && cwrRule.GitTargetNamespace == gitTargetObj.Namespace { - for _, rr := range cwrRule.Rules { - m.addGVRsFromClusterResourceRule(rr, gvrSet) + for _, gvr := range m.gvrsFromClusterRule(cwrRule) { + entry := gvrMap[gvr] + if entry == nil { + entry = &gvrEntry{namespaces: make(map[string]struct{})} + gvrMap[gvr] = entry + } + entry.clusterWide = true } } } @@ -665,8 +686,14 @@ func (m *Manager) GetClusterStateForGitDest( var resources []types.ResourceIdentifier objects := make(map[string]unstructured.Unstructured) - for gvr := range gvrSet { - gvrResources, err := m.listResourcesForGVR(ctx, dc, gvr, &gitTargetObj, objects) + for gvr, entry := range gvrMap { + var namespaces []string + if !entry.clusterWide { + for ns := range entry.namespaces { + namespaces = append(namespaces, ns) + } + } + gvrResources, err := m.listResourcesForGVR(ctx, dc, gvr, namespaces, objects) if err != nil { log.Error(err, "Failed to list GVR", "gvr", gvr) continue @@ -678,11 +705,8 @@ func (m *Manager) GetClusterStateForGitDest( return resources, objects, nil } -// addGVRsFromResourceRule adds GVRs from a CompiledResourceRule to the set. -func (m *Manager) addGVRsFromResourceRule( - rr rulestore.CompiledResourceRule, - gvrSet map[schema.GroupVersionResource]struct{}, -) { +// gvrsFromResourceRule returns the GVRs implied by a CompiledResourceRule. +func (m *Manager) gvrsFromResourceRule(rr rulestore.CompiledResourceRule) []schema.GroupVersionResource { groups := rr.APIGroups if len(groups) == 0 { groups = []string{""} @@ -692,99 +716,106 @@ func (m *Manager) addGVRsFromResourceRule( versions = []string{"v1"} } + var out []schema.GroupVersionResource for _, group := range groups { if group == "*" { - continue // Skip wildcards for now + continue } for _, version := range versions { if version == "*" { - continue // Skip wildcards + continue } for _, resource := range rr.Resources { normalized := normalizeResource(resource) if normalized == "*" || shouldIgnoreResource(group, normalized) { - continue // Skip wildcards + continue } - gvr := schema.GroupVersionResource{ + out = append(out, schema.GroupVersionResource{ Group: group, Version: version, Resource: normalized, - } - gvrSet[gvr] = struct{}{} + }) } } } + return out } -// addGVRsFromClusterResourceRule adds GVRs from a CompiledClusterResourceRule to the set. -func (m *Manager) addGVRsFromClusterResourceRule( - rr rulestore.CompiledClusterResourceRule, - gvrSet map[schema.GroupVersionResource]struct{}, -) { - groups := rr.APIGroups - if len(groups) == 0 { - groups = []string{""} - } - versions := rr.APIVersions - if len(versions) == 0 { - versions = []string{"v1"} - } - - for _, group := range groups { - if group == "*" { - continue // Skip wildcards for now +// gvrsFromClusterRule returns the GVRs implied by a CompiledClusterRule. +func (m *Manager) gvrsFromClusterRule(cwrRule rulestore.CompiledClusterRule) []schema.GroupVersionResource { + var out []schema.GroupVersionResource + for _, rr := range cwrRule.Rules { + groups := rr.APIGroups + if len(groups) == 0 { + groups = []string{""} } - for _, version := range versions { - if version == "*" { - continue // Skip wildcards + versions := rr.APIVersions + if len(versions) == 0 { + versions = []string{"v1"} + } + for _, group := range groups { + if group == "*" { + continue } - for _, resource := range rr.Resources { - normalized := normalizeResource(resource) - if normalized == "*" || shouldIgnoreResource(group, normalized) { - continue // Skip wildcards + for _, version := range versions { + if version == "*" { + continue } - gvr := schema.GroupVersionResource{ - Group: group, - Version: version, - Resource: normalized, + for _, resource := range rr.Resources { + normalized := normalizeResource(resource) + if normalized == "*" || shouldIgnoreResource(group, normalized) { + continue + } + out = append(out, schema.GroupVersionResource{ + Group: group, + Version: version, + Resource: normalized, + }) } - gvrSet[gvr] = struct{}{} } } } + return out } -// listResourcesForGVR lists all resources for a specific GVR that match the GitTarget criteria. +// listResourcesForGVR lists resources for a GVR, scoped to the given namespaces. +// If namespaces is empty, a cluster-wide list is performed (for ClusterWatchRules). // Identifiers are returned; sanitized full objects are written into the provided objects map // (keyed by ResourceIdentifier.Key()) for hydrating initial snapshot write events. func (m *Manager) listResourcesForGVR( ctx context.Context, dc dynamic.Interface, gvr schema.GroupVersionResource, - gitTarget *configv1alpha1.GitTarget, + namespaces []string, objects map[string]unstructured.Unstructured, ) ([]types.ResourceIdentifier, error) { if shouldIgnoreResource(gvr.Group, gvr.Resource) { return nil, nil } - var resources []types.ResourceIdentifier - - // List resources (cluster-wide for now, namespace filtering would go here) - list, err := dc.Resource(gvr).List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to list %v: %w", gvr, err) - } - - // Convert to ResourceIdentifiers, filtering by labels/selectors if needed - for i := range list.Items { - obj := &list.Items[i] + var allItems []unstructured.Unstructured - // Check if object matches GitTarget criteria - if !m.objectMatchesGitTarget(obj, gitTarget) { - continue + if len(namespaces) == 0 { + // ClusterWatchRule or cluster-scoped resource: list cluster-wide + list, err := dc.Resource(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list %v: %w", gvr, err) + } + allItems = list.Items + } else { + // WatchRule: list only in the namespaces that have a matching rule + for _, ns := range namespaces { + list, err := dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list %v in namespace %s: %w", gvr, ns, err) + } + allItems = append(allItems, list.Items...) } + } + var resources []types.ResourceIdentifier + for i := range allItems { + obj := &allItems[i] id := types.NewResourceIdentifier( gvr.Group, gvr.Version, @@ -799,16 +830,6 @@ func (m *Manager) listResourcesForGVR( return resources, nil } -// objectMatchesGitTarget checks if an object should be included for a GitTarget. -func (m *Manager) objectMatchesGitTarget( - _ *unstructured.Unstructured, - _ *configv1alpha1.GitTarget, -) bool { - // For now, simple match - in the future could filter by namespace, labels, etc. - // based on the rules that reference this GitTarget - return true -} - // ReconcileForRuleChange reconciles the watch manager when rules change. // Called by WatchRule/ClusterWatchRule controllers after rule modifications. // Single-pod MVP: No debouncing needed since we control pod lifecycle. From 1a6e203b472656ee84f50e310206364739c4bcab Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 19 Mar 2026 09:50:10 +0000 Subject: [PATCH 13/30] chore: Adding unit tests and only one commit per reconcile --- .devcontainer/Dockerfile | 13 +- .devcontainer/devcontainer.json | 3 +- .devcontainer/post-create.sh | 4 + .golangci.yml | 2 +- .../bug-snapshot-namespace-scoping.md | 0 ...owup-watchrule-addition-should-snapshot.md | 122 +++ docs/event-pipeline-design.md | 710 ++++++++---------- internal/controller/constants.go | 4 + internal/controller/gittarget_controller.go | 6 +- .../gittarget_controller_unit_test.go | 160 ++++ internal/watch/event_router.go | 28 + internal/watch/manager.go | 283 +++---- internal/watch/manager_snapshot_test.go | 329 ++++++++ test/e2e/e2e_test.go | 72 +- test/e2e/helpers.go | 4 +- test/e2e/quickstart_framework_e2e_test.go | 1 - 16 files changed, 1104 insertions(+), 637 deletions(-) rename docs/{ => done}/bug-snapshot-namespace-scoping.md (100%) create mode 100644 docs/done/followup-watchrule-addition-should-snapshot.md create mode 100644 internal/controller/gittarget_controller_unit_test.go create mode 100644 internal/watch/manager_snapshot_test.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 87984e3b1..3dde6f611 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -96,9 +96,10 @@ RUN chgrp -R godev /go && \ # Install Go tools used by the project (using @version doesn't need go.mod) # These run AFTER permissions are set to ensure proper group ownership RUN go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0 \ - && go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest \ - && go install github.com/checkmake/checkmake/cmd/checkmake@latest - + && go install sigs.k8s.io/controller-runtime/tools/setup-envtest@v0.0.0-20260318145839-6c9615a2a166 \ + && go install github.com/checkmake/checkmake/cmd/checkmake@v0.3.2 \ + && go install golang.org/x/tools/cmd/goimports@v0.43.0 + # Initialize golangci-lint cache by running it once on an empty directory # This downloads linter dependencies without needing source code RUN mkdir -p /tmp/golangci-init && cd /tmp/golangci-init \ @@ -147,11 +148,11 @@ RUN arch="$(dpkg --print-architecture)" && \ # Install Delve debugger for Go debugging in VSCode # ACLs from CI stage should be preserved, so no need to re-apply -RUN go install github.com/go-delve/delve/cmd/dlv@latest +RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.1 # Install VSCode Go extension tools (gopls and staticcheck) -RUN go install golang.org/x/tools/gopls@latest \ - && go install honnef.co/go/tools/cmd/staticcheck@latest +RUN go install golang.org/x/tools/gopls@v0.21.1 \ + && go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 # Create vscode user for non-root development and add to godev group RUN groupadd --gid 1000 vscode \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1063f3f5c..33d480275 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -78,7 +78,8 @@ "source=gomodcache,target=/go/pkg/mod,type=volume", "source=gobuildcache,target=/home/vscode/.cache/go-build,type=volume", "source=codexconfig,target=/home/vscode/.codex,type=volume", - "source=claudeconfig,target=/home/vscode/.claude,type=volume" + "source=claudeconfig,target=/home/vscode/.claude,type=volume", + "source=persisted-home,target=/home/vscode/persisted-home,type=volume" ], "containerEnv": { "HOST_PROJECT_PATH": "${localWorkspaceFolder}", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index b12359457..4826b9396 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -87,4 +87,8 @@ else sudo chown -R vscode:vscode /home/vscode || true fi +# Persist the ~/.claude.json file by making it a symlink (this trick can be used for other potenial config file in the home folder as well) +touch /home/vscode/persisted-home/.claude.json +rm -f /home/vscode/.claude.json && ln -s /home/vscode/persisted-home/.claude.json /home/vscode/.claude.json + log "post-create completed" diff --git a/.golangci.yml b/.golangci.yml index cddce5c4d..b38ec592a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -368,7 +368,7 @@ linters: source: 'github\.com/onsi/(ginkgo|gomega)' linters: [revive] - text: "should not use dot imports" - path: 'test/utils/.*\.go' + path: 'test/(utils|e2e)/.*\.go' linters: [staticcheck] # Allow fmt.Print* in test/e2e (debugging outputs) - path: 'test/e2e/.*\.go' diff --git a/docs/bug-snapshot-namespace-scoping.md b/docs/done/bug-snapshot-namespace-scoping.md similarity index 100% rename from docs/bug-snapshot-namespace-scoping.md rename to docs/done/bug-snapshot-namespace-scoping.md diff --git a/docs/done/followup-watchrule-addition-should-snapshot.md b/docs/done/followup-watchrule-addition-should-snapshot.md new file mode 100644 index 000000000..fd5f397fb --- /dev/null +++ b/docs/done/followup-watchrule-addition-should-snapshot.md @@ -0,0 +1,122 @@ +# Follow-up: WatchRule Addition Should Trigger a Snapshot, Not Individual Live Events + +**Status:** Open +**Depends on:** `bug-snapshot-namespace-scoping.md` (both bugs now fixed) +**Discovered via:** `make test-e2e-bi` β€” the git log after the fix still shows individual `[CREATE]` commits for secrets instead of one `reconcile: sync N resources…` commit. + +--- + +## Problem + +When a `WatchRule` is applied for the first time (or after a controller restart), the controller sets up namespace-scoped informers for the watched namespace. Those informers immediately fire `ADDED` events for every resource that already exists in that namespace. Each event travels the **live event path** and produces its own commit: + +``` +[CREATE] v1/secrets/bi-controller-sops- +[CREATE] v1/secrets/git-creds-ssh- +[CREATE] v1/secrets/git-creds-invalid- +[CREATE] v1/secrets/git-creds- +[CREATE] v1/secrets/sops-age-key +``` + +The desired outcome is a single commit that captures the entire initial state of the watched namespace: + +``` +reconcile: sync 5 resources from cluster snapshot +``` + +This is the same atomic, auditable commit that the snapshot path already produces for the very first `GitTarget` bootstrap. A `WatchRule` addition is semantically identical: the watch scope just grew, and the new resources should be reconciled as a single unit. + +--- + +## Why It Happens + +There are two ways the controller can catch up on existing resources when a new `WatchRule` arrives: + +| Path | How it works | Commit output | +|---|---|---| +| **Live event path (current)** | Informer `ADDED` events fire one-by-one as the cache syncs | N individual `[CREATE]` commits | +| **Snapshot path (desired)** | `RequestClusterState` β†’ `GetClusterStateForGitDest` β†’ `ReconcileBatch` | 1 `reconcile: sync N resources…` commit | + +The snapshot path already exists and already scopes correctly to the WatchRule's namespace (fixed in `bug-snapshot-namespace-scoping.md`). It just isn't being triggered on rule changes. + +The live-event informers **cannot** be the right mechanism for initial population because: +1. The git history becomes unreadable β€” you can't tell from the log when the watch scope changed vs when a real cluster change happened. +2. If 50 secrets exist when the WatchRule is created, you get 50 commits. There is no way to correlate them back to "WatchRule X was added". +3. Once ADDED events have fired and the informer cache is warm, **any subsequent snapshot will produce a zero-diff** because the live commits already wrote all the files. So the snapshot path is effectively bypassed. + +--- + +## Root Cause + +`ReconcileForRuleChange` in `internal/watch/manager.go` reconciles the **informers** (starts/stops watchers) but does not emit a `RequestClusterState` control event for the affected `GitTarget`. The snapshot is therefore never triggered by a rule change. + +The `evaluateSnapshotGate` fix (Bug 2 in `bug-snapshot-namespace-scoping.md`) added an early-exit guard so that the snapshot does not re-run on every unrelated `GitTarget` reconcile. The complementary change β€” triggering the snapshot from the *right* place β€” was not yet implemented. + +--- + +## Fix Plan + +### Step 1 β€” Emit `RequestClusterState` from `ReconcileForRuleChange` + +After `ReconcileForRuleChange` has reconciled the informers, it should emit `RequestClusterState` for every `GitTarget` that is affected by the changed rule set. The affected targets are already known from the RuleStore. + +```go +// internal/watch/manager.go β€” ReconcileForRuleChange (after informer reconcile) + +affectedTargets := m.collectAffectedGitTargets() +for _, gitDest := range affectedTargets { + if err := m.EventRouter.EmitControlEvent(events.ControlEvent{ + Type: events.RequestClusterState, + GitDest: gitDest, + }); err != nil { + log.Error(err, "failed to emit RequestClusterState for rule change", "gitDest", gitDest) + } +} +``` + +`collectAffectedGitTargets` is a small helper that snapshots both WatchRule and ClusterWatchRule stores and returns the unique set of `ResourceReference` values that point to a `GitTarget`. + +### Step 2 β€” Suppress informer ADDED events during snapshot + +When a snapshot is in flight the `GitTargetEventStream` is already in `RECONCILING` state (buffering live events). The buffered `ADDED` events will be flushed after `OnReconciliationComplete()` β€” but since the snapshot already wrote all those files, the live events will produce no-op file writes and therefore no further commits. This is the existing dedup behaviour and no extra work is needed here. + +However, if the informer ADDED events are flushed *before* the snapshot batch lands (race condition), they will produce N commits before the snapshot gets to say anything. The safest guard is: + +- The `GitTargetEventStream` in `RECONCILING` state **buffers** live events (already the case). +- `BeginReconciliation()` is called by the snapshot emitter before `RequestClusterState` is processed, so ADDED events that arrive during cache sync are buffered. +- After `OnReconciliationComplete()` the buffered events are flushed and the dedup layer discards them (no-op writes). + +Verify this ordering in the existing `BeginReconciliation` / `OnReconciliationComplete` tests. + +### Step 3 β€” Update the e2e assertion + +The bi-directional e2e test currently asserts that the commit count stays stable after Flux applies resources back. Once this fix is in, the secret-phase of the test should produce **one** `reconcile:` commit rather than five `[CREATE]` commits. Update the expected commit log snapshot in the test accordingly. + +--- + +## Expected Git History After Fix + +``` +badc99d chore(bootstrap): initialize path bi-directional/.../live +a1b2c3d reconcile: sync 5 resources from cluster snapshot ← WatchRule added +d4e5f6g [UPDATE] shop.example.com/v1/icecreamorders/bi-alice-order- +``` + +Instead of the current: + +``` +badc99d chore(bootstrap): initialize path bi-directional/.../live +90c5268 [CREATE] v1/secrets/bi-controller-sops- +83771a3 [CREATE] v1/secrets/git-creds- +20b7155 [CREATE] v1/secrets/git-creds-invalid- +9ed0925 [CREATE] v1/secrets/git-creds-ssh- +d093af3 [CREATE] v1/secrets/sops-age-key +``` + +--- + +## Invariant + +After this fix the following invariant should hold: + +> Every change to the **watch scope** (WatchRule add/update/delete, controller restart) produces exactly **one** `reconcile: sync N resources from cluster snapshot` commit. Individual `[CREATE]` commits in the history always reflect real-time cluster changes, never initial population. diff --git a/docs/event-pipeline-design.md b/docs/event-pipeline-design.md index 99723eda1..b2cf92fed 100644 --- a/docs/event-pipeline-design.md +++ b/docs/event-pipeline-design.md @@ -1,178 +1,231 @@ # Event Pipeline Design: From Cluster to Git Commit -## Overview +## Status -This document analyses the current event flow from Kubernetes cluster through to -Git commits, identifies the problem with reconcile events producing many individual -commits, and proposes a design that: - -- produces a **single Git commit** for an entire reconcile run -- **buffers live events** while a reconcile is running (per GitTarget, not globally) -- keeps the existing per-event commit behaviour for live cluster changes unchanged -- allows the state machine to re-enter `RECONCILING` for planned re-reconciles +**Fully implemented and verified** as of March 2026. All items in the original +proposal are shipped. Two follow-on bugs discovered during e2e testing have also +been fixed β€” see [Β§10 Bug Fixes](#10-bug-fixes-discovered-during-implementation). --- -## 1. Current `git.Event` Interface +## 1. How Kubernetes Informers Work -**File:** `internal/git/types.go` +Before describing our design, it helps to understand what the Kubernetes informer +framework actually does when you start watching a resource type, because it has a +non-obvious property that is central to this design. + +### 1.1 Starting a watch: LIST first, then WATCH + +When a client-go informer starts it does **not** open a raw watch stream +immediately. It follows a two-phase protocol: -```go -type Event struct { - Object *unstructured.Unstructured // nil for DELETE - Identifier types.ResourceIdentifier - Operation string // CREATE | UPDATE | DELETE | RECONCILE_RESOURCE - UserInfo UserInfo - Path string // folder prefix from owning GitTarget - GitTargetName string // set by GitTargetEventStream - GitTargetNamespace string -} +``` +Phase 1 β€” LIST + GET /apis/v1/secrets?limit=500 + β†’ returns a snapshot of all currently existing secrets + β†’ response includes a resourceVersion (e.g. "42819") + +Phase 2 β€” WATCH (starts from the LIST's resourceVersion) + GET /apis/v1/secrets?watch=true&resourceVersion=42819 + β†’ API server streams only events that happened AFTER rv 42819 ``` -### What is missing today +The informer delivers **all items from the LIST as synthetic ADDED events** to +every registered event handler before it transitions to the streaming phase. This +means: -There is **no way for the BranchWorker or `generateCommitsFromEvents` to tell a -reconcile event apart from a live event**. `generateCommitsFromEvents` -([internal/git/git.go:715](../internal/git/git.go#L715)) creates **one commit per -event**. A cluster with 500 resources on first install produces 500 commits. +> **Starting an informer on a namespace that already contains 50 secrets will +> cause 50 ADDED events to fire, even though those secrets are not new.** -A second gap: the state machine in `GitTargetEventStream` enters `StartupReconcile` -exactly once (at construction). After `OnReconciliationComplete()` fires it never -returns to the buffering state. A planned re-reconcile (provider URL change, -selector change, etc.) would therefore not buffer live events and could interleave -live commits with reconcile writes. +This is the root cause of the "N individual `[CREATE]` commits" problem this +design solves. ---- +### 1.2 `HasSynced()` β€” the clean boundary between LIST and WATCH -## 2. Full Event Pipeline (current) +`cache.WaitForCacheSync(stopCh, informer.HasSynced)` blocks until the informer +reports that its initial LIST has been fully processed. Concretely: -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Event producers β”‚ -β”‚ β”‚ -β”‚ A) WatchManager (live) B) FolderReconciler β”‚ -β”‚ cluster change detected reconcile() computes diff β”‚ -β”‚ β†’ dedup by content hash β†’ N Γ— EmitCreateEvent / β”‚ -β”‚ β†’ git.Event{Op:…} EmitDeleteEvent / β”‚ -β”‚ EmitReconcileResource β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ (live path) β”‚ (reconcile path, N events) - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ GitTargetEventStream (per GitTarget) β”‚ - β”‚ β”‚ - β”‚ STARTUP_RECONCILE: buffer live events β”‚ - β”‚ forward reconcile events β”‚ - β”‚ LIVE_PROCESSING: dedup + forward live β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ Enqueue(event) ← N separate calls - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ BranchWorker β”‚ - β”‚ chan Event (cap 100) β”‚ - β”‚ β”‚ - β”‚ flush triggers: β”‚ - β”‚ β‰₯20 events / β‰₯1 MiB β”‚ ← batch may be SPLIT here - β”‚ 1 min ticker β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ generateCommitsFromEvents() β”‚ - β”‚ for each event: β”‚ - β”‚ applyEventToWorktree() β”‚ - β”‚ createCommitForEvent() ← one commit each - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` +1. The informer's reflector fetches the LIST response. +2. Every item is pushed through the internal `DeltaFIFO` queue. +3. All registered event handlers are called for each item. +4. Only then does `HasSynced()` return `true`. ---- +After `HasSynced()` returns `true`: -## 3. Proposed Design +- All ADDED events from the initial LIST have already been delivered. +- The informer is now in the WATCH phase, streaming events with + `resourceVersion > LIST_rv`. +- Any resource that was created **after** the LIST's `resourceVersion` will + arrive as a fresh ADDED event through the WATCH stream, **not** as part of + the initial sync burst. -### 3.1 Replace N individual reconcile events with one `ReconcileBatch` +### 1.3 The "constant stream" question β€” can you tell initial from real? -`FolderReconciler.reconcile()` already has **all events in memory at the same time** -when the diff is computed. Instead of emitting N individual events one by one, it -emits a single `ReconcileBatch` value that carries all of them. +A common concern: if resources are being created continuously, how does +`HasSynced()` know when the initial burst ends? Could a resource created one +millisecond before the LIST be indistinguishable from one created one millisecond +after? -```go -// ReconcileBatch is a complete set of file changes from one reconcile run. -// It arrives at the BranchWorker as a single unit and is always committed together. -type ReconcileBatch struct { - // Events is the complete ordered list of file changes for this reconcile. - Events []Event +The answer is: **yes, they are cleanly distinguishable, because of +`resourceVersion`**. - // CommitMessage is the summary commit message, e.g.: - // "reconcile: sync 312 resources from cluster snapshot" - CommitMessage string +The API server assigns a monotonically increasing `resourceVersion` to every +write. The LIST response captures a consistent snapshot at a specific +`resourceVersion`. The WATCH starts from the next one. There is no ambiguity: - // GitTargetName / GitTargetNamespace identify the owning GitTarget. - GitTargetName string - GitTargetNamespace string -} +``` +rv 42810 secret "old-secret" created ← appears in LIST (ADDED during sync) +rv 42819 LIST captured at this point ← HasSynced boundary +rv 42820 secret "new-secret" created ← appears in WATCH (ADDED post-sync) ``` -The `EventEmitter` interface used by `FolderReconciler` changes from three -per-resource methods to a single batch method: +`cache.WaitForCacheSync` returns after all items up to rv 42819 have been +delivered. The ADDED event for "new-secret" (rv 42820) will only arrive through +the live WATCH stream, after `HasSynced` has already returned `true`. -```go -// Before (three separate calls, each producing one Event): -type EventEmitter interface { - EmitCreateEvent(resource, obj) error - EmitDeleteEvent(resource) error - EmitReconcileResourceEvent(resource, obj) error -} +This property is what makes it safe to call `OnReconciliationComplete()` right +after `WaitForCacheSync` β€” any resource created after the LIST boundary is +guaranteed to arrive through the live WATCH stream (in LIVE_PROCESSING state), +not to be silently swallowed in the RECONCILING buffer. -// After (one call with the complete diff): -type ReconcileEmitter interface { - EmitReconcileBatch(batch ReconcileBatch) error -} -``` +--- -`FolderReconciler.reconcile()` builds the full event slice from the diff, wraps it -in `ReconcileBatch`, and calls `EmitReconcileBatch` once. +## 2. Component Responsibilities + +Each component in the pipeline has a single, well-bounded responsibility. No +component reaches across its boundary. + +### `WatchManager` ([`internal/watch/manager.go`](../internal/watch/manager.go)) + +**Responsibility:** Translate Kubernetes cluster state into a stream of +`git.Event` values, one per changed resource. + +- Owns informer lifecycle (start, stop, namespace scoping). +- Deduplicates events by content hash β€” status-only changes that produce no + meaningful YAML diff are dropped before they reach the queue. +- Does **not** know about Git commits, branches, or reconcile batches. +- On a WatchRule change, orchestrates the `BeginReconciliation` β†’ informer + start β†’ snapshot β†’ `CompleteReconciliation` sequence (Β§6). +- Calls `GetClusterStateForGitDest` via the EventRouter for snapshots; this + uses direct API List calls (not the informer cache) and is namespace-scoped + per WatchRule. + +### `EventRouter` ([`internal/watch/event_router.go`](../internal/watch/event_router.go)) + +**Responsibility:** Dispatch events and control signals to the right recipient +without any business logic. + +- Routes live `git.Event` values from WatchManager to the correct + `GitTargetEventStream` via `RouteToGitTargetEventStream`. +- Routes control events (`RequestClusterState`, `RequestRepoState`) to + WatchManager and BranchWorker services respectively. +- Owns the registry of `GitTargetEventStream` instances (by GitTarget key). +- Exposes `BeginReconciliationForStream` and `CompleteReconciliationForStream` + so WatchManager can drive stream state transitions without depending directly + on `GitTargetEventStream`. +- Does **not** inspect event content or make routing decisions based on + resource type. + +### `GitTargetEventStream` ([`internal/reconcile/git_target_event_stream.go`](../internal/reconcile/git_target_event_stream.go)) + +**Responsibility:** Enforce ordering between reconcile batches and live events +for one GitTarget, and deduplicate redundant live events. + +- Implements the `RECONCILING` / `LIVE_PROCESSING` state machine (Β§4). +- In `RECONCILING`: buffers live events so they cannot interleave with a + batch that is being written. +- In `LIVE_PROCESSING`: checks a per-resource event hash before forwarding; + events whose content has not changed since the last processed event are + discarded. +- Forwards batches (`EmitReconcileBatch`) and individual events (`OnWatchEvent`) + to the `BranchWorker` as `WorkItem` values. +- Does **not** know what the events represent at the domain level (secrets, + deployments, etc.). + +### `FolderReconciler` ([`internal/reconcile/folder_reconciler.go`](../internal/reconcile/folder_reconciler.go)) + +**Responsibility:** Compute the diff between cluster state and Git state and +emit exactly one `ReconcileBatch` describing what must change. + +- Holds the last known cluster resource list and Git resource list. +- Updated via `OnClusterState` and `OnRepoState`; calls `reconcile()` on each + update. +- `reconcile()` computes `toCreate`, `toDelete`, `existingInBoth` and packages + them into a single `ReconcileBatch` with a human-readable commit message. +- Returns early (no emit) when the diff is empty β€” cluster and Git already + agree. +- Does **not** start informers, make API calls, or interact with Git directly. + +### `BranchWorker` ([`internal/git/branch_worker.go`](../internal/git/branch_worker.go)) + +**Responsibility:** Serialise all writes to one Git branch (one per provider Γ— +branch combination) and push atomically. + +- Accepts `WorkItem` values from its queue: either a `Single` (one live event) + or a `Batch` (a complete reconcile batch). +- For `Single`: buffers events and flushes them (one commit per event) based on + count, size, or a periodic tick. +- For `Batch`: flushes any live buffer first (preserving ordering), then writes + all files in the batch and creates exactly **one** commit with the batch's + commit message. +- Does **not** decide what to write or when β€” that decision was made upstream. + +### `GitTargetController` ([`internal/controller/gittarget_controller.go`](../internal/controller/gittarget_controller.go)) + +**Responsibility:** Drive the GitTarget startup lifecycle through a set of +ordered gates, and keep the `GitTarget` status conditions accurate. + +- Evaluates gates in order: Validated β†’ EncryptionConfigured β†’ Bootstrapped β†’ + SnapshotSynced β†’ EventStreamLive. +- `evaluateSnapshotGate` runs the initial cluster snapshot **once**, guarded by + `SnapshotSynced=True`. It never re-runs the snapshot for unrelated triggers + (e.g. Flux touching the encryption secret). +- `evaluateEventStreamGate` calls `OnReconciliationComplete()` if the stream is + still in `RECONCILING` after the snapshot lands. +- Does **not** start informers, list resources, or make Git commits. -### 3.2 BranchWorker queue accepts both singles and batches +--- -The queue type changes from `chan Event` to `chan WorkItem`, where `WorkItem` is a -small discriminated-union struct: +## 3. `git.Event` and Batch Types + +**File:** [`internal/git/types.go`](../internal/git/types.go) ```go -// WorkItem is either a single live event or a complete reconcile batch. -// Exactly one field is non-nil. +type Event struct { + Object *unstructured.Unstructured // nil for DELETE + Identifier types.ResourceIdentifier + Operation string // CREATE | UPDATE | DELETE | RECONCILE_RESOURCE + UserInfo UserInfo + Path string // folder prefix from owning GitTarget + GitTargetName string // set by GitTargetEventStream + GitTargetNamespace string +} + +// ReconcileBatch carries all file changes from one reconcile run as a single unit. +// It arrives at BranchWorker as one WorkItem and is always committed together. +type ReconcileBatch struct { + Events []Event + CommitMessage string // e.g. "reconcile: sync 42 resources from cluster snapshot" + GitTargetName string + GitTargetNamespace string +} + +// WorkItem is the unit of work in the BranchWorker queue. +// Exactly one of Single or Batch is non-nil. type WorkItem struct { Single *Event Batch *ReconcileBatch } ``` -This does not touch the `Event` type at all and keeps the channel strongly typed. -The BranchWorker processes items in arrival order. When it encounters a `Batch`, it -writes all files without committing, then creates a single commit. When it -encounters a `Single`, it writes the file and commits immediately (existing -behaviour). - -``` -BranchWorker receives WorkItem: - - if item.Single != nil: - applyEventToWorktree(event) - createCommitForEvent(event) ← unchanged, one commit per live event - - if item.Batch != nil: - for each event in batch.Events: - applyEventToWorktree(event) ← write file only, no commit - createCommitForBatch(batch) ← ONE commit for the whole batch -``` +`Event` is unchanged from the original design. `ReconcileBatch` and `WorkItem` +were added alongside it. -Because the entire batch arrives as one `WorkItem`, **there is no flush-tick -splitting problem**. The BranchWorker does not need to know anything about -reconcile group IDs or counts; it simply processes the item it received. +--- -### 3.3 State machine: `RECONCILING` and `LIVE_PROCESSING` +## 4. State Machine: `RECONCILING` and `LIVE_PROCESSING` -Rename `StartupReconcile` β†’ `RECONCILING`. The key addition is that the state -machine can **re-enter `RECONCILING`** from `LIVE_PROCESSING` whenever a new -reconcile is triggered. +**File:** [`internal/reconcile/git_target_event_stream.go`](../internal/reconcile/git_target_event_stream.go) ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -193,176 +246,19 @@ reconcile is triggered. β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -**`BeginReconciliation()`** is a new method on `GitTargetEventStream`. It is -called by `GitTargetController` whenever it decides a reconcile is needed (new -target, selector change, provider URL change, etc.). If the stream is already in -`RECONCILING` the call is a no-op. - -**`OnReconciliationComplete()`** works as today: transitions to `LIVE_PROCESSING` -and flushes buffered live events. - -Live events buffered during `RECONCILING` still pass through the normal dedup path -when they are flushed β€” so if the reconcile batch already wrote the same content, -the live event will produce no further commit (the "no-op file write" check in -`handleCreateOrUpdateOperation` takes care of this). - -### 3.4 What happens to other GitTargets on the same branch - -Each `GitTargetEventStream` has its own independent state machine. Only the stream -whose reconcile is running enters `RECONCILING`. Other GitTargets sharing the same -`BranchWorker` continue to enqueue `WorkItem{Single: &event}` items normally. - -The BranchWorker serialises all work regardless of source, so a live commit from -GitTarget B may land before or after the reconcile batch from GitTarget A β€” this is -fine and already the correct semantics. - ---- - -## 4. Updated Event and Batch Interfaces - -### `internal/git/types.go` additions - -```go -// ReconcileBatch is a complete set of file changes from one reconcile run. -// It is enqueued as a single WorkItem so the BranchWorker always commits it atomically. -type ReconcileBatch struct { - Events []Event - CommitMessage string - GitTargetName string - GitTargetNamespace string -} - -// WorkItem is the unit of work in the BranchWorker queue. -// Exactly one of Single or Batch is non-nil. -type WorkItem struct { - Single *Event - Batch *ReconcileBatch -} -``` - -The `Event` struct itself is **unchanged**. No batch-related fields are added to it. - -### `internal/reconcile/git_target_event_stream.go` changes +Every `GitTargetEventStream` starts in `RECONCILING` (the controller has not +yet confirmed that the initial snapshot is complete). After +`OnReconciliationComplete()` it enters `LIVE_PROCESSING`. It can re-enter +`RECONCILING` when a planned re-reconcile (WatchRule change, controller restart) +is triggered. -```go -// Interface used by FolderReconciler β€” replaces the three per-resource methods. -type ReconcileEmitter interface { - EmitReconcileBatch(batch git.ReconcileBatch) error -} - -// GitTargetEventStream new/changed public methods: - -// BeginReconciliation transitions to RECONCILING (buffering live events). -// Safe to call when already RECONCILING (no-op). -func (s *GitTargetEventStream) BeginReconciliation() - -// EmitReconcileBatch forwards a complete reconcile batch to the BranchWorker -// as a single WorkItem. Called while in RECONCILING state. -func (s *GitTargetEventStream) EmitReconcileBatch(batch git.ReconcileBatch) error - -// OnReconciliationComplete transitions to LIVE_PROCESSING and flushes buffered live events. -func (s *GitTargetEventStream) OnReconciliationComplete() - -// OnWatchEvent handles incoming live events from WatchManager (unchanged signature). -func (s *GitTargetEventStream) OnWatchEvent(event git.Event) -``` - -### `internal/reconcile/folder_reconciler.go` changes - -`FolderReconciler` depends on `ReconcileEmitter` instead of the old `EventEmitter`. -`reconcile()` assembles the full event list and calls `EmitReconcileBatch` once: - -```go -func (r *FolderReconciler) reconcile() { - if !r.clusterStateSeen || !r.gitStateSeen { return } - - toCreate, toDelete, existingInBoth := r.findDifferences(...) - - var events []git.Event - for _, res := range toCreate { - events = append(events, git.Event{Op: "CREATE", Identifier: res, Object: r.objectFor(res), - UserInfo: git.UserInfo{Username: "gitops-reverser"}, Path: r.path}) - } - for _, res := range toDelete { - events = append(events, git.Event{Op: "DELETE", Identifier: res, - UserInfo: git.UserInfo{Username: "gitops-reverser"}, Path: r.path}) - } - for _, res := range existingInBoth { - events = append(events, git.Event{Op: "RECONCILE_RESOURCE", Identifier: res, - Object: r.objectFor(res), UserInfo: git.UserInfo{Username: "gitops-reverser"}, - Path: r.path}) - } - - total := len(events) - if total == 0 { return } // nothing to do; cluster and git already match - - batch := git.ReconcileBatch{ - Events: events, - CommitMessage: fmt.Sprintf("reconcile: sync %d resources from cluster snapshot", total), - } - r.reconcileEmitter.EmitReconcileBatch(batch) -} -``` - -### `internal/git/branch_worker.go` changes - -```go -eventQueue chan WorkItem // was: chan Event -``` - -`processEvents` loop handles `WorkItem` instead of `Event`: - -```go -case item := <-w.eventQueue: - if item.Single != nil { - w.eventBuffer = append(w.eventBuffer, *item.Single) - // existing count/size flush logic unchanged - } - if item.Batch != nil { - // Flush any buffered live events first (preserve ordering) - if len(w.eventBuffer) > 0 { - w.commitAndPush(w.eventBuffer) - w.eventBuffer = nil - } - // Process the entire batch as a unit - w.commitAndPushBatch(item.Batch) - } -``` - -### `internal/git/git.go` changes - -Add `WriteEventsAsBatch` (or a flag parameter) that writes all files and creates -exactly one commit: - -```go -func generateBatchCommit( - ctx context.Context, - writer eventContentWriter, - repo *git.Repository, - batch *ReconcileBatch, -) (plumbing.Hash, error) { - worktree, _ := repo.Worktree() - for _, event := range batch.Events { - applyEventToWorktree(ctx, writer, worktree, event) // write files, no commit - } - return worktree.Commit(batch.CommitMessage, &git.CommitOptions{ - Author: &object.Signature{ - Name: "gitops-reverser", - Email: "noreply@configbutler.ai", - When: time.Now(), - }, - Committer: &object.Signature{ - Name: "gitops-reverser", - Email: "noreply@configbutler.ai", - When: time.Now(), - }, - }) -} -``` +Flushed events pass through the same dedup path as live events. If the +reconcile batch already wrote the same content, the BranchWorker detects no file +change and produces no additional commit. --- -## 5. Full Pipeline (proposed) +## 5. Full Event Pipeline ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -410,118 +306,158 @@ func generateBatchCommit( --- -## 6. Desired Git History +## 6. WatchRule Addition β€” Grouping the Initial Sync Burst + +This is where Β§1 directly shapes the implementation. When a new `WatchRule` is +applied, the controller must start watching a resource type that may already +have many existing objects. Those objects will fire as ADDED events (Β§1.1), and +we must group them into one commit rather than N individual `[CREATE]` commits. -### Today (500 resources, fresh install) +`ReconcileForRuleChange` in [`internal/watch/manager.go`](../internal/watch/manager.go) +follows this exact sequence: ``` -abc1234 [CREATE] apps/v1/deployments/default/frontend -abc1235 [CREATE] apps/v1/deployments/default/backend -... (498 more) +Step 1 β€” beginReconciliationForAffectedTargets() + BeginReconciliation() on each affected GitTargetEventStream. + Stream enters RECONCILING. Any ADDED event that arrives now is buffered, + not forwarded to the BranchWorker. + +Step 2 β€” startInformersForGVRs() + Starts a dynamic informer for each new GVR. + Calls cache.WaitForCacheSync() β€” BLOCKS until HasSynced() is true. + + Because of Β§1.2: by the time WaitForCacheSync returns, every resource + that existed at LIST time has fired its ADDED event and been buffered + in the stream. No initial-sync ADDED event can arrive after this point. + +Step 3 β€” clearDeduplicationCacheForGVRs() + Clears content-hash entries for changed GVRs to prevent false positives. + +Step 4 β€” emitSnapshotForRuleChange() + Emits RequestClusterState β†’ GetClusterStateForGitDest (direct API List, + namespace-scoped per Β§7) β†’ FolderReconciler.reconcile() β†’ EmitReconcileBatch. + ONE "reconcile: sync N resources from cluster snapshot" commit is produced. + +Step 5 β€” completeReconciliationForAffectedTargets() + OnReconciliationComplete() on each stream. + Stream enters LIVE_PROCESSING. + The buffered ADDED events from step 2 are flushed. The BranchWorker finds + the files already present (written by the batch in step 4) and produces + no additional commits. ``` -### After proposed change +### Why call `OnReconciliationComplete()` here and not later? + +The GitTarget controller's normal reconcile loop also calls +`OnReconciliationComplete()` (in `evaluateEventStreamGate`). However, once the +GitTarget is in steady state (Ready=True), its next scheduled reconcile is +`RequeueLongInterval = 10 minutes` away. + +If we left the stream in `RECONCILING` for up to 10 minutes, every real cluster +event during that window would be silently buffered and then replayed as a +single delayed burst β€” breaking the per-event commit invariant for live changes. + +Calling `OnReconciliationComplete()` in step 5 is safe because +`cache.WaitForCacheSync` (step 2) guarantees that **no initial-sync ADDED event +can arrive after step 5**. Any resource created after the LIST boundary (Β§1.3) +is a real new resource and will arrive via the live WATCH stream after the +stream has returned to LIVE_PROCESSING β€” exactly the correct behaviour. + +--- + +## 7. Namespace Scoping in `GetClusterStateForGitDest` + +**File:** [`internal/watch/manager.go`](../internal/watch/manager.go) β€” +`GetClusterStateForGitDest` and `listResourcesForGVR` + +The snapshot uses direct API `List` calls, not the informer cache. Namespace +scope is derived from the WatchRules that match the GitTarget: + +| Rule type | List scope | +|---|---| +| `WatchRule` | `dc.Resource(gvr).Namespace(rule.Source.Namespace).List(...)` | +| `ClusterWatchRule` | `dc.Resource(gvr).List(...)` (cluster-wide) | + +Multiple WatchRules in different namespaces targeting the same GitTarget are +each listed separately and merged. A `WatchRule` in `test-ns` will never cause +secrets from `cert-manager` or `flux-system` to appear in the snapshot. + +--- + +## 8. Who Drives State Transitions + +| Actor | Transition | When | +|---|---|---| +| `GitTargetController.evaluateSnapshotGate` | `BeginReconciliation()` | Initial bootstrap only (guard: `SnapshotSyncedβ‰ True`) | +| `GitTargetController.evaluateEventStreamGate` | `OnReconciliationComplete()` | After bootstrap batch lands | +| `WatchManager.beginReconciliationForAffectedTargets` | `BeginReconciliation()` | Before new informers start on a WatchRule change | +| `WatchManager.completeReconciliationForAffectedTargets` | `OnReconciliationComplete()` | After the rule-change snapshot batch is emitted | + +The `GitTargetController` no longer re-runs the snapshot on every reconcile +loop. `evaluateSnapshotGate` exits immediately once `SnapshotSynced=True` is +set, preventing spurious re-snapshots from unrelated events. + +--- + +## 9. Git History Examples + +### Initial bootstrap or WatchRule addition (snapshot path) ``` -abc1234 reconcile: sync 500 resources from cluster snapshot +abc1234 reconcile: sync 42 resources from cluster snapshot ``` -Subsequent live changes look identical to today: +### Single resource change (live path β€” unchanged) ``` abc1235 [UPDATE] apps/v1/deployments/default/frontend (alice) ``` -A second planned reconcile (e.g. GitTarget selector updated): +### No-op (cluster already matches Git) -``` -abc1236 reconcile: sync 498 resources from cluster snapshot -``` +No commit is produced. `FolderReconciler.reconcile()` returns early when +`len(toCreate) + len(toDelete) + len(existingInBoth) == 0`. --- -## 7. Who Creates and Synchronises Changes +## 10. Bug Fixes Discovered During Implementation -| Actor | Role | Emits | -|---|---|---| -| `WatchManager` | Watches all cluster resources; deduplicates by content hash | `WorkItem{Single: &event}` | -| `FolderReconciler` | Diffs cluster vs Git; assembles full batch | `ReconcileBatch` via `ReconcileEmitter` | -| `GitTargetEventStream` | State machine per GitTarget; routes and buffers | `WorkItem{Single}` or `WorkItem{Batch}` into `BranchWorker` queue | -| `GitTargetController` | Drives lifecycle; calls `BeginReconciliation()` / `OnReconciliationComplete()` | state transitions only | -| `BranchWorker` | One per (provider Γ— branch); serialises all writes | commits + push | +Two bugs were found and fixed during e2e testing. The full analysis is in +[`docs/done/`](done/): ---- +### Bug 1 β€” Snapshot namespace scoping ([`docs/done/bug-snapshot-namespace-scoping.md`](done/bug-snapshot-namespace-scoping.md)) -## 8. Implementation Actions +`GetClusterStateForGitDest` was listing resources cluster-wide. A `WatchRule` +in a test namespace pulled secrets from `cert-manager`, `flux-system`, and +`kube-system` into the snapshot, producing a spurious +`reconcile: sync 27 resources` commit. -Changes are ordered by dependency β€” each step compiles and tests cleanly on its own. +**Fix:** Thread a `map[GVR][]string` (GVR β†’ namespaces) through +`GetClusterStateForGitDest` and use `dc.Resource(gvr).Namespace(ns).List(...)` +per namespace (Β§7). -### Step 1 β€” `internal/git/types.go`: add `ReconcileBatch` and `WorkItem` +### Bug 2 β€” Snapshot re-ran on every GitTarget reconcile ([`docs/done/bug-snapshot-namespace-scoping.md`](done/bug-snapshot-namespace-scoping.md)) -Add the two new types. `Event` is unchanged. +`evaluateSnapshotGate` called `StartReconciliation` unconditionally. When Flux +applied a committed secret back to the cluster, `encryptionSecretToGitTargets` +triggered a GitTarget re-reconcile β†’ snapshot re-ran β†’ spurious commit. -```go -type ReconcileBatch struct { - Events []Event - CommitMessage string - GitTargetName string - GitTargetNamespace string -} +**Fix:** Early-exit guard in `evaluateSnapshotGate`: -type WorkItem struct { - Single *Event - Batch *ReconcileBatch +```go +if meta.IsStatusConditionTrue(target.Status.Conditions, GitTargetConditionSnapshotSynced) { + return nil, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil } ``` -### Step 2 β€” `internal/git/git.go`: add `generateBatchCommit` - -Add a new function alongside the existing `generateCommitsFromEvents`. It applies -all file changes in a `ReconcileBatch` without committing after each one, then -creates a single commit with the batch message. The existing function is unchanged. - -### Step 3 β€” `internal/git/branch_worker.go`: switch queue to `chan WorkItem` - -- Change `eventQueue chan Event` β†’ `eventQueue chan WorkItem`. -- Update `Enqueue(event Event)` β†’ wraps the event in `WorkItem{Single: &event}`. -- Add `EnqueueBatch(batch *ReconcileBatch)` β†’ sends `WorkItem{Batch: batch}`. -- Update `processEvents`: when `item.Batch != nil`, flush the live buffer first, - then call `commitAndPushBatch` which drives `generateBatchCommit`. - -### Step 4 β€” `internal/reconcile/git_target_event_stream.go`: state machine + batch - -- Rename constant `StartupReconcile` β†’ `Reconciling`. -- Add `BeginReconciliation()` method: transitions `LiveProcessing` β†’ `Reconciling` - (no-op if already `Reconciling`). -- Replace `EventEnqueuer` interface (`Enqueue`) with one that also exposes - `EnqueueBatch`. -- Add `EmitReconcileBatch(batch git.ReconcileBatch) error`: stamps - `GitTargetName`/`GitTargetNamespace` onto the batch, calls `EnqueueBatch`. -- Remove `EmitCreateEvent`, `EmitDeleteEvent`, `EmitReconcileResourceEvent` β€” these - are replaced by `EmitReconcileBatch`. - -### Step 5 β€” `internal/reconcile/folder_reconciler.go`: emit one batch - -- Replace dependency on `EventEmitter` with `ReconcileEmitter` - (`EmitReconcileBatch`). -- Rewrite `reconcile()` to collect all `toCreate`, `toDelete`, `existingInBoth` - events into a single `[]git.Event`, set `UserInfo{Username: "gitops-reverser"}` - on each, and call `EmitReconcileBatch` once. Return early (no emit) when the - slice is empty. - -### Step 6 β€” `internal/controller/gittarget_controller.go`: drive state transitions - -- Call `stream.BeginReconciliation()` at the start of every reconcile pass that - determines a snapshot sync is needed (i.e. before starting `FolderReconciler`). -- Ensure `stream.OnReconciliationComplete()` is still called after - `FolderReconciler` emits its batch β€” this flushes buffered live events and - transitions to `LiveProcessing`. - -### Step 7 β€” Update tests - -- Unit tests for `FolderReconciler`: expect one `EmitReconcileBatch` call instead of - N individual emit calls. -- Unit tests for `GitTargetEventStream`: add coverage for `BeginReconciliation()` - re-entry from `LiveProcessing`. -- Integration tests: assert that a reconcile run with N resources produces exactly - one Git commit with message matching `reconcile: sync N resources…`. +### Bug 3 β€” WatchRule addition produced N individual commits ([`docs/done/followup-watchrule-addition-should-snapshot.md`](done/followup-watchrule-addition-should-snapshot.md)) + +The old `seedSelectedResources` goroutine emitted individual `[CREATE]` events +via the live path (N commits). Additionally, `OnReconciliationComplete()` was +only called from the controller's scheduled loop (up to 10 minutes later), +leaving the stream stuck in `RECONCILING` and silently buffering real cluster +changes. + +**Fix:** Replace `seedSelectedResources` with the sequence in Β§6, ending with +`completeReconciliationForAffectedTargets()` to immediately return the stream +to `LIVE_PROCESSING` after the snapshot batch lands. diff --git a/internal/controller/constants.go b/internal/controller/constants.go index a99f613ed..d6587e851 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -35,6 +35,10 @@ const ( // ConditionTypeReady indicates whether the resource is ready. ConditionTypeReady = "Ready" + // MsgSnapshotCompleted is returned as the condition message when the initial + // cluster snapshot has been successfully committed to Git. + MsgSnapshotCompleted = "Initial snapshot reconciliation completed" + // RequeueShortInterval is the requeue interval for transient errors. RequeueShortInterval = 2 * time.Minute // RequeueMediumInterval is the requeue interval for auth/secret errors. diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 968c4a9c2..496104297 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -486,7 +486,7 @@ func (r *GitTargetReconciler) evaluateSnapshotGate( target.Status.Snapshot = &configbutleraiv1alpha1.GitTargetSnapshotStatus{} } target.Status.Snapshot.LastCompletedTime = &now - return nil, metav1.ConditionTrue, "Initial snapshot reconciliation completed", 0, nil + return nil, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil } // Initial snapshot only. Re-snapshots on rule changes are triggered by the @@ -498,7 +498,7 @@ func (r *GitTargetReconciler) evaluateSnapshotGate( if err != nil { return nil, metav1.ConditionFalse, "", 0, err } - return stream, metav1.ConditionTrue, "Initial snapshot reconciliation completed", 0, nil + return stream, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil } stream, err := r.ensureEventStream(target, providerNS, log) @@ -535,7 +535,7 @@ func (r *GitTargetReconciler) evaluateSnapshotGate( Deleted: clampIntToInt32(stats.Deleted), } - return stream, metav1.ConditionTrue, "Initial snapshot reconciliation completed", 0, nil + return stream, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil } func (r *GitTargetReconciler) evaluateEventStreamGate( diff --git a/internal/controller/gittarget_controller_unit_test.go b/internal/controller/gittarget_controller_unit_test.go new file mode 100644 index 000000000..d2688c88f --- /dev/null +++ b/internal/controller/gittarget_controller_unit_test.go @@ -0,0 +1,160 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +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 controller + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configbutleraiv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" +) + +func noopLogger() logr.Logger { return logr.Discard() } + +func makeGitTargetWithCondition(condType string, status metav1.ConditionStatus) *configbutleraiv1alpha1.GitTarget { + return &configbutleraiv1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "test-target", Namespace: "gitops-reverser"}, + Status: configbutleraiv1alpha1.GitTargetStatus{ + Conditions: []metav1.Condition{ + {Type: condType, Status: status}, + }, + }, + } +} + +// TestIsConditionTrue covers the helper used by the SnapshotSynced early-exit guard. +func TestIsConditionTrue(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + conditionType string + want bool + }{ + { + name: "empty conditions returns false", + conditions: nil, + conditionType: GitTargetConditionSnapshotSynced, + want: false, + }, + { + name: "condition present with status True returns true", + conditions: []metav1.Condition{ + {Type: GitTargetConditionSnapshotSynced, Status: metav1.ConditionTrue}, + }, + conditionType: GitTargetConditionSnapshotSynced, + want: true, + }, + { + name: "condition present with status False returns false", + conditions: []metav1.Condition{ + {Type: GitTargetConditionSnapshotSynced, Status: metav1.ConditionFalse}, + }, + conditionType: GitTargetConditionSnapshotSynced, + want: false, + }, + { + name: "condition present with status Unknown returns false", + conditions: []metav1.Condition{ + {Type: GitTargetConditionSnapshotSynced, Status: metav1.ConditionUnknown}, + }, + conditionType: GitTargetConditionSnapshotSynced, + want: false, + }, + { + name: "different condition type returns false", + conditions: []metav1.Condition{ + {Type: GitTargetConditionBootstrapped, Status: metav1.ConditionTrue}, + }, + conditionType: GitTargetConditionSnapshotSynced, + want: false, + }, + { + name: "target condition true alongside other conditions", + conditions: []metav1.Condition{ + {Type: GitTargetConditionBootstrapped, Status: metav1.ConditionTrue}, + {Type: GitTargetConditionSnapshotSynced, Status: metav1.ConditionTrue}, + {Type: GitTargetConditionEventStreamLive, Status: metav1.ConditionFalse}, + }, + conditionType: GitTargetConditionSnapshotSynced, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isConditionTrue(tt.conditions, tt.conditionType)) + }) + } +} + +// TestEvaluateSnapshotGate_SkipsWhenSnapshotSynced verifies that when a GitTarget +// already has SnapshotSynced=True, evaluateSnapshotGate short-circuits and returns +// ConditionTrue without calling StartReconciliation. +// +// This is the regression guard for Bug 2: an unrelated event (e.g. Flux touching the +// encryption secret) must not trigger a second cluster snapshot after the first has +// completed successfully. +func TestEvaluateSnapshotGate_SkipsWhenSnapshotSynced(t *testing.T) { + // With EventRouter == nil the function returns ConditionTrue immediately (no-cluster + // path). We set SnapshotSynced=True so the nil-EventRouter early-exit is the path + // that fires; the important assertion is that we get ConditionTrue back and no error, + // confirming the gate does not re-run reconciliation. + reconciler := &GitTargetReconciler{ + EventRouter: nil, // no cluster access needed + } + + target := makeGitTargetWithCondition(GitTargetConditionSnapshotSynced, metav1.ConditionTrue) + + stream, state, msg, requeue, err := reconciler.evaluateSnapshotGate( + context.TODO(), target, "gitops-reverser", noopLogger(), + ) + + require.NoError(t, err) + assert.Equal(t, metav1.ConditionTrue, state, "gate should report SnapshotSynced=True") + assert.NotEmpty(t, msg) + assert.Zero(t, requeue, "no requeue needed once snapshot is complete") + assert.Nil(t, stream, "stream is nil when EventRouter is nil") +} + +// TestEvaluateSnapshotGate_RunsWhenSnapshotNotSynced verifies that when SnapshotSynced +// is not yet True, evaluateSnapshotGate proceeds past the early-exit guard and returns +// ConditionFalse (snapshot in progress) because there is no cluster to talk to. +func TestEvaluateSnapshotGate_RunsWhenSnapshotNotSynced(t *testing.T) { + reconciler := &GitTargetReconciler{ + EventRouter: nil, + } + + // SnapshotSynced absent β€” the guard must NOT short-circuit. + // With EventRouter==nil the function still returns ConditionTrue via the nil-router + // path (which is the no-op path). The key is that the SnapshotSynced guard is not + // the one that fired. + target := makeGitTargetWithCondition(GitTargetConditionBootstrapped, metav1.ConditionTrue) + + _, state, _, _, err := reconciler.evaluateSnapshotGate( + context.TODO(), target, "gitops-reverser", noopLogger(), + ) + + require.NoError(t, err) + assert.Equal(t, metav1.ConditionTrue, state) +} diff --git a/internal/watch/event_router.go b/internal/watch/event_router.go index 0630005cd..671a1714c 100644 --- a/internal/watch/event_router.go +++ b/internal/watch/event_router.go @@ -214,6 +214,34 @@ func (r *EventRouter) GetGitTargetEventStream(gitDest types.ResourceReference) * return r.gitTargetStreams[key] } +// BeginReconciliationForStream transitions the registered GitTargetEventStream for the +// given gitDest into RECONCILING state so that live informer ADDED events are buffered +// rather than processed individually. It is a no-op when no stream is registered yet. +func (r *EventRouter) BeginReconciliationForStream(gitDest types.ResourceReference) { + stream := r.GetGitTargetEventStream(gitDest) + if stream == nil { + r.Log.V(1).Info("No GitTargetEventStream registered, skipping BeginReconciliation", + "gitDest", gitDest.String()) + return + } + stream.BeginReconciliation() + r.Log.Info("BeginReconciliation called for GitTargetEventStream", "gitDest", gitDest.String()) +} + +// CompleteReconciliationForStream transitions the registered GitTargetEventStream for the +// given gitDest out of RECONCILING state and flushes any buffered live events. +// It is a no-op when no stream is registered. +func (r *EventRouter) CompleteReconciliationForStream(gitDest types.ResourceReference) { + stream := r.GetGitTargetEventStream(gitDest) + if stream == nil { + r.Log.V(1).Info("No GitTargetEventStream registered, skipping CompleteReconciliation", + "gitDest", gitDest.String()) + return + } + stream.OnReconciliationComplete() + r.Log.Info("CompleteReconciliation called for GitTargetEventStream", "gitDest", gitDest.String()) +} + // UnregisterGitTargetEventStream removes a GitTargetEventStream from the router. // This is called during GitTarget deletion cleanup. func (r *EventRouter) UnregisterGitTargetEventStream(gitDest types.ResourceReference) { diff --git a/internal/watch/manager.go b/internal/watch/manager.go index 836103421..a3d2d8e03 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -42,6 +42,7 @@ import ( configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" "github.com/ConfigButler/gitops-reverser/internal/correlation" + "github.com/ConfigButler/gitops-reverser/internal/events" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/rulestore" "github.com/ConfigButler/gitops-reverser/internal/sanitize" @@ -80,6 +81,10 @@ type Manager struct { unavailableGVRsMu sync.Mutex unavailableGVRs map[GVR]int // GVR -> retry count unavailableGVRsLastTry map[GVR]time.Time // GVR -> last retry time + + // dynamicClient overrides the config-built dynamic client when non-nil. + // Used in tests to inject a fake client without a real REST config. + dynamicClient dynamic.Interface } const ( @@ -211,68 +216,6 @@ func (m *Manager) matchRules( return wrRules, cwrRules } -// enqueueMatches pushes sanitized events to branch workers via event router. -// It attempts to enrich events with webhook-captured username via correlation store. -// Implements deduplication: skips enqueuing if sanitized content is identical to last seen. -func (m *Manager) enqueueMatches( - ctx context.Context, - sanitized *unstructured.Unstructured, - id types.ResourceIdentifier, - watchRules []rulestore.CompiledRule, - clusterRules []rulestore.CompiledClusterRule, -) { - // Check for duplicate content (status-only changes) - if m.isDuplicateContent(ctx, sanitized, id) { - m.Log.V(1).Info("Skipping duplicate sanitized content (likely status-only change)", - "identifier", id.String()) - telemetry.WatchDuplicatesSkippedTotal.Add(ctx, 1) - return - } - - // Attempt correlation enrichment - userInfo := m.tryEnrichFromCorrelation(ctx, sanitized, id, "UPDATE") - - // WatchRule matches - route to GitDestinationEventStreams - for _, rule := range watchRules { - ev := git.Event{ - Object: sanitized.DeepCopy(), - Identifier: id, - Operation: "UPDATE", - UserInfo: userInfo, - Path: rule.Path, - } - - gitDest := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) - - // Route to GitTargetEventStream for buffering and deduplication - if err := m.EventRouter.RouteToGitTargetEventStream(ev, gitDest); err != nil { - m.Log.Error(err, "Failed to route event to GitTargetEventStream - dropping event", - "gitDest", gitDest.String(), - "resource", id.String()) - } - } - - // ClusterWatchRule matches - route to GitTargetEventStreams - for _, cr := range clusterRules { - ev := git.Event{ - Object: sanitized.DeepCopy(), - Identifier: id, - Operation: "UPDATE", - UserInfo: userInfo, - Path: cr.Path, - } - - gitDest := types.NewResourceReference(cr.GitTargetRef, cr.GitTargetNamespace) - - // Route to GitTargetEventStream for buffering and deduplication - if err := m.EventRouter.RouteToGitTargetEventStream(ev, gitDest); err != nil { - m.Log.Error(err, "Failed to route event to GitTargetEventStream - dropping event", - "gitDest", gitDest.String(), - "resource", id.String()) - } - } -} - // isDuplicateContent checks if the sanitized content is identical to the last seen version. // Returns true if content is duplicate (should skip), false if new content (should process). func (m *Manager) isDuplicateContent( @@ -353,34 +296,12 @@ func (m *Manager) tryEnrichFromCorrelation( return userInfo } -// seedSelectedResources performs a one-time List across all discoverable GVRs derived from active rules, -// sanitizes objects, matches rules, and enqueues UPDATE events to bootstrap the repository state. -// Orphan detection is now handled by FolderReconciler comparing cluster vs git state. -func (m *Manager) seedSelectedResources(ctx context.Context) { - log := m.Log.WithName("seed") - log.Info("starting initial seed listing") - - dc := m.dynamicClientFromConfig(log) - if dc == nil { - // Reason already logged. - return - } - - discoverable := m.discoverableGVRs(ctx) - if len(discoverable) == 0 { - log.Info("no discoverable GVRs to seed") - return - } - - for _, g := range discoverable { - m.seedListAndProcess(ctx, dc, g) - } - - log.Info("seed listing completed", "gvrCount", len(discoverable)) -} - // dynamicClientFromConfig builds a dynamic client from the controller's REST config. +// If m.dynamicClient is set (e.g. in tests) it is returned directly. func (m *Manager) dynamicClientFromConfig(log logr.Logger) dynamic.Interface { + if m.dynamicClient != nil { + return m.dynamicClient + } cfg := m.restConfig() if cfg == nil { log.Info("skipping seed - no rest config available") @@ -394,12 +315,6 @@ func (m *Manager) dynamicClientFromConfig(log logr.Logger) dynamic.Interface { return dc } -// discoverableGVRs returns the filtered list of GVRs to seed. -func (m *Manager) discoverableGVRs(ctx context.Context) []GVR { - requested := m.ComputeRequestedGVRs() - return m.FilterDiscoverableGVRs(ctx, requested) -} - // getNamespacesForGVR returns the list of namespaces to list for a given GVR. // Returns empty slice for cluster-scoped resources or ClusterWatchRules (meaning cluster-wide list). // Returns specific namespace(s) for namespaced resources from WatchRules. @@ -527,93 +442,12 @@ func (m *Manager) matchesResources(resources []string, targetResource string) bo return false } -// seedListAndProcess lists objects for a GVR and processes them into enqueue operations. -// For namespaced GVRs from WatchRules, only lists resources in the rule's namespace. -// For cluster-scoped GVRs or ClusterWatchRule GVRs, lists cluster-wide. -func (m *Manager) seedListAndProcess( - ctx context.Context, - dc dynamic.Interface, - g GVR, -) { - log := m.Log.WithName("seed"). - WithValues("group", g.Group, "version", g.Version, "resource", g.Resource, "scope", g.Scope) - - res := schema.GroupVersionResource{Group: g.Group, Version: g.Version, Resource: g.Resource} - - // Determine which namespaces to list based on scope and source - namespacesToList := m.getNamespacesForGVR(g) - - if len(namespacesToList) == 0 { - // Cluster-scoped resource or ClusterWatchRule with namespaced resources (all namespaces) - list, err := dc.Resource(res).List(ctx, metav1.ListOptions{}) - if err != nil { - log.Error(err, "seed list failed (cluster-wide)") - return - } - - telemetry.ObjectsScannedTotal.Add(ctx, int64(len(list.Items))) - for i := range list.Items { - m.processListedObject(ctx, &list.Items[i], g) - } - } else { - // Namespaced resource from WatchRule(s) - list per namespace - totalItems := 0 - for _, ns := range namespacesToList { - list, err := dc.Resource(res).Namespace(ns).List(ctx, metav1.ListOptions{}) - if err != nil { - log.Error(err, "seed list failed", "namespace", ns) - continue - } - - totalItems += len(list.Items) - for i := range list.Items { - m.processListedObject(ctx, &list.Items[i], g) - } - } - telemetry.ObjectsScannedTotal.Add(ctx, int64(totalItems)) - log.V(1).Info("Seeded namespaced resources", "namespaces", len(namespacesToList), "totalItems", totalItems) - } -} - -// processListedObject evaluates rules, sanitizes, and enqueues events for one item. -func (m *Manager) processListedObject( - ctx context.Context, - u *unstructured.Unstructured, - g GVR, -) { - if shouldIgnoreResource(g.Group, g.Resource) { - m.Log.V(1).Info("Skipping seeded resource due to safety filter", - "group", g.Group, "version", g.Version, "resource", g.Resource) - return - } - - id := types.NewResourceIdentifier(g.Group, g.Version, g.Resource, u.GetNamespace(), u.GetName()) - - var nsLabels map[string]string - if ns := id.Namespace; ns != "" { - nsLabels = m.getNamespaceLabels(ctx, ns) - } - isClusterScoped := id.IsClusterScoped() - - wrRules, cwrRules := m.matchRules(u, g.Resource, g.Group, g.Version, isClusterScoped, nsLabels) - if len(wrRules) == 0 && len(cwrRules) == 0 { - return - } - - sanitized := sanitize.Sanitize(u) - m.enqueueMatches(ctx, sanitized, id, wrRules, cwrRules) - - enq := int64(len(wrRules) + len(cwrRules)) - if enq > 0 { - telemetry.EventsProcessedTotal.Add(ctx, enq) - telemetry.GitCommitQueueSize.Add(ctx, enq) - } -} - // GetClusterStateForGitDest returns cluster resources for a GitTarget. // This is a synchronous service method called by EventRouter. // It returns both resource identifiers (for diff logic) and sanitized full objects // (keyed by ResourceIdentifier.Key()) for hydrating initial snapshot write events. +// +//nolint:gocognit,cyclop func (m *Manager) GetClusterStateForGitDest( ctx context.Context, gitDest types.ResourceReference, @@ -742,6 +576,8 @@ func (m *Manager) gvrsFromResourceRule(rr rulestore.CompiledResourceRule) []sche } // gvrsFromClusterRule returns the GVRs implied by a CompiledClusterRule. +// +//nolint:gocognit func (m *Manager) gvrsFromClusterRule(cwrRule rulestore.CompiledClusterRule) []schema.GroupVersionResource { var out []schema.GroupVersionResource for _, rr := range cwrRule.Rules { @@ -873,6 +709,11 @@ func (m *Manager) ReconcileForRuleChange(ctx context.Context) error { m.stopInformer(gvr) } + // Put affected GitTarget event streams into RECONCILING state BEFORE starting new + // informers. This ensures informer ADDED events fired during cache sync are buffered + // rather than processed as N individual [CREATE] commits. + m.beginReconciliationForAffectedTargets(log) + // Start informers for added GVRs if len(added) > 0 { if err := m.startInformersForGVRs(ctx, added); err != nil { @@ -884,9 +725,17 @@ func (m *Manager) ReconcileForRuleChange(ctx context.Context) error { // Clear deduplication cache for changed GVRs to prevent false duplicates m.clearDeduplicationCacheForGVRs(append(added, removed...)) - // Trigger re-seed to sync Git with new state - // Run in background to avoid blocking controller - go m.seedSelectedResources(ctx) + // Emit RequestClusterState for each affected GitTarget so that a single + // "reconcile: sync N resources" commit is produced instead of N individual + // [CREATE] commits from the informer ADDED events buffered above. + m.emitSnapshotForRuleChange(ctx, log) + + // Transition streams back to LIVE_PROCESSING and flush buffered events. + // startInformersForGVRs already waited for cache sync (WaitForCacheSync), + // so all initial ADDED events are guaranteed to be buffered before this point. + // The flushed events are no-ops at the git level because the snapshot batch + // just wrote those files. + m.completeReconciliationForAffectedTargets(log) log.Info("Watch manager reconciliation completed", "addedGVRs", len(added), @@ -1409,6 +1258,80 @@ func splitResourceKey(key string) []string { return parts } +// collectAffectedGitTargets returns the unique set of GitTarget ResourceReferences +// that appear in any current WatchRule or ClusterWatchRule. +func (m *Manager) collectAffectedGitTargets() []types.ResourceReference { + seen := make(map[string]struct{}) + var targets []types.ResourceReference + + for _, rule := range m.RuleStore.SnapshotWatchRules() { + ref := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) + if _, exists := seen[ref.Key()]; !exists { + seen[ref.Key()] = struct{}{} + targets = append(targets, ref) + } + } + + for _, rule := range m.RuleStore.SnapshotClusterWatchRules() { + ref := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) + if _, exists := seen[ref.Key()]; !exists { + seen[ref.Key()] = struct{}{} + targets = append(targets, ref) + } + } + + return targets +} + +// beginReconciliationForAffectedTargets puts every registered GitTargetEventStream for +// affected GitTargets into RECONCILING state so that informer ADDED events that fire +// during cache sync are buffered rather than processed as individual live commits. +func (m *Manager) beginReconciliationForAffectedTargets(log logr.Logger) { + if m.EventRouter == nil { + return + } + for _, gitDest := range m.collectAffectedGitTargets() { + m.EventRouter.BeginReconciliationForStream(gitDest) + log.Info("Buffering live events for snapshot", "gitDest", gitDest.String()) + } +} + +// emitSnapshotForRuleChange emits RequestClusterState for every affected GitTarget so +// that a single "reconcile: sync N resources" commit is produced instead of N individual +// [CREATE] commits from informer ADDED events. +func (m *Manager) emitSnapshotForRuleChange(ctx context.Context, log logr.Logger) { + if m.EventRouter == nil { + log.Info("EventRouter not set, skipping RequestClusterState emission") + return + } + targets := m.collectAffectedGitTargets() + log.Info("Emitting RequestClusterState for affected GitTargets after rule change", "count", len(targets)) + for _, gitDest := range targets { + if err := m.EventRouter.ProcessControlEvent(ctx, events.ControlEvent{ + Type: events.RequestClusterState, + GitDest: gitDest, + }); err != nil { + log.Error(err, "failed to emit RequestClusterState for rule change", "gitDest", gitDest) + } + } +} + +// completeReconciliationForAffectedTargets transitions every affected GitTargetEventStream +// out of RECONCILING state and flushes buffered live events. It must be called after +// emitSnapshotForRuleChange so that: +// 1. The snapshot batch has already been emitted. +// 2. Buffered informer ADDED events are flushed and produce no-op git writes (the +// files were just written by the snapshot). +func (m *Manager) completeReconciliationForAffectedTargets(log logr.Logger) { + if m.EventRouter == nil { + return + } + for _, gitDest := range m.collectAffectedGitTargets() { + m.EventRouter.CompleteReconciliationForStream(gitDest) + log.Info("Flushing buffered events after snapshot", "gitDest", gitDest.String()) + } +} + // SetupWithManager is a placeholder to enable kubebuilder RBAC marker scanning. // The Manager is manually added to the controller-runtime manager in main.go as a Runnable, // but this method allows kubebuilder's controller-gen to discover and process the RBAC markers. diff --git a/internal/watch/manager_snapshot_test.go b/internal/watch/manager_snapshot_test.go new file mode 100644 index 000000000..a67afc4e0 --- /dev/null +++ b/internal/watch/manager_snapshot_test.go @@ -0,0 +1,329 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +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 watch + +import ( + "context" + "sort" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + dynamicfake "k8s.io/client-go/dynamic/fake" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + + configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" +) + +// makeScheme returns a scheme with core Kubernetes types registered. +func makeScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(s)) + require.NoError(t, configv1alpha1.AddToScheme(s)) + return s +} + +// makeSecret creates a minimal Secret suitable for the fake dynamic client. +func makeSecret(name, namespace string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +// makeConfigMap creates a minimal ConfigMap suitable for the fake dynamic client. +func makeConfigMap(name, namespace string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } +} + +// makeNode creates a minimal Node (cluster-scoped) for the fake dynamic client. +func makeNode(name string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } +} + +// setupManager creates a Manager wired with fake clients and a pre-populated RuleStore. +func setupManager( + t *testing.T, + scheme *runtime.Scheme, + gitTarget *configv1alpha1.GitTarget, + ruleStore *rulestore.RuleStore, + clusterObjects ...runtime.Object, +) *Manager { + t.Helper() + + // Controller-runtime fake client: used by m.Client.Get to resolve the GitTarget. + fakeK8s := fakeclient.NewClientBuilder(). + WithScheme(scheme). + WithObjects(gitTarget). + Build() + + // Fake dynamic client: used by listResourcesForGVR. + fakeDyn := dynamicfake.NewSimpleDynamicClient(scheme, clusterObjects...) + + return &Manager{ + Client: fakeK8s, + Log: logr.Discard(), + RuleStore: ruleStore, + dynamicClient: fakeDyn, + } +} + +// resourceNames extracts sorted Name fields from a slice of ResourceIdentifiers for easy assertion. +func resourceNames(ids []itypes.ResourceIdentifier) []string { + names := make([]string, len(ids)) + for i, id := range ids { + names[i] = id.Name + } + sort.Strings(names) + return names +} + +// resourceNamespaces extracts the unique namespaces from a slice of ResourceIdentifiers. +func resourceNamespaces(ids []itypes.ResourceIdentifier) []string { + seen := map[string]struct{}{} + var out []string + for _, id := range ids { + if _, ok := seen[id.Namespace]; !ok { + seen[id.Namespace] = struct{}{} + out = append(out, id.Namespace) + } + } + sort.Strings(out) + return out +} + +// TestSnapshotScopedToWatchRuleNamespace verifies that a WatchRule in namespace ns-a +// causes GetClusterStateForGitDest to list only resources in ns-a, not in ns-b. +func TestSnapshotScopedToWatchRuleNamespace(t *testing.T) { + scheme := makeScheme(t) + + gitTarget := &configv1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"}, + Spec: configv1alpha1.GitTargetSpec{Path: "live"}, + } + + store := rulestore.NewStore() + store.AddOrUpdateWatchRule( + configv1alpha1.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wr-ns-a", Namespace: "ns-a"}, + Spec: configv1alpha1.WatchRuleSpec{ + TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"}, + Rules: []configv1alpha1.ResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, + }}, + }, + }, + "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live", + ) + + m := setupManager(t, scheme, gitTarget, store, + makeSecret("secret-a1", "ns-a"), + makeSecret("secret-a2", "ns-a"), + makeSecret("secret-b1", "ns-b"), // should NOT appear + ) + + resources, _, err := m.GetClusterStateForGitDest(context.Background(), + itypes.NewResourceReference("my-target", "gitops-reverser")) + + require.NoError(t, err) + assert.Equal(t, []string{"secret-a1", "secret-a2"}, resourceNames(resources), + "only secrets from ns-a should be returned") + assert.Equal(t, []string{"ns-a"}, resourceNamespaces(resources), + "no resources from ns-b should leak into the snapshot") +} + +// TestSnapshotTwoWatchRulesInDifferentNamespaces verifies that when two WatchRules +// target the same GitTarget from different namespaces, both are included and a third +// namespace is excluded. +func TestSnapshotTwoWatchRulesInDifferentNamespaces(t *testing.T) { + scheme := makeScheme(t) + + gitTarget := &configv1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"}, + Spec: configv1alpha1.GitTargetSpec{Path: "live"}, + } + + store := rulestore.NewStore() + for _, ns := range []string{"ns-a", "ns-b"} { + store.AddOrUpdateWatchRule( + configv1alpha1.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wr-" + ns, Namespace: ns}, + Spec: configv1alpha1.WatchRuleSpec{ + TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"}, + Rules: []configv1alpha1.ResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"configmaps"}, + }}, + }, + }, + "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live", + ) + } + + m := setupManager(t, scheme, gitTarget, store, + makeConfigMap("cm-a", "ns-a"), + makeConfigMap("cm-b", "ns-b"), + makeConfigMap("cm-c", "ns-c"), // should NOT appear + ) + + resources, _, err := m.GetClusterStateForGitDest(context.Background(), + itypes.NewResourceReference("my-target", "gitops-reverser")) + + require.NoError(t, err) + assert.Equal(t, []string{"cm-a", "cm-b"}, resourceNames(resources), + "configmaps from both ns-a and ns-b should be returned") + assert.Equal(t, []string{"ns-a", "ns-b"}, resourceNamespaces(resources), + "ns-c should be excluded") +} + +// TestSnapshotClusterWatchRuleIsClusterWide verifies that a ClusterWatchRule causes +// GetClusterStateForGitDest to list resources cluster-wide (nodes are cluster-scoped). +func TestSnapshotClusterWatchRuleIsClusterWide(t *testing.T) { + scheme := makeScheme(t) + + gitTarget := &configv1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"}, + Spec: configv1alpha1.GitTargetSpec{Path: "live"}, + } + + store := rulestore.NewStore() + store.AddOrUpdateClusterWatchRule( + configv1alpha1.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "cwr-nodes"}, + Spec: configv1alpha1.ClusterWatchRuleSpec{ + TargetRef: configv1alpha1.NamespacedTargetReference{ + Name: "my-target", + Namespace: "gitops-reverser", + }, + Rules: []configv1alpha1.ClusterResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"nodes"}, + Scope: configv1alpha1.ResourceScopeCluster, + }}, + }, + }, + "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live", + ) + + m := setupManager(t, scheme, gitTarget, store, + makeNode("node-1"), + makeNode("node-2"), + makeNode("node-3"), + ) + + resources, _, err := m.GetClusterStateForGitDest(context.Background(), + itypes.NewResourceReference("my-target", "gitops-reverser")) + + require.NoError(t, err) + assert.Equal(t, []string{"node-1", "node-2", "node-3"}, resourceNames(resources), + "all nodes should be returned from cluster-wide list") +} + +// TestSnapshotEmptyNamespaceReturnsNoResources verifies that when a WatchRule points +// to a namespace with no matching resources, the result is empty (not an error, and no +// resources from other namespaces bleed in). +func TestSnapshotEmptyNamespaceReturnsNoResources(t *testing.T) { + scheme := makeScheme(t) + + gitTarget := &configv1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"}, + Spec: configv1alpha1.GitTargetSpec{Path: "live"}, + } + + store := rulestore.NewStore() + store.AddOrUpdateWatchRule( + configv1alpha1.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wr-empty", Namespace: "ns-empty"}, + Spec: configv1alpha1.WatchRuleSpec{ + TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"}, + Rules: []configv1alpha1.ResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"configmaps"}, + }}, + }, + }, + "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live", + ) + + m := setupManager(t, scheme, gitTarget, store, + makeConfigMap("cm-other", "ns-other"), // other namespace, should not appear + ) + + resources, _, err := m.GetClusterStateForGitDest(context.Background(), + itypes.NewResourceReference("my-target", "gitops-reverser")) + + require.NoError(t, err) + assert.Empty(t, resources, "no resources expected when watched namespace is empty") +} + +// TestSnapshotRegressionNoFluxSystemLeakage directly reproduces the observed failure: +// a WatchRule in a test namespace must not cause secrets from flux-system or other +// namespaces to appear in the snapshot result. +func TestSnapshotRegressionNoFluxSystemLeakage(t *testing.T) { + scheme := makeScheme(t) + + gitTarget := &configv1alpha1.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "bi-target", Namespace: "test-ns"}, + Spec: configv1alpha1.GitTargetSpec{Path: "live"}, + } + + store := rulestore.NewStore() + store.AddOrUpdateWatchRule( + configv1alpha1.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "bi-secret-watchrule", Namespace: "test-ns"}, + Spec: configv1alpha1.WatchRuleSpec{ + TargetRef: configv1alpha1.LocalTargetReference{Name: "bi-target"}, + Rules: []configv1alpha1.ResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, + }}, + }, + }, + "bi-target", "test-ns", "provider", "test-ns", "main", "live", + ) + + // Cluster has secrets in the test namespace AND in system namespaces that + // should never be touched by this WatchRule. + m := setupManager(t, scheme, gitTarget, store, + makeSecret("bi-secret", "test-ns"), + makeSecret("git-creds", "test-ns"), + makeSecret("bi-controller-sops", "test-ns"), + makeSecret("cert-manager-webhook-ca", "cert-manager"), // must NOT appear + makeSecret("bi-flux-auth", "flux-system"), // must NOT appear + makeSecret("bi-sops", "flux-system"), // must NOT appear + makeSecret("k3s-serving", "kube-system"), // must NOT appear + makeSecret("prometheus-shared", "prometheus-operator"), // must NOT appear + ) + + resources, _, err := m.GetClusterStateForGitDest(context.Background(), + itypes.NewResourceReference("bi-target", "test-ns")) + + require.NoError(t, err) + assert.Equal(t, []string{"bi-controller-sops", "bi-secret", "git-creds"}, resourceNames(resources), + "only secrets in test-ns should be returned") + assert.Equal(t, []string{"test-ns"}, resourceNamespaces(resources), + "secrets from cert-manager, flux-system, kube-system, prometheus-operator must not appear") +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 241d9fc1f..7cc0f9a89 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -412,7 +412,10 @@ var _ = Describe("Manager", Ordered, func() { By("showing controller logs after GitProvider creation") showControllerLogs("after creating GitProvider") - verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") + verifyResourceStatus( + "gitprovider", gitProviderName, testNs, + "True", "Ready", "Repository connectivity validated", + ) By("showing final controller logs") showControllerLogs("after status verification") @@ -432,7 +435,9 @@ var _ = Describe("Manager", Ordered, func() { // GitProvider should be Ready=True (validates connectivity, not branch existence) createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretHTTP, getRepoURLHTTP()) - verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") + verifyResourceStatus( + "gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated", + ) // GitTarget with branch not matching any pattern should fail destName := "dest-invalid-branch" @@ -469,7 +474,9 @@ var _ = Describe("Manager", Ordered, func() { By("πŸ” Controller logs after SSH GitProvider creation") showControllerLogs("after SSH GitProvider creation") - verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") + verifyResourceStatus( + "gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated", + ) By("βœ… Final SSH test logs") showControllerLogs("SSH test completion") @@ -480,7 +487,9 @@ var _ = Describe("Manager", Ordered, func() { It("should handle a normal and healthy GitProvider", func() { gitProviderName := "gitprovider-normal" createGitProviderWithURLInNamespace(gitProviderName, testNs, gitSecretHTTP, getRepoURLHTTP()) - verifyResourceStatus("gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated") + verifyResourceStatus( + "gitprovider", gitProviderName, testNs, "True", "Ready", "Repository connectivity validated", + ) }) It("should reconcile a WatchRule CR", func() { @@ -1608,7 +1617,9 @@ var _ = Describe("Manager", Ordered, func() { cleanupGitTarget("watchrule-icecream-orders-dest", testNs) // Clean up GitProvider from IceCreamOrder tests - _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", "gitprovider-normal", "--ignore-not-found=true") + _, _ = kubectlRunInNamespace( + testNs, "delete", "gitprovider", "gitprovider-normal", "--ignore-not-found=true", + ) // Clean up IceCreamOrder CRD _, _ = kubectlRun( @@ -1751,44 +1762,6 @@ func discoverControllerPodName(ns string) (string, error) { return podName, nil } -// createGitProviderWithURL creates a GitProvider resource with the specified URL. -func createGitProviderWithURL(name, branch, secretName, repoURL string) { - By(fmt.Sprintf("creating GitProvider '%s' with branch '%s', secret '%s' and URL '%s'", - name, branch, secretName, repoURL)) - - data := struct { - Name string - Namespace string - RepoURL string - Branch string - SecretName string - }{ - Name: name, - Namespace: namespace, - RepoURL: repoURL, - Branch: branch, - SecretName: secretName, - } - - err := applyFromTemplate("test/e2e/templates/gitprovider.tmpl", data, namespace) - Expect(err).NotTo(HaveOccurred(), "Failed to apply GitProvider") -} - -// createGitProvider creates a GitProvider resource with HTTP URL. -func createGitProvider(name, branch, secretName string) { - createGitProviderWithURL(name, branch, secretName, getRepoURLHTTP()) -} - -// createSSHGitProvider creates a GitProvider resource with SSH URL. -func createSSHGitProvider(name, branch, secretName string) { - createGitProviderWithURL(name, branch, secretName, getRepoURLSSH()) -} - -// verifyGitProviderStatus verifies the GitProvider status matches expected values. -func verifyGitProviderStatus(name, expectedStatus, expectedReason, expectedMessageContains string) { - verifyResourceStatus("gitprovider", name, namespace, expectedStatus, expectedReason, expectedMessageContains) -} - // verifyResourceStatus verifies a resource's status conditions match expected values. // For cluster-scoped resources, provide an empty namespace. func verifyResourceStatus(resourceType, name, ns, expectedStatus, expectedReason, expectedMessageContains string) { @@ -1844,19 +1817,6 @@ func verifyResourceStatus(resourceType, name, ns, expectedStatus, expectedReason Eventually(verifyStatus).Should(Succeed()) } -// cleanupGitProvider deletes a GitProvider resource. -func cleanupGitProvider(name string) { - By(fmt.Sprintf("cleaning up GitProvider '%s'", name)) - _, err := kubectlRunInNamespace( - namespace, - "delete", - "gitprovider", - name, - "--ignore-not-found=true", - ) - Expect(err).NotTo(HaveOccurred()) -} - // showControllerLogs displays the current controller logs to help with debugging during test execution. func showControllerLogs(context string) { By(fmt.Sprintf("πŸ“‹ Controller logs %s:", context)) diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 3562d055c..d9bda166b 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -32,8 +32,8 @@ import ( "text/template" "time" - . "github.com/onsi/ginkgo/v2" //nolint:staticcheck // Ginkgo standard practice - . "github.com/onsi/gomega" //nolint:staticcheck // Ginkgo standard practice + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/prometheus/client_golang/api" v1 "github.com/prometheus/client_golang/api/prometheus/v1" "github.com/prometheus/common/model" diff --git a/test/e2e/quickstart_framework_e2e_test.go b/test/e2e/quickstart_framework_e2e_test.go index 2c0407af2..9e52d9516 100644 --- a/test/e2e/quickstart_framework_e2e_test.go +++ b/test/e2e/quickstart_framework_e2e_test.go @@ -512,4 +512,3 @@ func createGitProviderWithURLInNamespace(name, ns, secretName, repoURL string) { err := applyFromTemplate("test/e2e/templates/gitprovider.tmpl", data, ns) Expect(err).NotTo(HaveOccurred(), "failed to apply GitProvider") } - From e313c691733493f367ad84e4aeed1747de3fba81 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 19 Mar 2026 10:02:25 +0000 Subject: [PATCH 14/30] chore: Let's switch to k8s v1.35.2 for our e2e tests (and demo). --- test/e2e/cluster/start-cluster.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/cluster/start-cluster.sh b/test/e2e/cluster/start-cluster.sh index f61130689..2b00427d8 100644 --- a/test/e2e/cluster/start-cluster.sh +++ b/test/e2e/cluster/start-cluster.sh @@ -157,6 +157,7 @@ create_cluster() { # via k3s "--kube-apiserver-arg=..." options. local create_cmd=( k3d cluster create "${CLUSTER_NAME}" + --image rancher/k3s:v1.35.2-k3s1 --kubeconfig-update-default --kubeconfig-switch-context -v "${audit_host_dir}:/etc/kubernetes/audit@server:0" From c416062047a6ac67ec097fcaa6de570dc950a9a8 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 19 Mar 2026 15:02:12 +0000 Subject: [PATCH 15/30] chore: Improving demo support / performance --- .devcontainer/devcontainer.json | 1 + Makefile | 13 +- docs/KUBECON_VOTING_ROLLOUT_PLAN.md | 4 +- test/e2e/cluster/start-cluster.sh | 1 + .../{talk_e2e_test.go => demo_e2e_test.go} | 152 +++++++++--------- test/e2e/namespace.go | 4 + .../demo-only/cloudflared-public/README.md | 4 +- .../cloudflared-deplyoment.yaml | 3 +- .../demo-only/vote/crds/quizsessions.yaml | 9 ++ .../vote/examples/quizsession-2.yaml | 18 +++ .../examples/quizsession-kubecon-2026.yaml | 2 +- .../setup/demo-only/vote/ingress-auth.yaml | 2 +- .../demo-only/vote/ingress-frontend.yaml | 2 +- .../setup/demo-only/vote/ingress-kubeapi.yaml | 2 +- .../setup/demo-only/vote/ingress-podinfo.yaml | 4 +- .../setup/demo-only/vote/kustomization.yaml | 1 + .../demo-only/vote/rbac-auth-service.yaml | 4 + .../clusterwatchrule-demo.tmpl} | 0 .../{talk-demo => demo}/watchrule-all.tmpl | 0 test/loadtest/main.go | 31 ++-- voter | 1 - 21 files changed, 154 insertions(+), 104 deletions(-) rename test/e2e/{talk_e2e_test.go => demo_e2e_test.go} (64%) create mode 100644 test/e2e/setup/demo-only/vote/examples/quizsession-2.yaml rename test/e2e/templates/{talk-demo/clusterwatchrule-talk.tmpl => demo/clusterwatchrule-demo.tmpl} (100%) rename test/e2e/templates/{talk-demo => demo}/watchrule-all.tmpl (100%) delete mode 160000 voter diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 33d480275..c840c394b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -79,6 +79,7 @@ "source=gobuildcache,target=/home/vscode/.cache/go-build,type=volume", "source=codexconfig,target=/home/vscode/.codex,type=volume", "source=claudeconfig,target=/home/vscode/.claude,type=volume", + "source=dotkube,target=/home/vscode/.kube,type=volume", "source=persisted-home,target=/home/vscode/persisted-home,type=volume" ], "containerEnv": { diff --git a/Makefile b/Makefile index 0e554a63b..10f95dbee 100644 --- a/Makefile +++ b/Makefile @@ -581,13 +581,14 @@ test-e2e-quickstart-helm: ## Run quickstart smoke test (Helm install) go test ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=quickstart-framework .PHONY: test-e2e-demo -test-e2e-demo: prepare-e2e-demo ## Prepare a reusable talk/demo repo and leave demo resources in place +test-e2e-demo: prepare-e2e-demo ## Prepare a reusable demo repo and leave demo resources in place export CTX=$(CTX) export INSTALL_MODE=$(INSTALL_MODE) export NAMESPACE=$(NAMESPACE) - export E2E_ENABLE_TALK_FRAMEWORK=true + export E2E_ENABLE_DEMO=true export E2E_AGE_KEY_FILE=$(CS)/age-key.txt - go test ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=talk-demo + export TESTNAMESPACE=vote + go test ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=demo .PHONY: test-e2e-audit-redis test-e2e-audit-redis: ## Run dedicated audit->Redis enqueue e2e scenario @@ -625,9 +626,9 @@ test-e2e-quickstart-manifest: ## Run quickstart smoke test (manifest install) # when not provided, so `make loadtest` just works from inside the devcontainer. # Override at the command line: make loadtest LOADTEST_CODE=xxxx LOADTEST_CODE ?= $(shell kubectl -n vote logs deploy/vote-auth-service --tail=5 2>/dev/null | grep 'join-code:' | tail -1 | awk '{print $$NF}' | cut -d= -f2) -LOADTEST_USERS ?= 250 -LOADTEST_RAMP ?= 10s -LOADTEST_BASE_URL ?= https://vote.reversegitops.dev +LOADTEST_USERS ?= 200 +LOADTEST_RAMP ?= 20s +LOADTEST_BASE_URL ?= https://demo.configbutler.ai LOADTEST_SESSION ?= kubecon-2026 LOADTEST_NS ?= vote diff --git a/docs/KUBECON_VOTING_ROLLOUT_PLAN.md b/docs/KUBECON_VOTING_ROLLOUT_PLAN.md index a839895e2..d370e04c1 100644 --- a/docs/KUBECON_VOTING_ROLLOUT_PLAN.md +++ b/docs/KUBECON_VOTING_ROLLOUT_PLAN.md @@ -2,7 +2,7 @@ ## Goal -Run a simple audience voting portal on `voting.reversegitops.dev`, hosted from this devcontainer-driven setup, in a way that: +Run a simple audience voting portal on `demo.configbutler.ai`, hosted from this devcontainer-driven setup, in a way that: - is realistic enough for the talk, - exercises the "path back" from live changes into Git, @@ -85,7 +85,7 @@ Get public access working early. Recommendation: - use `cloudflared` only after the app works locally inside k3d, -- expose one hostname only: `voting.reversegitops.dev`, +- expose one hostname only: `demo.configbutler.ai`, - avoid adding extra public endpoints during the talk setup, - prepare a local fallback URL or port-forward for rehearsal. diff --git a/test/e2e/cluster/start-cluster.sh b/test/e2e/cluster/start-cluster.sh index 2b00427d8..014f0bd90 100644 --- a/test/e2e/cluster/start-cluster.sh +++ b/test/e2e/cluster/start-cluster.sh @@ -158,6 +158,7 @@ create_cluster() { local create_cmd=( k3d cluster create "${CLUSTER_NAME}" --image rancher/k3s:v1.35.2-k3s1 + --api-port 6550 --kubeconfig-update-default --kubeconfig-switch-context -v "${audit_host_dir}:/etc/kubernetes/audit@server:0" diff --git a/test/e2e/talk_e2e_test.go b/test/e2e/demo_e2e_test.go similarity index 64% rename from test/e2e/talk_e2e_test.go rename to test/e2e/demo_e2e_test.go index eb3adc312..ad8d8347b 100644 --- a/test/e2e/talk_e2e_test.go +++ b/test/e2e/demo_e2e_test.go @@ -34,12 +34,12 @@ import ( ) const ( - talkFrameworkEnabledEnv = "E2E_ENABLE_TALK_FRAMEWORK" - talkDemoNamespace = "vote" - talkDemoPath = "demo/vote-stack" + demoEnabledEnv = "E2E_ENABLE_DEMO" + demoNamespace = "vote" + demoPath = "adam/rai/8f" ) -type talkDemoRun struct { +type demoRun struct { repoName string checkoutDir string repoURL string @@ -52,22 +52,22 @@ type talkDemoRun struct { encryptionName string } -var _ = Describe("Talk Demo", Label("talk-demo"), Ordered, func() { - var run talkDemoRun +var _ = Describe("Demo", Label("demo"), Ordered, func() { + var run demoRun var testNs string BeforeAll(func() { - if !talkFrameworkEnabled() { + if !demoEnabled() { Skip(fmt.Sprintf( - "talk demo is disabled; set %s=true to run", - talkFrameworkEnabledEnv, + "demo is disabled; set %s=true to run", + demoEnabledEnv, )) } - run = newTalkDemoRun() + run = newDemoRun() By("creating test namespace and applying git secrets") - testNs = testNamespaceFor("talk-demo") + testNs = testNamespaceFor("demo") run.sourceNamespace = testNs _, _ = kubectlRun("create", "namespace", testNs) // idempotent; ignore AlreadyExists secretsYaml := strings.TrimSpace(os.Getenv("E2E_SECRETS_YAML")) @@ -78,35 +78,35 @@ var _ = Describe("Talk Demo", Label("talk-demo"), Ordered, func() { }) It("prepares a reusable demo repository without cleanup", func() { - By("asserting the talk demo repository checkout exists") - run.setupGiteaRepository() + By("asserting the demo repository checkout exists") + run.setupRepository() By("copying Git credentials into the vote namespace") - run.copyGitSecretToTalkNamespace() + run.copyGitSecret() - By("applying talk demo GitOps Reverser resources") - run.applyTalkResources() + By("applying demo GitOps Reverser resources") + run.applyResources() - By("verifying the talk demo resources become Ready") - run.verifyTalkResourcesReady() + By("verifying the demo resources become Ready") + run.verifyResourcesReady() By("seeding the demo repository with a few representative updates") - run.seedTalkRepository() + run.seedRepository() By("waiting for the initial snapshot to seed the demo repository") - run.verifyTalkRepositorySeeded() + run.verifyRepositorySeeded() By("printing demo artifacts for the presenter") run.logArtifacts() }) }) -func talkFrameworkEnabled() bool { - value := strings.ToLower(strings.TrimSpace(os.Getenv(talkFrameworkEnabledEnv))) +func demoEnabled() bool { + value := strings.ToLower(strings.TrimSpace(os.Getenv(demoEnabledEnv))) return value == "1" || value == "true" || value == "yes" } -func newTalkDemoRun() talkDemoRun { +func newDemoRun() demoRun { repoName := strings.TrimSpace(os.Getenv("E2E_REPO_NAME")) checkoutDir := strings.TrimSpace(os.Getenv("E2E_CHECKOUT_DIR")) sourceNamespace := resolveE2ENamespace() @@ -126,27 +126,27 @@ func newTalkDemoRun() talkDemoRun { repoName, ) - return talkDemoRun{ + return demoRun{ repoName: repoName, checkoutDir: checkoutDir, repoURL: repoURL, sourceNamespace: sourceNamespace, gitSecretName: gitSecretName, - providerName: "talk-demo-provider", - targetName: "talk-demo-target", - watchRuleName: "talk-demo-watch-all", - clusterWatchRuleName: "talk-demo-cluster-resources", - encryptionName: "talk-demo-sops-age-key", + providerName: "demo-provider-" + repoName, + targetName: "demo-target-" + repoName, + watchRuleName: "demo-watch-all-" + repoName, + clusterWatchRuleName: "demo-cluster-resources-" + repoName, + encryptionName: "demo-sops-age-key", } } -func (r *talkDemoRun) setupGiteaRepository() { +func (r *demoRun) setupRepository() { _, err := os.Stat(filepath.Join(r.checkoutDir, ".git")) Expect(err).NotTo(HaveOccurred(), "expected checkout to exist at E2E_CHECKOUT_DIR") } -func (r *talkDemoRun) copyGitSecretToTalkNamespace() { +func (r *demoRun) copyGitSecret() { output, err := kubectlRunInNamespace(r.sourceNamespace, "get", "secret", r.gitSecretName, "-o", "json") Expect(err).NotTo(HaveOccurred(), "failed to fetch source Git Secret") @@ -157,7 +157,7 @@ func (r *talkDemoRun) copyGitSecretToTalkNamespace() { Expect(err).NotTo(HaveOccurred(), "failed to read Secret metadata") Expect(found).To(BeTrue(), "expected Secret metadata") - metadata["namespace"] = talkDemoNamespace + metadata["namespace"] = demoNamespace delete(metadata, "creationTimestamp") delete(metadata, "generateName") delete(metadata, "managedFields") @@ -178,18 +178,18 @@ func (r *talkDemoRun) copyGitSecretToTalkNamespace() { manifest, err := json.Marshal(secretObj) Expect(err).NotTo(HaveOccurred(), "failed to marshal copied Git Secret") - _, err = kubectlRunWithStdin(talkDemoNamespace, string(manifest), "apply", "-f", "-") + _, err = kubectlRunWithStdin(demoNamespace, string(manifest), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply copied Git Secret into vote namespace") } -func (r *talkDemoRun) applyTalkResources() { - createGitProviderWithURLInNamespace(r.providerName, talkDemoNamespace, r.gitSecretName, r.repoURL) +func (r *demoRun) applyResources() { + createGitProviderWithURLInNamespace(r.providerName, demoNamespace, r.gitSecretName, r.repoURL) createGitTargetWithEncryptionOptions( r.targetName, - talkDemoNamespace, + demoNamespace, r.providerName, - talkDemoPath, + demoPath, "main", r.encryptionName, true, @@ -201,12 +201,12 @@ func (r *talkDemoRun) applyTalkResources() { DestinationName string }{ Name: r.watchRuleName, - Namespace: talkDemoNamespace, + Namespace: demoNamespace, DestinationName: r.targetName, } - err := applyFromTemplate("test/e2e/templates/talk-demo/watchrule-all.tmpl", watchRuleData, talkDemoNamespace) - Expect(err).NotTo(HaveOccurred(), "failed to apply talk demo WatchRule") + err := applyFromTemplate("test/e2e/templates/demo/watchrule-all.tmpl", watchRuleData, demoNamespace) + Expect(err).NotTo(HaveOccurred(), "failed to apply demo WatchRule") clusterWatchRuleData := struct { Name string @@ -214,74 +214,74 @@ func (r *talkDemoRun) applyTalkResources() { DestinationName string }{ Name: r.clusterWatchRuleName, - Namespace: talkDemoNamespace, + Namespace: demoNamespace, DestinationName: r.targetName, } - err = applyFromTemplate("test/e2e/templates/talk-demo/clusterwatchrule-talk.tmpl", clusterWatchRuleData, "") - Expect(err).NotTo(HaveOccurred(), "failed to apply talk demo ClusterWatchRule") + err = applyFromTemplate("test/e2e/templates/demo/clusterwatchrule-demo.tmpl", clusterWatchRuleData, "") + Expect(err).NotTo(HaveOccurred(), "failed to apply demo ClusterWatchRule") } -func (r *talkDemoRun) verifyTalkResourcesReady() { - verifyResourceStatus("gitprovider", r.providerName, talkDemoNamespace, "True", "Ready", "") - verifyResourceStatus("gittarget", r.targetName, talkDemoNamespace, "True", "Ready", "") - verifyResourceStatus("watchrule", r.watchRuleName, talkDemoNamespace, "True", "Ready", "") +func (r *demoRun) verifyResourcesReady() { + verifyResourceStatus("gitprovider", r.providerName, demoNamespace, "True", "Ready", "") + verifyResourceStatus("gittarget", r.targetName, demoNamespace, "True", "Ready", "") + verifyResourceStatus("watchrule", r.watchRuleName, demoNamespace, "True", "Ready", "") verifyResourceStatus("clusterwatchrule", r.clusterWatchRuleName, "", "True", "Ready", "") } -func (r *talkDemoRun) seedTalkRepository() { +func (r *demoRun) seedRepository() { value := strconv.FormatInt(time.Now().UnixNano(), 10) r.patchClusterResource( "namespace", - talkDemoNamespace, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + demoNamespace, + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "gitprovider", r.providerName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "gittarget", r.targetName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "watchrule", r.watchRuleName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchClusterResource( "clusterwatchrule", r.clusterWatchRuleName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "deployment", "vote-frontend", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "service", "vote-frontend", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "ingressroute", "frontend-static", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) r.patchNamespacedResource( "quizsession", "kubecon-2026", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/talk-demo-prepared-at":"%s"}}}`, value), + fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) } -func (r *talkDemoRun) patchClusterResource(resource, name, patch string) { +func (r *demoRun) patchClusterResource(resource, name, patch string) { _, err := kubectlRun( "patch", resource, @@ -294,9 +294,9 @@ func (r *talkDemoRun) patchClusterResource(resource, name, patch string) { Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("failed to patch cluster resource %s/%s", resource, name)) } -func (r *talkDemoRun) patchNamespacedResource(resource, name, patch string) { +func (r *demoRun) patchNamespacedResource(resource, name, patch string) { _, err := kubectlRunInNamespace( - talkDemoNamespace, + demoNamespace, "patch", resource, name, @@ -308,22 +308,22 @@ func (r *talkDemoRun) patchNamespacedResource(resource, name, patch string) { Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("failed to patch namespaced resource %s/%s", resource, name)) } -func (r *talkDemoRun) verifyTalkRepositorySeeded() { +func (r *demoRun) verifyRepositorySeeded() { expectedFiles := []string{ - filepath.Join(r.checkoutDir, talkDemoPath, ".sops.yaml"), - filepath.Join(r.checkoutDir, talkDemoPath, "v1/namespaces/vote.yaml"), - filepath.Join(r.checkoutDir, talkDemoPath, "apps/v1/deployments/vote/vote-frontend.yaml"), - filepath.Join(r.checkoutDir, talkDemoPath, "v1/services/vote/vote-frontend.yaml"), - filepath.Join(r.checkoutDir, talkDemoPath, "traefik.io/v1alpha1/ingressroutes/vote/frontend-static.yaml"), + filepath.Join(r.checkoutDir, demoPath, ".sops.yaml"), + filepath.Join(r.checkoutDir, demoPath, "v1/namespaces/vote.yaml"), + filepath.Join(r.checkoutDir, demoPath, "apps/v1/deployments/vote/vote-frontend.yaml"), + filepath.Join(r.checkoutDir, demoPath, "v1/services/vote/vote-frontend.yaml"), + filepath.Join(r.checkoutDir, demoPath, "traefik.io/v1alpha1/ingressroutes/vote/frontend-static.yaml"), filepath.Join( r.checkoutDir, - talkDemoPath, - "configbutler.ai/v1alpha1/gitproviders/vote/talk-demo-provider.yaml", + demoPath, + "configbutler.ai/v1alpha1/gitproviders/vote/demo-provider.yaml", ), } quizSubmissionPattern := filepath.Join( r.checkoutDir, - talkDemoPath, + demoPath, "examples.configbutler.ai/v1alpha1/quizsubmissions/vote/*.yaml", ) @@ -345,19 +345,19 @@ func (r *talkDemoRun) verifyTalkRepositorySeeded() { }, 2*time.Minute, 2*time.Second).Should(Succeed()) } -func (r *talkDemoRun) logArtifacts() { +func (r *demoRun) logArtifacts() { _, _ = fmt.Fprintf( GinkgoWriter, - "Talk demo ready:\n repo=%s\n checkout=%s\n repoURL=%s\n namespace=%s\n path=%s\n", + "Demo ready:\n repo=%s\n checkout=%s\n repoURL=%s\n namespace=%s\n path=%s\n", r.repoName, r.checkoutDir, r.repoURL, - talkDemoNamespace, - talkDemoPath, + demoNamespace, + demoPath, ) } -func (r *talkDemoRun) gitPull() error { +func (r *demoRun) gitPull() error { pullCmd := exec.Command("git", "pull", "--ff-only") pullCmd.Dir = r.checkoutDir output, err := pullCmd.CombinedOutput() @@ -367,7 +367,7 @@ func (r *talkDemoRun) gitPull() error { return nil } -func (r *talkDemoRun) gitCommitCount() (int, error) { +func (r *demoRun) gitCommitCount() (int, error) { cmd := exec.Command("git", "rev-list", "--count", "--all") cmd.Dir = r.checkoutDir output, err := cmd.CombinedOutput() diff --git a/test/e2e/namespace.go b/test/e2e/namespace.go index 2441f7efb..61d1954d0 100644 --- a/test/e2e/namespace.go +++ b/test/e2e/namespace.go @@ -39,6 +39,10 @@ func resolveE2ENamespace() string { // The name is scoped to the current Ginkgo run via the random seed, // tying it to the repo and other per-run resources. // Example: "8675309-test-manager" +// If TESTNAMESPACE is set, that value is returned directly (used by demo runs to pin to "vote"). func testNamespaceFor(suite string) string { + if value := strings.TrimSpace(os.Getenv("TESTNAMESPACE")); value != "" { + return value + } return fmt.Sprintf("%d-test-%s", GinkgoRandomSeed(), suite) } diff --git a/test/e2e/setup/demo-only/cloudflared-public/README.md b/test/e2e/setup/demo-only/cloudflared-public/README.md index 99d55bdc1..122502ce7 100644 --- a/test/e2e/setup/demo-only/cloudflared-public/README.md +++ b/test/e2e/setup/demo-only/cloudflared-public/README.md @@ -10,7 +10,7 @@ Setup steps: 1. Copy `tunnel-credentials.yaml.example` to `tunnel-credentials.yaml`. 2. Paste the `eyJ...` tunnel token from Cloudflare's dashboard into `stringData.token`. -3. In the Cloudflare dashboard, add a public hostname for `vote.reversegitops.dev` and point it at `http://traefik.traefik-system.svc.cluster.local:80`. +3. In the Cloudflare dashboard, add a public hostname for `demo.configbutler.ai` and point it at `http://traefik.traefik-system.svc.cluster.local:80`. 4. Deploy the app resources in `../vote`. 5. Apply the manifests: @@ -22,7 +22,7 @@ Quick checks: - `kubectl -n cloudflared-public get pods` - `kubectl -n cloudflared-public logs deploy/cloudflared --tail=100` -- `kubectl get ingress -A | grep vote.reversegitops.dev` +- `kubectl get ingress -A | grep demo.configbutler.ai` Traefik is configured to trust forwarded client-IP headers from the default k3s pod network (`10.42.0.0/16`) so requests proxied by `cloudflared` preserve the original visitor IP. diff --git a/test/e2e/setup/demo-only/cloudflared-public/cloudflared-deplyoment.yaml b/test/e2e/setup/demo-only/cloudflared-public/cloudflared-deplyoment.yaml index 0b563c8bf..8229f7c4d 100644 --- a/test/e2e/setup/demo-only/cloudflared-public/cloudflared-deplyoment.yaml +++ b/test/e2e/setup/demo-only/cloudflared-public/cloudflared-deplyoment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: cloudflared - image: cloudflare/cloudflared:latest + image: cloudflare/cloudflared:2026.3.0 args: - tunnel - --no-autoupdate @@ -38,3 +38,4 @@ spec: port: 2000 initialDelaySeconds: 10 periodSeconds: 10 + failureThreshold: 10 diff --git a/test/e2e/setup/demo-only/vote/crds/quizsessions.yaml b/test/e2e/setup/demo-only/vote/crds/quizsessions.yaml index 3fe3d305a..2d3d210e5 100644 --- a/test/e2e/setup/demo-only/vote/crds/quizsessions.yaml +++ b/test/e2e/setup/demo-only/vote/crds/quizsessions.yaml @@ -20,6 +20,12 @@ spec: subresources: status: {} additionalPrinterColumns: + - name: State + type: string + jsonPath: .spec.state + - name: Join Code + type: string + jsonPath: .status.joinCode - name: Title type: string jsonPath: .spec.title @@ -99,6 +105,9 @@ spec: status: type: object properties: + joinCode: + type: string + description: Current active join code for this session observedGeneration: type: integer format: int64 diff --git a/test/e2e/setup/demo-only/vote/examples/quizsession-2.yaml b/test/e2e/setup/demo-only/vote/examples/quizsession-2.yaml new file mode 100644 index 000000000..29c230b89 --- /dev/null +++ b/test/e2e/setup/demo-only/vote/examples/quizsession-2.yaml @@ -0,0 +1,18 @@ +apiVersion: examples.configbutler.ai/v1alpha1 +kind: QuizSession +metadata: + name: a-second-one + namespace: vote +spec: + title: This is another question + state: live + questions: + - id: q1 + type: singleChoice + title: How do you make simple YAML changes? + required: true + choices: + - "Create a PR" + - "Rolled something myself" + - "Different" + \ No newline at end of file diff --git a/test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml b/test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml index 2824f6e61..9d7680156 100644 --- a/test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml +++ b/test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml @@ -9,7 +9,7 @@ spec: questions: - id: q1 type: singleChoice - title: Was the talk useful? + title: What should be next? required: true choices: - "Yes" diff --git a/test/e2e/setup/demo-only/vote/ingress-auth.yaml b/test/e2e/setup/demo-only/vote/ingress-auth.yaml index d8f5ae33a..4cc380680 100644 --- a/test/e2e/setup/demo-only/vote/ingress-auth.yaml +++ b/test/e2e/setup/demo-only/vote/ingress-auth.yaml @@ -17,7 +17,7 @@ spec: entryPoints: - web routes: - - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/auth`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/auth`) # This is an extra indepth layer, but probably not the 'long' term solution since kubectl needs access to /apis for example to discover stuff... kind: Rule middlewares: - name: auth-to-public diff --git a/test/e2e/setup/demo-only/vote/ingress-frontend.yaml b/test/e2e/setup/demo-only/vote/ingress-frontend.yaml index f160d7ddb..7fba614d9 100644 --- a/test/e2e/setup/demo-only/vote/ingress-frontend.yaml +++ b/test/e2e/setup/demo-only/vote/ingress-frontend.yaml @@ -7,7 +7,7 @@ spec: entryPoints: - web routes: - - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/`) + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/`) kind: Rule services: - name: vote-frontend diff --git a/test/e2e/setup/demo-only/vote/ingress-kubeapi.yaml b/test/e2e/setup/demo-only/vote/ingress-kubeapi.yaml index 306d24c19..a8980d9d6 100644 --- a/test/e2e/setup/demo-only/vote/ingress-kubeapi.yaml +++ b/test/e2e/setup/demo-only/vote/ingress-kubeapi.yaml @@ -47,7 +47,7 @@ spec: routes: # All Kubernetes API paths. auth-forwarder handles both browser (mints a token via # session cookie / join code) and kubectl (validates and passes through bearer token). - - match: Host(`vote.reversegitops.dev`) && (PathPrefix(`/api`) || PathPrefix(`/apis`) || PathPrefix(`/openapi`)) + - match: Host(`demo.configbutler.ai`) && (PathPrefix(`/api`) || PathPrefix(`/apis`) || PathPrefix(`/openapi`)) kind: Rule middlewares: - name: api-ratelimit diff --git a/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml b/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml index 286b0e1cc..5beecd9a5 100644 --- a/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml +++ b/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml @@ -37,7 +37,7 @@ spec: entryPoints: - web routes: - - match: Host(`vote.reversegitops.dev`) && Path(`/podinfo`) + - match: Host(`demo.configbutler.ai`) && Path(`/podinfo`) kind: Rule middlewares: - name: ratelimit @@ -45,7 +45,7 @@ spec: services: - name: podinfo port: 80 - - match: Host(`vote.reversegitops.dev`) && PathPrefix(`/podinfo/`) + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfo/`) kind: Rule middlewares: - name: ratelimit diff --git a/test/e2e/setup/demo-only/vote/kustomization.yaml b/test/e2e/setup/demo-only/vote/kustomization.yaml index 88e674259..8b46ff8e1 100644 --- a/test/e2e/setup/demo-only/vote/kustomization.yaml +++ b/test/e2e/setup/demo-only/vote/kustomization.yaml @@ -16,4 +16,5 @@ resources: - crds/quizsessions.yaml - crds/quizsubmissions.yaml - examples/quizsession-kubecon-2026.yaml + - examples/quizsession-2.yaml - examples/quizsubmission-kubecon-2026.yaml diff --git a/test/e2e/setup/demo-only/vote/rbac-auth-service.yaml b/test/e2e/setup/demo-only/vote/rbac-auth-service.yaml index f69932afc..488b05ab6 100644 --- a/test/e2e/setup/demo-only/vote/rbac-auth-service.yaml +++ b/test/e2e/setup/demo-only/vote/rbac-auth-service.yaml @@ -48,6 +48,10 @@ rules: - apiGroups: ["examples.configbutler.ai"] resources: ["quizsessions"] verbs: ["get", "list", "watch"] + # Write join code into quizsession status + - apiGroups: ["examples.configbutler.ai"] + resources: ["quizsessions/status"] + verbs: ["patch"] # Access to quizsubmissions (cluster-scoped CRD) - apiGroups: ["examples.configbutler.ai"] resources: ["quizsubmissions"] diff --git a/test/e2e/templates/talk-demo/clusterwatchrule-talk.tmpl b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl similarity index 100% rename from test/e2e/templates/talk-demo/clusterwatchrule-talk.tmpl rename to test/e2e/templates/demo/clusterwatchrule-demo.tmpl diff --git a/test/e2e/templates/talk-demo/watchrule-all.tmpl b/test/e2e/templates/demo/watchrule-all.tmpl similarity index 100% rename from test/e2e/templates/talk-demo/watchrule-all.tmpl rename to test/e2e/templates/demo/watchrule-all.tmpl diff --git a/test/loadtest/main.go b/test/loadtest/main.go index 9f9adfdad..4dde57395 100644 --- a/test/loadtest/main.go +++ b/test/loadtest/main.go @@ -59,8 +59,8 @@ type submissionSpec struct { } type objectMeta struct { - GenerateName string `json:"generateName"` - Namespace string `json:"namespace"` + Name string `json:"name"` + Namespace string `json:"namespace"` } type quizSubmission struct { @@ -103,7 +103,7 @@ func clamp(v, lo, hi float64) float64 { return v } -func randomSubmission(rng *rand.Rand, session, namespace string) quizSubmission { +func randomSubmission(rng *rand.Rand, userID int, runID, session, namespace string) quizSubmission { // q1 singleChoice β€” weighted 70% Yes / 20% Somewhat / 10% No q1Pool := []string{ "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", @@ -123,15 +123,15 @@ func randomSubmission(rng *rand.Rand, session, namespace string) quizSubmission // q4 number β€” realistic cluster count [1, 50] q4 := float64(1 + rng.Intn(50)) - // q5 freeText - q5 := freeTextPool[rng.Intn(len(freeTextPool))] + // q5 freeText β€” include userID to ensure uniqueness across submissions + q5 := fmt.Sprintf("%s [loadtest-user-%d]", freeTextPool[rng.Intn(len(freeTextPool))], userID) return quizSubmission{ APIVersion: apiGroup + "/" + apiVersion, Kind: "QuizSubmission", Metadata: objectMeta{ - GenerateName: session + "-", - Namespace: namespace, + Name: fmt.Sprintf("%s-%d", runID, userID), + Namespace: namespace, }, Spec: submissionSpec{ SessionRef: sessionRef{Name: session}, @@ -162,7 +162,7 @@ type userResult struct { func simulateUser( ctx context.Context, userID int, - baseURL, code, session, namespace string, + baseURL, code, runID, session, namespace string, rng *rand.Rand, ) userResult { res := userResult{userID: userID} @@ -211,7 +211,7 @@ func simulateUser( } // ── Step 2: Submit quiz answers ─────────────────────────────────────────── - sub := randomSubmission(rng, session, namespace) + sub := randomSubmission(rng, userID, runID, session, namespace) body, err := json.Marshal(sub) if err != nil { res.err = fmt.Sprintf("marshal submission: %v", err) @@ -357,10 +357,21 @@ func main() { os.Exit(1) } + // Generate a short alphanumeric run ID (e.g. "q1g3") shared across all users + // so names look like "q1g3-0", "q1g3-1", … and are stable within one run. + const runIDChars = "abcdefghijklmnopqrstuvwxyz0123456789" + runIDRng := rand.New(rand.NewSource(time.Now().UnixNano())) + runIDb := make([]byte, 4) + for i := range runIDb { + runIDb[i] = runIDChars[runIDRng.Intn(len(runIDChars))] + } + runID := string(runIDb) + fmt.Printf("Starting load test\n") fmt.Printf(" Base URL: %s\n", *baseURL) fmt.Printf(" Session: %s/%s\n", *namespace, *session) fmt.Printf(" Join code: %s\n", *code) + fmt.Printf(" Run ID: %s\n", runID) fmt.Printf(" Participants: %d\n", *users) fmt.Printf(" Ramp duration: %s\n", *rampDuration) fmt.Println() @@ -386,7 +397,7 @@ func main() { return case <-time.After(delay): } - resultCh <- simulateUser(ctx, userID, *baseURL, *code, *session, *namespace, rng) + resultCh <- simulateUser(ctx, userID, *baseURL, *code, runID, *session, *namespace, rng) }(i) } diff --git a/voter b/voter deleted file mode 160000 index 92ab78b73..000000000 --- a/voter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 92ab78b73697259d41cc023bcb5b0d281121b0af From 4731bd54bef0ec92262c53f638abad7100f2d450 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 12:31:47 +0000 Subject: [PATCH 16/30] chore: Add KRO and podinfo so that we really can deploy stuff --- .claude/settings.local.json | 4 +- Makefile | 6 +- docs/demo-findings.md | 2 + hack/checkout-gitea-repo.sh | 270 ++++++++++++++++++ test/e2e/demo_e2e_test.go | 3 +- test/e2e/setup/demo-only/kustomization.yaml | 1 + .../demo-only/podinfos-production/0-ns.yaml | 4 + .../1-ingress-generic.yaml | 29 ++ .../podinfos-production/2-kro-template.yaml | 110 +++++++ .../3-example-instance.yaml | 10 + .../podinfos-production/kustomization.yaml | 8 + .../setup/demo-only/vote/ingress-podinfo.yaml | 55 ---- .../setup/demo-only/vote/kustomization.yaml | 2 - test/e2e/setup/demo-only/vote/podinfo.yaml | 59 ---- test/e2e/setup/flux/kustomization.yaml | 1 + test/e2e/setup/flux/releases/kro.yaml | 47 +++ 16 files changed, 492 insertions(+), 119 deletions(-) create mode 100644 docs/demo-findings.md create mode 100755 hack/checkout-gitea-repo.sh create mode 100644 test/e2e/setup/demo-only/podinfos-production/0-ns.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-production/2-kro-template.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-production/kustomization.yaml delete mode 100644 test/e2e/setup/demo-only/vote/ingress-podinfo.yaml delete mode 100644 test/e2e/setup/demo-only/vote/podinfo.yaml create mode 100644 test/e2e/setup/flux/releases/kro.yaml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e9296a141..95f856f22 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(go test:*)", "Bash(make lint:*)", "Bash(make test-e2e:*)", - "Bash(go build:*)" + "Bash(go build:*)", + "Bash(kubectl apply:*)", + "Bash(kubectl wait:*)" ] } } diff --git a/Makefile b/Makefile index 10f95dbee..450e8a215 100644 --- a/Makefile +++ b/Makefile @@ -379,7 +379,7 @@ $(CS)/services.ready: $(SERVICES_READY_INPUTS) | $(CS) .PHONY: prepare-e2e-demo prepare-e2e-demo: $(CS)/demo.ready ## Prepare demo-only prerequisites for talk/demo e2e runs -$(CS)/demo.ready: $(CS)/services.ready $$(DEMO_MANIFESTS) | $(CS) +$(CS)/demo.ready: $(CS)/services.ready $(CS)/$(NAMESPACE)/prepare-e2e.ready $$(DEMO_MANIFESTS) | $(CS) $(call REQUIRE_FILE,$(DEMO_TUNNEL_CREDENTIALS),demo tunnel credentials,Create it from '$(DEMO_TUNNEL_CREDENTIALS).example' and set stringData.token before running this target.) $(call REQUIRE_FILE,$(DEMO_PULL_SECRET),demo image pull secret,Create it from '$(DEMO_PULL_SECRET).example' before running this target.) kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/vote/ns.yaml @@ -393,6 +393,10 @@ $(CS)/demo.ready: $(CS)/services.ready $$(DEMO_MANIFESTS) | $(CS) crd/quizsessions.examples.configbutler.ai \ crd/quizsubmissions.examples.configbutler.ai \ --timeout=180s + kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/podinfos-production/2-kro-template.yaml + kubectl --context $(CTX) wait --for=condition=Ready \ + resourcegraphdefinition/podinfo-app \ + --timeout=120s kubectl --context $(CTX) apply -k $(DEMO_MANIFESTS_DIR) touch $@ diff --git a/docs/demo-findings.md b/docs/demo-findings.md new file mode 100644 index 000000000..a1f89e408 --- /dev/null +++ b/docs/demo-findings.md @@ -0,0 +1,2 @@ +* Pushing of large amount of changes is not really fast +* It's more than time to switch to audit event only (and use watchrules for initial cluster sync). \ No newline at end of file diff --git a/hack/checkout-gitea-repo.sh b/hack/checkout-gitea-repo.sh new file mode 100755 index 000000000..a529e2cce --- /dev/null +++ b/hack/checkout-gitea-repo.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: checkout-gitea-repo.sh [checkout-dir] + +Checks out a Gitea repository using the Kubernetes Secret convention: + git-creds- + +Defaults: + GITEA_NAMESPACE=gitea-e2e + GITEA_SERVICE=gitea-http + GITEA_LOCAL_PORT=13000 + GITEA_ORG_NAME=testorg + GITEA_SECRET_PREFIX=git-creds- + +Optional environment variables: + KUBECTL_CONTEXT kubeconfig context to use + SECRET_NAMESPACE namespace that contains the git credential Secret + GIT_AUTHOR_NAME git author name to set locally when absent + GIT_AUTHOR_EMAIL git author email to set locally when absent +EOF +} + +require_cmd() { + local cmd="$1" + command -v "${cmd}" >/dev/null 2>&1 || { + echo "ERROR: required command not found: ${cmd}" >&2 + exit 1 + } +} + +for cmd in kubectl curl git base64 nohup awk grep; do + require_cmd "${cmd}" +done + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +REPO_NAME="${1:-}" +CHECKOUT_DIR_INPUT="${2:-}" + +if [[ -z "${REPO_NAME}" ]]; then + usage >&2 + exit 2 +fi + +GITEA_NAMESPACE="${GITEA_NAMESPACE:-gitea-e2e}" +GITEA_SERVICE="${GITEA_SERVICE:-gitea-http}" +GITEA_LOCAL_PORT="${GITEA_LOCAL_PORT:-13000}" +GITEA_ORG_NAME="${GITEA_ORG_NAME:-testorg}" +GITEA_SECRET_PREFIX="${GITEA_SECRET_PREFIX:-git-creds-}" +SECRET_NAMESPACE="${SECRET_NAMESPACE:-}" +KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}" +GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME:-Gitea Repo User}" +GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-gitea-repo-user@example.local}" + +SECRET_NAME="${GITEA_SECRET_PREFIX}${REPO_NAME}" +CHECKOUT_DIR="${CHECKOUT_DIR_INPUT:-${PWD}/${REPO_NAME}}" +PORT_FORWARD_PID_FILE="${TMPDIR:-/tmp}/gitea-port-forward-${GITEA_NAMESPACE}-${GITEA_SERVICE}-${GITEA_LOCAL_PORT}.pid" +PORT_FORWARD_LOG_FILE="${TMPDIR:-/tmp}/gitea-port-forward-${GITEA_NAMESPACE}-${GITEA_SERVICE}-${GITEA_LOCAL_PORT}.log" + +kubectl_cmd() { + if [[ -n "${KUBECTL_CONTEXT}" ]]; then + kubectl --context "${KUBECTL_CONTEXT}" "$@" + else + kubectl "$@" + fi +} + +gitea_api_url() { + printf 'http://127.0.0.1:%s/api/v1' "${GITEA_LOCAL_PORT}" +} + +repo_url() { + printf 'http://127.0.0.1:%s/%s/%s.git' "${GITEA_LOCAL_PORT}" "${GITEA_ORG_NAME}" "${REPO_NAME}" +} + +repo_url_with_auth() { + printf 'http://%s:%s@127.0.0.1:%s/%s/%s.git' \ + "${SECRET_USERNAME}" \ + "${SECRET_PASSWORD}" \ + "${GITEA_LOCAL_PORT}" \ + "${GITEA_ORG_NAME}" \ + "${REPO_NAME}" +} + +api_ready() { + curl -fsS "$(gitea_api_url)/version" >/dev/null 2>&1 +} + +discover_service_port() { + local service_port + + service_port="$( + kubectl_cmd -n "${GITEA_NAMESPACE}" get svc "${GITEA_SERVICE}" \ + -o go-template='{{range .spec.ports}}{{if eq .name "http"}}{{.port}}{{"\n"}}{{end}}{{end}}' 2>/dev/null \ + | head -n1 + )" + + if [[ -z "${service_port}" ]]; then + service_port="$( + kubectl_cmd -n "${GITEA_NAMESPACE}" get svc "${GITEA_SERVICE}" \ + -o go-template='{{(index .spec.ports 0).port}}' 2>/dev/null + )" + fi + + if [[ -z "${service_port}" ]]; then + echo "ERROR: could not determine service port for ${GITEA_NAMESPACE}/${GITEA_SERVICE}" >&2 + exit 1 + fi + + printf '%s' "${service_port}" +} + +ensure_port_forward() { + local service_port existing_pid + + if api_ready; then + return 0 + fi + + if [[ -f "${PORT_FORWARD_PID_FILE}" ]]; then + existing_pid="$(tr -d '\n\r' < "${PORT_FORWARD_PID_FILE}" || true)" + if [[ -n "${existing_pid}" ]] && kill -0 "${existing_pid}" 2>/dev/null; then + for _ in {1..15}; do + if api_ready; then + return 0 + fi + sleep 1 + done + fi + rm -f "${PORT_FORWARD_PID_FILE}" + fi + + service_port="$(discover_service_port)" + + echo "Starting persistent port-forward: ${GITEA_NAMESPACE}/${GITEA_SERVICE} ${GITEA_LOCAL_PORT}:${service_port}" + nohup kubectl_cmd -n "${GITEA_NAMESPACE}" port-forward --address 127.0.0.1 \ + "svc/${GITEA_SERVICE}" "${GITEA_LOCAL_PORT}:${service_port}" \ + >"${PORT_FORWARD_LOG_FILE}" 2>&1 & + echo $! > "${PORT_FORWARD_PID_FILE}" + + for _ in {1..30}; do + if api_ready; then + return 0 + fi + sleep 1 + done + + echo "ERROR: Gitea API did not become reachable at $(gitea_api_url)" >&2 + echo "Port-forward log: ${PORT_FORWARD_LOG_FILE}" >&2 + exit 1 +} + +find_secret_namespace() { + local matches + + if [[ -n "${SECRET_NAMESPACE}" ]]; then + printf '%s' "${SECRET_NAMESPACE}" + return 0 + fi + + matches="$( + kubectl_cmd get secret -A --no-headers 2>/dev/null \ + | awk -v secret_name="${SECRET_NAME}" '$2 == secret_name {print $1}' + )" + + if [[ -z "${matches}" ]]; then + echo "ERROR: could not find Secret named ${SECRET_NAME} in any namespace" >&2 + exit 1 + fi + + if [[ "$(printf '%s\n' "${matches}" | sed '/^$/d' | wc -l | tr -d ' ')" != "1" ]]; then + echo "ERROR: found multiple namespaces for Secret ${SECRET_NAME}; set SECRET_NAMESPACE explicitly" >&2 + printf '%s\n' "${matches}" >&2 + exit 1 + fi + + printf '%s' "${matches}" +} + +secret_data() { + local namespace="$1" + local key="$2" + + kubectl_cmd -n "${namespace}" get secret "${SECRET_NAME}" \ + -o go-template="{{index .data \"${key}\"}}" 2>/dev/null +} + +decode_b64() { + printf '%s' "$1" | base64 -d +} + +ensure_secret_credentials() { + local namespace="$1" + local encoded_username encoded_password + + encoded_username="$(secret_data "${namespace}" username)" + encoded_password="$(secret_data "${namespace}" password)" + + if [[ -z "${encoded_username}" || -z "${encoded_password}" ]]; then + echo "ERROR: Secret ${namespace}/${SECRET_NAME} does not contain username/password" >&2 + exit 1 + fi + + SECRET_USERNAME="$(decode_b64 "${encoded_username}")" + SECRET_PASSWORD="$(decode_b64 "${encoded_password}")" + + if [[ -z "${SECRET_USERNAME}" || -z "${SECRET_PASSWORD}" ]]; then + echo "ERROR: decoded username/password from ${namespace}/${SECRET_NAME} are empty" >&2 + exit 1 + fi +} + +setup_repo_credentials() { + local credential_file="$1" + + git -C "${CHECKOUT_DIR}" config credential.helper "store --file=${credential_file}" + git -C "${CHECKOUT_DIR}" config credential.useHttpPath true + git -C "${CHECKOUT_DIR}" remote set-url origin "$(repo_url_with_auth)" + printf '%s\n' "$(repo_url_with_auth)" > "${credential_file}" + chmod 0600 "${credential_file}" || true +} + +clone_or_update_repo() { + local credential_file + credential_file="${CHECKOUT_DIR}/.git/.gitea-credentials" + + mkdir -p "$(dirname "${CHECKOUT_DIR}")" + + if [[ -d "${CHECKOUT_DIR}/.git" ]]; then + setup_repo_credentials "${credential_file}" + git -C "${CHECKOUT_DIR}" fetch origin --prune >/dev/null + else + if [[ -e "${CHECKOUT_DIR}" ]]; then + echo "ERROR: checkout path exists but is not a git repository: ${CHECKOUT_DIR}" >&2 + exit 1 + fi + + git clone "$(repo_url_with_auth)" "${CHECKOUT_DIR}" >/dev/null 2>&1 + setup_repo_credentials "${credential_file}" + fi + + if [[ -z "$(git -C "${CHECKOUT_DIR}" config --get user.name || true)" ]]; then + git -C "${CHECKOUT_DIR}" config user.name "${GIT_AUTHOR_NAME}" + fi + + if [[ -z "$(git -C "${CHECKOUT_DIR}" config --get user.email || true)" ]]; then + git -C "${CHECKOUT_DIR}" config user.email "${GIT_AUTHOR_EMAIL}" + fi +} + +ensure_port_forward +SECRET_NAMESPACE="$(find_secret_namespace)" +ensure_secret_credentials "${SECRET_NAMESPACE}" +clone_or_update_repo + +cat <= 1} + url: ${"https://demo.configbutler.ai/podinfos/" + deployment.metadata.name + "/"} + additionalPrinterColumns: + - name: URL + type: string + jsonPath: .status.url + resources: + - id: deployment + readyWhen: + - "${deployment.status.availableReplicas >= 1}" + template: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: ${schema.spec.name} + namespace: podinfos-production + labels: + app.kubernetes.io/name: ${schema.spec.name} + spec: + replicas: ${schema.spec.replicas} + selector: + matchLabels: + app.kubernetes.io/name: ${schema.spec.name} + template: + metadata: + labels: + app.kubernetes.io/name: ${schema.spec.name} + spec: + containers: + - name: podinfo + image: ghcr.io/stefanprodan/podinfo:6.11.1 + imagePullPolicy: IfNotPresent + env: + - name: PODINFO_UI_MESSAGE + value: ${schema.spec.message} + - name: PODINFO_UI_COLOR + value: ${schema.spec.color} + ports: + - name: http + containerPort: 9898 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 10m + memory: 32Mi + - id: service + template: + apiVersion: v1 + kind: Service + metadata: + name: ${schema.spec.name} + namespace: podinfos-production + labels: + app.kubernetes.io/name: ${schema.spec.name} + spec: + selector: + app.kubernetes.io/name: ${schema.spec.name} + ports: + - name: http + port: 80 + targetPort: http + - id: ingressroute + template: + apiVersion: traefik.io/v1alpha1 + kind: IngressRoute + metadata: + name: ${schema.spec.name} + namespace: podinfos-production + spec: + entryPoints: + - web + routes: + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos/${schema.spec.name}`) + kind: Rule + middlewares: + - name: ratelimit + - name: podinfos-add-trailing-slash + - name: podinfos-strip-prefix + services: + - name: ${schema.spec.name} + port: 80 diff --git a/test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml b/test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml new file mode 100644 index 000000000..71fbf3221 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml @@ -0,0 +1,10 @@ +apiVersion: kro.run/v1alpha1 +kind: PodInfoApp +metadata: + name: fiets + namespace: podinfos-production +spec: + replicas: 1 + name: fiets + message: The Dutch word for bicycle is "fiets". We have a lot of them. + color: "#115711" diff --git a/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml new file mode 100644 index 000000000..a28def968 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - 0-ns.yaml + - 1-ingress-generic.yaml + - 2-kro-template.yaml + - 3-example-instance.yaml diff --git a/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml b/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml deleted file mode 100644 index 5beecd9a5..000000000 --- a/test/e2e/setup/demo-only/vote/ingress-podinfo.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: podinfo-add-trailing-slash - namespace: vote -spec: - redirectRegex: - regex: ^https?://([^/]+)/podinfo$ - replacement: https://${1}/podinfo/ ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: podinfo-strip-prefix - namespace: vote -spec: - stripPrefix: - prefixes: - - /podinfo ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: ratelimit - namespace: vote -spec: - rateLimit: - average: 10 - burst: 5 ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: vote-public - namespace: vote -spec: - entryPoints: - - web - routes: - - match: Host(`demo.configbutler.ai`) && Path(`/podinfo`) - kind: Rule - middlewares: - - name: ratelimit - - name: podinfo-add-trailing-slash - services: - - name: podinfo - port: 80 - - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfo/`) - kind: Rule - middlewares: - - name: ratelimit - - name: podinfo-strip-prefix - services: - - name: podinfo - port: 80 diff --git a/test/e2e/setup/demo-only/vote/kustomization.yaml b/test/e2e/setup/demo-only/vote/kustomization.yaml index 8b46ff8e1..dff5e04a1 100644 --- a/test/e2e/setup/demo-only/vote/kustomization.yaml +++ b/test/e2e/setup/demo-only/vote/kustomization.yaml @@ -6,13 +6,11 @@ resources: - pull-secret.yaml - deployments.yaml - apf.yaml - - podinfo.yaml - rbac-auth-service.yaml - rbac-participants.yaml - ingress-auth.yaml - ingress-frontend.yaml - ingress-kubeapi.yaml - - ingress-podinfo.yaml - crds/quizsessions.yaml - crds/quizsubmissions.yaml - examples/quizsession-kubecon-2026.yaml diff --git a/test/e2e/setup/demo-only/vote/podinfo.yaml b/test/e2e/setup/demo-only/vote/podinfo.yaml deleted file mode 100644 index dc4a893ed..000000000 --- a/test/e2e/setup/demo-only/vote/podinfo.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: podinfo - namespace: vote - labels: - app.kubernetes.io/name: podinfo -spec: - replicas: 2 - selector: - matchLabels: - app.kubernetes.io/name: podinfo - template: - metadata: - labels: - app.kubernetes.io/name: podinfo - spec: - containers: - - name: podinfo - image: ghcr.io/stefanprodan/podinfo:6.11.1 - env: - - name: PODINFO_UI_MESSAGE - value: hello from simon - ports: - - name: http - containerPort: 9898 - readinessProbe: - httpGet: - path: /readyz - port: http - initialDelaySeconds: 3 - periodSeconds: 10 - livenessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 10 - periodSeconds: 10 - resources: - requests: - cpu: 10m - memory: 32Mi - limits: - memory: 128Mi ---- -apiVersion: v1 -kind: Service -metadata: - name: podinfo - namespace: vote - labels: - app.kubernetes.io/name: podinfo -spec: - selector: - app.kubernetes.io/name: podinfo - ports: - - name: http - port: 80 - targetPort: http diff --git a/test/e2e/setup/flux/kustomization.yaml b/test/e2e/setup/flux/kustomization.yaml index ad704b597..a08f476ec 100644 --- a/test/e2e/setup/flux/kustomization.yaml +++ b/test/e2e/setup/flux/kustomization.yaml @@ -15,6 +15,7 @@ resources: - releases/cert-manager.yaml - releases/gitea.yaml - releases/valkey.yaml + - releases/kro.yaml generatorOptions: disableNameSuffixHash: true diff --git a/test/e2e/setup/flux/releases/kro.yaml b/test/e2e/setup/flux/releases/kro.yaml new file mode 100644 index 000000000..dcc03f9e8 --- /dev/null +++ b/test/e2e/setup/flux/releases/kro.yaml @@ -0,0 +1,47 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: kro + namespace: flux-system +spec: + interval: 24h + url: oci://registry.k8s.io/kro/charts/kro + layerSelector: + mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + operation: copy + ref: + semver: "0.8.5" +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: kro + namespace: flux-system +spec: + interval: 60m + timeout: 10m + chartRef: + kind: OCIRepository + name: kro + namespace: flux-system + targetNamespace: kro-system + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + values: + config: + allowCRDDeletion: true + metrics: + service: + create: true + type: "ClusterIP" + port: 8080 + serviceMonitor: + enabled: true + interval: 30s + scrapeTimeout: 5s + telemetryPath: /metrics From 267e6a1a763d825828b4dfa4570e4b024a7397e3 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 13:30:31 +0000 Subject: [PATCH 17/30] chore: Thats simpler --- test/e2e/setup/demo-only/kustomization.yaml | 2 + .../demo-only/podinfos-preview/0-ns.yaml | 4 ++ .../podinfos-preview/1-ingress-generic.yaml | 55 +++++++++++++++++++ .../podinfos-preview/kustomization.yaml | 6 ++ .../1-ingress-generic.yaml | 44 ++++++++++++--- .../podinfos-production/kustomization.yaml | 1 - .../kro-template.yaml} | 18 +++--- .../podinfos-shared/kustomization.yaml | 5 ++ 8 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml rename test/e2e/setup/demo-only/{podinfos-production/2-kro-template.yaml => podinfos-shared/kro-template.yaml} (86%) create mode 100644 test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml diff --git a/test/e2e/setup/demo-only/kustomization.yaml b/test/e2e/setup/demo-only/kustomization.yaml index 0f6f435b9..33d80ace2 100644 --- a/test/e2e/setup/demo-only/kustomization.yaml +++ b/test/e2e/setup/demo-only/kustomization.yaml @@ -4,4 +4,6 @@ kind: Kustomization resources: - cloudflared-public - vote + - podinfos-shared - podinfos-production + - podinfos-preview diff --git a/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml b/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml new file mode 100644 index 000000000..375c90312 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: podinfos diff --git a/test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml b/test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml new file mode 100644 index 000000000..57a7e70ed --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml @@ -0,0 +1,55 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ratelimit + namespace: podinfos +spec: + rateLimit: + average: 10 + burst: 5 +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: podinfos-add-trailing-slash + namespace: podinfos +spec: + redirectRegex: + regex: ^https?://([^/]+)/([^/]+)/([^/]+)$ + replacement: https://${1}/${2}/${3}/ +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-namespace-prefix + namespace: podinfos +spec: + stripPrefix: + prefixes: + - /podinfos-preview +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-appname + namespace: podinfos +spec: + stripPrefixRegex: + regex: + - /[^/]+ +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: podinfos-gateway + namespace: podinfos +spec: + entryPoints: + - web + routes: + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos-preview/`) + kind: Rule + middlewares: + - name: ratelimit + - name: podinfos-add-trailing-slash + - name: strip-namespace-prefix diff --git a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml new file mode 100644 index 000000000..7b77295c2 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - 0-ns.yaml + - 1-ingress-generic.yaml diff --git a/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml b/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml index fc31e1bde..02cd43099 100644 --- a/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml +++ b/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml @@ -1,29 +1,55 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware +metadata: + name: ratelimit + namespace: podinfos-production +spec: + rateLimit: + average: 10 + burst: 5 +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware metadata: name: podinfos-add-trailing-slash namespace: podinfos-production spec: redirectRegex: - regex: ^https?://([^/]+)/podinfos/([^/]+)$ - replacement: https://${1}/podinfos/${2}/ + regex: ^https?://([^/]+)/([^/]+)/([^/]+)$ + replacement: https://${1}/${2}/${3}/ --- apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: - name: podinfos-strip-prefix + name: strip-namespace-prefix + namespace: podinfos-production +spec: + stripPrefix: + prefixes: + - /podinfos +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-appname namespace: podinfos-production spec: stripPrefixRegex: regex: - - /podinfos/[^/]+ + - /[^/]+ --- apiVersion: traefik.io/v1alpha1 -kind: Middleware +kind: IngressRoute metadata: - name: ratelimit + name: podinfos-gateway namespace: podinfos-production spec: - rateLimit: - average: 10 - burst: 5 + entryPoints: + - web + routes: + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos/`) + kind: Rule + middlewares: + - name: ratelimit + - name: podinfos-add-trailing-slash + - name: strip-namespace-prefix diff --git a/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml index a28def968..d3de5f9ef 100644 --- a/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml @@ -4,5 +4,4 @@ kind: Kustomization resources: - 0-ns.yaml - 1-ingress-generic.yaml - - 2-kro-template.yaml - 3-example-instance.yaml diff --git a/test/e2e/setup/demo-only/podinfos-production/2-kro-template.yaml b/test/e2e/setup/demo-only/podinfos-shared/kro-template.yaml similarity index 86% rename from test/e2e/setup/demo-only/podinfos-production/2-kro-template.yaml rename to test/e2e/setup/demo-only/podinfos-shared/kro-template.yaml index 4f6b79987..5e0f05abf 100644 --- a/test/e2e/setup/demo-only/podinfos-production/2-kro-template.yaml +++ b/test/e2e/setup/demo-only/podinfos-shared/kro-template.yaml @@ -14,7 +14,7 @@ spec: status: availableReplicas: ${deployment.status.availableReplicas} ready: ${deployment.status.availableReplicas >= 1} - url: ${"https://demo.configbutler.ai/podinfos/" + deployment.metadata.name + "/"} + url: ${"https://demo.configbutler.ai/" + schema.metadata.namespace.replace("-production", "") + "/" + deployment.metadata.name + "/"} additionalPrinterColumns: - name: URL type: string @@ -28,7 +28,7 @@ spec: kind: Deployment metadata: name: ${schema.spec.name} - namespace: podinfos-production + namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} spec: @@ -78,7 +78,7 @@ spec: kind: Service metadata: name: ${schema.spec.name} - namespace: podinfos-production + namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} spec: @@ -94,17 +94,15 @@ spec: kind: IngressRoute metadata: name: ${schema.spec.name} - namespace: podinfos-production + namespace: ${schema.metadata.namespace} spec: - entryPoints: - - web + parentRefs: + - name: podinfos-gateway routes: - - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos/${schema.spec.name}`) + - match: PathPrefix(`/${schema.spec.name}`) kind: Rule middlewares: - - name: ratelimit - - name: podinfos-add-trailing-slash - - name: podinfos-strip-prefix + - name: strip-appname services: - name: ${schema.spec.name} port: 80 diff --git a/test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml new file mode 100644 index 000000000..579bf93e5 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - kro-template.yaml From d2e71d95e8b400d3b1fdb6a3c664f07f5969358e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 13:46:35 +0000 Subject: [PATCH 18/30] chore: Let's get that working as well --- Makefile | 2 +- test/e2e/setup/demo-only/kustomization.yaml | 4 +- ...emplate.yaml => podinfo-kro-template.yaml} | 2 +- .../podinfos-base/1-ingress-specific.yaml | 23 ++++++++ .../podinfos-base/2-middlewares.yaml | 26 +++++++++ .../3-example-instance.yaml | 1 - .../podinfos-base/kustomization.yaml | 7 +++ .../demo-only/podinfos-preview/0-ns.yaml | 2 +- .../podinfos-preview/1-ingress-generic.yaml | 55 ------------------- .../podinfos-preview/1-patch-ingress.yaml | 21 +++++++ .../podinfos-preview/kustomization.yaml | 7 ++- .../1-ingress-generic.yaml | 55 ------------------- .../podinfos-production/kustomization.yaml | 5 +- .../podinfos-shared/kustomization.yaml | 5 -- 14 files changed, 91 insertions(+), 124 deletions(-) rename test/e2e/setup/demo-only/{podinfos-shared/kro-template.yaml => podinfo-kro-template.yaml} (95%) create mode 100644 test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml rename test/e2e/setup/demo-only/{podinfos-production => podinfos-base}/3-example-instance.yaml (85%) create mode 100644 test/e2e/setup/demo-only/podinfos-base/kustomization.yaml delete mode 100644 test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml delete mode 100644 test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml delete mode 100644 test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml diff --git a/Makefile b/Makefile index 450e8a215..b826cd403 100644 --- a/Makefile +++ b/Makefile @@ -393,7 +393,7 @@ $(CS)/demo.ready: $(CS)/services.ready $(CS)/$(NAMESPACE)/prepare-e2e.ready $$(D crd/quizsessions.examples.configbutler.ai \ crd/quizsubmissions.examples.configbutler.ai \ --timeout=180s - kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/podinfos-production/2-kro-template.yaml + kubectl --context $(CTX) apply -f $(DEMO_MANIFESTS_DIR)/podinfo-kro-template.yaml kubectl --context $(CTX) wait --for=condition=Ready \ resourcegraphdefinition/podinfo-app \ --timeout=120s diff --git a/test/e2e/setup/demo-only/kustomization.yaml b/test/e2e/setup/demo-only/kustomization.yaml index 33d80ace2..2e3866c5c 100644 --- a/test/e2e/setup/demo-only/kustomization.yaml +++ b/test/e2e/setup/demo-only/kustomization.yaml @@ -2,8 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - podinfo-kro-template.yaml - cloudflared-public - vote - - podinfos-shared - - podinfos-production - podinfos-preview + - podinfos-production diff --git a/test/e2e/setup/demo-only/podinfos-shared/kro-template.yaml b/test/e2e/setup/demo-only/podinfo-kro-template.yaml similarity index 95% rename from test/e2e/setup/demo-only/podinfos-shared/kro-template.yaml rename to test/e2e/setup/demo-only/podinfo-kro-template.yaml index 5e0f05abf..7071e4dc0 100644 --- a/test/e2e/setup/demo-only/podinfos-shared/kro-template.yaml +++ b/test/e2e/setup/demo-only/podinfo-kro-template.yaml @@ -14,7 +14,7 @@ spec: status: availableReplicas: ${deployment.status.availableReplicas} ready: ${deployment.status.availableReplicas >= 1} - url: ${"https://demo.configbutler.ai/" + schema.metadata.namespace.replace("-production", "") + "/" + deployment.metadata.name + "/"} + url: ${"https://demo.configbutler.ai/" + deployment.metadata.namespace.replace("-production", "") + "/" + deployment.metadata.name + "/"} additionalPrinterColumns: - name: URL type: string diff --git a/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml b/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml new file mode 100644 index 000000000..a522665de --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml @@ -0,0 +1,23 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-namespace-prefix +spec: + stripPrefix: + prefixes: + - /podinfos +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: podinfos-gateway +spec: + entryPoints: + - web + routes: + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos/`) + kind: Rule + middlewares: + - name: ratelimit + - name: podinfos-add-trailing-slash + - name: strip-namespace-prefix diff --git a/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml b/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml new file mode 100644 index 000000000..793027ef0 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml @@ -0,0 +1,26 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: ratelimit +spec: + rateLimit: + average: 10 + burst: 5 +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: podinfos-add-trailing-slash +spec: + redirectRegex: + regex: ^https?://([^/]+)/([^/]+)/([^/]+)$ + replacement: https://${1}/${2}/${3}/ +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-appname +spec: + stripPrefixRegex: + regex: + - /[^/]+ diff --git a/test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml b/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml similarity index 85% rename from test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml rename to test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml index 71fbf3221..bd62cf2ca 100644 --- a/test/e2e/setup/demo-only/podinfos-production/3-example-instance.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml @@ -2,7 +2,6 @@ apiVersion: kro.run/v1alpha1 kind: PodInfoApp metadata: name: fiets - namespace: podinfos-production spec: replicas: 1 name: fiets diff --git a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml new file mode 100644 index 000000000..72b8766bb --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - 1-ingress-specific.yaml + - 2-middlewares.yaml + - 3-example-instance.yaml diff --git a/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml b/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml index 375c90312..ae9c52be1 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/0-ns.yaml @@ -1,4 +1,4 @@ apiVersion: v1 kind: Namespace metadata: - name: podinfos + name: podinfos-preview diff --git a/test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml b/test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml deleted file mode 100644 index 57a7e70ed..000000000 --- a/test/e2e/setup/demo-only/podinfos-preview/1-ingress-generic.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: ratelimit - namespace: podinfos -spec: - rateLimit: - average: 10 - burst: 5 ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: podinfos-add-trailing-slash - namespace: podinfos -spec: - redirectRegex: - regex: ^https?://([^/]+)/([^/]+)/([^/]+)$ - replacement: https://${1}/${2}/${3}/ ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: strip-namespace-prefix - namespace: podinfos -spec: - stripPrefix: - prefixes: - - /podinfos-preview ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: strip-appname - namespace: podinfos -spec: - stripPrefixRegex: - regex: - - /[^/]+ ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: podinfos-gateway - namespace: podinfos -spec: - entryPoints: - - web - routes: - - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos-preview/`) - kind: Rule - middlewares: - - name: ratelimit - - name: podinfos-add-trailing-slash - - name: strip-namespace-prefix diff --git a/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml b/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml new file mode 100644 index 000000000..18c855e20 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml @@ -0,0 +1,21 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-namespace-prefix +spec: + stripPrefix: + prefixes: + - /podinfos-preview +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: podinfos-gateway +spec: + routes: + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos-preview/`) + kind: Rule + middlewares: + - name: ratelimit + - name: podinfos-add-trailing-slash + - name: strip-namespace-prefix diff --git a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml index 7b77295c2..691386492 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml @@ -1,6 +1,11 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization +namespace: podinfos-preview + resources: + - ../podinfos-base - 0-ns.yaml - - 1-ingress-generic.yaml + +patches: + - path: 1-patch-ingress.yaml diff --git a/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml b/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml deleted file mode 100644 index 02cd43099..000000000 --- a/test/e2e/setup/demo-only/podinfos-production/1-ingress-generic.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: ratelimit - namespace: podinfos-production -spec: - rateLimit: - average: 10 - burst: 5 ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: podinfos-add-trailing-slash - namespace: podinfos-production -spec: - redirectRegex: - regex: ^https?://([^/]+)/([^/]+)/([^/]+)$ - replacement: https://${1}/${2}/${3}/ ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: strip-namespace-prefix - namespace: podinfos-production -spec: - stripPrefix: - prefixes: - - /podinfos ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: strip-appname - namespace: podinfos-production -spec: - stripPrefixRegex: - regex: - - /[^/]+ ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: podinfos-gateway - namespace: podinfos-production -spec: - entryPoints: - - web - routes: - - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos/`) - kind: Rule - middlewares: - - name: ratelimit - - name: podinfos-add-trailing-slash - - name: strip-namespace-prefix diff --git a/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml index d3de5f9ef..62d9da4ae 100644 --- a/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-production/kustomization.yaml @@ -1,7 +1,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization +namespace: podinfos-production + resources: + - ../podinfos-base - 0-ns.yaml - - 1-ingress-generic.yaml - - 3-example-instance.yaml diff --git a/test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml deleted file mode 100644 index 579bf93e5..000000000 --- a/test/e2e/setup/demo-only/podinfos-shared/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - kro-template.yaml From 3fe041284f4973f06b9afda74231a235e9eb8d2e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 21:02:55 +0000 Subject: [PATCH 19/30] chore: Working with a monster script --- .claude/settings.local.json | 3 +- hack/demo/setup-monster-deployment.sh | 275 ++++++++++++++++++ test/e2e/setup/demo-only/README.md | 9 + test/e2e/setup/demo-only/kustomization.yaml | 1 + .../setup/demo-only/podinfo-kro-template.yaml | 12 + .../podinfos-base/1-ingress-specific.yaml | 4 + .../podinfos-base/4-git-creds-demo.yaml | 6 + .../demo-only/podinfos-base/5-gitops.yaml | 27 ++ .../podinfos-base/6-reverse-gitops.yaml | 38 +++ .../podinfos-base/kustomization.yaml | 3 + .../setup/demo-only/podinfos-intent/0-ns.yaml | 4 + .../podinfos-intent/1-example-instance.yaml | 9 + .../podinfos-intent/kustomization.yaml | 8 + .../podinfos-preview/1-patch-ingress.yaml | 4 + test/e2e/setup/flux/kustomization.yaml | 3 + test/e2e/setup/flux/releases/reflector.yaml | 34 +++ test/loadtest/main.go | 71 +++-- 17 files changed, 492 insertions(+), 19 deletions(-) create mode 100755 hack/demo/setup-monster-deployment.sh create mode 100644 test/e2e/setup/demo-only/README.md create mode 100644 test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-intent/0-ns.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml create mode 100644 test/e2e/setup/flux/releases/reflector.yaml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 95f856f22..003f78fff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(make test-e2e:*)", "Bash(go build:*)", "Bash(kubectl apply:*)", - "Bash(kubectl wait:*)" + "Bash(kubectl wait:*)", + "Bash(chmod +x /workspaces/gitops-reverser/hack/demo/setup-monster-deployment.sh)" ] } } diff --git a/hack/demo/setup-monster-deployment.sh b/hack/demo/setup-monster-deployment.sh new file mode 100755 index 000000000..54befee0c --- /dev/null +++ b/hack/demo/setup-monster-deployment.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Demo script: wire up gitops-reverser to sync PodInfoApp changes from +# podinfos-preview to the "monster-deployment" git branch. +# +# What this script does: +# 1. Finds the first git-repo-* secret (searches gitops-reverser ns first, +# then default; override with SECRET_SEARCH_NS) +# 2. Copies it into the podinfos-preview namespace +# 3. Creates a GitProvider, GitTarget and WatchRule in podinfos-preview +# to sync PodInfoApp instances to the "monster-deployment" branch +# 4. Generates a Kustomize overlay (monster-deployment-production/) that +# deploys those same instances to the podinfos-production namespace +# +# Prerequisites: +# - kubectl configured against the demo cluster +# - jq installed +# - gitops-reverser controller running in the cluster +# - At least one git-repo-* secret and a GitProvider referencing it +# +# Optional env vars: +# SECRET_SEARCH_NS Space-separated list of namespaces to search for the +# git-repo-* secret (default: "gitops-reverser default") +# GIT_PATH Base path inside the git repo (default: "gitops-reverser-demo") +# PROVIDER_NAME Name for the GitProvider (default: "monster-deployment-provider") +# TARGET_NAME Name for the GitTarget (default: "monster-deployment-target") +# WATCHRULE_NAME Name for the WatchRule (default: "monster-deployment-watchrule") + +set -euo pipefail + +# ────────────────────────────────────────────────────────────────────────────── +# Configuration (override via env vars) +# ────────────────────────────────────────────────────────────────────────────── +PREVIEW_NS="podinfos-preview" +PRODUCTION_NS="podinfos-production" +BRANCH="monster-deployment" + +PROVIDER_NAME="${PROVIDER_NAME:-monster-deployment-provider}" +TARGET_NAME="${TARGET_NAME:-monster-deployment-target}" +WATCHRULE_NAME="${WATCHRULE_NAME:-monster-deployment-watchrule}" + +# Base path inside the git repo where gitops-reverser writes resources. +# PodInfoApp files land at: $GIT_PATH/kro.run/v1alpha1/podinfoapps/$PREVIEW_NS/.yaml +GIT_PATH="${GIT_PATH:-gitops-reverser-demo}" + +# Name given to the secret after it is copied into podinfos-preview +COPIED_SECRET_NAME="git-repo-preview" + +# Namespaces to search for the git-repo-* secret +read -ra SECRET_SEARCH_NS <<< "${SECRET_SEARCH_NS:-gitops-reverser default}" + +# Where the production kustomization is written +OUTPUT_DIR="./monster-deployment-production" + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── +die() { echo "ERROR: $*" >&2; exit 1; } +info() { echo "==> $*"; } +ok() { echo " βœ“ $*"; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1 β€” please install it first." +} + +require_cmd kubectl +require_cmd jq + +# ────────────────────────────────────────────────────────────────────────────── +# Step 1: Discover the git-repo-* secret and its associated GitProvider URL +# ────────────────────────────────────────────────────────────────────────────── +info "Searching for git-repo-* secret in namespaces: ${SECRET_SEARCH_NS[*]} ..." + +SECRET_NAME="" +SECRET_NS="" + +for ns in "${SECRET_SEARCH_NS[@]}"; do + found=$(kubectl get secrets -n "$ns" \ + -o jsonpath='{.items[*].metadata.name}' 2>/dev/null \ + | tr ' ' '\n' | grep -E '^git-repo-' | head -1 || true) + if [[ -n "$found" ]]; then + SECRET_NAME="$found" + SECRET_NS="$ns" + break + fi +done + +[[ -n "$SECRET_NAME" ]] \ + || die "No 'git-repo-*' secret found in namespaces: ${SECRET_SEARCH_NS[*]}." + +ok "Found secret '$SECRET_NAME' in namespace '$SECRET_NS'" + +# Look up the repo URL from the GitProvider that references this secret. +info "Resolving repo URL from GitProvider referencing '$SECRET_NAME'..." + +REPO_URL=$(kubectl get gitproviders --all-namespaces \ + -o jsonpath='{range .items[*]}{.spec.secretRef.name}{"\t"}{.spec.url}{"\n"}{end}' \ + 2>/dev/null \ + | awk -v s="$SECRET_NAME" '$1 == s { print $2; exit }' || true) + +[[ -n "$REPO_URL" ]] \ + || die "Could not determine repo URL. Make sure a GitProvider exists that has spec.secretRef.name='$SECRET_NAME'." + +ok "Repo URL: $REPO_URL" + +# ────────────────────────────────────────────────────────────────────────────── +# Step 2: Copy the secret into podinfos-preview +# ────────────────────────────────────────────────────────────────────────────── +info "Copying secret '$SECRET_NAME' β†’ '$COPIED_SECRET_NAME' in namespace '$PREVIEW_NS'..." + +kubectl get secret "$SECRET_NAME" -n "$SECRET_NS" -o json \ + | jq --arg name "$COPIED_SECRET_NAME" --arg ns "$PREVIEW_NS" ' + .metadata = { + name: $name, + namespace: $ns + } + | del(.status) + ' \ + | kubectl apply -f - + +ok "Secret copied." + +# ────────────────────────────────────────────────────────────────────────────── +# Step 3: Create GitProvider in podinfos-preview +# ────────────────────────────────────────────────────────────────────────────── +info "Creating GitProvider '$PROVIDER_NAME' in '$PREVIEW_NS'..." + +kubectl apply -f - <.yaml +# +# The kustomization overrides the namespace to $PRODUCTION_NS so the same +# resource manifests deploy into production without modification. +# ────────────────────────────────────────────────────────────────────────────── +info "Discovering PodInfoApp instances in '$PREVIEW_NS'..." + +PODINFO_NAMES=$(kubectl get podinfoapps -n "$PREVIEW_NS" \ + -o jsonpath='{.items[*].metadata.name}' 2>/dev/null \ + | tr ' ' '\n' \ + | grep -v '^$' \ + || true) + +mkdir -p "$OUTPUT_DIR" + +info "Generating kustomization at '$OUTPUT_DIR/kustomization.yaml'..." + +{ + cat <.yaml +# +# To apply: +# git checkout $BRANCH +# kubectl apply -k $OUTPUT_DIR/ + +namespace: $PRODUCTION_NS + +resources: +YAML + + if [[ -n "$PODINFO_NAMES" ]]; then + while IFS= read -r name; do + echo " - ../$GIT_PATH/kro.run/v1alpha1/podinfoapps/$PREVIEW_NS/$name.yaml" + done <<< "$PODINFO_NAMES" + else + cat <.yaml +YAML + fi +} > "$OUTPUT_DIR/kustomization.yaml" + +ok "Kustomization written with $(echo "$PODINFO_NAMES" | grep -c . || echo 0) resource(s)." + +# ────────────────────────────────────────────────────────────────────────────── +# Done +# ────────────────────────────────────────────────────────────────────────────── +echo "" +echo "All done!" +echo "" +echo " Resources created in namespace '$PREVIEW_NS':" +echo " Secret: $COPIED_SECRET_NAME (copied from $SECRET_NS/$SECRET_NAME)" +echo " GitProvider: $PROVIDER_NAME" +echo " GitTarget: $TARGET_NAME (branch: $BRANCH, path: $GIT_PATH)" +echo " WatchRule: $WATCHRULE_NAME (watches: PodInfoApp)" +echo "" +echo " Production kustomization:" +echo " $OUTPUT_DIR/kustomization.yaml" +echo "" +echo "Next steps:" +echo " 1. Verify resources are Ready:" +echo " kubectl get gitproviders,gittargets,watchrules -n $PREVIEW_NS" +echo "" +echo " 2. Create or update PodInfoApp instances in '$PREVIEW_NS' β€”" +echo " gitops-reverser pushes every change to branch '$BRANCH'." +echo "" +echo " 3. Commit $OUTPUT_DIR/kustomization.yaml to the '$BRANCH' branch," +echo " then apply it to the cluster to promote instances to production:" +echo " kubectl apply -k $OUTPUT_DIR/" diff --git a/test/e2e/setup/demo-only/README.md b/test/e2e/setup/demo-only/README.md new file mode 100644 index 000000000..186a1f845 --- /dev/null +++ b/test/e2e/setup/demo-only/README.md @@ -0,0 +1,9 @@ +Setup the repo by + +``` +make clean-cluster +REPO_NAME=demo make test-e2e-demo +``` + +Which will prepare all resources in the vote namespace + diff --git a/test/e2e/setup/demo-only/kustomization.yaml b/test/e2e/setup/demo-only/kustomization.yaml index 2e3866c5c..836b5bc4f 100644 --- a/test/e2e/setup/demo-only/kustomization.yaml +++ b/test/e2e/setup/demo-only/kustomization.yaml @@ -5,5 +5,6 @@ resources: - podinfo-kro-template.yaml - cloudflared-public - vote + - podinfos-intent - podinfos-preview - podinfos-production diff --git a/test/e2e/setup/demo-only/podinfo-kro-template.yaml b/test/e2e/setup/demo-only/podinfo-kro-template.yaml index 7071e4dc0..67c3ee590 100644 --- a/test/e2e/setup/demo-only/podinfo-kro-template.yaml +++ b/test/e2e/setup/demo-only/podinfo-kro-template.yaml @@ -20,6 +20,12 @@ spec: type: string jsonPath: .status.url resources: + - id: gateway # This resource is 'included' so that we only expand in namespaces that also would serve it + externalRef: + apiVersion: traefik.io/v1alpha1 + kind: IngressRoute + metadata: + name: podinfos-gateway - id: deployment readyWhen: - "${deployment.status.availableReplicas >= 1}" @@ -31,6 +37,7 @@ spec: namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} + configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} spec: replicas: ${schema.spec.replicas} selector: @@ -40,6 +47,7 @@ spec: metadata: labels: app.kubernetes.io/name: ${schema.spec.name} + configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} spec: containers: - name: podinfo @@ -81,6 +89,7 @@ spec: namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} + configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} spec: selector: app.kubernetes.io/name: ${schema.spec.name} @@ -95,6 +104,9 @@ spec: metadata: name: ${schema.spec.name} namespace: ${schema.metadata.namespace} + labels: + app.kubernetes.io/name: ${schema.spec.name} + configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} spec: parentRefs: - name: podinfos-gateway diff --git a/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml b/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml index a522665de..1e592a8c8 100644 --- a/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/1-ingress-specific.yaml @@ -2,6 +2,8 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: strip-namespace-prefix + labels: + configbutler.ai/path-prefix: "podinfos" spec: stripPrefix: prefixes: @@ -11,6 +13,8 @@ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: podinfos-gateway + labels: + configbutler.ai/path-prefix: "podinfos" spec: entryPoints: - web diff --git a/test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml b/test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml new file mode 100644 index 000000000..c83c8aea2 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Secret +metadata: + name: git-creds-demo + annotations: + reflector.v1.k8s.emberstack.com/reflects: "vote/git-creds-demo" diff --git a/test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml b/test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml new file mode 100644 index 000000000..5c10d1dcd --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml @@ -0,0 +1,27 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: demo +spec: + interval: 10m + timeout: 1m + url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git + ref: + branch: main + secretRef: + name: git-creds-demo +# TODO: send back that webhook so that we have a quick idea on whats happening on the other side +# The goal is get the files exactly from that place +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: demo +spec: + interval: 24h # In practice I feel that we should turn it off, and then only request a sync when something is changed + timeout: 5m + path: ./podinfos + #prune: true + sourceRef: + kind: GitRepository + name: demo diff --git a/test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml new file mode 100644 index 000000000..a654b5627 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml @@ -0,0 +1,38 @@ +apiVersion: configbutler.ai/v1alpha1 +kind: GitProvider +metadata: + name: demo +spec: + url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git + allowedBranches: + - main + push: + interval: "5s" + maxCommits: 10 + secretRef: + name: git-creds-demo +--- +apiVersion: configbutler.ai/v1alpha1 +kind: GitTarget +metadata: + name: demo +spec: + providerRef: + kind: GitProvider + name: monster-deployment-provider + branch: main + path: podinfos +--- +apiVersion: configbutler.ai/v1alpha1 +kind: WatchRule +metadata: + name: demo +spec: + targetRef: + kind: GitTarget + name: demo + rules: + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["kro.run"] + apiVersions: ["v1alpha1"] + resources: ["podinfoapps"] diff --git a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml index 72b8766bb..b792d3bef 100644 --- a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml @@ -5,3 +5,6 @@ resources: - 1-ingress-specific.yaml - 2-middlewares.yaml - 3-example-instance.yaml + - 4-git-creds-demo.yaml + - 5-gitops.yaml + - 6-reverse-gitops diff --git a/test/e2e/setup/demo-only/podinfos-intent/0-ns.yaml b/test/e2e/setup/demo-only/podinfos-intent/0-ns.yaml new file mode 100644 index 000000000..876888a5b --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-intent/0-ns.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: podinfos-intent diff --git a/test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml b/test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml new file mode 100644 index 000000000..bd62cf2ca --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml @@ -0,0 +1,9 @@ +apiVersion: kro.run/v1alpha1 +kind: PodInfoApp +metadata: + name: fiets +spec: + replicas: 1 + name: fiets + message: The Dutch word for bicycle is "fiets". We have a lot of them. + color: "#115711" diff --git a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml new file mode 100644 index 000000000..a18d46a39 --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: podinfos-intent + +resources: + - 0-ns.yaml + - 1-example-instance.yaml diff --git a/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml b/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml index 18c855e20..5fcf0c3da 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/1-patch-ingress.yaml @@ -2,6 +2,8 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: strip-namespace-prefix + labels: + configbutler.ai/path-prefix: "podinfos" spec: stripPrefix: prefixes: @@ -11,6 +13,8 @@ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: podinfos-gateway + labels: + configbutler.ai/path-prefix: "podinfos" spec: routes: - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfos-preview/`) diff --git a/test/e2e/setup/flux/kustomization.yaml b/test/e2e/setup/flux/kustomization.yaml index a08f476ec..267bc1fc0 100644 --- a/test/e2e/setup/flux/kustomization.yaml +++ b/test/e2e/setup/flux/kustomization.yaml @@ -10,12 +10,15 @@ resources: - repositories/jetstack.yaml - repositories/gitea-charts.yaml - repositories/valkey.yaml + - repositories/demo.yaml - releases/ingress.yaml - releases/prometheus-operator.yaml - releases/cert-manager.yaml - releases/gitea.yaml - releases/valkey.yaml - releases/kro.yaml + - releases/reflector.yaml + - releases/demo.yaml generatorOptions: disableNameSuffixHash: true diff --git a/test/e2e/setup/flux/releases/reflector.yaml b/test/e2e/setup/flux/releases/reflector.yaml new file mode 100644 index 000000000..2b7d8d686 --- /dev/null +++ b/test/e2e/setup/flux/releases/reflector.yaml @@ -0,0 +1,34 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: reflector + namespace: flux-system +spec: + interval: 24h + url: oci://ghcr.io/emberstack/helm-charts/reflector + layerSelector: + mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + operation: copy + ref: + semver: "10.0.21" +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: reflector + namespace: flux-system +spec: + interval: 60m + timeout: 10m + chartRef: + kind: OCIRepository + name: reflector + namespace: flux-system + targetNamespace: reflector + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 diff --git a/test/loadtest/main.go b/test/loadtest/main.go index 4dde57395..30d8022ec 100644 --- a/test/loadtest/main.go +++ b/test/loadtest/main.go @@ -331,7 +331,17 @@ func printReport(results []userResult, wall time.Duration) { // ── Main ────────────────────────────────────────────────────────────────────── -func main() { +type config struct { + baseURL string + code string + session string + namespace string + users int + rampDuration time.Duration + timeout time.Duration +} + +func parseConfig() config { baseURL := flag.String("base-url", "https://vote.reversegitops.dev", "base URL of the voter app") code := flag.String("code", "", @@ -357,6 +367,18 @@ func main() { os.Exit(1) } + return config{ + baseURL: *baseURL, + code: *code, + session: *session, + namespace: *namespace, + users: *users, + rampDuration: *rampDuration, + timeout: *timeout, + } +} + +func generateRunID() string { // Generate a short alphanumeric run ID (e.g. "q1g3") shared across all users // so names look like "q1g3-0", "q1g3-1", … and are stable within one run. const runIDChars = "abcdefghijklmnopqrstuvwxyz0123456789" @@ -365,39 +387,39 @@ func main() { for i := range runIDb { runIDb[i] = runIDChars[runIDRng.Intn(len(runIDChars))] } - runID := string(runIDb) + return string(runIDb) +} +func printStart(cfg config, runID string) { fmt.Printf("Starting load test\n") - fmt.Printf(" Base URL: %s\n", *baseURL) - fmt.Printf(" Session: %s/%s\n", *namespace, *session) - fmt.Printf(" Join code: %s\n", *code) + fmt.Printf(" Base URL: %s\n", cfg.baseURL) + fmt.Printf(" Session: %s/%s\n", cfg.namespace, cfg.session) + fmt.Printf(" Join code: %s\n", cfg.code) fmt.Printf(" Run ID: %s\n", runID) - fmt.Printf(" Participants: %d\n", *users) - fmt.Printf(" Ramp duration: %s\n", *rampDuration) + fmt.Printf(" Participants: %d\n", cfg.users) + fmt.Printf(" Ramp duration: %s\n", cfg.rampDuration) fmt.Println() +} - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - - resultCh := make(chan userResult, *users) +func runLoadTest(ctx context.Context, cfg config, runID string) []userResult { + resultCh := make(chan userResult, cfg.users) var wg sync.WaitGroup - start := time.Now() - for i := range *users { + for i := range cfg.users { wg.Add(1) go func(userID int) { defer wg.Done() // Seed per goroutine so answers are not identical across users. rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(userID)*997)) // Staggered arrival: uniform random delay within the ramp window. - delay := time.Duration(rng.Float64() * float64(*rampDuration)) + delay := time.Duration(rng.Float64() * float64(cfg.rampDuration)) select { case <-ctx.Done(): resultCh <- userResult{userID: userID, err: "cancelled before start"} return case <-time.After(delay): } - resultCh <- simulateUser(ctx, userID, *baseURL, *code, runID, *session, *namespace, rng) + resultCh <- simulateUser(ctx, userID, cfg.baseURL, cfg.code, runID, cfg.session, cfg.namespace, rng) }(i) } @@ -408,20 +430,33 @@ func main() { }() // Collect and live-print results as they arrive. - results := make([]userResult, 0, *users) + results := make([]userResult, 0, cfg.users) done := 0 for r := range resultCh { done++ results = append(results, r) if r.ok { fmt.Printf(" [%3d/%d] user %3d login=%4dms submit=%4dms OK\n", - done, *users, r.userID, + done, cfg.users, r.userID, r.loginLatency.Milliseconds(), r.submitLatency.Milliseconds()) } else { fmt.Printf(" [%3d/%d] user %3d FAIL: %s\n", - done, *users, r.userID, r.err) + done, cfg.users, r.userID, r.err) } } + return results +} + +func main() { + cfg := parseConfig() + runID := generateRunID() + printStart(cfg, runID) + + ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) + defer cancel() + + start := time.Now() + results := runLoadTest(ctx, cfg, runID) printReport(results, time.Since(start)) } From 7f5bf2297a4b0882699117cbcce4c63b2b46f595 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 21:47:26 +0000 Subject: [PATCH 20/30] chore: Replaced monster deployment by a more elaborate initiliaztion that is more gitops, also setting up the gitea webhooks now for demo environment --- hack/demo/setup-monster-deployment.sh | 275 ------------------ hack/e2e/gitea-run-setup.sh | 144 ++++++++- .../demo-only/gitea-webhook-receiver.yaml | 31 ++ test/e2e/setup/demo-only/kustomization.yaml | 1 + .../setup/demo-only/podinfo-kro-template.yaml | 10 +- .../podinfos-base/4-git-creds-demo.yaml | 6 - .../podinfos-base/kustomization.yaml | 4 +- .../1-gitops.yaml} | 2 +- .../podinfos-gitops/kustomization.yaml | 5 + .../2-reverse-gitops.yaml} | 7 +- .../podinfos-intent/kustomization.yaml | 22 ++ .../podinfos-preview/kustomization.yaml | 9 + test/e2e/setup/flux/kustomization.yaml | 2 - 13 files changed, 218 insertions(+), 300 deletions(-) delete mode 100755 hack/demo/setup-monster-deployment.sh create mode 100644 test/e2e/setup/demo-only/gitea-webhook-receiver.yaml delete mode 100644 test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml rename test/e2e/setup/demo-only/{podinfos-base/5-gitops.yaml => podinfos-gitops/1-gitops.yaml} (97%) create mode 100644 test/e2e/setup/demo-only/podinfos-gitops/kustomization.yaml rename test/e2e/setup/demo-only/{podinfos-base/6-reverse-gitops.yaml => podinfos-intent/2-reverse-gitops.yaml} (90%) diff --git a/hack/demo/setup-monster-deployment.sh b/hack/demo/setup-monster-deployment.sh deleted file mode 100755 index 54befee0c..000000000 --- a/hack/demo/setup-monster-deployment.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 -# -# Demo script: wire up gitops-reverser to sync PodInfoApp changes from -# podinfos-preview to the "monster-deployment" git branch. -# -# What this script does: -# 1. Finds the first git-repo-* secret (searches gitops-reverser ns first, -# then default; override with SECRET_SEARCH_NS) -# 2. Copies it into the podinfos-preview namespace -# 3. Creates a GitProvider, GitTarget and WatchRule in podinfos-preview -# to sync PodInfoApp instances to the "monster-deployment" branch -# 4. Generates a Kustomize overlay (monster-deployment-production/) that -# deploys those same instances to the podinfos-production namespace -# -# Prerequisites: -# - kubectl configured against the demo cluster -# - jq installed -# - gitops-reverser controller running in the cluster -# - At least one git-repo-* secret and a GitProvider referencing it -# -# Optional env vars: -# SECRET_SEARCH_NS Space-separated list of namespaces to search for the -# git-repo-* secret (default: "gitops-reverser default") -# GIT_PATH Base path inside the git repo (default: "gitops-reverser-demo") -# PROVIDER_NAME Name for the GitProvider (default: "monster-deployment-provider") -# TARGET_NAME Name for the GitTarget (default: "monster-deployment-target") -# WATCHRULE_NAME Name for the WatchRule (default: "monster-deployment-watchrule") - -set -euo pipefail - -# ────────────────────────────────────────────────────────────────────────────── -# Configuration (override via env vars) -# ────────────────────────────────────────────────────────────────────────────── -PREVIEW_NS="podinfos-preview" -PRODUCTION_NS="podinfos-production" -BRANCH="monster-deployment" - -PROVIDER_NAME="${PROVIDER_NAME:-monster-deployment-provider}" -TARGET_NAME="${TARGET_NAME:-monster-deployment-target}" -WATCHRULE_NAME="${WATCHRULE_NAME:-monster-deployment-watchrule}" - -# Base path inside the git repo where gitops-reverser writes resources. -# PodInfoApp files land at: $GIT_PATH/kro.run/v1alpha1/podinfoapps/$PREVIEW_NS/.yaml -GIT_PATH="${GIT_PATH:-gitops-reverser-demo}" - -# Name given to the secret after it is copied into podinfos-preview -COPIED_SECRET_NAME="git-repo-preview" - -# Namespaces to search for the git-repo-* secret -read -ra SECRET_SEARCH_NS <<< "${SECRET_SEARCH_NS:-gitops-reverser default}" - -# Where the production kustomization is written -OUTPUT_DIR="./monster-deployment-production" - -# ────────────────────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────────────────────── -die() { echo "ERROR: $*" >&2; exit 1; } -info() { echo "==> $*"; } -ok() { echo " βœ“ $*"; } - -require_cmd() { - command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1 β€” please install it first." -} - -require_cmd kubectl -require_cmd jq - -# ────────────────────────────────────────────────────────────────────────────── -# Step 1: Discover the git-repo-* secret and its associated GitProvider URL -# ────────────────────────────────────────────────────────────────────────────── -info "Searching for git-repo-* secret in namespaces: ${SECRET_SEARCH_NS[*]} ..." - -SECRET_NAME="" -SECRET_NS="" - -for ns in "${SECRET_SEARCH_NS[@]}"; do - found=$(kubectl get secrets -n "$ns" \ - -o jsonpath='{.items[*].metadata.name}' 2>/dev/null \ - | tr ' ' '\n' | grep -E '^git-repo-' | head -1 || true) - if [[ -n "$found" ]]; then - SECRET_NAME="$found" - SECRET_NS="$ns" - break - fi -done - -[[ -n "$SECRET_NAME" ]] \ - || die "No 'git-repo-*' secret found in namespaces: ${SECRET_SEARCH_NS[*]}." - -ok "Found secret '$SECRET_NAME' in namespace '$SECRET_NS'" - -# Look up the repo URL from the GitProvider that references this secret. -info "Resolving repo URL from GitProvider referencing '$SECRET_NAME'..." - -REPO_URL=$(kubectl get gitproviders --all-namespaces \ - -o jsonpath='{range .items[*]}{.spec.secretRef.name}{"\t"}{.spec.url}{"\n"}{end}' \ - 2>/dev/null \ - | awk -v s="$SECRET_NAME" '$1 == s { print $2; exit }' || true) - -[[ -n "$REPO_URL" ]] \ - || die "Could not determine repo URL. Make sure a GitProvider exists that has spec.secretRef.name='$SECRET_NAME'." - -ok "Repo URL: $REPO_URL" - -# ────────────────────────────────────────────────────────────────────────────── -# Step 2: Copy the secret into podinfos-preview -# ────────────────────────────────────────────────────────────────────────────── -info "Copying secret '$SECRET_NAME' β†’ '$COPIED_SECRET_NAME' in namespace '$PREVIEW_NS'..." - -kubectl get secret "$SECRET_NAME" -n "$SECRET_NS" -o json \ - | jq --arg name "$COPIED_SECRET_NAME" --arg ns "$PREVIEW_NS" ' - .metadata = { - name: $name, - namespace: $ns - } - | del(.status) - ' \ - | kubectl apply -f - - -ok "Secret copied." - -# ────────────────────────────────────────────────────────────────────────────── -# Step 3: Create GitProvider in podinfos-preview -# ────────────────────────────────────────────────────────────────────────────── -info "Creating GitProvider '$PROVIDER_NAME' in '$PREVIEW_NS'..." - -kubectl apply -f - <.yaml -# -# The kustomization overrides the namespace to $PRODUCTION_NS so the same -# resource manifests deploy into production without modification. -# ────────────────────────────────────────────────────────────────────────────── -info "Discovering PodInfoApp instances in '$PREVIEW_NS'..." - -PODINFO_NAMES=$(kubectl get podinfoapps -n "$PREVIEW_NS" \ - -o jsonpath='{.items[*].metadata.name}' 2>/dev/null \ - | tr ' ' '\n' \ - | grep -v '^$' \ - || true) - -mkdir -p "$OUTPUT_DIR" - -info "Generating kustomization at '$OUTPUT_DIR/kustomization.yaml'..." - -{ - cat <.yaml -# -# To apply: -# git checkout $BRANCH -# kubectl apply -k $OUTPUT_DIR/ - -namespace: $PRODUCTION_NS - -resources: -YAML - - if [[ -n "$PODINFO_NAMES" ]]; then - while IFS= read -r name; do - echo " - ../$GIT_PATH/kro.run/v1alpha1/podinfoapps/$PREVIEW_NS/$name.yaml" - done <<< "$PODINFO_NAMES" - else - cat <.yaml -YAML - fi -} > "$OUTPUT_DIR/kustomization.yaml" - -ok "Kustomization written with $(echo "$PODINFO_NAMES" | grep -c . || echo 0) resource(s)." - -# ────────────────────────────────────────────────────────────────────────────── -# Done -# ────────────────────────────────────────────────────────────────────────────── -echo "" -echo "All done!" -echo "" -echo " Resources created in namespace '$PREVIEW_NS':" -echo " Secret: $COPIED_SECRET_NAME (copied from $SECRET_NS/$SECRET_NAME)" -echo " GitProvider: $PROVIDER_NAME" -echo " GitTarget: $TARGET_NAME (branch: $BRANCH, path: $GIT_PATH)" -echo " WatchRule: $WATCHRULE_NAME (watches: PodInfoApp)" -echo "" -echo " Production kustomization:" -echo " $OUTPUT_DIR/kustomization.yaml" -echo "" -echo "Next steps:" -echo " 1. Verify resources are Ready:" -echo " kubectl get gitproviders,gittargets,watchrules -n $PREVIEW_NS" -echo "" -echo " 2. Create or update PodInfoApp instances in '$PREVIEW_NS' β€”" -echo " gitops-reverser pushes every change to branch '$BRANCH'." -echo "" -echo " 3. Commit $OUTPUT_DIR/kustomization.yaml to the '$BRANCH' branch," -echo " then apply it to the cluster to promote instances to production:" -echo " kubectl apply -k $OUTPUT_DIR/" diff --git a/hack/e2e/gitea-run-setup.sh b/hack/e2e/gitea-run-setup.sh index a247b7adb..200206207 100755 --- a/hack/e2e/gitea-run-setup.sh +++ b/hack/e2e/gitea-run-setup.sh @@ -60,6 +60,11 @@ fi SECRET_HTTP_NAME="${E2E_GIT_SECRET_HTTP:-git-creds-${REPO_NAME}}" SECRET_SSH_NAME="${E2E_GIT_SECRET_SSH:-git-creds-ssh-${REPO_NAME}}" SECRET_INVALID_NAME="${E2E_GIT_SECRET_INVALID:-git-creds-invalid-${REPO_NAME}}" +FLUX_RECEIVER_NAMESPACE="${FLUX_RECEIVER_NAMESPACE:-flux-system}" +FLUX_RECEIVER_NAME="${FLUX_RECEIVER_NAME:-gitea-webreceiver-${REPO_NAME}}" +FLUX_RECEIVER_SECRET_NAME="${FLUX_RECEIVER_SECRET_NAME:-${FLUX_RECEIVER_NAME}}" +FLUX_WEBHOOK_SERVICE_NAME="${FLUX_WEBHOOK_SERVICE_NAME:-webhook-receiver}" +FLUX_WEBHOOK_URL_BASE="${FLUX_WEBHOOK_URL_BASE:-http://${FLUX_WEBHOOK_SERVICE_NAME}.${FLUX_RECEIVER_NAMESPACE}.svc.cluster.local}" project_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cs_abs="${project_root}/${CS}" @@ -73,6 +78,8 @@ token_file="${run_dir}/token.txt" secrets_yaml="${run_dir}/secrets.yaml" repo_ready="${run_dir}/repo.ready" checkout_ready="${run_dir}/checkout.ready" +receiver_webhook_url_file="${run_dir}/receiver-webhook-url.txt" +receiver_webhook_id_file="${run_dir}/receiver-webhook-id.txt" mkdir -p "${ssh_dir}" @@ -225,6 +232,116 @@ ensure_repo() { rm -f "${tmp}" } +read_flux_receiver_token() { + if ! kubectl --context "${CTX}" -n "${FLUX_RECEIVER_NAMESPACE}" get secret "${FLUX_RECEIVER_SECRET_NAME}" >/dev/null 2>&1; then + return 1 + fi + + kubectl --context "${CTX}" -n "${FLUX_RECEIVER_NAMESPACE}" get secret "${FLUX_RECEIVER_SECRET_NAME}" \ + -o jsonpath='{.data.token}' | base64 -d +} + +wait_for_flux_receiver_path() { + local receiver_path + + if ! kubectl --context "${CTX}" -n "${FLUX_RECEIVER_NAMESPACE}" get receiver "${FLUX_RECEIVER_NAME}" >/dev/null 2>&1; then + return 1 + fi + + for i in {1..30}; do + receiver_path="$( + kubectl --context "${CTX}" -n "${FLUX_RECEIVER_NAMESPACE}" get receiver "${FLUX_RECEIVER_NAME}" \ + -o jsonpath='{.status.webhookPath}' 2>/dev/null || true + )" + if [[ -n "${receiver_path}" ]]; then + printf '%s' "${receiver_path}" + return 0 + fi + sleep 2 + done + + return 1 +} + +ensure_repo_webhook() { + local receiver_token receiver_path callback_url hooks_json managed_hook_ids hook_id payload tmp code + + if ! receiver_token="$(read_flux_receiver_token)"; then + echo "Flux receiver secret ${FLUX_RECEIVER_NAMESPACE}/${FLUX_RECEIVER_SECRET_NAME} not found; skipping repo webhook setup" + return 0 + fi + + if [[ -z "${receiver_token}" ]]; then + echo "WARN: Flux receiver secret ${FLUX_RECEIVER_NAMESPACE}/${FLUX_RECEIVER_SECRET_NAME} has an empty token; skipping repo webhook setup" >&2 + return 0 + fi + + if ! receiver_path="$(wait_for_flux_receiver_path)"; then + echo "WARN: Flux receiver ${FLUX_RECEIVER_NAMESPACE}/${FLUX_RECEIVER_NAME} is missing or has no webhookPath; skipping repo webhook setup" >&2 + return 0 + fi + + callback_url="${FLUX_WEBHOOK_URL_BASE}${receiver_path}" + printf '%s\n' "${callback_url}" > "${receiver_webhook_url_file}" + + echo "Ensuring Gitea repo webhook points to ${callback_url}" + hooks_json="$( + curl -fsS -X GET "${API_URL}/repos/${ORG_NAME}/${REPO_NAME}/hooks" \ + -H "Content-Type: application/json" \ + -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}" + )" + + managed_hook_ids="$( + echo "${hooks_json}" | jq -r \ + --arg url_base "${FLUX_WEBHOOK_URL_BASE}" \ + '.[] | select(.type == "gitea") | select((.config.url // "") | startswith($url_base + "/hook/")) | .id' + )" + + if [[ -n "${managed_hook_ids}" ]]; then + while IFS= read -r hook_id; do + [[ -n "${hook_id}" ]] || continue + curl -fsS -X DELETE "${API_URL}/repos/${ORG_NAME}/${REPO_NAME}/hooks/${hook_id}" \ + -H "Content-Type: application/json" \ + -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}" >/dev/null + done <<< "${managed_hook_ids}" + fi + + payload="$( + jq -nc \ + --arg url "${callback_url}" \ + --arg secret "${receiver_token}" \ + '{type:"gitea",active:true,events:["push","create","delete"],config:{url:$url,content_type:"json",secret:$secret}}' + )" + + tmp="$(mktemp)" + code="$( + curl -sS -o "${tmp}" -w "%{http_code}" \ + -X POST "${API_URL}/repos/${ORG_NAME}/${REPO_NAME}/hooks" \ + -H "Content-Type: application/json" \ + -u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}" \ + -d "${payload}" \ + || true + )" + + if [[ "${code}" != "201" ]]; then + echo "ERROR: Failed to create repo webhook (HTTP ${code})" >&2 + sed 's/^/ /' "${tmp}" >&2 || true + rm -f "${tmp}" + exit 1 + fi + + hook_id="$(jq -r '.id // ""' "${tmp}")" + if [[ -z "${hook_id}" ]]; then + echo "ERROR: Repo webhook creation succeeded but response did not contain an id" >&2 + sed 's/^/ /' "${tmp}" >&2 || true + rm -f "${tmp}" + exit 1 + fi + + printf '%s\n' "${hook_id}" > "${receiver_webhook_id_file}" + rm -f "${tmp}" +} + generate_known_hosts() { local known_hosts_file tmp pf_pid local_port ssh_host known_hosts_file="${ssh_dir}/known_hosts" @@ -264,7 +381,7 @@ generate_known_hosts() { } write_secrets_manifest() { - local token secrets_tmp ssh_args=() + local token secrets_tmp ssh_args=() reflector_args=() token="$(tr -d '\n' < "${token_file}" | tr -d '\r')" [[ -n "${token}" ]] || { echo "ERROR: token.txt is empty" >&2; exit 1; } @@ -281,13 +398,29 @@ write_secrets_manifest() { ssh_args+=(--from-file=known_hosts="${ssh_dir}/known_hosts") fi + if [[ "${REPO_NAME}" == "demo" ]]; then + reflector_args=( + "reflector.v1.k8s.emberstack.com/reflection-allowed=true" + "reflector.v1.k8s.emberstack.com/reflection-auto-enabled=true" + "reflector.v1.k8s.emberstack.com/reflection-auto-namespaces=podinfos-intent,podinfos-preview,podinfos-production" + ) + fi + # Generate namespace-free manifests so each test suite can apply them # to its own dynamically-named test namespace. { - kubectl create secret generic "${SECRET_HTTP_NAME}" \ - --from-literal=username="${GITEA_ADMIN_USER}" \ - --from-literal=password="${token}" \ - --dry-run=client -o yaml + if [[ "${#reflector_args[@]}" -gt 0 ]]; then + kubectl create secret generic "${SECRET_HTTP_NAME}" \ + --from-literal=username="${GITEA_ADMIN_USER}" \ + --from-literal=password="${token}" \ + --dry-run=client -o yaml | \ + kubectl annotate --local -f - "${reflector_args[@]}" -o yaml + else + kubectl create secret generic "${SECRET_HTTP_NAME}" \ + --from-literal=username="${GITEA_ADMIN_USER}" \ + --from-literal=password="${token}" \ + --dry-run=client -o yaml + fi printf '\n---\n' kubectl create secret generic "${SECRET_SSH_NAME}" \ --from-file=ssh-privatekey="${ssh_dir}/id_rsa" \ @@ -359,4 +492,5 @@ generate_known_hosts configure_ssh_key_in_gitea ensure_repo write_secrets_manifest +ensure_repo_webhook ensure_checkout diff --git a/test/e2e/setup/demo-only/gitea-webhook-receiver.yaml b/test/e2e/setup/demo-only/gitea-webhook-receiver.yaml new file mode 100644 index 000000000..1455cb282 --- /dev/null +++ b/test/e2e/setup/demo-only/gitea-webhook-receiver.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gitea-webreceiver-demo + namespace: flux-system +stringData: + token: FvfrahPqlC7R29QSa61AybaJh # It's only internally, so ok for now +--- +apiVersion: notification.toolkit.fluxcd.io/v1 +kind: Receiver +metadata: + name: gitea-webreceiver-demo + namespace: flux-system +spec: + type: github # gitea itself is not supported, but is github compatible + events: # ping event is not supported by gitea + - "push" + - "create" + - "delete" + secretRef: + name: gitea-webreceiver-demo + resources: + - kind: GitRepository + name: demo + namespace: podinfos-intent + - kind: GitRepository + name: demo + namespace: podinfos-preview + - kind: GitRepository + name: demo + namespace: podinfos-production diff --git a/test/e2e/setup/demo-only/kustomization.yaml b/test/e2e/setup/demo-only/kustomization.yaml index 836b5bc4f..835cce780 100644 --- a/test/e2e/setup/demo-only/kustomization.yaml +++ b/test/e2e/setup/demo-only/kustomization.yaml @@ -5,6 +5,7 @@ resources: - podinfo-kro-template.yaml - cloudflared-public - vote + - gitea-webhook-receiver.yaml - podinfos-intent - podinfos-preview - podinfos-production diff --git a/test/e2e/setup/demo-only/podinfo-kro-template.yaml b/test/e2e/setup/demo-only/podinfo-kro-template.yaml index 67c3ee590..d93f7d66f 100644 --- a/test/e2e/setup/demo-only/podinfo-kro-template.yaml +++ b/test/e2e/setup/demo-only/podinfo-kro-template.yaml @@ -20,7 +20,7 @@ spec: type: string jsonPath: .status.url resources: - - id: gateway # This resource is 'included' so that we only expand in namespaces that also would serve it + - id: gateway # Trying to include this is a trick to only expand in namespaces that also would serve traffic externalRef: apiVersion: traefik.io/v1alpha1 kind: IngressRoute @@ -37,7 +37,7 @@ spec: namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} - configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} + configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: replicas: ${schema.spec.replicas} selector: @@ -47,7 +47,7 @@ spec: metadata: labels: app.kubernetes.io/name: ${schema.spec.name} - configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} + configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: containers: - name: podinfo @@ -89,7 +89,7 @@ spec: namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} - configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} + configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: selector: app.kubernetes.io/name: ${schema.spec.name} @@ -106,7 +106,7 @@ spec: namespace: ${schema.metadata.namespace} labels: app.kubernetes.io/name: ${schema.spec.name} - configbutler.ai/path-prefix: ${gateway.metadata.labels.?["configbutler.ai/path-prefix"]} + configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: parentRefs: - name: podinfos-gateway diff --git a/test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml b/test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml deleted file mode 100644 index c83c8aea2..000000000 --- a/test/e2e/setup/demo-only/podinfos-base/4-git-creds-demo.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: git-creds-demo - annotations: - reflector.v1.k8s.emberstack.com/reflects: "vote/git-creds-demo" diff --git a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml index b792d3bef..8f8b8b737 100644 --- a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml @@ -5,6 +5,4 @@ resources: - 1-ingress-specific.yaml - 2-middlewares.yaml - 3-example-instance.yaml - - 4-git-creds-demo.yaml - - 5-gitops.yaml - - 6-reverse-gitops + - ../podinfos-gitops diff --git a/test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml similarity index 97% rename from test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml rename to test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml index 5c10d1dcd..b13164930 100644 --- a/test/e2e/setup/demo-only/podinfos-base/5-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml @@ -21,7 +21,7 @@ spec: interval: 24h # In practice I feel that we should turn it off, and then only request a sync when something is changed timeout: 5m path: ./podinfos - #prune: true + prune: true sourceRef: kind: GitRepository name: demo diff --git a/test/e2e/setup/demo-only/podinfos-gitops/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-gitops/kustomization.yaml new file mode 100644 index 000000000..f7e29d06e --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-gitops/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - 1-gitops.yaml diff --git a/test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml similarity index 90% rename from test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml rename to test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index a654b5627..a269be51f 100644 --- a/test/e2e/setup/demo-only/podinfos-base/6-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -6,9 +6,10 @@ spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git allowedBranches: - main + - monster push: interval: "5s" - maxCommits: 10 + maxCommits: 30 secretRef: name: git-creds-demo --- @@ -19,8 +20,8 @@ metadata: spec: providerRef: kind: GitProvider - name: monster-deployment-provider - branch: main + name: demo + branch: monster path: podinfos --- apiVersion: configbutler.ai/v1alpha1 diff --git a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml index a18d46a39..018cd2fc5 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml @@ -6,3 +6,25 @@ namespace: podinfos-intent resources: - 0-ns.yaml - 1-example-instance.yaml + - ../podinfos-gitops + - 2-reverse-gitops.yaml + +patches: + - target: + group: source.toolkit.fluxcd.io + version: v1 + kind: GitRepository + name: demo + patch: |- + - op: replace + path: /spec/ref/branch + value: monster + - target: # Turn the normal GitOps off for now -> We can enable it for the demo, but it will create race conditions otherwise... + group: kustomize.toolkit.fluxcd.io + version: v1 + kind: Kustomization + name: demo + patch : |- + - op: replace + path: /spec/suspend + value: true \ No newline at end of file diff --git a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml index 691386492..8f8f82ce1 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml @@ -9,3 +9,12 @@ resources: patches: - path: 1-patch-ingress.yaml + - target: + group: source.toolkit.fluxcd.io + version: v1 + kind: GitRepository + name: demo + patch: |- + - op: replace + path: /spec/ref/branch + value: monster \ No newline at end of file diff --git a/test/e2e/setup/flux/kustomization.yaml b/test/e2e/setup/flux/kustomization.yaml index 267bc1fc0..826c93f1f 100644 --- a/test/e2e/setup/flux/kustomization.yaml +++ b/test/e2e/setup/flux/kustomization.yaml @@ -10,7 +10,6 @@ resources: - repositories/jetstack.yaml - repositories/gitea-charts.yaml - repositories/valkey.yaml - - repositories/demo.yaml - releases/ingress.yaml - releases/prometheus-operator.yaml - releases/cert-manager.yaml @@ -18,7 +17,6 @@ resources: - releases/valkey.yaml - releases/kro.yaml - releases/reflector.yaml - - releases/demo.yaml generatorOptions: disableNameSuffixHash: true From aad5d32757b81b35a49540fd310dc7368c8d66f2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 22:39:31 +0000 Subject: [PATCH 21/30] chore: trying to delay bootstrap until first meaningfull event --- .../unify-event-batch-and-bootstrap-plan.md | 215 ++++++++++++++++++ internal/controller/gittarget_controller.go | 135 ----------- .../gittarget_controller_unit_test.go | 6 +- internal/git/bootstrapped_repo_template.go | 72 ++---- internal/git/branch_worker.go | 76 +++---- internal/git/branch_worker_test.go | 96 +++----- internal/git/git.go | 37 ++- internal/git/git_operations_test.go | 68 ++++++ internal/git/types.go | 4 + internal/git/worker_manager.go | 74 +----- 10 files changed, 412 insertions(+), 371 deletions(-) create mode 100644 docs/design/unify-event-batch-and-bootstrap-plan.md diff --git a/docs/design/unify-event-batch-and-bootstrap-plan.md b/docs/design/unify-event-batch-and-bootstrap-plan.md new file mode 100644 index 000000000..7c4dcdba4 --- /dev/null +++ b/docs/design/unify-event-batch-and-bootstrap-plan.md @@ -0,0 +1,215 @@ +# Plan: Unify Event Handling Around One Batch Type + +## Summary + +Refactor the write pipeline so there is only one queued/writeable event container: a batch type with `[]Event`, used for both live events and reconciliation batches. + +A single live event should become a batch with one item. + +Bootstrap should stay as file preparation behavior, but we should be explicit that we **do not need a separate init commit**. The tests that matter mostly assert that bootstrap files exist when needed, not that they were created in a dedicated `chore(bootstrap): initialize path {path}` commit. + +## Why This Refactor Is Worth Doing + +The current code still carries two parallel abstractions: + +- a single live event path, +- a multi-event reconcile batch path. + +That duplication shows up in: + +- queue item types, +- branch worker enqueue APIs, +- branch worker commit/push functions, +- git write entrypoints, +- commit generation helpers, +- tests and mocks. + +Unifying around one batch/request type reduces code duplication and makes it easier to add future grouping strategies without creating yet another parallel path. + +## Current Code Shape + +### Current internal types + +Today we still have: + +- [`Event`](/workspaces/gitops-reverser/internal/git/types.go) +- [`ReconcileBatch`](/workspaces/gitops-reverser/internal/git/types.go) +- [`WorkItem{Single,Batch}`](/workspaces/gitops-reverser/internal/git/types.go) + +And the branch worker still exposes two enqueue APIs: + +- [`Enqueue(event Event)`](/workspaces/gitops-reverser/internal/git/branch_worker.go) +- [`EnqueueBatch(batch *ReconcileBatch)`](/workspaces/gitops-reverser/internal/git/branch_worker.go) + +### Current duplicated execution paths + +In the branch worker: + +- [`commitAndPush(events []Event)`](/workspaces/gitops-reverser/internal/git/branch_worker.go) +- [`commitAndPushBatch(batch *ReconcileBatch)`](/workspaces/gitops-reverser/internal/git/branch_worker.go) + +In git write logic: + +- [`WriteEventsWithContentWriter(...)`](/workspaces/gitops-reverser/internal/git/git.go) +- [`WriteBatchWithContentWriter(...)`](/workspaces/gitops-reverser/internal/git/git.go) +- [`generateCommitsFromEvents(...)`](/workspaces/gitops-reverser/internal/git/git.go) +- [`generateBatchCommit(...)`](/workspaces/gitops-reverser/internal/git/git.go) + +The behavior is similar, but the code is split because one path means β€œmany commits from many events” and the other means β€œone atomic commit for the whole batch”. + +## Proposed Design + +### 1. Replace `WorkItem{Single,Batch}` with one request type + +Create one internal write request type in [`internal/git/types.go`](/workspaces/gitops-reverser/internal/git/types.go), for example: + +- `Events []Event` +- `CommitMessage string` +- `GitTargetName` +- `GitTargetNamespace` +- `BootstrapOptions` +- `CommitMode` + +Suggested `CommitMode` values: + +- `PerEvent` +- `Atomic` + +This keeps the semantic difference without keeping two separate transport types. + +### 2. Make BranchWorker accept one queue shape + +Replace: + +- `Enqueue(event Event)` +- `EnqueueBatch(batch *ReconcileBatch)` + +with one enqueue method that accepts the unified request type. + +Behavior mapping: + +- live watch event -> request with `Events` length 1 and `CommitMode=PerEvent` +- flushed live buffer -> request with `Events` length N and `CommitMode=PerEvent` +- reconcile output -> request with `Events` length N and `CommitMode=Atomic` + +### 3. Collapse branch worker execution to one commit/push path + +Replace: + +- `commitAndPush(...)` +- `commitAndPushBatch(...)` + +with one function, for example: + +- `commitAndPushRequest(req *WriteRequest)` + +That function should own: + +- GitProvider/auth lookup +- target encryption resolution +- bootstrap option resolution +- writer configuration +- delegation into the unified git write function + +### 4. Collapse `git.go` to one real write implementation + +Keep a single internal write entrypoint that handles: + +- branch checkout/reset +- bootstrap staging +- event application +- commit creation +- push/conflict retry + +Recommended shape: + +- keep `WriteEvents(...)` temporarily as a thin compatibility wrapper +- internally translate to the unified request type +- route all real work through one implementation + +The main simplifications should be: + +- merge `tryWriteEventsAttempt` and `tryWriteBatchAttempt` +- merge `generateCommitsFromEvents` and `generateBatchCommit` +- branch only on `CommitMode` + +### 5. Preserve current commit semantics + +This refactor should not change visible behavior: + +- `PerEvent` still means one commit per event +- `Atomic` still means one commit for the whole reconcile batch + +So this is a code-structure refactor, not a behavior refactor. + +## Bootstrap Behavior Decision + +We should keep bootstrap as path-scoped file preparation, but we should **not** add back a separate init commit. + +That means: + +- bootstrap files can still be written/staged when needed, +- they should be committed together with the first relevant real write, +- there is no need for a dedicated `chore(bootstrap): initialize path {path}` commit. + +### Why this is safe + +The current code already works this way: + +- [`EnsurePathBootstrapped(...)`](/workspaces/gitops-reverser/internal/git/branch_worker.go) prepares missing bootstrap files locally +- [`ensureBootstrapTemplateInPath(...)`](/workspaces/gitops-reverser/internal/git/bootstrapped_repo_template.go) writes and stages them in the worktree +- the actual commit happens later in [`WriteEventsWithContentWriter(...)`](/workspaces/gitops-reverser/internal/git/git.go) or [`WriteBatchWithContentWriter(...)`](/workspaces/gitops-reverser/internal/git/git.go) + +### Test impact + +The existing tests mostly depend on bootstrap side effects, not on a dedicated bootstrap commit: + +- e2e tests check that `.sops.yaml` exists in the repo path when encryption is used +- unit tests check that bootstrap files are prepared and preserved correctly +- current tests do **not** appear to assert the dedicated bootstrap commit message +- current tests do **not** appear to require bootstrap to exist as a separate commit in git history + +So the event unification refactor does not need to preserve or restore a standalone bootstrap commit. + +## Recommended Sequence + +### Step 1. Keep bootstrap behavior as-is + +Do not introduce a separate bootstrap commit. + +Goal: + +- bootstrap files continue to be prepared/staged when needed +- they continue to be committed with the first relevant write +- no extra bootstrap-only git history behavior is introduced + +### Step 2. Refactor event handling to one request type + +- remove `WorkItem{Single,Batch}` +- remove `Enqueue` vs `EnqueueBatch` +- remove `commitAndPush` vs `commitAndPushBatch` +- remove `WriteEvents...` vs `WriteBatch...` duplication internally + +### Step 3. Keep visible behavior unchanged + +Only the internal event transport and write plumbing should change. + +## Test Plan + +- Update unit tests in `internal/git` to validate both `PerEvent` and `Atomic` modes through the unified request path. +- Update branch worker tests to assert live events and reconcile batches both enter the same queue type. +- Update reconcile/event stream tests and mocks to use one enqueue method. +- Keep bootstrap-related assertions focused on file presence and idempotence, not on a dedicated bootstrap commit. +- Run: + - `make lint` + - `make test` + - `make test-e2e` + - `make test-e2e-quickstart-manifest` + - `make test-e2e-quickstart-helm` + +## Assumptions + +- `Event` remains the per-resource payload type. +- The new unified request type is internal only. +- Commit semantics (`PerEvent` vs `Atomic`) must remain unchanged during this refactor. +- We do not need a separate init commit for bootstrap. diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 496104297..fa2b35573 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -51,7 +51,6 @@ const ( GitTargetConditionReady = ConditionTypeReady GitTargetConditionValidated = "Validated" GitTargetConditionEncryptionConfigured = "EncryptionConfigured" - GitTargetConditionBootstrapped = "Bootstrapped" GitTargetConditionSnapshotSynced = "SnapshotSynced" GitTargetConditionEventStreamLive = "EventStreamLive" ) @@ -72,9 +71,7 @@ const ( GitTargetReasonMissingSecret = "MissingSecret" GitTargetReasonInvalidConfig = "InvalidConfig" GitTargetReasonSecretCreateDisabled = "SecretCreateDisabled" - GitTargetReasonBootstrapApplied = "BootstrapApplied" GitTargetReasonWorkerNotFound = "WorkerNotFound" - GitTargetReasonBootstrapFailed = "BootstrapFailed" GitTargetReasonRunning = "Running" GitTargetReasonCompleted = "Completed" GitTargetReasonSnapshotFailed = "SnapshotFailed" @@ -84,7 +81,6 @@ const ( GitTargetReadyReasonValidationFailed = "ValidationFailed" GitTargetReadyReasonEncryptionNotConfigured = "EncryptionNotConfigured" - GitTargetReadyReasonBootstrapNotComplete = "BootstrapNotComplete" GitTargetReadyReasonInitialSyncInProgress = "InitialSyncInProgress" GitTargetReadyReasonStreamNotLive = "StreamNotLive" ) @@ -136,13 +132,6 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( GitTargetReasonBlocked, "Blocked by Validated=False", ) - r.setCondition( - &target, - GitTargetConditionBootstrapped, - metav1.ConditionUnknown, - GitTargetReasonBlocked, - "Blocked by Validated=False", - ) r.setCondition( &target, GitTargetConditionSnapshotSynced, @@ -177,13 +166,6 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( encryptionReady, encryptionMessage, encryptionRequeueAfter := r.evaluateEncryptionGate(ctx, &target, log) if !encryptionReady { - r.setCondition( - &target, - GitTargetConditionBootstrapped, - metav1.ConditionUnknown, - GitTargetReasonBlocked, - "Blocked by EncryptionConfigured=False", - ) r.setCondition( &target, GitTargetConditionSnapshotSynced, @@ -210,34 +192,6 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{RequeueAfter: encryptionRequeueAfter}, nil } - bootstrapReady, bootstrapMsg, bootstrapRequeueAfter := r.evaluateBootstrapGate(ctx, &target, providerNS, log) - if !bootstrapReady { - r.setCondition( - &target, - GitTargetConditionSnapshotSynced, - metav1.ConditionUnknown, - GitTargetReasonNotStarted, - "Initial snapshot sync has not started", - ) - r.setCondition( - &target, - GitTargetConditionEventStreamLive, - metav1.ConditionUnknown, - GitTargetReasonNotStarted, - "Event stream activation has not started", - ) - r.setReadyCondition( - &target, - metav1.ConditionFalse, - GitTargetReadyReasonBootstrapNotComplete, - bootstrapMsg, - ) - if err := r.updateStatusWithRetry(ctx, &target); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{RequeueAfter: bootstrapRequeueAfter}, nil - } - stream, snapshotState, snapshotMessage, snapshotRequeueAfter, snapshotErr := r.evaluateSnapshotGate( ctx, &target, @@ -408,72 +362,6 @@ func (r *GitTargetReconciler) evaluateEncryptionGate( return true, "", 0 } -func (r *GitTargetReconciler) evaluateBootstrapGate( - ctx context.Context, - target *configbutleraiv1alpha1.GitTarget, - providerNS string, - log logr.Logger, -) (bool, string, time.Duration) { - if r.WorkerManager == nil { - r.setCondition( - target, - GitTargetConditionBootstrapped, - metav1.ConditionFalse, - GitTargetReasonWorkerNotFound, - "Worker manager is not configured", - ) - return false, "Bootstrapped gate failed: WorkerNotFound", RequeueShortInterval - } - - if err := r.WorkerManager.EnsureWorker( - ctx, - target.Spec.ProviderRef.Name, - providerNS, - target.Spec.Branch, - ); err != nil { - log.Error(err, "Failed to ensure worker") - r.setCondition( - target, - GitTargetConditionBootstrapped, - metav1.ConditionFalse, - GitTargetReasonWorkerNotFound, - err.Error(), - ) - return false, "Bootstrapped gate failed: WorkerNotFound", RequeueShortInterval - } - - if err := r.WorkerManager.EnsureTargetBootstrapped( - ctx, - target.Name, - target.Namespace, - target.Spec.ProviderRef.Name, - providerNS, - target.Spec.Branch, - target.Spec.Path, - ); err != nil { - log.Error(err, "Failed to bootstrap target path") - r.setCondition( - target, - GitTargetConditionBootstrapped, - metav1.ConditionFalse, - GitTargetReasonBootstrapFailed, - err.Error(), - ) - return false, "Bootstrapped gate failed: BootstrapFailed", RequeueShortInterval - } - - r.setCondition( - target, - GitTargetConditionBootstrapped, - metav1.ConditionTrue, - GitTargetReasonBootstrapApplied, - fmt.Sprintf("Bootstrap ensured for path %s", target.Spec.Path), - ) - - r.updateRepositoryStatus(ctx, target, log) - return true, "", 0 -} - func (r *GitTargetReconciler) evaluateSnapshotGate( ctx context.Context, target *configbutleraiv1alpha1.GitTarget, @@ -909,29 +797,6 @@ func hasAgeKeyEntry(data map[string][]byte) bool { return false } -func (r *GitTargetReconciler) updateRepositoryStatus( - ctx context.Context, - target *configbutleraiv1alpha1.GitTarget, - log logr.Logger, -) { - if r.WorkerManager == nil { - return - } - - providerNS := target.Namespace - worker, exists := r.WorkerManager.GetWorkerForTarget(target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch) - if !exists { - return - } - - report, err := worker.SyncAndGetMetadata(ctx) - if err != nil { - log.Error(err, "Failed to sync repository metadata") - return - } - target.Status.LastCommit = report.HEAD.Sha -} - // handleFetchError handles errors from fetching GitTarget. func (r *GitTargetReconciler) handleFetchError( err error, diff --git a/internal/controller/gittarget_controller_unit_test.go b/internal/controller/gittarget_controller_unit_test.go index d2688c88f..315971775 100644 --- a/internal/controller/gittarget_controller_unit_test.go +++ b/internal/controller/gittarget_controller_unit_test.go @@ -84,7 +84,7 @@ func TestIsConditionTrue(t *testing.T) { { name: "different condition type returns false", conditions: []metav1.Condition{ - {Type: GitTargetConditionBootstrapped, Status: metav1.ConditionTrue}, + {Type: GitTargetConditionValidated, Status: metav1.ConditionTrue}, }, conditionType: GitTargetConditionSnapshotSynced, want: false, @@ -92,7 +92,7 @@ func TestIsConditionTrue(t *testing.T) { { name: "target condition true alongside other conditions", conditions: []metav1.Condition{ - {Type: GitTargetConditionBootstrapped, Status: metav1.ConditionTrue}, + {Type: GitTargetConditionValidated, Status: metav1.ConditionTrue}, {Type: GitTargetConditionSnapshotSynced, Status: metav1.ConditionTrue}, {Type: GitTargetConditionEventStreamLive, Status: metav1.ConditionFalse}, }, @@ -149,7 +149,7 @@ func TestEvaluateSnapshotGate_RunsWhenSnapshotNotSynced(t *testing.T) { // With EventRouter==nil the function still returns ConditionTrue via the nil-router // path (which is the no-op path). The key is that the SnapshotSynced guard is not // the one that fired. - target := makeGitTargetWithCondition(GitTargetConditionBootstrapped, metav1.ConditionTrue) + target := makeGitTargetWithCondition(GitTargetConditionValidated, metav1.ConditionTrue) _, state, _, _, err := reconciler.evaluateSnapshotGate( context.TODO(), target, "gitops-reverser", noopLogger(), diff --git a/internal/git/bootstrapped_repo_template.go b/internal/git/bootstrapped_repo_template.go index b54358eab..13578ff09 100644 --- a/internal/git/bootstrapped_repo_template.go +++ b/internal/git/bootstrapped_repo_template.go @@ -20,7 +20,6 @@ package git import ( "bytes" - "context" "embed" "fmt" "io/fs" @@ -30,21 +29,14 @@ import ( "sort" "strings" "text/template" - "time" gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport" ) const ( - sopsConfigFileName = ".sops.yaml" - bootstrapTemplateDir = "bootstrapped-repo-template" - bootstrapCommitMessageRoot = "chore(bootstrap): initialize path" - bootstrapCommitAuthorName = "GitOps Reverser" - bootstrapCommitAuthorEmail = "noreply@configbutler.ai" - bootstrapTemplateFilePerm = 0600 + sopsConfigFileName = ".sops.yaml" + bootstrapTemplateDir = "bootstrapped-repo-template" + bootstrapTemplateFilePerm = 0600 ) //go:embed bootstrapped-repo-template/* bootstrapped-repo-template/.sops.yaml @@ -55,68 +47,30 @@ type bootstrapTemplateData struct { } type pathBootstrapOptions struct { + Enabled bool TemplateData bootstrapTemplateData IncludeSOPSConfig bool } -func commitPathBootstrapTemplateIfNeeded( - ctx context.Context, +func ensureBootstrapTemplateInPath( repo *gogit.Repository, - branch plumbing.ReferenceName, targetPath string, options pathBootstrapOptions, - auth transport.AuthMethod, -) (plumbing.Hash, bool, error) { - worktree, err := repo.Worktree() - if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to get worktree: %w", err) - } - - if err := stageBootstrapTemplateInPath(worktree, targetPath, options); err != nil { - return plumbing.ZeroHash, false, err - } - - status, err := worktree.Status() - if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to get worktree status: %w", err) - } - if status.IsClean() { - return plumbing.ZeroHash, false, nil - } - - baseHash, err := TryReference(repo, branch) - if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to resolve branch reference: %w", err) +) error { + if !options.Enabled { + return nil } - hash, err := worktree.Commit(bootstrapCommitMessage(targetPath), &gogit.CommitOptions{ - Author: &object.Signature{ - Name: bootstrapCommitAuthorName, - Email: bootstrapCommitAuthorEmail, - When: time.Now(), - }, - Committer: &object.Signature{ - Name: bootstrapCommitAuthorName, - Email: bootstrapCommitAuthorEmail, - When: time.Now(), - }, - }) + worktree, err := repo.Worktree() if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to commit bootstrap files: %w", err) + return fmt.Errorf("failed to get worktree: %w", err) } - if err := PushAtomic(ctx, repo, baseHash, branch, auth); err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to push bootstrap commit: %w", err) + if err := stageBootstrapTemplateInPath(worktree, targetPath, options); err != nil { + return err } - return hash, true, nil -} - -func bootstrapCommitMessage(targetPath string) string { - if targetPath == "" { - return bootstrapCommitMessageRoot + " " - } - return fmt.Sprintf("%s %s", bootstrapCommitMessageRoot, targetPath) + return nil } func stageBootstrapTemplateInPath(worktree *gogit.Worktree, targetPath string, options pathBootstrapOptions) error { diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 150440507..80a143038 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -31,8 +31,6 @@ import ( "time" gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -218,8 +216,9 @@ func (w *BranchWorker) ListResourcesInPath(path string) ([]itypes.ResourceIdenti return w.listResourceIdentifiersInPath(repoPath, path) } -// EnsurePathBootstrapped applies bootstrap templates to a path. +// EnsurePathBootstrapped prepares bootstrap templates locally for a path. // Existing files are preserved, and only missing template files are added. +// The files are staged in the local worktree but never committed or pushed here. func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace string) error { w.repoMu.Lock() defer w.repoMu.Unlock() @@ -242,26 +241,16 @@ func (w *BranchWorker) EnsurePathBootstrapped(path, targetName, targetNamespace return err } - repoPath, auth, err := w.prepareBootstrapRepository(ctx) + repoPath, err := w.prepareBootstrapRepository(ctx) if err != nil { return err } - commitHash, committed, err := w.bootstrapPathIfNeeded(ctx, repoPath, normalizedPath, bootstrapOptions, auth) - if err != nil { + if err := w.bootstrapPathIfNeeded(repoPath, normalizedPath, bootstrapOptions); err != nil { return err } - if !committed { - return nil - } - - w.metaMu.Lock() - w.branchExists = true - w.lastCommitSHA = printSha(commitHash) - w.lastFetchTime = time.Now() - w.metaMu.Unlock() - w.Log.Info("Bootstrapped path", "path", normalizedPath, "commit", printSha(commitHash)) + w.Log.Info("Prepared bootstrap files in worktree", "path", normalizedPath) return nil } @@ -295,19 +284,20 @@ func (w *BranchWorker) resolveBootstrapOptions( if err != nil { w.Log.Error(err, "Skipping SOPS bootstrap due to invalid target encryption configuration", "target", targetKey.String()) - return pathBootstrapOptions{}, nil + return pathBootstrapOptions{Enabled: true}, nil } if encryptionConfig == nil { - return pathBootstrapOptions{}, nil + return pathBootstrapOptions{Enabled: true}, nil } if len(encryptionConfig.AgeRecipients) == 0 { w.Log.Info("Skipping SOPS bootstrap due to missing resolved age recipients", "target", targetKey.String()) - return pathBootstrapOptions{}, nil + return pathBootstrapOptions{Enabled: true}, nil } return pathBootstrapOptions{ + Enabled: true, IncludeSOPSConfig: true, TemplateData: bootstrapTemplateData{ AgeRecipients: encryptionConfig.AgeRecipients, @@ -317,52 +307,42 @@ func (w *BranchWorker) resolveBootstrapOptions( func (w *BranchWorker) prepareBootstrapRepository( ctx context.Context, -) (string, transport.AuthMethod, error) { +) (string, error) { provider, err := w.getGitProvider(ctx) if err != nil { - return "", nil, fmt.Errorf("failed to get GitProvider: %w", err) + return "", fmt.Errorf("failed to get GitProvider: %w", err) } auth, err := getAuthFromSecret(ctx, w.Client, provider) if err != nil { - return "", nil, fmt.Errorf("failed to get auth: %w", err) + return "", fmt.Errorf("failed to get auth: %w", err) } repoPath := w.repoPathForRemote(provider.Spec.URL) pullReport, err := PrepareBranch(ctx, provider.Spec.URL, repoPath, w.Branch, auth) if err != nil { - return "", nil, fmt.Errorf("failed to prepare repository: %w", err) + return "", fmt.Errorf("failed to prepare repository: %w", err) } w.updateBranchMetadataFromPullReport(pullReport) - return repoPath, auth, nil + return repoPath, nil } func (w *BranchWorker) bootstrapPathIfNeeded( - ctx context.Context, repoPath string, normalizedPath string, options pathBootstrapOptions, - auth transport.AuthMethod, -) (plumbing.Hash, bool, error) { +) error { repo, err := gogit.PlainOpen(repoPath) if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to open repository: %w", err) + return fmt.Errorf("failed to open repository: %w", err) } - hash, committed, err := commitPathBootstrapTemplateIfNeeded( - ctx, - repo, - plumbing.NewBranchReferenceName(w.Branch), - normalizedPath, - options, - auth, - ) - if err != nil { - return plumbing.ZeroHash, false, fmt.Errorf("failed to bootstrap path %q: %w", normalizedPath, err) + if err := ensureBootstrapTemplateInPath(repo, normalizedPath, options); err != nil { + return fmt.Errorf("failed to bootstrap path %q: %w", normalizedPath, err) } - return hash, committed, nil + return nil } // listResourceIdentifiersInPath lists resource identifiers in a specific path. @@ -518,6 +498,8 @@ func (w *BranchWorker) commitAndPush(events []Event) { eventLog.Error(err, "Failed to resolve target encryption configuration") continue } + + event.BootstrapOptions = buildBootstrapOptions(encryptionConfig) } encryptionWorkDir := filepath.Join(repoPath, event.Path) @@ -593,6 +575,7 @@ func (w *BranchWorker) commitAndPushBatch(batch *ReconcileBatch) { log.Error(err, "Failed to resolve target encryption configuration") return } + batch.BootstrapOptions = buildBootstrapOptions(encryptionConfig) // Stamp path and GitTarget info onto each event in the batch for i := range batch.Events { @@ -601,6 +584,7 @@ func (w *BranchWorker) commitAndPushBatch(batch *ReconcileBatch) { } batch.Events[i].GitTargetName = batch.GitTargetName batch.Events[i].GitTargetNamespace = batch.GitTargetNamespace + batch.Events[i].BootstrapOptions = batch.BootstrapOptions } } @@ -829,4 +813,18 @@ func (w *BranchWorker) updateBranchMetadataFromPullReport(report *PullReport) { } } +func buildBootstrapOptions(encryptionConfig *ResolvedEncryptionConfig) pathBootstrapOptions { + if encryptionConfig == nil || len(encryptionConfig.AgeRecipients) == 0 { + return pathBootstrapOptions{Enabled: true} + } + + return pathBootstrapOptions{ + Enabled: true, + IncludeSOPSConfig: true, + TemplateData: bootstrapTemplateData{ + AgeRecipients: encryptionConfig.AgeRecipients, + }, + } +} + // parseIdentifierFromPath and getAuthFromSecret are defined in helpers.go diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index f75bc16c6..0db0d067c 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -294,17 +294,13 @@ func TestBranchWorker_EnsurePathBootstrapped_EmptyPathCreatesTemplate(t *testing require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) - ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) - require.NoError(t, err) - assert.Equal(t, 1, countDepth(t, serverRepo, ref.Hash()), "Bootstrap should only commit once for same path") - - clonePath := filepath.Join(tempDir, "inspect") - _, err = PrepareBranch(ctx, remoteURL, clonePath, "main", nil) - require.NoError(t, err) + _, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "Bootstrap should not create the branch remotely") - _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "README.md")) + repoPath := worker.repoPathForRemote(remoteURL) + _, err = os.Stat(filepath.Join(repoPath, "clusters/prod", "README.md")) require.NoError(t, err) - _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/prod", sopsConfigFileName)) require.NoError(t, err) } @@ -345,17 +341,14 @@ func TestBranchWorker_EnsurePathBootstrapped_NonEmptyPathBootstrapsMissingFiles( ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) require.NoError(t, err) - assert.Equal(t, 2, countDepth(t, serverRepo, ref.Hash()), "Missing bootstrap files should be added once") - - clonePath := filepath.Join(tempDir, "inspect") - _, err = PrepareBranch(ctx, remoteURL, clonePath, "main", nil) - require.NoError(t, err) + assert.Equal(t, 1, countDepth(t, serverRepo, ref.Hash()), "Bootstrap should not create a remote commit") - _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "README.md")) + repoPath := worker.repoPathForRemote(remoteURL) + _, err = os.Stat(filepath.Join(repoPath, "clusters/prod", "README.md")) require.NoError(t, err) - _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/prod", sopsConfigFileName)) require.NoError(t, err) - _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", "existing.txt")) + _, err = os.Stat(filepath.Join(repoPath, "clusters/prod", "existing.txt")) require.NoError(t, err) } @@ -384,13 +377,10 @@ func TestBranchWorker_EnsurePathBootstrapped_NoEncryptionSkipsSOPSConfig(t *test worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) - clonePath := filepath.Join(tempDir, "inspect") - _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + repoPath := worker.repoPathForRemote(remoteURL) + _, err := os.Stat(filepath.Join(repoPath, "clusters/dev", "README.md")) require.NoError(t, err) - - _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", "README.md")) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/dev", sopsConfigFileName)) assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped when encryption is not configured") } @@ -430,14 +420,11 @@ func TestBranchWorker_EnsurePathBootstrapped_ExistingFileNotOverwritten(t *testi worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/prod", "bootstrap-target", "default")) - clonePath := filepath.Join(tempDir, "inspect") - _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) - require.NoError(t, err) - - readmeContent, err := os.ReadFile(filepath.Join(clonePath, "clusters/prod", "README.md")) + repoPath := worker.repoPathForRemote(remoteURL) + readmeContent, err := os.ReadFile(filepath.Join(repoPath, "clusters/prod", "README.md")) require.NoError(t, err) assert.Equal(t, customREADME, string(readmeContent), "Bootstrap must not overwrite existing files") - _, err = os.Stat(filepath.Join(clonePath, "clusters/prod", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/prod", sopsConfigFileName)) require.NoError(t, err) } @@ -466,32 +453,20 @@ func TestBranchWorker_EnsurePathBootstrapped_EnableEncryptionLaterAddsSOPSConfig worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) - cloneBeforePath := filepath.Join(tempDir, "inspect-before") - _, err := PrepareBranch(ctx, remoteURL, cloneBeforePath, "main", nil) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(cloneBeforePath, "clusters/dev", "README.md")) + repoPath := worker.repoPathForRemote(remoteURL) + _, err := os.Stat(filepath.Join(repoPath, "clusters/dev", "README.md")) require.NoError(t, err) - _, err = os.Stat(filepath.Join(cloneBeforePath, "clusters/dev", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/dev", sopsConfigFileName)) assert.True(t, os.IsNotExist(err), "SOPS config should not exist before encryption is configured") attachEncryptionToTarget(ctx, t, k8sClient, "bootstrap-target", "default") require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) - ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + _, err = serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "Bootstrap should not create a remote commit") + _, err = os.Stat(filepath.Join(repoPath, "clusters/dev", "README.md")) require.NoError(t, err) - assert.Equal( - t, - 2, - countDepth(t, serverRepo, ref.Hash()), - "Enabling encryption later should add one bootstrap commit", - ) - - cloneAfterPath := filepath.Join(tempDir, "inspect-after") - _, err = PrepareBranch(ctx, remoteURL, cloneAfterPath, "main", nil) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(cloneAfterPath, "clusters/dev", "README.md")) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(cloneAfterPath, "clusters/dev", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/dev", sopsConfigFileName)) require.NoError(t, err) } @@ -533,13 +508,10 @@ func TestBranchWorker_EnsurePathBootstrapped_InvalidEncryptionSecretSkipsSOPSCon worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) - clonePath := filepath.Join(tempDir, "inspect") - _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) + repoPath := worker.repoPathForRemote(remoteURL) + _, err := os.Stat(filepath.Join(repoPath, "clusters/dev", "README.md")) require.NoError(t, err) - - _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", "README.md")) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/dev", sopsConfigFileName)) assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped for invalid encryption secret") } @@ -581,13 +553,10 @@ func TestBranchWorker_EnsurePathBootstrapped_MissingSOPSKeySkipsSOPSConfig(t *te worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) - clonePath := filepath.Join(tempDir, "inspect") - _, err := PrepareBranch(ctx, remoteURL, clonePath, "main", nil) - require.NoError(t, err) - - _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", "README.md")) + repoPath := worker.repoPathForRemote(remoteURL) + _, err := os.Stat(filepath.Join(repoPath, "clusters/dev", "README.md")) require.NoError(t, err) - _, err = os.Stat(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + _, err = os.Stat(filepath.Join(repoPath, "clusters/dev", sopsConfigFileName)) assert.True(t, os.IsNotExist(err), "Bootstrap SOPS config should be skipped when no .agekey entry is present") } @@ -657,11 +626,8 @@ func TestBranchWorker_EnsurePathBootstrapped_RendersAllResolvedRecipients(t *tes worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) require.NoError(t, worker.EnsurePathBootstrapped("clusters/dev", "bootstrap-target", "default")) - clonePath := filepath.Join(tempDir, "inspect") - _, err = PrepareBranch(ctx, remoteURL, clonePath, "main", nil) - require.NoError(t, err) - - sopsConfig, err := os.ReadFile(filepath.Join(clonePath, "clusters/dev", sopsConfigFileName)) + repoPath := worker.repoPathForRemote(remoteURL) + sopsConfig, err := os.ReadFile(filepath.Join(repoPath, "clusters/dev", sopsConfigFileName)) require.NoError(t, err) assert.Contains(t, string(sopsConfig), secretIdentity.Recipient().String()) assert.Contains(t, string(sopsConfig), secondaryIdentity.Recipient().String()) diff --git a/internal/git/git.go b/internal/git/git.go index 589897497..a0070d57f 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -481,13 +481,27 @@ func tryOpenExistingRepo(path string, logger logr.Logger) *git.Repository { return nil } - // Verify repository is valid by checking HEAD - _, err = repo.Head() + headRef, err := repo.Storer.Reference(plumbing.HEAD) if err != nil { logger.Info("Existing repository is invalid, will clone fresh", "path", path, "error", err) return nil } + if headRef.Type() == plumbing.SymbolicReference { + if _, refErr := repo.Reference(headRef.Target(), false); refErr == nil || + errors.Is(refErr, plumbing.ErrReferenceNotFound) { + return repo + } + logger.Info( + "Existing repository has invalid HEAD target, will clone fresh", + "path", + path, + "target", + headRef.Target(), + ) + return nil + } + return repo } @@ -818,6 +832,9 @@ func generateBatchCommit( if err != nil { return false, plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err) } + if err := ensureBootstrapTemplateInPath(repo, batchTargetPath(batch), batch.BootstrapOptions); err != nil { + return false, plumbing.ZeroHash, err + } anyChanges := false for _, event := range batch.Events { @@ -870,6 +887,10 @@ func generateCommitsFromEvents( commitsCreated := 0 for _, event := range events { + if err := ensureBootstrapTemplateInPath(repo, sanitizePath(event.Path), event.BootstrapOptions); err != nil { + return commitsCreated, lastHash, err + } + changesApplied, err := applyEventToWorktree(ctx, writer, worktree, event) if err != nil { return commitsCreated, lastHash, err @@ -1039,6 +1060,18 @@ func generateFilePath(id types.ResourceIdentifier) string { return defaultPath + ".sops.yaml" } +func batchTargetPath(batch *ReconcileBatch) string { + if batch == nil { + return "" + } + for _, event := range batch.Events { + if sanitized := sanitizePath(event.Path); sanitized != "" { + return sanitized + } + } + return "" +} + // createCommitForEvent creates a commit for the given event. func createCommitForEvent(worktree *git.Worktree, event Event) (plumbing.Hash, error) { commitMessage := GetCommitMessage(event) diff --git a/internal/git/git_operations_test.go b/internal/git/git_operations_test.go index d46376efa..ab67bd30b 100644 --- a/internal/git/git_operations_test.go +++ b/internal/git/git_operations_test.go @@ -981,6 +981,74 @@ spec: assert.True(t, status.IsClean()) } +func TestWriteEvents_FirstRelevantCommitIncludesBootstrapFiles(t *testing.T) { + tempDir := t.TempDir() + serverPath := filepath.Join(tempDir, "server.git") + remoteURL := "file://" + serverPath + localPath := filepath.Join(tempDir, "local") + inspectPath := filepath.Join(tempDir, "inspect") + + serverRepo := createBareRepo(t, serverPath) + + pullReport, err := PrepareBranch(context.Background(), remoteURL, localPath, "main", nil) + require.NoError(t, err) + require.True(t, pullReport.HEAD.Unborn) + require.False(t, pullReport.ExistsOnRemote) + + event := Event{ + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "sample-config", + "namespace": "default", + }, + "data": map[string]interface{}{ + "key": "value", + }, + }, + }, + Identifier: types.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "default", + Name: "sample-config", + }, + Operation: "CREATE", + UserInfo: UserInfo{ + Username: "tester@example.com", + }, + Path: "clusters/dev", + BootstrapOptions: pathBootstrapOptions{ + Enabled: true, + IncludeSOPSConfig: true, + TemplateData: bootstrapTemplateData{ + AgeRecipients: []string{"age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq7k8m6"}, + }, + }, + } + + result, err := WriteEvents(context.Background(), localPath, []Event{event}, "main", nil) + require.NoError(t, err) + require.Equal(t, 1, result.CommitsCreated) + + ref, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + assert.Equal(t, 1, countDepth(t, serverRepo, ref.Hash())) + + _, err = PrepareBranch(context.Background(), remoteURL, inspectPath, "main", nil) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(inspectPath, "clusters/dev", "README.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(inspectPath, "clusters/dev", sopsConfigFileName)) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(inspectPath, "clusters/dev", "v1", "configmaps", "default", "sample-config.yaml")) + require.NoError(t, err) +} + func TestPullBranch_WhipedRepo(t *testing.T) { tempDir := t.TempDir() serverPath := filepath.Join(tempDir, "server") diff --git a/internal/git/types.go b/internal/git/types.go index 8bf759283..73d37aba0 100644 --- a/internal/git/types.go +++ b/internal/git/types.go @@ -92,6 +92,7 @@ type ReconcileBatch struct { CommitMessage string GitTargetName string GitTargetNamespace string + BootstrapOptions pathBootstrapOptions } // WorkItem is the unit of work in the BranchWorker queue. @@ -127,4 +128,7 @@ type Event struct { // GitTargetNamespace is the namespace of the target owning this event. GitTargetNamespace string + + // BootstrapOptions controls path-scoped bootstrap file staging for this event. + BootstrapOptions pathBootstrapOptions } diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index ead4ea81e..2bdc03c9f 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -36,19 +36,17 @@ type WorkerManager struct { Client client.Client Log logr.Logger - mu sync.RWMutex - workers map[BranchKey]*BranchWorker - bootstrapLocks map[BranchKey]*sync.Mutex - ctx context.Context + mu sync.RWMutex + workers map[BranchKey]*BranchWorker + ctx context.Context } // NewWorkerManager creates a new worker manager. func NewWorkerManager(client client.Client, log logr.Logger) *WorkerManager { return &WorkerManager{ - Client: client, - Log: log, - workers: make(map[BranchKey]*BranchWorker), - bootstrapLocks: make(map[BranchKey]*sync.Mutex), + Client: client, + Log: log, + workers: make(map[BranchKey]*BranchWorker), } } @@ -65,18 +63,6 @@ func (m *WorkerManager) RegisterTarget( return err } - if err := m.EnsureTargetBootstrapped( - ctx, - targetName, - targetNamespace, - providerName, - providerNamespace, - branch, - path, - ); err != nil { - return err - } - m.Log.Info("GitTarget registered with branch worker", "target", fmt.Sprintf("%s/%s", targetNamespace, targetName), "workerKey", BranchKey{ @@ -126,51 +112,6 @@ func (m *WorkerManager) EnsureWorker( return nil } -// EnsureTargetBootstrapped ensures the target path bootstrap files are present. -// This intentionally runs outside the manager-wide lock. -func (m *WorkerManager) EnsureTargetBootstrapped( - _ context.Context, - targetName, targetNamespace string, - providerName, providerNamespace string, - branch, path string, -) error { - key := BranchKey{ - RepoNamespace: providerNamespace, - RepoName: providerName, - Branch: branch, - } - - m.mu.RLock() - worker, exists := m.workers[key] - m.mu.RUnlock() - if !exists { - return fmt.Errorf("worker not found for %s", key.String()) - } - - bootstrapLock := m.getBootstrapLock(key) - bootstrapLock.Lock() - defer bootstrapLock.Unlock() - - if err := worker.EnsurePathBootstrapped(path, targetName, targetNamespace); err != nil { - return fmt.Errorf("failed to ensure path bootstrap for %s/%s: %w", targetNamespace, targetName, err) - } - - return nil -} - -func (m *WorkerManager) getBootstrapLock(key BranchKey) *sync.Mutex { - m.mu.Lock() - defer m.mu.Unlock() - - lock, exists := m.bootstrapLocks[key] - if !exists { - lock = &sync.Mutex{} - m.bootstrapLocks[key] = lock - } - - return lock -} - // UnregisterTarget removes a GitTarget from its worker. // Destroys the worker if it was the last target using it. // This is called by GitTarget controller when a target is deleted. @@ -198,7 +139,6 @@ func (m *WorkerManager) UnregisterTarget( m.Log.Info("Unregistering target, destroying worker", "key", key.String()) worker.Stop() delete(m.workers, key) - delete(m.bootstrapLocks, key) return nil } @@ -260,7 +200,6 @@ func (m *WorkerManager) ReconcileWorkers(ctx context.Context) error { m.Log.Info("Cleaning up orphaned worker", "key", key.String()) worker.Stop() delete(m.workers, key) - delete(m.bootstrapLocks, key) } } @@ -290,7 +229,6 @@ func (m *WorkerManager) Start(ctx context.Context) error { } m.workers = make(map[BranchKey]*BranchWorker) - m.bootstrapLocks = make(map[BranchKey]*sync.Mutex) m.Log.Info("WorkerManager stopped") return nil } From c759f191d7b4bc7c20f11bbece8f5425282b532f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 20 Mar 2026 22:40:16 +0000 Subject: [PATCH 22/30] chore: get the architecture back in the git.go, but not yet happy with this --- .../event-batch-refactor-e2e-investigation.md | 335 +++++++++++++++ internal/controller/gittarget_controller.go | 26 +- internal/git/branch_worker.go | 391 +++++++++++------- internal/git/git.go | 169 ++++---- internal/git/types.go | 23 +- internal/reconcile/git_target_event_stream.go | 4 +- .../reconcile/git_target_event_stream_test.go | 11 + .../gittarget_lifecycle_integration_test.go | 13 + 8 files changed, 701 insertions(+), 271 deletions(-) create mode 100644 docs/design/event-batch-refactor-e2e-investigation.md diff --git a/docs/design/event-batch-refactor-e2e-investigation.md b/docs/design/event-batch-refactor-e2e-investigation.md new file mode 100644 index 000000000..50d261e14 --- /dev/null +++ b/docs/design/event-batch-refactor-e2e-investigation.md @@ -0,0 +1,335 @@ +# Event Batch Refactor: E2E Investigation Handoff + +## Summary + +The event unification refactor is mostly in place and compiles, but full e2e is still blocked by one `GitTarget` readiness failure. + +The failing scenario is: + +- encrypted secret flow in [test/e2e/e2e_test.go](/workspaces/gitops-reverser/test/e2e/e2e_test.go) +- test name: `should commit encrypted Secret manifests when WatchRule includes secrets` + +The important part is that the failure happens **before** the actual Secret commit verification. + +The `GitTarget` never reaches `Ready=True`, so the test times out while waiting for readiness. + +## Current Validation State + +What passed: + +- `make lint` +- `make test` +- `docker info` + +What failed: + +- `make test-e2e` + +What was not run after the full e2e failure: + +- `make test-e2e-quickstart-manifest` +- `make test-e2e-quickstart-helm` + +## Refactor Scope That Landed + +The internal event path was unified around one request type. + +Main files touched: + +- [internal/git/types.go](/workspaces/gitops-reverser/internal/git/types.go) +- [internal/git/branch_worker.go](/workspaces/gitops-reverser/internal/git/branch_worker.go) +- [internal/git/git.go](/workspaces/gitops-reverser/internal/git/git.go) +- [internal/reconcile/git_target_event_stream.go](/workspaces/gitops-reverser/internal/reconcile/git_target_event_stream.go) +- [internal/reconcile/git_target_event_stream_test.go](/workspaces/gitops-reverser/internal/reconcile/git_target_event_stream_test.go) +- [internal/reconcile/gittarget_lifecycle_integration_test.go](/workspaces/gitops-reverser/internal/reconcile/gittarget_lifecycle_integration_test.go) + +The refactor introduced: + +- `WriteRequest` +- `CommitMode` +- one branch-worker request pipeline for both live events and reconcile batches +- one git write implementation underneath the old wrappers + +This refactor does **not** appear to be the direct cause of the failing e2e assertion. + +## Exact Failing Assertion + +The failing assertion is in the helper: + +- [test/e2e/e2e_test.go:1817](/workspaces/gitops-reverser/test/e2e/e2e_test.go:1817) + +The specific test starts here: + +- [test/e2e/e2e_test.go:524](/workspaces/gitops-reverser/test/e2e/e2e_test.go:524) + +The timeout occurs while waiting for: + +- `gittarget/` to become `Ready=True` + +The failing step is the first readiness check after creating: + +- the `GitTarget` +- the `WatchRule` + +This means the failure occurs before: + +- creating the watched Secret +- patching the Secret +- verifying Git contents +- decrypting committed data + +## What The Live GitTarget Looks Like While Failing + +During a focused repro, the live object looked like this: + +- `Validated=True` +- `EncryptionConfigured=True` +- `SnapshotSynced=False` +- `EventStreamLive=Unknown` +- `Ready=False` + +Most important condition values: + +```yaml +type: SnapshotSynced +status: "False" +reason: SnapshotFailed +message: branch worker not found for provider=/gitprovider-normal branch=main +``` + +```yaml +type: Ready +status: "False" +reason: InitialSyncInProgress +message: SnapshotSynced gate failed: SnapshotFailed +``` + +So the real blocker is: + +- not validation +- not encryption setup +- not the write path +- but the snapshot gate failing because no branch worker exists yet + +## When It Happens In The Lifecycle + +The observed order is: + +1. `GitProvider` becomes `Ready=True` +2. `GitTarget` becomes `Validated=True` +3. `GitTarget` becomes `EncryptionConfigured=True` +4. `GitTarget` enters snapshot setup +5. `ensureEventStream(...)` cannot find a branch worker +6. `SnapshotSynced=False` +7. `Ready=False` + +So the failure happens in the transition from: + +- validation/encryption + +to: + +- snapshot/event-stream startup + +## Relevant Controller Code + +The failure path is in: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +Important functions: + +- `evaluateSnapshotGate(...)` +- `ensureEventStream(...)` + +The event stream path depends on finding a worker via: + +- `WorkerManager.GetWorkerForTarget(...)` + +If no worker exists, the snapshot gate fails immediately. + +## Relevant Worker Code + +Worker lifecycle is managed in: + +- [internal/git/worker_manager.go](/workspaces/gitops-reverser/internal/git/worker_manager.go) + +Important functions: + +- `EnsureWorker(...)` +- `GetWorkerForTarget(...)` +- `RegisterTarget(...)` + +Important observation: + +- `RegisterTarget(...)` exists, but there is no active call site in the current codebase outside the manager itself + +That means worker creation appears to have lost its earlier lifecycle hook. + +## Most Likely Root Cause + +The strongest current hypothesis is: + +- after removing bootstrap as a visible lifecycle phase, we also lost the point where a branch worker was guaranteed to exist before snapshot/event-stream setup + +In other words: + +- `GitTarget` readiness now depends on a worker +- but no code is reliably materializing that worker before `SnapshotSynced` runs + +This explains why the target gets stuck exactly at snapshot startup. + +## What Was Tried Already + +I added a fallback inside: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +The change attempts to: + +- call `WorkerManager.EnsureWorker(...)` from `ensureEventStream(...)` +- then retry `GetWorkerForTarget(...)` + +This was intended to lazily create the worker if the snapshot gate runs before worker registration. + +### Result + +After rebuilding the controller image and rerunning e2e: + +- the same readiness failure still occurs +- the live `GitTarget` still reports: + - `SnapshotFailed` + - `branch worker not found` + +This means one of the following is still true: + +- the fallback path is not actually being exercised +- the worker manager is not usable from that path at runtime +- the worker is created but not retained/visible to `GetWorkerForTarget(...)` +- the controller is failing earlier than expected and the old error message is still the one being surfaced + +## Important Log Findings + +### Focused reduced repro + +In a reduced run containing: + +- normal GitProvider +- simple WatchRule reconciliation +- encrypted secret test + +the live `GitTarget` clearly showed: + +- `Validated=True` +- `EncryptionConfigured=True` +- `SnapshotSynced=False` due to missing branch worker + +### Full e2e after image rebuild + +Full e2e still fails on the same test. + +The failing step is still the first readiness wait for: + +- `watchrule-secret-encryption-test-dest` + +However, the controller logs are noisy and not giving a clean trace for the `GitTarget` reconcile body. The most useful evidence remains the live `GitTarget.status.conditions`. + +## Why This Does Not Look Like A Git Write Bug + +This is probably not caused by the new unified `WriteRequest` path because: + +- the failure happens before the watched Secret is created +- no event commit is attempted yet +- the readiness gate fails before any resource write path is exercised + +So the investigation should focus on: + +- `GitTarget` startup lifecycle +- worker creation timing +- snapshot gate assumptions + +not on: + +- commit generation +- atomic/per-event write behavior +- bootstrap file staging + +## Strongest Next Steps + +### 1. Restore explicit worker materialization before snapshot gate + +Most likely fix: + +- ensure the `GitTarget` reconcile path explicitly creates the branch worker before `evaluateSnapshotGate(...)` + +This is cleaner than relying on event-stream fallback. + +Places to inspect: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) +- [internal/git/worker_manager.go](/workspaces/gitops-reverser/internal/git/worker_manager.go) + +### 2. Confirm how worker creation used to happen + +Useful question: + +- was worker creation previously coupled to bootstrap, registration, or an earlier ready phase? + +If yes, that behavior probably needs to come back in a simpler form. + +### 3. Add temporary logging around worker creation + +Useful logs: + +- before calling `EnsureWorker(...)` +- after `EnsureWorker(...)` +- after `GetWorkerForTarget(...)` +- worker key string being used +- whether `WorkerManager.ctx` is initialized at that point + +### 4. Verify manager context assumptions + +`EnsureWorker(...)` starts workers using: + +- `worker.Start(m.ctx)` + +If `m.ctx` is unset or stale, worker startup may not succeed as expected. + +That is worth checking directly. + +## Secondary Observation + +While testing locally, a direct package-level run of: + +- `go test ./internal/controller ./internal/git ./internal/reconcile` + +failed in the controller suite because `envtest` assets were missing: + +- `/usr/local/kubebuilder/bin/etcd` not found + +This is unrelated to the e2e blocker, but worth remembering when reproducing outside `make test`. + +## Suggested Restart Point For Tomorrow + +If starting fresh, I would begin here: + +1. Open [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) +2. Add explicit worker creation before snapshot evaluation +3. Log the worker key and whether creation succeeds +4. Rerun the focused encrypted-secret scenario first +5. If that passes, rerun `make test-e2e` +6. Only then continue to: + - `make test-e2e-quickstart-manifest` + - `make test-e2e-quickstart-helm` + +## Short Conclusion + +The current blocker is best described as: + +- a `GitTarget` lifecycle regression in worker creation timing + +not as: + +- a batch-write regression + +The event refactor itself is mostly fine. The broken part is that the `GitTarget` snapshot gate now runs before a branch worker is guaranteed to exist, and the encrypted-secret e2e test is the first place where that consistently shows up. diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index fa2b35573..c5483f29c 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -494,12 +494,26 @@ func (r *GitTargetReconciler) ensureEventStream( worker, exists := r.WorkerManager.GetWorkerForTarget(target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch) if !exists { - return nil, fmt.Errorf( - "branch worker not found for provider=%s/%s branch=%s", - providerNS, - target.Spec.ProviderRef.Name, - target.Spec.Branch, - ) + if err := r.WorkerManager.EnsureWorker(context.Background(), target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch); err != nil { + return nil, fmt.Errorf( + "failed to ensure branch worker for provider=%s/%s branch=%s: %w", + providerNS, + target.Spec.ProviderRef.Name, + target.Spec.Branch, + err, + ) + } + + var ensured bool + worker, ensured = r.WorkerManager.GetWorkerForTarget(target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch) + if !ensured { + return nil, fmt.Errorf( + "branch worker not found for provider=%s/%s branch=%s", + providerNS, + target.Spec.ProviderRef.Name, + target.Spec.Branch, + ) + } } gitDest := types.NewResourceReference(target.Name, target.Namespace) diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 80a143038..999a7d8e1 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -51,6 +51,8 @@ const ( metadataCacheDuration = 30 * time.Second ) +var errEventTargetMetadataMissing = errors.New("event git target metadata missing") + // BranchWorker processes events for a single (GitProvider, Branch) combination. // It can serve multiple GitTargets that write to different paths in the same branch. // This design ensures serialized commits per branch, preventing merge conflicts. @@ -168,32 +170,46 @@ func (w *BranchWorker) Stop() { // Enqueue adds a single live event to this worker's queue. func (w *BranchWorker) Enqueue(event Event) { - item := WorkItem{Single: &event} + w.enqueueRequest(&WriteRequest{ + Events: []Event{event}, + CommitMode: CommitModePerEvent, + CommitMessage: "", + }) +} + +// EnqueueRequest adds a write request to this worker's queue. +func (w *BranchWorker) EnqueueRequest(request *WriteRequest) { + w.enqueueRequest(request) +} + +func (w *BranchWorker) enqueueRequest(request *WriteRequest) { + if request == nil { + return + } + item := WorkItem{Request: request} select { case w.eventQueue <- item: - w.Log.V(1).Info("Event enqueued", - "operation", event.Operation, - "path", event.Path) + w.Log.V(1).Info("Write request enqueued", + "events", len(request.Events), + "mode", request.CommitMode, + "gitTarget", request.GitTargetName) default: - w.Log.Error(nil, "Event queue full, event dropped", - "operation", event.Operation, - "path", event.Path) + w.Log.Error(nil, "Event queue full, request dropped", + "events", len(request.Events), + "mode", request.CommitMode, + "gitTarget", request.GitTargetName) } } // EnqueueBatch adds a reconcile batch to this worker's queue as a single atomic unit. func (w *BranchWorker) EnqueueBatch(batch *ReconcileBatch) { - item := WorkItem{Batch: batch} - select { - case w.eventQueue <- item: - w.Log.V(1).Info("Batch enqueued", - "events", len(batch.Events), - "gitTarget", batch.GitTargetName) - default: - w.Log.Error(nil, "Event queue full, batch dropped", - "events", len(batch.Events), - "gitTarget", batch.GitTargetName) + if batch == nil { + return } + if batch.CommitMode == "" { + batch.CommitMode = CommitModeAtomic + } + w.enqueueRequest(batch) } // ListResourcesInPath returns resource identifiers found in a Git folder. @@ -417,31 +433,16 @@ func (w *BranchWorker) processEvents() { return case item := <-w.eventQueue: - if item.Single != nil { - // Buffer single live event - eventBuffer = append(eventBuffer, *item.Single) - bufferByteCount += w.estimateEventSize(*item.Single) - - // Check limits - if len(eventBuffer) >= maxCommits || bufferByteCount >= maxBytesBytes { - w.commitAndPush(eventBuffer) - eventBuffer = nil - bufferByteCount = 0 - } - } else if item.Batch != nil { - // Flush any buffered live events first to preserve ordering - if len(eventBuffer) > 0 { - w.commitAndPush(eventBuffer) - eventBuffer = nil - bufferByteCount = 0 - } - // Process the entire batch as a single atomic commit - w.commitAndPushBatch(item.Batch) - } + eventBuffer, bufferByteCount = w.handleQueueItem( + item, + eventBuffer, + bufferByteCount, + maxCommits, + ) case <-pushTicker.C: if len(eventBuffer) > 0 { - w.commitAndPush(eventBuffer) + w.commitAndPushRequest(newPerEventWriteRequest(eventBuffer)) eventBuffer = nil bufferByteCount = 0 } @@ -449,99 +450,67 @@ func (w *BranchWorker) processEvents() { } } -// commitAndPush processes a batch of events. -// Events may have different paths but all go to same branch. -func (w *BranchWorker) commitAndPush(events []Event) { - w.repoMu.Lock() - defer w.repoMu.Unlock() - - log := w.Log.WithValues("eventCount", len(events)) - - log.Info("Starting git commit and push", - "branch", w.Branch) - - provider, err := w.getGitProvider(w.ctx) - if err != nil { - log.Error(err, "Failed to get GitProvider") - return +func (w *BranchWorker) handleQueueItem( + item WorkItem, + eventBuffer []Event, + bufferByteCount int64, + maxCommits int, +) ([]Event, int64) { + if item.Request == nil { + return eventBuffer, bufferByteCount } - auth, err := getAuthFromSecret(w.ctx, w.Client, provider) - if err != nil { - log.Error(err, "Failed to get auth") - return + if item.Request.CommitMode == CommitModeAtomic { + return w.handleAtomicRequest(item.Request, eventBuffer) } - repoPath := w.repoPathForRemote(provider.Spec.URL) - - var result *WriteEventsResult - for _, event := range events { - eventLog := log.WithValues( - "targetNamespace", event.GitTargetNamespace, - "targetName", event.GitTargetName, - ) - - var encryptionConfig *ResolvedEncryptionConfig - if event.GitTargetName != "" && event.GitTargetNamespace != "" { - var target configv1alpha1.GitTarget - targetKey := types.NamespacedName{ - Name: event.GitTargetName, - Namespace: event.GitTargetNamespace, - } - if err := w.Client.Get(w.ctx, targetKey, &target); err != nil { - eventLog.Error(err, "Failed to resolve GitTarget for encryption") - continue - } - - encryptionConfig, err = ResolveTargetEncryption(w.ctx, w.Client, &target) - if err != nil { - eventLog.Error(err, "Failed to resolve target encryption configuration") - continue - } - - event.BootstrapOptions = buildBootstrapOptions(encryptionConfig) - } - - encryptionWorkDir := filepath.Join(repoPath, event.Path) - if err := configureSecretEncryptionWriter(w.contentWriter, encryptionWorkDir, encryptionConfig); err != nil { - eventLog.Error(err, "Failed to configure secret encryptor") - continue - } + return w.bufferPerEventRequest(item.Request, eventBuffer, bufferByteCount, maxCommits) +} - result, err = WriteEventsWithContentWriter(w.ctx, w.contentWriter, repoPath, []Event{event}, w.Branch, auth) - if err != nil { - eventLog.Error(err, "Failed to write event") - continue - } - } - if result == nil { - return +func (w *BranchWorker) handleAtomicRequest( + request *WriteRequest, + eventBuffer []Event, +) ([]Event, int64) { + if len(eventBuffer) > 0 { + w.commitAndPushRequest(newPerEventWriteRequest(eventBuffer)) } + w.commitAndPushRequest(request) + return nil, 0 +} - log.Info("Successfully pushed commits", - "commitsCreated", result.CommitsCreated, - "conflictPulls", len(result.ConflictPulls), - "failures", result.Failures) - - // Update metadata if conflicts were resolved - if len(result.ConflictPulls) > 0 { - lastPull := result.ConflictPulls[len(result.ConflictPulls)-1] - w.updateBranchMetadataFromPullReport(lastPull) +func (w *BranchWorker) bufferPerEventRequest( + request *WriteRequest, + eventBuffer []Event, + bufferByteCount int64, + maxCommits int, +) ([]Event, int64) { + for _, event := range request.Events { + eventBuffer = append(eventBuffer, event) + bufferByteCount += w.estimateEventSize(event) + + if len(eventBuffer) >= maxCommits || bufferByteCount >= maxBytesBytes { + w.commitAndPushRequest(newPerEventWriteRequest(eventBuffer)) + eventBuffer = nil + bufferByteCount = 0 + } } - // Metrics - telemetry.GitOperationsTotal.Add(w.ctx, int64(len(events))) - telemetry.CommitsTotal.Add(w.ctx, 1) - telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(events))) + return eventBuffer, bufferByteCount } -// commitAndPushBatch processes a reconcile batch as a single atomic commit. -func (w *BranchWorker) commitAndPushBatch(batch *ReconcileBatch) { +// commitAndPushRequest processes a write request for this branch. +func (w *BranchWorker) commitAndPushRequest(request *WriteRequest) { w.repoMu.Lock() defer w.repoMu.Unlock() - log := w.Log.WithValues("batchEvents", len(batch.Events), "gitTarget", batch.GitTargetName) - log.Info("Starting git batch commit and push", "branch", w.Branch) + if request == nil || len(request.Events) == 0 { + return + } + + log := w.Log.WithValues("eventCount", len(request.Events), "commitMode", request.CommitMode) + + log.Info("Starting git commit and push", + "branch", w.Branch) provider, err := w.getGitProvider(w.ctx) if err != nil { @@ -557,65 +526,39 @@ func (w *BranchWorker) commitAndPushBatch(batch *ReconcileBatch) { repoPath := w.repoPathForRemote(provider.Spec.URL) - // Resolve encryption and path from the GitTarget - var encryptionConfig *ResolvedEncryptionConfig - if batch.GitTargetName != "" && batch.GitTargetNamespace != "" { - targetKey := types.NamespacedName{ - Name: batch.GitTargetName, - Namespace: batch.GitTargetNamespace, - } - var target configv1alpha1.GitTarget - if err := w.Client.Get(w.ctx, targetKey, &target); err != nil { - log.Error(err, "Failed to resolve GitTarget for batch") - return - } - - encryptionConfig, err = ResolveTargetEncryption(w.ctx, w.Client, &target) - if err != nil { - log.Error(err, "Failed to resolve target encryption configuration") - return - } - batch.BootstrapOptions = buildBootstrapOptions(encryptionConfig) - - // Stamp path and GitTarget info onto each event in the batch - for i := range batch.Events { - if batch.Events[i].Path == "" { - batch.Events[i].Path = target.Spec.Path - } - batch.Events[i].GitTargetName = batch.GitTargetName - batch.Events[i].GitTargetNamespace = batch.GitTargetNamespace - batch.Events[i].BootstrapOptions = batch.BootstrapOptions - } + preparedRequest, encryptionConfig, err := w.prepareWriteRequest(w.ctx, request) + if err != nil { + log.Error(err, "Failed to prepare write request") + return } - // Configure encryption writer for the batch path - if len(batch.Events) > 0 { - encryptionWorkDir := filepath.Join(repoPath, batch.Events[0].Path) - if err := configureSecretEncryptionWriter(w.contentWriter, encryptionWorkDir, encryptionConfig); err != nil { - log.Error(err, "Failed to configure secret encryptor") - return - } + encryptionWorkDir := filepath.Join(repoPath, requestEncryptionPath(preparedRequest)) + if err := configureSecretEncryptionWriter(w.contentWriter, encryptionWorkDir, encryptionConfig); err != nil { + log.Error(err, "Failed to configure secret encryptor") + return } - result, err := WriteBatchWithContentWriter(w.ctx, w.contentWriter, repoPath, batch, w.Branch, auth) + result, err := WriteRequestWithContentWriter(w.ctx, w.contentWriter, repoPath, preparedRequest, w.Branch, auth) if err != nil { - log.Error(err, "Failed to write batch") + log.Error(err, "Failed to write request") return } - log.Info("Successfully pushed batch commit", + log.Info("Successfully pushed commits", "commitsCreated", result.CommitsCreated, "conflictPulls", len(result.ConflictPulls), "failures", result.Failures) + // Update metadata if conflicts were resolved if len(result.ConflictPulls) > 0 { lastPull := result.ConflictPulls[len(result.ConflictPulls)-1] w.updateBranchMetadataFromPullReport(lastPull) } - telemetry.GitOperationsTotal.Add(w.ctx, int64(len(batch.Events))) + // Metrics + telemetry.GitOperationsTotal.Add(w.ctx, int64(len(preparedRequest.Events))) telemetry.CommitsTotal.Add(w.ctx, 1) - telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(batch.Events))) + telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(preparedRequest.Events))) } // handleShutdown finalizes processing when context is canceled. @@ -624,7 +567,7 @@ func (w *BranchWorker) handleShutdown( ) { w.Log.Info("Handling shutdown, flushing buffer") if len(eventBuffer) > 0 { - w.commitAndPush(eventBuffer) + w.commitAndPushRequest(newPerEventWriteRequest(eventBuffer)) } } @@ -827,4 +770,132 @@ func buildBootstrapOptions(encryptionConfig *ResolvedEncryptionConfig) pathBoots } } +func newPerEventWriteRequest(events []Event) *WriteRequest { + clonedEvents := append([]Event(nil), events...) + return &WriteRequest{ + Events: clonedEvents, + CommitMode: CommitModePerEvent, + } +} + +func requestEncryptionPath(request *WriteRequest) string { + if request == nil { + return "" + } + for _, event := range request.Events { + if strings.TrimSpace(event.Path) != "" { + return event.Path + } + } + return "" +} + +func (w *BranchWorker) prepareWriteRequest( + ctx context.Context, + request *WriteRequest, +) (*WriteRequest, *ResolvedEncryptionConfig, error) { + if request == nil { + return nil, nil, errors.New("write request is required") + } + + prepared := *request + prepared.Events = append([]Event(nil), request.Events...) + if prepared.CommitMode == "" { + prepared.CommitMode = CommitModePerEvent + } + + if prepared.CommitMode == CommitModeAtomic { + return w.prepareAtomicWriteRequest(ctx, &prepared) + } + + return w.preparePerEventWriteRequest(ctx, &prepared) +} + +func (w *BranchWorker) prepareAtomicWriteRequest( + ctx context.Context, + request *WriteRequest, +) (*WriteRequest, *ResolvedEncryptionConfig, error) { + if request.GitTargetName == "" || request.GitTargetNamespace == "" { + return request, nil, nil + } + + targetKey := types.NamespacedName{ + Name: request.GitTargetName, + Namespace: request.GitTargetNamespace, + } + var target configv1alpha1.GitTarget + if err := w.Client.Get(ctx, targetKey, &target); err != nil { + return nil, nil, fmt.Errorf("failed to resolve GitTarget for request: %w", err) + } + + encryptionConfig, err := ResolveTargetEncryption(ctx, w.Client, &target) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve target encryption configuration: %w", err) + } + + request.BootstrapOptions = buildBootstrapOptions(encryptionConfig) + for i := range request.Events { + if request.Events[i].Path == "" { + request.Events[i].Path = target.Spec.Path + } + request.Events[i].GitTargetName = request.GitTargetName + request.Events[i].GitTargetNamespace = request.GitTargetNamespace + request.Events[i].BootstrapOptions = request.BootstrapOptions + } + + return request, encryptionConfig, nil +} + +func (w *BranchWorker) preparePerEventWriteRequest( + ctx context.Context, + request *WriteRequest, +) (*WriteRequest, *ResolvedEncryptionConfig, error) { + var requestEncryption *ResolvedEncryptionConfig + for i := range request.Events { + encryptionConfig, err := w.resolveEventEncryption(ctx, &request.Events[i]) + if errors.Is(err, errEventTargetMetadataMissing) { + encryptionConfig = nil + } else if err != nil { + return nil, nil, err + } + request.Events[i].BootstrapOptions = buildBootstrapOptions(encryptionConfig) + if i == 0 { + requestEncryption = encryptionConfig + } + } + + return request, requestEncryption, nil +} + +func (w *BranchWorker) resolveEventEncryption( + ctx context.Context, + event *Event, +) (*ResolvedEncryptionConfig, error) { + if event == nil || event.GitTargetName == "" || event.GitTargetNamespace == "" { + return nil, errEventTargetMetadataMissing + } + + targetKey := types.NamespacedName{ + Name: event.GitTargetName, + Namespace: event.GitTargetNamespace, + } + var target configv1alpha1.GitTarget + if err := w.Client.Get(ctx, targetKey, &target); err != nil { + return nil, fmt.Errorf("failed to resolve GitTarget for encryption: %w", err) + } + + encryptionConfig, err := ResolveTargetEncryption(ctx, w.Client, &target) + if err != nil { + return nil, fmt.Errorf("failed to resolve target encryption configuration: %w", err) + } + + if event.Path == "" { + event.Path = target.Spec.Path + } + event.GitTargetName = target.Name + event.GitTargetNamespace = target.Namespace + + return encryptionConfig, nil +} + // parseIdentifierFromPath and getAuthFromSecret are defined in helpers.go diff --git a/internal/git/git.go b/internal/git/git.go index a0070d57f..fd3e349a4 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -200,9 +200,38 @@ func WriteEventsWithContentWriter( events []Event, targetBranchName string, auth transport.AuthMethod, +) (*WriteEventsResult, error) { + return WriteRequestWithContentWriter(ctx, writer, repoPath, &WriteRequest{ + Events: append([]Event(nil), events...), + CommitMode: CommitModePerEvent, + }, targetBranchName, auth) +} + +// WriteRequestWithContentWriter handles writes through a single request-based implementation. +func WriteRequestWithContentWriter( + ctx context.Context, + writer eventContentWriter, + repoPath string, + request *WriteRequest, + targetBranchName string, + auth transport.AuthMethod, ) (*WriteEventsResult, error) { logger := log.FromContext(ctx) - logger.Info("Starting writeEvents operation", "path", repoPath, "events", len(events)) + if request == nil { + return nil, errors.New("write request is required") + } + if request.CommitMode == "" { + request.CommitMode = CommitModePerEvent + } + logger.Info( + "Starting write request operation", + "path", + repoPath, + "events", + len(request.Events), + "commitMode", + request.CommitMode, + ) if writer == nil { return nil, errors.New("content writer is required") } @@ -229,13 +258,13 @@ func WriteEventsWithContentWriter( } } - done, err := tryWriteEventsAttempt( + done, err := tryWriteRequestAttempt( ctx, logger, writer, result, repo, - events, + request, targetBranch, targetBranchName, auth, @@ -251,13 +280,13 @@ func WriteEventsWithContentWriter( return result, nil } -func tryWriteEventsAttempt( +func tryWriteRequestAttempt( ctx context.Context, logger logr.Logger, writer eventContentWriter, result *WriteEventsResult, repo *git.Repository, - events []Event, + request *WriteRequest, targetBranch plumbing.ReferenceName, targetBranchName string, auth transport.AuthMethod, @@ -273,7 +302,7 @@ func tryWriteEventsAttempt( } } - commitsCreated, lastHash, err := generateCommitsFromEvents(ctx, writer, repo, events) + commitsCreated, lastHash, err := generateCommitsFromRequest(ctx, writer, repo, request) if err != nil { return false, fmt.Errorf("failed to generate commits: %w", err) } @@ -734,113 +763,57 @@ func WriteBatchWithContentWriter( targetBranchName string, auth transport.AuthMethod, ) (*WriteEventsResult, error) { - logger := log.FromContext(ctx) - logger.Info("Starting writeBatch operation", "path", repoPath, "events", len(batch.Events)) - if writer == nil { - return nil, errors.New("content writer is required") - } - - repo, err := git.PlainOpen(repoPath) - if err != nil { - return nil, fmt.Errorf("failed to open repository: %w", err) - } - - targetBranch := plumbing.NewBranchReferenceName(targetBranchName) - - result := &WriteEventsResult{ - CommitsCreated: 0, - ConflictPulls: []*PullReport{}, - Failures: 0, + if batch == nil { + return nil, errors.New("reconcile batch is required") } - - const maxRetries = 3 - for ; result.Failures < maxRetries; result.Failures++ { - if result.Failures > 0 { - if err := tryResolve(ctx, logger, result, repo, targetBranch, auth); err != nil { - continue - } - } - - done, err := tryWriteBatchAttempt( - ctx, logger, writer, result, repo, batch, targetBranch, targetBranchName, auth, - ) - if err != nil { - return nil, err - } - if done { - break - } + request := *batch + if request.CommitMode == "" { + request.CommitMode = CommitModeAtomic } - - return result, nil + return WriteRequestWithContentWriter(ctx, writer, repoPath, &request, targetBranchName, auth) } -func tryWriteBatchAttempt( +// generateCommitsFromRequest creates commits from the provided write request. +func generateCommitsFromRequest( ctx context.Context, - logger logr.Logger, writer eventContentWriter, - result *WriteEventsResult, repo *git.Repository, - batch *ReconcileBatch, - targetBranch plumbing.ReferenceName, - targetBranchName string, - auth transport.AuthMethod, -) (bool, error) { - baseBranch, baseHash, err := GetCurrentBranch(repo) - if err != nil { - return false, err - } - - if baseBranch != targetBranch { - if err := switchOrCreateBranch(repo, targetBranch, logger, targetBranchName, baseHash); err != nil { - return false, err - } + request *WriteRequest, +) (int, plumbing.Hash, error) { + if request == nil { + return 0, plumbing.ZeroHash, errors.New("write request is required") } - - committed, lastHash, err := generateBatchCommit(ctx, writer, repo, batch) - if err != nil { - return false, fmt.Errorf("failed to generate batch commit: %w", err) + if request.CommitMode == "" { + request.CommitMode = CommitModePerEvent } - - result.LastHash = printSha(lastHash) - if !committed { - logger.Info("No changes in batch, no need to push") - result.CommitsCreated = 0 - return true, nil - } - result.CommitsCreated = 1 - - if err := PushAtomic(ctx, repo, baseHash, baseBranch, auth); err == nil { - logger.Info("Batch pushed to remote", "failureCount", result.Failures) - return true, nil + if request.CommitMode == CommitModeAtomic { + return generateAtomicCommit(ctx, writer, repo, request) } - return false, nil + return generatePerEventCommits(ctx, writer, repo, request.Events) } -// generateBatchCommit applies all events in a ReconcileBatch without committing after each, -// then creates a single commit with the batch message. -// Returns (committed bool, lastHash, error). -func generateBatchCommit( +// generateAtomicCommit applies all events without committing after each, then creates a single commit. +func generateAtomicCommit( ctx context.Context, writer eventContentWriter, repo *git.Repository, - batch *ReconcileBatch, -) (bool, plumbing.Hash, error) { + request *WriteRequest, +) (int, plumbing.Hash, error) { logger := log.FromContext(ctx) worktree, err := repo.Worktree() if err != nil { - return false, plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err) + return 0, plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err) } - if err := ensureBootstrapTemplateInPath(repo, batchTargetPath(batch), batch.BootstrapOptions); err != nil { - return false, plumbing.ZeroHash, err + if err := ensureBootstrapTemplateInPath(repo, batchTargetPath(request), request.BootstrapOptions); err != nil { + return 0, plumbing.ZeroHash, err } anyChanges := false - for _, event := range batch.Events { + for _, event := range request.Events { changesApplied, err := applyEventToWorktree(ctx, writer, worktree, event) if err != nil { - return false, plumbing.ZeroHash, err + return 0, plumbing.ZeroHash, err } if changesApplied { anyChanges = true @@ -848,10 +821,10 @@ func generateBatchCommit( } if !anyChanges { - return false, plumbing.ZeroHash, nil + return 0, plumbing.ZeroHash, nil } - hash, err := worktree.Commit(batch.CommitMessage, &git.CommitOptions{ + hash, err := worktree.Commit(request.CommitMessage, &git.CommitOptions{ Author: &object.Signature{ Name: "gitops-reverser", Email: "noreply@configbutler.ai", @@ -864,15 +837,15 @@ func generateBatchCommit( }, }) if err != nil { - return false, plumbing.ZeroHash, fmt.Errorf("failed to create batch commit: %w", err) + return 0, plumbing.ZeroHash, fmt.Errorf("failed to create batch commit: %w", err) } - logger.Info("Created batch commit", "message", batch.CommitMessage, "events", len(batch.Events)) - return true, hash, nil + logger.Info("Created atomic commit", "message", request.CommitMessage, "events", len(request.Events)) + return 1, hash, nil } -// generateCommitsFromEvents creates commits from the provided events. -func generateCommitsFromEvents( +// generatePerEventCommits creates one commit per event. +func generatePerEventCommits( ctx context.Context, writer eventContentWriter, repo *git.Repository, @@ -1060,11 +1033,11 @@ func generateFilePath(id types.ResourceIdentifier) string { return defaultPath + ".sops.yaml" } -func batchTargetPath(batch *ReconcileBatch) string { - if batch == nil { +func batchTargetPath(request *WriteRequest) string { + if request == nil { return "" } - for _, event := range batch.Events { + for _, event := range request.Events { if sanitized := sanitizePath(event.Path); sanitized != "" { return sanitized } diff --git a/internal/git/types.go b/internal/git/types.go index 73d37aba0..a29b6e0cd 100644 --- a/internal/git/types.go +++ b/internal/git/types.go @@ -85,21 +85,32 @@ type UserInfo struct { UID string } -// ReconcileBatch is a complete set of file changes from one reconcile run. -// It is enqueued as a single WorkItem so the BranchWorker always commits it atomically. -type ReconcileBatch struct { +// CommitMode defines how a write request should be committed. +type CommitMode string + +const ( + // CommitModePerEvent creates one commit per event in the request. + CommitModePerEvent CommitMode = "per_event" + // CommitModeAtomic creates one commit for all events in the request. + CommitModeAtomic CommitMode = "atomic" +) + +// WriteRequest is the unit of work queued and written by the BranchWorker. +type WriteRequest struct { Events []Event CommitMessage string GitTargetName string GitTargetNamespace string BootstrapOptions pathBootstrapOptions + CommitMode CommitMode } +// ReconcileBatch is a backward-compatible alias for a write request emitted by reconciliation. +type ReconcileBatch = WriteRequest + // WorkItem is the unit of work in the BranchWorker queue. -// Exactly one of Single or Batch is non-nil. type WorkItem struct { - Single *Event - Batch *ReconcileBatch + Request *WriteRequest } // Event represents a resource change event to be processed by a branch worker. diff --git a/internal/reconcile/git_target_event_stream.go b/internal/reconcile/git_target_event_stream.go index 34f6cbd56..da8dcc71c 100644 --- a/internal/reconcile/git_target_event_stream.go +++ b/internal/reconcile/git_target_event_stream.go @@ -64,6 +64,7 @@ type GitTargetEventStream struct { // EventEnqueuer interface for enqueuing events and batches (allows mocking). type EventEnqueuer interface { Enqueue(event git.Event) + EnqueueRequest(request *git.WriteRequest) EnqueueBatch(batch *git.ReconcileBatch) } @@ -134,7 +135,8 @@ func (s *GitTargetEventStream) OnWatchEvent(event git.Event) { func (s *GitTargetEventStream) EmitReconcileBatch(batch git.ReconcileBatch) error { batch.GitTargetName = s.gitTargetName batch.GitTargetNamespace = s.gitTargetNamespace - s.branchWorker.EnqueueBatch(&batch) + batch.CommitMode = git.CommitModeAtomic + s.branchWorker.EnqueueRequest(&batch) return nil } diff --git a/internal/reconcile/git_target_event_stream_test.go b/internal/reconcile/git_target_event_stream_test.go index 47a61aa66..fd5748c08 100644 --- a/internal/reconcile/git_target_event_stream_test.go +++ b/internal/reconcile/git_target_event_stream_test.go @@ -214,6 +214,17 @@ func (m *mockBranchWorker) Enqueue(event git.Event) { m.events = append(m.events, event) } +func (m *mockBranchWorker) EnqueueRequest(request *git.WriteRequest) { + if request == nil { + return + } + if request.CommitMode == git.CommitModeAtomic { + m.batches = append(m.batches, request) + return + } + m.events = append(m.events, request.Events...) +} + func (m *mockBranchWorker) EnqueueBatch(batch *git.ReconcileBatch) { m.batches = append(m.batches, batch) } diff --git a/internal/reconcile/gittarget_lifecycle_integration_test.go b/internal/reconcile/gittarget_lifecycle_integration_test.go index 2ed022613..2f064159c 100644 --- a/internal/reconcile/gittarget_lifecycle_integration_test.go +++ b/internal/reconcile/gittarget_lifecycle_integration_test.go @@ -283,6 +283,19 @@ func (m *mockEventEnqueuer) Enqueue(event git.Event) { m.events = append(m.events, event) } +func (m *mockEventEnqueuer) EnqueueRequest(request *git.WriteRequest) { + m.mu.Lock() + defer m.mu.Unlock() + if request == nil { + return + } + if request.CommitMode == git.CommitModeAtomic { + m.batches = append(m.batches, request) + return + } + m.events = append(m.events, request.Events...) +} + func (m *mockEventEnqueuer) EnqueueBatch(batch *git.ReconcileBatch) { m.mu.Lock() defer m.mu.Unlock() From 7ec368045df020e6c05e30357274d78124d375d6 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 21 Mar 2026 08:08:55 +0000 Subject: [PATCH 23/30] chore: appeared to have found the bug already, adding support for cancelling with resource preservation (make investigation easier). --- .../e2e-encrypted-secret-situation-sketch.md | 204 +++++++ ...ttarget-lifecycle-and-repo-architecture.md | 526 ++++++++++++++++++ internal/controller/gittarget_controller.go | 13 +- podinfo-app-demo.yaml | 10 + test/e2e/audit_redis_e2e_test.go | 3 +- test/e2e/bi_directional_e2e_test.go | 102 +--- test/e2e/e2e_suite_test.go | 28 +- test/e2e/e2e_test.go | 3 +- test/e2e/helpers.go | 31 ++ test/e2e/kubectl.go | 35 +- test/e2e/quickstart_framework_e2e_test.go | 2 +- test/e2e/suite_state.go | 121 ++++ 12 files changed, 982 insertions(+), 96 deletions(-) create mode 100644 docs/design/e2e-encrypted-secret-situation-sketch.md create mode 100644 docs/design/gittarget-lifecycle-and-repo-architecture.md create mode 100644 podinfo-app-demo.yaml create mode 100644 test/e2e/suite_state.go diff --git a/docs/design/e2e-encrypted-secret-situation-sketch.md b/docs/design/e2e-encrypted-secret-situation-sketch.md new file mode 100644 index 000000000..ad8cceb2a --- /dev/null +++ b/docs/design/e2e-encrypted-secret-situation-sketch.md @@ -0,0 +1,204 @@ +# E2E Situation Sketch: Encrypted Secret Readiness Failure + +## Purpose + +This is a compact sketch of the exact situation in the failing e2e test. + +It is meant as a quick orientation document: + +- where in the test we are +- which Kubernetes objects already exist +- which internal runtime objects are expected to exist +- what the test is waiting for +- what has not happened yet + +## The Failing Test + +The failing test is: + +- [test/e2e/e2e_test.go:524](test/e2e/e2e_test.go:524) + +Name: + +- `should commit encrypted Secret manifests when WatchRule includes secrets` + +The assertion eventually fails in: + +- [test/e2e/e2e_test.go:1817](test/e2e/e2e_test.go:1817) + +## Where We Are In The Test Timeline + +At the point of failure, the test has done this already: + +1. created the test namespace +2. created git credential Secret(s) in that namespace +3. created the `sops-age-key` Secret in that namespace +4. created a healthy `GitProvider` +5. created a new `GitTarget` +6. created a new `WatchRule` + +Then the test waits for: + +- `GitTarget Ready=True` + +That wait fails. + +## What Has Already Been Created + +### Kubernetes objects that exist before the failure + +These should already exist: + +- namespace: `-test-manager` +- `Secret/git-creds-e2e-test-` +- `Secret/sops-age-key` +- `GitProvider/gitprovider-normal` +- `GitTarget/watchrule-secret-encryption-test-dest` +- `WatchRule/watchrule-secret-encryption-test` + +### Controller-runtime / in-memory objects that are expected to exist + +By this point, the system expects to have or be able to create: + +- a branch worker for: + - provider namespace = test namespace + - provider name = `gitprovider-normal` + - branch = `main` +- a `GitTargetEventStream` +- a snapshot reconciler for the GitTarget path + +## What The Test Expects At This Moment + +Right after creating the `GitTarget` and `WatchRule`, the test expects: + +- `GitProvider` is already `Ready=True` +- `GitTarget` becomes `Ready=True` +- `WatchRule` becomes `Ready=True` + +For the `GitTarget`, that implies these underlying conditions should settle to: + +- `Validated=True` +- `EncryptionConfigured=True` +- `SnapshotSynced=True` +- `EventStreamLive=True` +- `Ready=True` + +## What Actually Happens + +The live `GitTarget` status shows: + +- `Validated=True` +- `EncryptionConfigured=True` +- `SnapshotSynced=False` +- `EventStreamLive=Unknown` +- `Ready=False` + +The blocking condition is: + +```yaml +type: SnapshotSynced +status: "False" +reason: SnapshotFailed +message: branch worker not found for provider=/gitprovider-normal branch=main +``` + +So the system has passed: + +- provider validation +- branch validation +- conflict checking +- encryption setup + +but fails when trying to start the initial snapshot/event-stream path. + +## What Has Not Happened Yet + +At the moment the failure occurs, the test has **not** yet done any of the following: + +- created the watched Secret `test-secret-encryption` +- patched that Secret +- waited for a Git commit containing the encrypted Secret manifest +- verified `.sops.yaml` in the repo path +- decrypted the committed Secret + +This is important because it means: + +- the failing e2e is not yet exercising the main Secret write path +- the failure happens earlier, in GitTarget startup/lifecycle + +## Situation Sketch + +### Objects and expected dependencies + +```text +GitProvider/gitprovider-normal + -> should allow branch "main" + -> should provide repo URL + git credentials + +GitTarget/watchrule-secret-encryption-test-dest + -> references GitProvider/gitprovider-normal + -> path = e2e/secret-encryption-test + -> branch = main + -> encryption secret = sops-age-key + -> should become: + Validated=True + EncryptionConfigured=True + SnapshotSynced=True + EventStreamLive=True + Ready=True + +WatchRule/watchrule-secret-encryption-test + -> references the GitTarget + -> should become Ready=True + -> should drive watched resources into that GitTarget path +``` + +### What the controller seems to need at that point + +```text +GitTarget Reconcile + -> validate provider/branch/conflicts + -> validate or generate encryption secret + -> ensure branch worker exists + -> ensure GitTargetEventStream exists + -> run initial snapshot sync + -> switch event stream to live mode + -> mark Ready=True +``` + +### Where it currently breaks + +```text +GitTarget Reconcile + -> Validated=True + -> EncryptionConfigured=True + -> SnapshotSynced tries to start + -> branch worker missing + -> SnapshotSynced=False + -> Ready=False +``` + +## Short Interpretation + +The failing e2e is currently stuck in this exact state: + +- the Kubernetes setup is already correct +- encryption setup is already correct +- no watched Secret has been processed yet +- the GitTarget cannot cross from β€œconfiguration is valid” into β€œinitial sync is running/completed” + +So this is best understood as: + +- a startup lifecycle problem + +not: + +- a later write/commit/encryption rendering problem + +## Most Useful Question To Ask Tomorrow + +At this exact point in the e2e, the most useful question is: + +- why does a valid `GitTarget` with a valid `GitProvider` and valid encryption config still not have a usable branch worker by the time `SnapshotSynced` starts? + +That question is narrower and more actionable than asking whether the batch refactor broke Secret commits. diff --git a/docs/design/gittarget-lifecycle-and-repo-architecture.md b/docs/design/gittarget-lifecycle-and-repo-architecture.md new file mode 100644 index 000000000..65cd8f680 --- /dev/null +++ b/docs/design/gittarget-lifecycle-and-repo-architecture.md @@ -0,0 +1,526 @@ +# GitTarget Lifecycle And Repo Architecture + +## Purpose + +This document describes two things: + +1. where the local Git working repository is created and managed +2. what the current `GitTarget` lifecycle means, step by step + +It also explains: + +- what `GitTargetConditionValidated` means +- when the controller moves from one condition to the next +- where the generated SOPS age Secret is created +- how bootstrap files fit into the current design + +## High-Level Model + +Today the system has three related layers: + +1. `GitTarget` controller lifecycle +2. branch worker lifecycle +3. local repository/worktree lifecycle + +These are related, but they are not the same thing. + +### 1. GitTarget lifecycle + +This is the user-visible state machine exposed in `GitTarget.status.conditions`. + +The main conditions are defined in: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +Current lifecycle conditions: + +- `Ready` +- `Validated` +- `EncryptionConfigured` +- `SnapshotSynced` +- `EventStreamLive` + +### 2. Branch worker lifecycle + +A branch worker is the in-memory component that serializes writes for one: + +- provider namespace +- provider name +- branch + +It is managed by: + +- [internal/git/worker_manager.go](/workspaces/gitops-reverser/internal/git/worker_manager.go) + +### 3. Local repository/worktree lifecycle + +Each branch worker uses a local repository checkout under `/tmp`. + +This is managed by: + +- [internal/git/branch_worker.go](/workspaces/gitops-reverser/internal/git/branch_worker.go) +- [internal/git/git.go](/workspaces/gitops-reverser/internal/git/git.go) + +## Where The Local Git Repository Is Created + +The local repository path is derived in the branch worker. + +Relevant functions: + +- [internal/git/branch_worker.go](/workspaces/gitops-reverser/internal/git/branch_worker.go) + - `repoRootPath()` + - `repoPathForRemote(remoteURL string)` + +### Current path layout + +The worker stores repositories under: + +```text +/tmp/gitops-reverser-workers////repos/ +``` + +This means: + +- one logical worker owns one provider+branch combination +- the actual repo directory is keyed by a hash of the remote URL +- the repo directory is reused across operations for that worker + +### Why the remote URL is hashed + +The worker uses: + +- `repoCacheKey(remoteURL string)` + +to build a stable directory name from the remote URL. + +That avoids path issues from raw URLs while still keeping one reusable local checkout per remote. + +## Where The Local Repository Is Opened Or Initialized + +The main entrypoint for repository preparation is: + +- [internal/git/git.go](/workspaces/gitops-reverser/internal/git/git.go) + - `PrepareBranch(...)` + +### What `PrepareBranch(...)` does + +`PrepareBranch(...)` is responsible for: + +- ensuring the parent directory exists +- opening an existing local repository if present +- creating a clean repository if missing or broken +- ensuring the `origin` remote is set correctly +- syncing local branch state against the remote + +This function does **not** just write files. It is the core β€œmake sure local repo state is usable” operation. + +### Where it is called from + +The branch worker calls `PrepareBranch(...)` in three important places: + +- `ensureRepositoryInitialized(...)` +- `prepareBootstrapRepository(...)` +- the main write/sync path before commit/push + +Relevant locations: + +- [internal/git/branch_worker.go](/workspaces/gitops-reverser/internal/git/branch_worker.go) + +## What The Branch Worker Owns + +The branch worker is the owner of: + +- local repository synchronization +- worktree mutation +- bootstrapping files in the worktree +- batching or serializing events for commit/push + +Important file: + +- [internal/git/branch_worker.go](/workspaces/gitops-reverser/internal/git/branch_worker.go) + +### Worker identity + +A worker is keyed by: + +- provider namespace +- provider name +- branch + +This means multiple `GitTarget`s can share a worker if they write to different paths on the same branch. + +### Worker manager + +Workers are created and looked up by: + +- [internal/git/worker_manager.go](/workspaces/gitops-reverser/internal/git/worker_manager.go) + +Important functions: + +- `EnsureWorker(...)` +- `GetWorkerForTarget(...)` +- `RegisterTarget(...)` +- `UnregisterTarget(...)` + +## Exact Current GitTarget Lifecycle + +The lifecycle is implemented in: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +The controller processes the gates in this order: + +1. `Validated` +2. `EncryptionConfigured` +3. `SnapshotSynced` +4. `EventStreamLive` +5. `Ready` + +`Ready=True` only happens after the earlier gates are satisfied. + +## What `Validated` Means + +`Validated` means: + +- the referenced `GitProvider` exists +- the target branch matches the provider’s `allowedBranches` +- there is no path conflict with another `GitTarget` using the same provider+branch+path + +This logic lives in: + +- `evaluateValidatedGate(...)` +- `validateProviderAndBranch(...)` +- `checkForConflicts(...)` + +all in: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +### What `Validated=True` does not mean + +It does **not** mean: + +- the local repo has been cloned +- a worker definitely exists +- the branch exists remotely +- bootstrap files are present +- snapshot reconciliation has completed + +It only means that the target is logically allowed and conflict-free enough to continue. + +### When the controller jumps to `Validated=True` + +The controller sets `Validated=True` after: + +- the `GitProvider` lookup succeeds +- branch pattern validation succeeds +- conflict detection succeeds + +The exact status set is: + +- `type: Validated` +- `status: True` +- `reason: OK` +- `message: Provider and branch validation passed` + +### When `Validated=False` happens + +Examples: + +- provider not found +- branch does not match allowed patterns +- another earlier `GitTarget` already owns the same provider+branch+path + +When `Validated=False`, later lifecycle conditions are explicitly blocked: + +- `EncryptionConfigured=Unknown` +- `SnapshotSynced=Unknown` +- `EventStreamLive=Unknown` +- `Ready=False` + +## What `EncryptionConfigured` Means + +`EncryptionConfigured` means: + +- if SOPS age encryption is not enabled, nothing more is required +- if SOPS age encryption is enabled, the encryption Secret exists and can be resolved into usable recipients + +This logic lives in: + +- `evaluateEncryptionGate(...)` +- `ensureEncryptionSecret(...)` + +in: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +### When `EncryptionConfigured=True` happens + +There are two main cases: + +1. Encryption is not enabled + +Then the condition becomes: + +- `True` +- `NotRequired` + +2. Encryption is enabled and valid + +Then the condition becomes: + +- `True` +- `OK` +- `Encryption configuration is valid` + +### When `EncryptionConfigured=False` happens + +Examples: + +- referenced secret missing +- invalid secret contents +- invalid age configuration +- `generateWhenMissing` disabled but secret absent + +If encryption is not configured correctly: + +- `SnapshotSynced` stays not started +- `EventStreamLive` stays not started +- `Ready=False` + +## Where The New SOPS Secret Is Created + +Yes, this is currently part of the `GitTarget` controller lifecycle. + +The generated age Secret is created by: + +- `ensureEncryptionSecret(...)` +- `createGeneratedEncryptionSecret(...)` + +in: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) + +### When this happens + +It happens during the `EncryptionConfigured` gate, not during bootstrap and not in the branch worker. + +So the flow is: + +1. `GitTarget` enters encryption evaluation +2. if `generateWhenMissing=true`, controller checks whether the Secret exists +3. if it does not exist, controller generates an age identity +4. controller creates or updates the Kubernetes Secret +5. then the controller resolves encryption config again + +### Important consequence + +The generated SOPS Secret is: + +- a Kubernetes resource concern +- handled by the `GitTarget` controller + +It is **not** created by: + +- bootstrap file staging +- the branch worker +- the git write path + +## What `SnapshotSynced` Means + +`SnapshotSynced` means: + +- the initial cluster snapshot for this `GitTarget` path has been reconciled against the Git path + +This is handled in: + +- `evaluateSnapshotGate(...)` + +and depends on: + +- the event router +- a `GitTargetEventStream` +- a branch worker +- a folder reconciler + +Relevant files: + +- [internal/controller/gittarget_controller.go](/workspaces/gitops-reverser/internal/controller/gittarget_controller.go) +- [internal/reconcile/git_target_event_stream.go](/workspaces/gitops-reverser/internal/reconcile/git_target_event_stream.go) + +### What happens in this gate + +The controller: + +1. ensures there is a `GitTargetEventStream` +2. puts it into reconciling/buffering mode +3. starts initial snapshot reconciliation +4. waits until both cluster state and repo state have been seen +5. records snapshot stats and completion time + +### Why this gate matters + +It prevents live watch events from racing ahead of the first consistent path sync. + +## What `EventStreamLive` Means + +`EventStreamLive` means: + +- the `GitTargetEventStream` has transitioned from reconciliation/buffering mode into normal live processing + +Handled in: + +- `evaluateEventStreamGate(...)` + +This is the final gate before `Ready=True`. + +If it fails: + +- `Ready=False` +- reason becomes stream-related, not validation-related + +## What `Ready` Means + +`Ready=True` means: + +- `Validated=True` +- `EncryptionConfigured=True` +- `SnapshotSynced=True` +- `EventStreamLive=True` + +and the controller sets: + +- `reason: OK` +- `message: All lifecycle gates satisfied` + +So `Ready` is now the final summary condition, while the other lifecycle conditions explain which stage is blocking. + +## Where Bootstrap Fits In Now + +Bootstrap is no longer a user-visible lifecycle condition. + +There is no current `Bootstrapped` condition in the `GitTarget` status path. + +### What bootstrap means now + +Bootstrap is an internal worktree preparation step that makes sure: + +- `README.md` +- `.sops.yaml` when needed + +exist inside the target path in the local repository worktree. + +Relevant code: + +- [internal/git/branch_worker.go](/workspaces/gitops-reverser/internal/git/branch_worker.go) + - `EnsurePathBootstrapped(...)` + - `prepareBootstrapRepository(...)` +- [internal/git/bootstrapped_repo_template.go](/workspaces/gitops-reverser/internal/git/bootstrapped_repo_template.go) + - `ensureBootstrapTemplateInPath(...)` + +### Important distinction + +Bootstrap is now: + +- local repo/worktree preparation + +not: + +- a dedicated `GitTarget` phase +- a separate status gate +- a separate init commit + +### Relationship to encryption + +Bootstrap and encryption do work together, but indirectly: + +- `GitTarget` controller resolves encryption configuration +- branch worker uses resolved recipients to decide whether `.sops.yaml` should be included +- bootstrap file rendering uses those recipients when writing `.sops.yaml` + +So: + +- the Secret is generated by the controller +- the `.sops.yaml` file is rendered by the git layer + +They are related, but they are not created by the same subsystem. + +## Current Repo/Bootstrap Flow In Practice + +For a path that needs to be written: + +1. branch worker prepares or reuses the local repo +2. branch worker calls `PrepareBranch(...)` +3. bootstrap files may be ensured in the local worktree +4. resource files are written or deleted +5. commit generation happens +6. push happens + +This means the worktree is the place where bootstrap and resource files come together before commit. + +## Important Current Architectural Tension + +Right now the lifecycle assumes: + +- a branch worker will exist by the time snapshot/event-stream setup runs + +But the recent e2e investigation suggests that this assumption is currently broken in at least one path. + +That is why the failing `GitTarget` gets stuck at: + +- `Validated=True` +- `EncryptionConfigured=True` +- `SnapshotSynced=False` + +with: + +- `branch worker not found` + +So the current architecture is: + +- conceptually clear +- but still missing a reliable worker-materialization step before snapshot startup + +## Short Answer Summary + +### Where is the local Git repo working directory created and managed? + +By the branch worker, under: + +- `/tmp/gitops-reverser-workers/...` + +using: + +- `repoRootPath()` +- `repoPathForRemote()` +- `PrepareBranch(...)` + +### What does `Validated` mean exactly? + +It means: + +- provider exists +- branch is allowed +- no conflicting `GitTarget` owns the same provider+branch+path + +It does **not** mean repo/worker/bootstrap is ready yet. + +### When do we jump to `Validated=True`? + +Immediately after provider validation and conflict checks pass, before encryption and before snapshot. + +### Where is the new SOPS Secret created? + +In the `GitTarget` controller during the `EncryptionConfigured` gate, via: + +- `ensureEncryptionSecret(...)` +- `createGeneratedEncryptionSecret(...)` + +### Is that tied to bootstrap? + +Only indirectly. + +- the controller creates the Kubernetes Secret +- the git layer later uses the resolved recipients to render `.sops.yaml` + +So bootstrap is no longer a lifecycle phase, but it still depends on encryption being resolved correctly. diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index c5483f29c..716bcd960 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -494,7 +494,12 @@ func (r *GitTargetReconciler) ensureEventStream( worker, exists := r.WorkerManager.GetWorkerForTarget(target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch) if !exists { - if err := r.WorkerManager.EnsureWorker(context.Background(), target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch); err != nil { + if err := r.WorkerManager.EnsureWorker( + context.Background(), + target.Spec.ProviderRef.Name, + providerNS, + target.Spec.Branch, + ); err != nil { return nil, fmt.Errorf( "failed to ensure branch worker for provider=%s/%s branch=%s: %w", providerNS, @@ -505,7 +510,11 @@ func (r *GitTargetReconciler) ensureEventStream( } var ensured bool - worker, ensured = r.WorkerManager.GetWorkerForTarget(target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch) + worker, ensured = r.WorkerManager.GetWorkerForTarget( + target.Spec.ProviderRef.Name, + providerNS, + target.Spec.Branch, + ) if !ensured { return nil, fmt.Errorf( "branch worker not found for provider=%s/%s branch=%s", diff --git a/podinfo-app-demo.yaml b/podinfo-app-demo.yaml new file mode 100644 index 000000000..92c557c9e --- /dev/null +++ b/podinfo-app-demo.yaml @@ -0,0 +1,10 @@ +apiVersion: kro.run/v1alpha1 +kind: PodInfoApp +metadata: + name: fiets + namespace: podinfos-intent +spec: + replicas: 1 + name: fiets + message: The Dutch word for bicycle is "fiets". We have a lot of them. 🚲 + color: "#115711" diff --git a/test/e2e/audit_redis_e2e_test.go b/test/e2e/audit_redis_e2e_test.go index d24279188..13aec6beb 100644 --- a/test/e2e/audit_redis_e2e_test.go +++ b/test/e2e/audit_redis_e2e_test.go @@ -48,8 +48,7 @@ var _ = Describe("Audit Redis Queue", Label("audit-redis"), Ordered, func() { }) AfterAll(func() { - By("deleting test namespace") - _, _ = kubectlRun("delete", "namespace", testNs, "--ignore-not-found=true") + cleanupNamespace(testNs) }) It("should enqueue incoming audit webhook events into a Redis stream", func() { diff --git a/test/e2e/bi_directional_e2e_test.go b/test/e2e/bi_directional_e2e_test.go index 5dbeb4eb7..1769ea014 100644 --- a/test/e2e/bi_directional_e2e_test.go +++ b/test/e2e/bi_directional_e2e_test.go @@ -119,46 +119,12 @@ var _ = Describe("Bi Directional", Label("bi-directional"), Ordered, func() { AfterAll(func() { run.cleanupFluxResources() - run.cleanupReverseResources(testNs) - _, _ = kubectlRunInNamespace( - testNs, - "delete", - "secret", - run.controllerEncryptionSecret, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - testNs, - "delete", - "secret", - run.reverseSecretName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - testNs, - "delete", - "icecreamorder", - run.firstOrderName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - testNs, - "delete", - "icecreamorder", - run.secondOrderName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - testNs, - "delete", - "icecreamorder", - run.revertedOrderName, - "--ignore-not-found=true", - ) - _, _ = kubectlRun("delete", "crd", "icecreamorders.shop.example.com", "--ignore-not-found=true") - - By("deleting test namespace") - _, _ = kubectlRun("delete", "namespace", testNs, "--ignore-not-found=true") + run.cleanupReverseResources() + // Namespace deletion is enough for ordinary namespaced leftovers like test Secrets and + // IceCreamOrder instances. Keep explicit cleanup only for Flux resources and cluster-scoped + // resources that live outside the test namespace. + cleanupClusterResource("crd", "icecreamorders.shop.example.com") + cleanupNamespace(testNs) }) It("should avoid a commit loop while Flux and gitops-reverser share IceCreamOrder resources", func() { @@ -1087,52 +1053,16 @@ func (r biDirectionalRun) waitForOrderDeleted(name string) { } func (r biDirectionalRun) cleanupFluxResources() { - _, _ = kubectlRunInNamespace( - "flux-system", - "delete", - "kustomization", - r.fluxLiveName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - "flux-system", - "delete", - "kustomization", - r.fluxCRDsName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - "flux-system", - "delete", - "gitrepository", - r.fluxGitRepositoryName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - "flux-system", - "delete", - "secret", - r.fluxSecretName, - "--ignore-not-found=true", - ) - _, _ = kubectlRunInNamespace( - "flux-system", - "delete", - "secret", - r.fluxDecryptionSecret, - "--ignore-not-found=true", - ) + cleanupNamespacedResource("flux-system", "kustomization", r.fluxLiveName) + cleanupNamespacedResource("flux-system", "kustomization", r.fluxCRDsName) + cleanupNamespacedResource("flux-system", "gitrepository", r.fluxGitRepositoryName) + cleanupNamespacedResource("flux-system", "secret", r.fluxSecretName) + cleanupNamespacedResource("flux-system", "secret", r.fluxDecryptionSecret) } -func (r biDirectionalRun) cleanupReverseResources(ns string) { - cleanupWatchRule(r.watchRuleName, ns) - cleanupWatchRule(r.secretWatchRuleName, ns) - cleanupGitTarget(r.gitTargetName, ns) - _, _ = kubectlRunInNamespace( - ns, - "delete", - "gitprovider", - r.gitProviderName, - "--ignore-not-found=true", - ) +func (r biDirectionalRun) cleanupReverseResources() { + cleanupWatchRule(r.watchRuleName, r.testNs) + cleanupWatchRule(r.secretWatchRuleName, r.testNs) + cleanupGitTarget(r.gitTargetName, r.testNs) + cleanupNamespacedResource(r.testNs, "gitprovider", r.gitProviderName) } diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index aaf6caf00..ffc7e2955 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -23,9 +23,11 @@ import ( "fmt" "os" "os/exec" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" "testing" . "github.com/onsi/ginkgo/v2" @@ -39,7 +41,9 @@ import ( // The default setup requires Kind, builds/loads the Manager Docker image locally, and installs // CertManager. func TestE2E(t *testing.T) { - RegisterFailHandler(Fail) + initE2ECommandContext() + watchForE2EInterrupts() + RegisterFailHandler(failAndPreserveResources) _, _ = fmt.Fprintf(GinkgoWriter, "Starting gitops-reverser integration test suite\n") RunSpecs(t, "e2e suite") } @@ -59,6 +63,12 @@ var _ = AfterEach(func() { dumpFailureDiagnostics() }) +var _ = ReportAfterEach(func(report SpecReport) { + if report.Failed() { + markE2EResourcesForPreservation("spec failed: " + report.FullText()) + } +}) + func ensureE2EPrepared() { ctx := resolveE2EContext() setE2EKubectlContext(ctx) @@ -230,3 +240,19 @@ func resolveE2EContext() string { return "kind-gitops-reverser-test-e2e" } + +func failAndPreserveResources(message string, callerSkip ...int) { + markE2EResourcesForPreservation("assertion failed") + Fail(message, callerSkip...) +} + +func watchForE2EInterrupts() { + signals := make(chan os.Signal, 2) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + + go func() { + sig := <-signals + markE2EResourcesForPreservation(fmt.Sprintf("received signal %s", sig)) + e2eCommandCancel() + }() +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 7cc0f9a89..ffa2776aa 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -105,8 +105,7 @@ var _ = Describe("Manager", Ordered, func() { // After all tests have been executed, clean up the test namespace AfterAll(func() { - By("deleting test namespace") - _, _ = kubectlRun("delete", "namespace", testNs, "--ignore-not-found=true") + cleanupNamespace(testNs) By("test infrastructure still running for debugging") fmt.Printf("\n") diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index d9bda166b..264e05b58 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -225,22 +225,53 @@ func applySOPSAgeKeyToNamespace(ns string) { // cleanupGitTarget deletes a GitTarget resource. func cleanupGitTarget(name, namespace string) { + if skipCleanupBecauseResourcesArePreserved(fmt.Sprintf("GitTarget %s/%s", namespace, name)) { + return + } By(fmt.Sprintf("cleaning up GitTarget '%s' in ns '%s'", name, namespace)) _, _ = kubectlRunInNamespace(namespace, "delete", "gittarget", name, "--ignore-not-found=true") } // cleanupWatchRule deletes a WatchRule resource. func cleanupWatchRule(name, namespace string) { + if skipCleanupBecauseResourcesArePreserved(fmt.Sprintf("WatchRule %s/%s", namespace, name)) { + return + } By(fmt.Sprintf("cleaning up WatchRule '%s' in ns '%s'", name, namespace)) _, _ = kubectlRunInNamespace(namespace, "delete", "watchrule", name, "--ignore-not-found=true") } // cleanupClusterWatchRule deletes a ClusterWatchRule resource. func cleanupClusterWatchRule(name string) { + if skipCleanupBecauseResourcesArePreserved(fmt.Sprintf("ClusterWatchRule %s", name)) { + return + } By(fmt.Sprintf("cleaning up ClusterWatchRule '%s'", name)) _, _ = kubectlRun("delete", "clusterwatchrule", name, "--ignore-not-found=true") } +func cleanupNamespace(name string) { + if skipCleanupBecauseResourcesArePreserved(fmt.Sprintf("namespace %s", name)) { + return + } + By(fmt.Sprintf("deleting test namespace '%s'", name)) + _, _ = kubectlRun("delete", "namespace", name, "--ignore-not-found=true") +} + +func cleanupNamespacedResource(namespace, resource, name string) { + if skipCleanupBecauseResourcesArePreserved(fmt.Sprintf("%s %s/%s", resource, namespace, name)) { + return + } + _, _ = kubectlRunInNamespace(namespace, "delete", resource, name, "--ignore-not-found=true") +} + +func cleanupClusterResource(resource, name string) { + if skipCleanupBecauseResourcesArePreserved(fmt.Sprintf("%s %s", resource, name)) { + return + } + _, _ = kubectlRun("delete", resource, name, "--ignore-not-found=true") +} + func controllerPodNames() ([]string, error) { output, err := kubectlRunInNamespace( namespace, diff --git a/test/e2e/kubectl.go b/test/e2e/kubectl.go index c76ac05ff..18e4f8832 100644 --- a/test/e2e/kubectl.go +++ b/test/e2e/kubectl.go @@ -103,12 +103,12 @@ func hasNamespaceArg(args []string) bool { func kubectlCmd(ctx context.Context, args ...string) *exec.Cmd { //nolint:gosec // e2e helper intentionally shells out to kubectl - return exec.CommandContext(ctx, "kubectl", kubectlArgs(args...)...) + return exec.CommandContext(e2eCommandContext(ctx), "kubectl", kubectlArgs(args...)...) } func kubectlCmdInNamespace(ctx context.Context, namespace string, args ...string) *exec.Cmd { //nolint:gosec // e2e helper intentionally shells out to kubectl - return exec.CommandContext(ctx, "kubectl", kubectlArgsInNamespace(namespace, args...)...) + return exec.CommandContext(e2eCommandContext(ctx), "kubectl", kubectlArgsInNamespace(namespace, args...)...) } func kubectlRun(args ...string) (string, error) { @@ -124,3 +124,34 @@ func kubectlRunWithStdin(namespace, stdin string, args ...string) (string, error cmd.Stdin = strings.NewReader(stdin) return utils.Run(cmd) } + +func setE2ECommandContext(ctx context.Context) { + if ctx == nil { + setE2ECommandDone(nil) + return + } + + setE2ECommandDone(ctx.Done()) +} + +func e2eCommandContext(ctx context.Context) context.Context { + if ctx != nil && ctx != context.Background() { + return ctx + } + + done := e2eCommandDone() + if done == nil { + return context.Background() + } + + commandCtx, cancel := context.WithCancel(context.Background()) + go func() { + select { + case <-done: + cancel() + case <-commandCtx.Done(): + } + }() + + return commandCtx +} diff --git a/test/e2e/quickstart_framework_e2e_test.go b/test/e2e/quickstart_framework_e2e_test.go index 9e52d9516..0c394d06c 100644 --- a/test/e2e/quickstart_framework_e2e_test.go +++ b/test/e2e/quickstart_framework_e2e_test.go @@ -76,7 +76,7 @@ var _ = Describe("Quickstart Framework", Label("quickstart-framework"), Ordered, }) AfterAll(func() { - _, _ = kubectlRun("delete", "namespace", testNamespaceFor("quickstart-framework"), "--ignore-not-found=true") + cleanupNamespace(testNamespaceFor("quickstart-framework")) }) It("sets up quickstart flow via Go framework", func() { diff --git a/test/e2e/suite_state.go b/test/e2e/suite_state.go new file mode 100644 index 000000000..28c1649fc --- /dev/null +++ b/test/e2e/suite_state.go @@ -0,0 +1,121 @@ +/* +SPDX-License-Identifier: Apache-2.0 + +Copyright 2025 ConfigButler + +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 e2e stores suite-scoped state shared by the end-to-end helpers. +// +// This state allows the suite to preserve resources for debugging when a spec fails or the run is interrupted +// with Ctrl+C / SIGTERM. Cleanup helpers consult it before deleting resources, and kubectl helpers use the +// shared cancellation signal so long-running commands can stop promptly during shutdown. +package e2e + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" +) + +var ( + e2ePreserveResources atomic.Bool //nolint:gochecknoglobals // suite-scoped preservation state shared across e2e helpers + e2ePreserveReasonMu sync.Mutex //nolint:gochecknoglobals // protects suite-scoped preservation reasons + e2ePreserveReasons []string //nolint:gochecknoglobals // suite-scoped preservation reasons shared across e2e helpers + e2eCommandCancel context.CancelFunc = func() {} //nolint:gochecknoglobals // suite-scoped cancellation hook for e2e commands + e2eCommandDoneCh <-chan struct{} //nolint:gochecknoglobals // suite-scoped done signal for e2e commands + e2eCommandDoneMu sync.RWMutex //nolint:gochecknoglobals // protects suite-scoped command done signal +) + +func markE2EResourcesForPreservation(reason string) { + trimmedReason := strings.TrimSpace(reason) + if trimmedReason == "" { + trimmedReason = "preserve requested" + } + + e2ePreserveResources.Store(true) + + e2ePreserveReasonMu.Lock() + defer e2ePreserveReasonMu.Unlock() + + for _, existing := range e2ePreserveReasons { + if existing == trimmedReason { + return + } + } + + e2ePreserveReasons = append(e2ePreserveReasons, trimmedReason) + _, _ = fmt.Fprintf(GinkgoWriter, "Preserving e2e resources: %s\n", trimmedReason) +} + +func shouldPreserveE2EResources() bool { + return e2ePreserveResources.Load() +} + +// skipCleanupBecauseResourcesArePreserved lets cleanup helpers opt out once the suite has decided to keep +// cluster resources around for post-failure or post-interrupt investigation. +func skipCleanupBecauseResourcesArePreserved(scope string) bool { + if !shouldPreserveE2EResources() { + return false + } + + message := "preserving e2e resources" + if trimmedScope := strings.TrimSpace(scope); trimmedScope != "" { + message = fmt.Sprintf("preserving e2e resources; skipping cleanup for %s", trimmedScope) + } + + By(message) + _, _ = fmt.Fprintf(GinkgoWriter, "%s\n", e2ePreservationSummary()) + return true +} + +func e2ePreservationSummary() string { + e2ePreserveReasonMu.Lock() + defer e2ePreserveReasonMu.Unlock() + + if len(e2ePreserveReasons) == 0 { + return "Preserving e2e resources for investigation." + } + + return fmt.Sprintf( + "Preserving e2e resources for investigation. Reasons: %s", + strings.Join(e2ePreserveReasons, "; "), + ) +} + +func initE2ECommandContext() { + e2eExecutionContext, cancel := context.WithCancel(context.Background()) + e2eCommandCancel = cancel + setE2ECommandContext(e2eExecutionContext) +} + +// setE2ECommandDone stores the shared cancellation signal used by kubectl helpers so Ctrl+C or suite aborts +// can stop long-running e2e commands. +func setE2ECommandDone(done <-chan struct{}) { + e2eCommandDoneMu.Lock() + defer e2eCommandDoneMu.Unlock() + + e2eCommandDoneCh = done +} + +func e2eCommandDone() <-chan struct{} { + e2eCommandDoneMu.RLock() + defer e2eCommandDoneMu.RUnlock() + + return e2eCommandDoneCh +} From 439323b1c1f35f4bd84949c5619eb0d2553167da Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 21 Mar 2026 08:16:59 +0000 Subject: [PATCH 24/30] fix: Have working printcolumns --- api/v1alpha1/clusterwatchrule_types.go | 3 +- api/v1alpha1/gitprovider_types.go | 4 + api/v1alpha1/gittarget_types.go | 6 + api/v1alpha1/watchrule_types.go | 3 +- .../configbutler.ai_clusterwatchrules.yaml | 8 +- .../bases/configbutler.ai_gitproviders.yaml | 16 ++- .../crd/bases/configbutler.ai_gittargets.yaml | 22 +++- .../crd/bases/configbutler.ai_watchrules.yaml | 8 +- docs/after-kubecon.md | 5 + docs/demo-findings.md | 2 - docs/design/crd-printer-columns-plan.md | 109 ++++++++++++++++++ .../e2e-encrypted-secret-situation-sketch.md | 0 12 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 docs/after-kubecon.md delete mode 100644 docs/demo-findings.md create mode 100644 docs/design/crd-printer-columns-plan.md rename docs/design/{ => done}/e2e-encrypted-secret-situation-sketch.md (100%) diff --git a/api/v1alpha1/clusterwatchrule_types.go b/api/v1alpha1/clusterwatchrule_types.go index 2a75b37e5..5476be43b 100644 --- a/api/v1alpha1/clusterwatchrule_types.go +++ b/api/v1alpha1/clusterwatchrule_types.go @@ -133,8 +133,9 @@ type ClusterWatchRuleStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster -// +kubebuilder:printcolumn:name="Destination",type=string,JSONPath=`.spec.destinationRef.name` +// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // ClusterWatchRule watches resources across the entire cluster. diff --git a/api/v1alpha1/gitprovider_types.go b/api/v1alpha1/gitprovider_types.go index ba4fbea7e..542e3696a 100644 --- a/api/v1alpha1/gitprovider_types.go +++ b/api/v1alpha1/gitprovider_types.go @@ -121,6 +121,10 @@ type GitProviderStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.spec.url` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // GitProvider is the Schema for the gitproviders API. type GitProvider struct { diff --git a/api/v1alpha1/gittarget_types.go b/api/v1alpha1/gittarget_types.go index 833919599..814012269 100644 --- a/api/v1alpha1/gittarget_types.go +++ b/api/v1alpha1/gittarget_types.go @@ -118,6 +118,12 @@ type GitTargetSnapshotStats struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.providerRef.name` +// +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch` +// +kubebuilder:printcolumn:name="Path",type=string,JSONPath=`.spec.path` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // GitTarget is the Schema for the gittargets API. type GitTarget struct { diff --git a/api/v1alpha1/watchrule_types.go b/api/v1alpha1/watchrule_types.go index 16afc7f0f..4908e185e 100644 --- a/api/v1alpha1/watchrule_types.go +++ b/api/v1alpha1/watchrule_types.go @@ -130,8 +130,9 @@ type WatchRuleStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Namespaced -// +kubebuilder:printcolumn:name="Destination",type=string,JSONPath=`.spec.destinationRef.name` +// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // WatchRule watches namespaced resources within its own namespace. diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index c91704eff..91f1e33e6 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -15,12 +15,16 @@ spec: scope: Cluster versions: - additionalPrinterColumns: - - jsonPath: .spec.destinationRef.name - name: Destination + - jsonPath: .spec.targetRef.name + name: Target type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status name: Ready type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + priority: 1 + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index 11bc0035f..ddfdf85ee 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -14,7 +14,21 @@ spec: singular: gitprovider scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .spec.url + name: URL + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: GitProvider is the Schema for the gitproviders API. diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index 4d9dde35e..d9d895b8c 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -14,7 +14,27 @@ spec: singular: gittarget scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .spec.providerRef.name + name: Provider + type: string + - jsonPath: .spec.branch + name: Branch + type: string + - jsonPath: .spec.path + name: Path + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: GitTarget is the Schema for the gittargets API. diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index c2fe12cd9..8f9b1a1b0 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -15,12 +15,16 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - jsonPath: .spec.destinationRef.name - name: Destination + - jsonPath: .spec.targetRef.name + name: Target type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status name: Ready type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + priority: 1 + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/docs/after-kubecon.md b/docs/after-kubecon.md new file mode 100644 index 000000000..f2af4fa52 --- /dev/null +++ b/docs/after-kubecon.md @@ -0,0 +1,5 @@ +* It's more than time to switch to audit event only (and use watchrules for initial cluster sync). +* I've noticed that make test-e2e is not always retriggering an image build when needed -> test this and see if we can fix it, that doesnt help. +* Can we also have more filtering on comitting of CRDs themself? At this moment you can only get them all, which feels a bit useless +* What do we do with namespaces? Make it a setting? Or just trust that people will do the right trickery to get rid of them when needed? Could make a nice setting on GitTarget +* In e2e test I see that CRD sync (of 61 resources) is not entirery happening in one commit -> the first handfull is sneaking in as seperate create commits. diff --git a/docs/demo-findings.md b/docs/demo-findings.md deleted file mode 100644 index a1f89e408..000000000 --- a/docs/demo-findings.md +++ /dev/null @@ -1,2 +0,0 @@ -* Pushing of large amount of changes is not really fast -* It's more than time to switch to audit event only (and use watchrules for initial cluster sync). \ No newline at end of file diff --git a/docs/design/crd-printer-columns-plan.md b/docs/design/crd-printer-columns-plan.md new file mode 100644 index 000000000..ca5eb8d0a --- /dev/null +++ b/docs/design/crd-printer-columns-plan.md @@ -0,0 +1,109 @@ +# CRD Printer Columns Plan + +## Summary + +Restore and standardize CRD printer columns around the current API surface only, with no legacy naming and no +"gimmick" resource counter unless we discover a truly trivial implementation during execution. The main goal is to +make `kubectl get` useful again across `GitProvider`, `GitTarget`, `WatchRule`, and `ClusterWatchRule`, with `Ready` +shown consistently everywhere and richer columns on `GitProvider` and `GitTarget` for the fields operators actually +care about. In addition to the boolean `Ready` signal, each resource should expose a Flux-style `Status` column that +prints the current `Ready` condition message so operators can see the latest high-level reason directly in list views. + +## Key Changes + +- +- Add `+kubebuilder:printcolumn` annotations to `api/v1alpha1/gitprovider_types.go` for: + - `URL` -> `.spec.url` + - `Ready` -> `.status.conditions[?(@.type=="Ready")].status` + - `Status` -> `.status.conditions[?(@.type=="Ready")].message` + - `Age` -> `.metadata.creationTimestamp` +- Add `+kubebuilder:printcolumn` annotations to `api/v1alpha1/gittarget_types.go` for: + - `Provider` -> `.spec.providerRef.name` + - `Branch` -> `.spec.branch` + - `Path` -> `.spec.path` + - `Ready` -> `.status.conditions[?(@.type=="Ready")].status` + - `Status` -> `.status.conditions[?(@.type=="Ready")].message` + - `Age` -> `.metadata.creationTimestamp` +- Fix existing `WatchRule` printer columns in `api/v1alpha1/watchrule_types.go` so they use the current field names + instead of stale `destinationRef`: + - `Target` -> `.spec.targetRef.name` + - `Ready` -> `.status.conditions[?(@.type=="Ready")].status` + - `Status` -> `.status.conditions[?(@.type=="Ready")].message` + - `Age` -> `.metadata.creationTimestamp` +- Fix existing `ClusterWatchRule` printer columns in `api/v1alpha1/clusterwatchrule_types.go` to use the current + target reference: + - `Target` -> single rendered target identity in the form `namespace/name` + - `Ready` -> `.status.conditions[?(@.type=="Ready")].status` + - `Status` -> `.status.conditions[?(@.type=="Ready")].message` + - `Age` -> `.metadata.creationTimestamp` +- Keep status semantics unchanged: all four resources already expose `Ready` via `status.conditions`, so this is + primarily a CRD/schema presentation fix rather than a controller-behavior redesign. +- Do not add a total-resource counter in v1 of this change unless, during implementation, there is an obviously + low-risk existing field that already represents the intended meaning. Today the rule controllers do not own such a + count, and the current `GitTarget` snapshot stats are diff-oriented, not a stable "current total" value. + +## Controller And Status Analysis + +- `GitProvider` already exposes conditions in status and is a natural fit for a compact operator-facing view with the + repository URL and readiness. +- `GitTarget` already maintains richer status than the rule resources, including snapshot progress and diff stats; it + is the right place for richer operational columns. +- `WatchRule` and `ClusterWatchRule` currently reconcile reference validity, store compiled rule state, and set + `Ready`; they do not maintain per-rule resource totals. +- A `Status` printer column sourced from the `Ready` condition message is a good fit here and follows familiar Flux + ergonomics. It gives operators immediate context without requiring a `describe`, as long as controllers keep their + ready messages short, stable, and human-readable. +- The main tradeoff is noise: if `Ready` messages are overly verbose or churn frequently, the list view becomes harder + to scan. Implementation should therefore prefer concise one-line messages and avoid embedding low-level error dumps + when setting ready-condition messages. +- Because of that controller shape, the logical extra columns for rule resources are target identity and readiness, + not synthetic counters. +- The generated CRDs in `config/crd/bases/configbutler.ai_watchrules.yaml` and + `config/crd/bases/configbutler.ai_clusterwatchrules.yaml` are currently stale and still point at + `.spec.destinationRef.name`; regeneration must be part of the change. +- The `ClusterWatchRule` target display should be treated as a UX requirement, not as two separate operator-facing + fields. Implementation should therefore prefer a single printable value formatted as `targetns/target`. + If kubebuilder/CRD printer columns cannot concatenate JSONPath values directly, the implementation should introduce + a small status field owned by the controller for this rendered target string rather than splitting it into `Target` + and `TargetNS`. + +## Sample And Template Alignment + +- Regenerate CRDs with `make manifests` so the base CRD YAML matches the corrected annotations. +- Review `test/e2e/templates/gitprovider.tmpl` for demo and e2e consistency with the new `GitProvider` operator view; + no schema change is needed there, but it should remain a clear example of the `spec.url` field that will now be + visible in `kubectl get`. +- Review `config/samples/clusterwatchrule.yaml` and keep it aligned with the current `targetRef` naming; no schema + change is needed, but the sample should remain the canonical example for the fields shown in the new columns. +- Optionally, if the current samples/tests rely on older human wording like `destination`, normalize helper text and + fixture naming toward `target` where it improves clarity without broad churn. + +## Test Plan + +- Add or update lightweight API/CRD assertions that verify generated CRDs contain the intended + `additionalPrinterColumns` for `GitProvider`, `GitTarget`, `WatchRule`, and `ClusterWatchRule`. +- Add or update controller-focused tests only if needed to keep the `Ready` condition messages concise and predictable + enough for the new `Status` column to be useful. +- Run `make manifests` and verify the resulting CRD YAML shows the corrected JSONPaths and column names. +- Run repo validation in the project-required order after implementation: + - `make fmt` + - `make generate` if the annotation/codegen path requires it + - `make manifests` + - `make vet` + - `make lint` + - `make test` + - `docker info` before e2e + - `make test-e2e` + - `make test-e2e-quickstart-manifest` + - `make test-e2e-quickstart-helm` + +## Assumptions + +- Legacy labels and legacy field names are intentionally not preserved. +- "Status on all resources" means a consistent `Ready` printer column on all relevant CRDs, not adding new condition + types, plus a `Status` printer column sourced from the `Ready` condition message. +- The preferred user-facing labels are current-API labels: `URL`, `Provider`, `Branch`, `Path`, `Target`, `Ready`, + `Status`, `Age`. +- `ClusterWatchRule` should expose one `Target` column whose displayed value is `namespace/name`. +- No new persisted resource-count field will be designed in this pass unless implementation uncovers a genuinely + trivial, semantically correct option. diff --git a/docs/design/e2e-encrypted-secret-situation-sketch.md b/docs/design/done/e2e-encrypted-secret-situation-sketch.md similarity index 100% rename from docs/design/e2e-encrypted-secret-situation-sketch.md rename to docs/design/done/e2e-encrypted-secret-situation-sketch.md From 1363d0b7b520a628bf2cb71126f0eaa26cf18e6d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 21 Mar 2026 10:35:26 +0000 Subject: [PATCH 25/30] chore: Adding more workers and tweaking the printcolumns --- api/v1alpha1/clusterwatchrule_types.go | 2 +- api/v1alpha1/gitprovider_types.go | 2 +- api/v1alpha1/gittarget_types.go | 2 +- api/v1alpha1/watchrule_types.go | 2 +- config/crd/bases/configbutler.ai_clusterwatchrules.yaml | 1 - config/crd/bases/configbutler.ai_gitproviders.yaml | 1 - config/crd/bases/configbutler.ai_gittargets.yaml | 1 - config/crd/bases/configbutler.ai_watchrules.yaml | 1 - test/e2e/cluster/start-cluster.sh | 2 ++ 9 files changed, 6 insertions(+), 8 deletions(-) diff --git a/api/v1alpha1/clusterwatchrule_types.go b/api/v1alpha1/clusterwatchrule_types.go index 5476be43b..e8a180632 100644 --- a/api/v1alpha1/clusterwatchrule_types.go +++ b/api/v1alpha1/clusterwatchrule_types.go @@ -135,7 +135,7 @@ type ClusterWatchRuleStatus struct { // +kubebuilder:resource:scope=Cluster // +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` -// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // ClusterWatchRule watches resources across the entire cluster. diff --git a/api/v1alpha1/gitprovider_types.go b/api/v1alpha1/gitprovider_types.go index 542e3696a..61969eabd 100644 --- a/api/v1alpha1/gitprovider_types.go +++ b/api/v1alpha1/gitprovider_types.go @@ -123,7 +123,7 @@ type GitProviderStatus struct { // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.spec.url` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` -// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // GitProvider is the Schema for the gitproviders API. diff --git a/api/v1alpha1/gittarget_types.go b/api/v1alpha1/gittarget_types.go index 814012269..9f11e632f 100644 --- a/api/v1alpha1/gittarget_types.go +++ b/api/v1alpha1/gittarget_types.go @@ -122,7 +122,7 @@ type GitTargetSnapshotStats struct { // +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch` // +kubebuilder:printcolumn:name="Path",type=string,JSONPath=`.spec.path` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` -// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // GitTarget is the Schema for the gittargets API. diff --git a/api/v1alpha1/watchrule_types.go b/api/v1alpha1/watchrule_types.go index 4908e185e..238fa632e 100644 --- a/api/v1alpha1/watchrule_types.go +++ b/api/v1alpha1/watchrule_types.go @@ -132,7 +132,7 @@ type WatchRuleStatus struct { // +kubebuilder:resource:scope=Namespaced // +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` -// +kubebuilder:printcolumn:name="Status",type=string,priority=1,JSONPath=`.status.conditions[?(@.type=="Ready")].message` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // WatchRule watches namespaced resources within its own namespace. diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index 91f1e33e6..61ea6a445 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -23,7 +23,6 @@ spec: type: string - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status - priority: 1 type: string - jsonPath: .metadata.creationTimestamp name: Age diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index ddfdf85ee..f37c97510 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -23,7 +23,6 @@ spec: type: string - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status - priority: 1 type: string - jsonPath: .metadata.creationTimestamp name: Age diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index d9d895b8c..dd5d8c8ea 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -29,7 +29,6 @@ spec: type: string - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status - priority: 1 type: string - jsonPath: .metadata.creationTimestamp name: Age diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 8f9b1a1b0..52e7096bf 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -23,7 +23,6 @@ spec: type: string - jsonPath: .status.conditions[?(@.type=="Ready")].message name: Status - priority: 1 type: string - jsonPath: .metadata.creationTimestamp name: Age diff --git a/test/e2e/cluster/start-cluster.sh b/test/e2e/cluster/start-cluster.sh index 014f0bd90..4e75cd6cb 100644 --- a/test/e2e/cluster/start-cluster.sh +++ b/test/e2e/cluster/start-cluster.sh @@ -158,6 +158,8 @@ create_cluster() { local create_cmd=( k3d cluster create "${CLUSTER_NAME}" --image rancher/k3s:v1.35.2-k3s1 + --servers 1 + --agents 3 --api-port 6550 --kubeconfig-update-default --kubeconfig-switch-context From a210541bc467cb1536ffb5e29cb1d61e553356c0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 21 Mar 2026 20:08:25 +0000 Subject: [PATCH 26/30] chore: various improvements shorter requeue time for missing secrets (2 minutes) no specific name in spec for KRO instancanes (they get a k8s one already!). Add specifc labels and annotations for KRO resources (for demo). Syncing - hopefully - the right way now to have a pull before the creating a branch. --- Makefile | 2 + internal/controller/gitprovider_controller.go | 2 +- .../controller/gitprovider_controller_test.go | 2 +- internal/git/branch_worker.go | 18 ++- internal/git/branch_worker_test.go | 103 ++++++++++++++++++ internal/sanitize/types.go | 7 +- podinfo-app-demo.yaml | 1 - .../setup/demo-only/podinfo-kro-template.yaml | 23 ++-- .../podinfos-base/3-example-instance.yaml | 1 - .../podinfos-base/kustomization.yaml | 2 +- .../demo-only/podinfos-gitops/1-gitops.yaml | 3 +- .../podinfos-intent/kustomization.yaml | 7 +- .../podinfos-preview/kustomization.yaml | 14 ++- 13 files changed, 159 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index b826cd403..b70d34fa1 100644 --- a/Makefile +++ b/Makefile @@ -587,6 +587,8 @@ test-e2e-quickstart-helm: ## Run quickstart smoke test (Helm install) .PHONY: test-e2e-demo test-e2e-demo: prepare-e2e-demo ## Prepare a reusable demo repo and leave demo resources in place export CTX=$(CTX) + export REPO_NAME=demo + rm -rf .stamps/repos/$$REPO_NAME export INSTALL_MODE=$(INSTALL_MODE) export NAMESPACE=$(NAMESPACE) export E2E_ENABLE_DEMO=true diff --git a/internal/controller/gitprovider_controller.go b/internal/controller/gitprovider_controller.go index c33ead2fd..663fddfde 100644 --- a/internal/controller/gitprovider_controller.go +++ b/internal/controller/gitprovider_controller.go @@ -161,7 +161,7 @@ func (r *GitProviderReconciler) getAuthFromSecret( secretName := gitProvider.Spec.SecretRef.Name r.setCondition(gitProvider, metav1.ConditionFalse, ReasonSecretMalformed, fmt.Sprintf("Secret '%s' malformed: %v", secretName, err)) - result, _ := r.updateStatusAndRequeue(ctx, gitProvider, RequeueMediumInterval) + result, _ := r.updateStatusAndRequeue(ctx, gitProvider, RequeueShortInterval) return nil, result, true } diff --git a/internal/controller/gitprovider_controller_test.go b/internal/controller/gitprovider_controller_test.go index 841378417..1224ff6a7 100644 --- a/internal/controller/gitprovider_controller_test.go +++ b/internal/controller/gitprovider_controller_test.go @@ -363,7 +363,7 @@ var _ = Describe("GitProvider Controller", func() { }) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(time.Minute * 5)) + Expect(result.RequeueAfter).To(Equal(time.Minute * 2)) // Verify the resource was updated with failure condition updatedProvider := &configbutleraiv1alpha1.GitProvider{} diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index 999a7d8e1..da285f8d3 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -525,6 +525,12 @@ func (w *BranchWorker) commitAndPushRequest(request *WriteRequest) { } repoPath := w.repoPathForRemote(provider.Spec.URL) + pullReport, err := PrepareBranch(w.ctx, provider.Spec.URL, repoPath, w.Branch, auth) + if err != nil { + log.Error(err, "Failed to prepare repository") + return + } + w.updateBranchMetadataFromPullReport(pullReport) preparedRequest, encryptionConfig, err := w.prepareWriteRequest(w.ctx, request) if err != nil { @@ -556,9 +562,15 @@ func (w *BranchWorker) commitAndPushRequest(request *WriteRequest) { } // Metrics - telemetry.GitOperationsTotal.Add(w.ctx, int64(len(preparedRequest.Events))) - telemetry.CommitsTotal.Add(w.ctx, 1) - telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(preparedRequest.Events))) + if telemetry.GitOperationsTotal != nil { + telemetry.GitOperationsTotal.Add(w.ctx, int64(len(preparedRequest.Events))) + } + if telemetry.CommitsTotal != nil { + telemetry.CommitsTotal.Add(w.ctx, 1) + } + if telemetry.ObjectsWrittenTotal != nil { + telemetry.ObjectsWrittenTotal.Add(w.ctx, int64(len(preparedRequest.Events))) + } } // handleShutdown finalizes processing when context is canceled. diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index 0db0d067c..6dd2f6f60 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -23,21 +23,25 @@ import ( "os" "path/filepath" "testing" + "time" "filippo.io/age" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) func setupBranchWorkerTest() (*BranchWorker, func()) { @@ -634,6 +638,105 @@ func TestBranchWorker_EnsurePathBootstrapped_RendersAllResolvedRecipients(t *tes assert.Contains(t, string(sopsConfig), publicOnlyIdentity.Recipient().String()) } +func TestBranchWorker_CommitAndPushRequest_PreparesRepositoryBeforeFirstWrite(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + serverRepo := createBareRepo(t, remotePath) + + seedPath := filepath.Join(tempDir, "seed") + seedRepo, seedWorktree := initLocalRepo(t, seedPath, remoteURL, "main") + require.NoError(t, os.WriteFile(filepath.Join(seedPath, "README.md"), []byte("seed\n"), 0o600)) + _, err := seedWorktree.Add("README.md") + require.NoError(t, err) + _, err = seedWorktree.Commit("seed remote", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}, + }) + require.NoError(t, err) + require.NoError(t, seedRepo.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("refs/heads/main:refs/heads/main"), + }, + })) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "main", nil) + worker.ctx = ctx + + request := &WriteRequest{ + Events: []Event{ + { + Operation: "CREATE", + Identifier: itypes.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "default", + Name: "example", + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "example", + "namespace": "default", + }, + "data": map[string]interface{}{ + "key": "value", + }, + }, + }, + UserInfo: UserInfo{Username: "tester"}, + Path: "clusters/dev", + }, + }, + CommitMode: CommitModePerEvent, + } + + worker.commitAndPushRequest(request) + + localRepoPath := worker.repoPathForRemote(remoteURL) + localRepo, err := git.PlainOpen(localRepoPath) + require.NoError(t, err) + + localHeadRef, err := localRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + + remoteHeadRef, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + assert.Equal(t, remoteHeadRef.Hash(), localHeadRef.Hash(), "local checkout should have been prepared and pushed") + + readmeContent, err := os.ReadFile(filepath.Join(localRepoPath, "README.md")) + require.NoError(t, err) + assert.Equal( + t, + "seed\n", + string(readmeContent), + "worker should prepare by pulling remote content before first write", + ) + + manifestPath := filepath.Join(localRepoPath, "clusters/dev", "v1", "configmaps", "default", "example.yaml") + content, err := os.ReadFile(manifestPath) + require.NoError(t, err) + assert.Contains(t, string(content), "name: example") + assert.Contains(t, string(content), "key: value") +} + func createTargetWithEncryption( ctx context.Context, t *testing.T, diff --git a/internal/sanitize/types.go b/internal/sanitize/types.go index f707f90f6..204a25176 100644 --- a/internal/sanitize/types.go +++ b/internal/sanitize/types.go @@ -83,7 +83,9 @@ func cleanAnnotations(annotations map[string]string) map[string]string { } func isOperationalLabel(key string) bool { - return strings.HasPrefix(key, "kustomize.toolkit.fluxcd.io/") + return strings.HasPrefix(key, "kustomize.toolkit.fluxcd.io/") || + strings.HasPrefix(key, "kro.run/") || + strings.HasPrefix(key, "applyset.kubernetes.io/") } func isOperationalAnnotation(key string) bool { @@ -91,5 +93,6 @@ func isOperationalAnnotation(key string) bool { strings.HasPrefix(key, "control-plane.alpha.kubernetes.io/") || strings.HasPrefix(key, "deployment.kubernetes.io/") || strings.HasPrefix(key, "autoscaling.alpha.kubernetes.io/") || - strings.HasPrefix(key, "kustomize.toolkit.fluxcd.io/") + strings.HasPrefix(key, "kustomize.toolkit.fluxcd.io/") || + strings.HasPrefix(key, "applyset.kubernetes.io/") } diff --git a/podinfo-app-demo.yaml b/podinfo-app-demo.yaml index 92c557c9e..7ce228f0a 100644 --- a/podinfo-app-demo.yaml +++ b/podinfo-app-demo.yaml @@ -5,6 +5,5 @@ metadata: namespace: podinfos-intent spec: replicas: 1 - name: fiets message: The Dutch word for bicycle is "fiets". We have a lot of them. 🚲 color: "#115711" diff --git a/test/e2e/setup/demo-only/podinfo-kro-template.yaml b/test/e2e/setup/demo-only/podinfo-kro-template.yaml index d93f7d66f..ea5b80c3c 100644 --- a/test/e2e/setup/demo-only/podinfo-kro-template.yaml +++ b/test/e2e/setup/demo-only/podinfo-kro-template.yaml @@ -7,7 +7,6 @@ spec: apiVersion: v1alpha1 kind: PodInfoApp spec: - name: string | required=true message: string | default="hello from kro" color: string | default="#34577c" replicas: integer | default=1 minimum=1 maximum=1 description="Number of replicas β€” keep at 1 during demo to avoid exhausting cluster resources" @@ -33,20 +32,20 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: ${schema.spec.name} + name: ${schema.metadata.name} namespace: ${schema.metadata.namespace} labels: - app.kubernetes.io/name: ${schema.spec.name} + app.kubernetes.io/name: ${schema.metadata.name} configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: replicas: ${schema.spec.replicas} selector: matchLabels: - app.kubernetes.io/name: ${schema.spec.name} + app.kubernetes.io/name: ${schema.metadata.name} template: metadata: labels: - app.kubernetes.io/name: ${schema.spec.name} + app.kubernetes.io/name: ${schema.metadata.name} configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: containers: @@ -85,14 +84,14 @@ spec: apiVersion: v1 kind: Service metadata: - name: ${schema.spec.name} + name: ${schema.metadata.name} namespace: ${schema.metadata.namespace} labels: - app.kubernetes.io/name: ${schema.spec.name} + app.kubernetes.io/name: ${schema.metadata.name} configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: selector: - app.kubernetes.io/name: ${schema.spec.name} + app.kubernetes.io/name: ${schema.metadata.name} ports: - name: http port: 80 @@ -102,19 +101,19 @@ spec: apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: ${schema.spec.name} + name: ${schema.metadata.name} namespace: ${schema.metadata.namespace} labels: - app.kubernetes.io/name: ${schema.spec.name} + app.kubernetes.io/name: ${schema.metadata.name} configbutler.ai/path-prefix: ${gateway.metadata.labels["configbutler.ai/path-prefix"]} spec: parentRefs: - name: podinfos-gateway routes: - - match: PathPrefix(`/${schema.spec.name}`) + - match: PathPrefix(`/${schema.metadata.name}`) kind: Rule middlewares: - name: strip-appname services: - - name: ${schema.spec.name} + - name: ${schema.metadata.name} port: 80 diff --git a/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml b/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml index bd62cf2ca..a4a5ae923 100644 --- a/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml @@ -4,6 +4,5 @@ metadata: name: fiets spec: replicas: 1 - name: fiets message: The Dutch word for bicycle is "fiets". We have a lot of them. color: "#115711" diff --git a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml index 8f8b8b737..ddcebcbd6 100644 --- a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml @@ -4,5 +4,5 @@ kind: Kustomization resources: - 1-ingress-specific.yaml - 2-middlewares.yaml - - 3-example-instance.yaml + #- 3-example-instance.yaml - ../podinfos-gitops diff --git a/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml index b13164930..50b85a588 100644 --- a/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml @@ -20,7 +20,8 @@ metadata: spec: interval: 24h # In practice I feel that we should turn it off, and then only request a sync when something is changed timeout: 5m - path: ./podinfos + path: .podinfos/kro.run/v1alpha1/podinfoapps/podinfos-intent/ + targetNamespace: podinfos-production prune: true sourceRef: kind: GitRepository diff --git a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml index 018cd2fc5..754ae9b50 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml @@ -5,7 +5,7 @@ namespace: podinfos-intent resources: - 0-ns.yaml - - 1-example-instance.yaml + #- 1-example-instance.yaml - ../podinfos-gitops - 2-reverse-gitops.yaml @@ -27,4 +27,7 @@ patches: patch : |- - op: replace path: /spec/suspend - value: true \ No newline at end of file + value: true + - op: replace + path: /spec/targetNamespace + value: podinfos-intent diff --git a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml index 8f8f82ce1..2d824f2cb 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml @@ -17,4 +17,16 @@ patches: patch: |- - op: replace path: /spec/ref/branch - value: monster \ No newline at end of file + value: monster + - target: # Turn the normal GitOps off for now -> We can enable it for the demo, but it will create race conditions otherwise... + group: kustomize.toolkit.fluxcd.io + version: v1 + kind: Kustomization + name: demo + patch : |- + - op: replace + path: /spec/suspend + value: true + - op: replace + path: /spec/targetNamespace + value: podinfos-preview \ No newline at end of file From 7708e371a103ddf97c6b9c47493034fee8013ef0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 21 Mar 2026 20:10:15 +0000 Subject: [PATCH 27/30] chore: other tweaks extra test to check support creation of branch only at the last moment (including a check before the branch is created). Fixing small mistakes in flux sync Getting gitea to english only --- internal/git/branch_worker_test.go | 118 ++++++++++++++++++ .../demo-only/podinfos-gitops/1-gitops.yaml | 3 +- .../podinfos-preview/kustomization.yaml | 3 - test/e2e/setup/flux/values/gitea-values.yaml | 3 + 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index 6dd2f6f60..d358e2c92 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -737,6 +737,124 @@ func TestBranchWorker_CommitAndPushRequest_PreparesRepositoryBeforeFirstWrite(t assert.Contains(t, string(content), "key: value") } +func TestBranchWorker_CommitAndPushRequest_NewBranchStartsFromLatestMain(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + remotePath := filepath.Join(tempDir, "remote.git") + remoteURL := "file://" + remotePath + serverRepo := createBareRepo(t, remotePath) + + seedPath := filepath.Join(tempDir, "seed") + seedRepo, seedWorktree := initLocalRepo(t, seedPath, remoteURL, "main") + hashA := commitFileChange(t, seedWorktree, seedPath, "README.md", "v1\n") + require.NoError(t, seedRepo.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("refs/heads/main:refs/heads/main"), + }, + })) + + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = configv1alpha1.AddToScheme(scheme) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + provider := &configv1alpha1.GitProvider{ + Spec: configv1alpha1.GitProviderSpec{ + URL: remoteURL, + }, + } + provider.Name = "test-repo" + provider.Namespace = "default" + require.NoError(t, k8sClient.Create(ctx, provider)) + + worker := NewBranchWorker(k8sClient, logr.Discard(), "test-repo", "default", "feature", nil) + worker.ctx = ctx + + // Pre-create a stale local checkout while remote main is still at commit A. + staleRepoPath := worker.repoPathForRemote(remoteURL) + staleReport, err := PrepareBranch(ctx, remoteURL, staleRepoPath, worker.Branch, nil) + require.NoError(t, err) + require.Equal(t, hashA.String(), staleReport.HEAD.Sha) + + // Advance remote main to commit B after the local checkout is stale. + hashB := commitFileChange(t, seedWorktree, seedPath, "LATEST.md", "from-main-b\n") + require.NoError(t, seedRepo.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec("refs/heads/main:refs/heads/main"), + }, + })) + + request := &WriteRequest{ + Events: []Event{ + { + Operation: "CREATE", + Identifier: itypes.ResourceIdentifier{ + Group: "", + Version: "v1", + Resource: "configmaps", + Namespace: "default", + Name: "example-feature", + }, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "example-feature", + "namespace": "default", + }, + "data": map[string]interface{}{ + "key": "value", + }, + }, + }, + UserInfo: UserInfo{Username: "tester"}, + Path: "clusters/dev", + }, + }, + CommitMode: CommitModePerEvent, + } + + worker.commitAndPushRequest(request) + + remoteMainRef, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true) + require.NoError(t, err) + assert.Equal(t, hashB, remoteMainRef.Hash(), "remote main should remain at the latest commit") + + remoteFeatureRef, err := serverRepo.Reference(plumbing.NewBranchReferenceName("feature"), true) + require.NoError(t, err) + + featureCommit, err := serverRepo.CommitObject(remoteFeatureRef.Hash()) + require.NoError(t, err) + require.Len(t, featureCommit.ParentHashes, 1, "new feature branch commit should be based on main plus one commit") + assert.Equal(t, hashB, featureCommit.ParentHashes[0], "feature branch should start from latest main commit") + + localRepo, err := git.PlainOpen(staleRepoPath) + require.NoError(t, err) + localFeatureRef, err := localRepo.Reference(plumbing.NewBranchReferenceName("feature"), true) + require.NoError(t, err) + assert.Equal(t, remoteFeatureRef.Hash(), localFeatureRef.Hash(), "local and remote feature branches should match") + + featureCheckoutPath := filepath.Join(tempDir, "verify-feature") + _, _ = initLocalRepo(t, featureCheckoutPath, remoteURL, "feature") + + latestMainContent, err := os.ReadFile(filepath.Join(featureCheckoutPath, "LATEST.md")) + require.NoError(t, err) + assert.Equal(t, "from-main-b\n", string(latestMainContent), "feature branch should include latest main content") + + manifestPath := filepath.Join( + featureCheckoutPath, + "clusters/dev", + "v1", + "configmaps", + "default", + "example-feature.yaml", + ) + manifestContent, err := os.ReadFile(manifestPath) + require.NoError(t, err) + assert.Contains(t, string(manifestContent), "name: example-feature") +} + func createTargetWithEncryption( ctx context.Context, t *testing.T, diff --git a/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml index 50b85a588..98e8b47d1 100644 --- a/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml @@ -20,9 +20,10 @@ metadata: spec: interval: 24h # In practice I feel that we should turn it off, and then only request a sync when something is changed timeout: 5m - path: .podinfos/kro.run/v1alpha1/podinfoapps/podinfos-intent/ + path: /podinfos/kro.run/v1alpha1/podinfoapps/podinfos-intent/ targetNamespace: podinfos-production prune: true + suspend: false sourceRef: kind: GitRepository name: demo diff --git a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml index 2d824f2cb..18d3478dc 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml @@ -24,9 +24,6 @@ patches: kind: Kustomization name: demo patch : |- - - op: replace - path: /spec/suspend - value: true - op: replace path: /spec/targetNamespace value: podinfos-preview \ No newline at end of file diff --git a/test/e2e/setup/flux/values/gitea-values.yaml b/test/e2e/setup/flux/values/gitea-values.yaml index 7afb2b7e2..c4d2f6e1d 100644 --- a/test/e2e/setup/flux/values/gitea-values.yaml +++ b/test/e2e/setup/flux/values/gitea-values.yaml @@ -36,6 +36,9 @@ gitea: DEFAULT_KEEP_EMAIL_PRIVATE: true repository: DEFAULT_BRANCH: main + i18n: + LANGS: en-US + NAMES: English indexer: ISSUE_INDEXER_TYPE: bleve REPO_INDEXER_ENABLED: false From 7f13c656fccddca39539f7b1208b2b95bc16e7a6 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 22 Mar 2026 23:05:21 +0000 Subject: [PATCH 28/30] chore: finalize for demo --- .claude/settings.local.json | 2 +- podinfo-app-demo.yaml | 9 -- test/e2e/setup/demo-only/README.md | 6 ++ .../demo-only/examples/podinfos-examples.yaml | 32 +++++++ .../setup/demo-only/podinfo-kro-template.yaml | 3 + .../podinfos-base/2-middlewares.yaml | 4 +- .../podinfos-base/3-example-instance.yaml | 8 -- .../podinfos-base/kustomization.yaml | 1 - .../demo-only/podinfos-gitops/1-gitops.yaml | 4 +- .../podinfos-intent/1-example-instance.yaml | 9 -- .../podinfos-intent/2-reverse-gitops.yaml | 10 +-- .../podinfos-intent/kustomization.yaml | 5 +- .../podinfos-preview/kustomization.yaml | 4 +- .../podinfos-production/1-assets.yaml | 73 +++++++++++++++ .../assets/podinfos-begijnhof.jpg | Bin 0 -> 124515 bytes .../assets/podinfos-fiets.jpg | Bin 0 -> 82745 bytes .../assets/podinfos-xxx.jpg | Bin 0 -> 97275 bytes .../podinfos-production/kustomization.yaml | 15 ++++ .../submission.yaml} | 16 ++-- .../vote/examples/quizsession-2.yaml | 18 ---- .../examples/quizsession-kubecon-2026.yaml | 40 --------- .../setup/demo-only/vote/kustomization.yaml | 6 +- .../setup/demo-only/vote/sessions/demo.yaml | 48 ++++++++++ .../demo-only/vote/sessions/feedback.yaml | 69 +++++++++++++++ test/loadtest/main.go | 83 ++++++------------ 25 files changed, 295 insertions(+), 170 deletions(-) delete mode 100644 podinfo-app-demo.yaml create mode 100644 test/e2e/setup/demo-only/examples/podinfos-examples.yaml delete mode 100644 test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml delete mode 100644 test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-production/1-assets.yaml create mode 100644 test/e2e/setup/demo-only/podinfos-production/assets/podinfos-begijnhof.jpg create mode 100644 test/e2e/setup/demo-only/podinfos-production/assets/podinfos-fiets.jpg create mode 100644 test/e2e/setup/demo-only/podinfos-production/assets/podinfos-xxx.jpg rename test/e2e/setup/demo-only/vote/{examples/quizsubmission-kubecon-2026.yaml => example/submission.yaml} (53%) delete mode 100644 test/e2e/setup/demo-only/vote/examples/quizsession-2.yaml delete mode 100644 test/e2e/setup/demo-only/vote/examples/quizsession-kubecon-2026.yaml create mode 100644 test/e2e/setup/demo-only/vote/sessions/demo.yaml create mode 100644 test/e2e/setup/demo-only/vote/sessions/feedback.yaml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 003f78fff..932ba050f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,7 @@ "Bash(go build:*)", "Bash(kubectl apply:*)", "Bash(kubectl wait:*)", - "Bash(chmod +x /workspaces/gitops-reverser/hack/demo/setup-monster-deployment.sh)" + "Bash(chmod +x /workspaces/gitops-reverser/hack/demo/setup-holland-stuff-deployment.sh)" ] } } diff --git a/podinfo-app-demo.yaml b/podinfo-app-demo.yaml deleted file mode 100644 index 7ce228f0a..000000000 --- a/podinfo-app-demo.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: kro.run/v1alpha1 -kind: PodInfoApp -metadata: - name: fiets - namespace: podinfos-intent -spec: - replicas: 1 - message: The Dutch word for bicycle is "fiets". We have a lot of them. 🚲 - color: "#115711" diff --git a/test/e2e/setup/demo-only/README.md b/test/e2e/setup/demo-only/README.md index 186a1f845..ade3fd1dc 100644 --- a/test/e2e/setup/demo-only/README.md +++ b/test/e2e/setup/demo-only/README.md @@ -7,3 +7,9 @@ REPO_NAME=demo make test-e2e-demo Which will prepare all resources in the vote namespace +The podinfo demo images are served from `https://demo.configbutler.ai/podinfo-assets/` by the +`podinfos-production` overlay so both preview and production can reuse the same public URLs. + +https://demo.configbutler.ai/podinfo-assets/podinfos-begijnhof.jpg +https://demo.configbutler.ai/podinfo-assets/podinfos-fiets.jpg +https://demo.configbutler.ai/podinfo-assets/podinfos-xxx.jpg \ No newline at end of file diff --git a/test/e2e/setup/demo-only/examples/podinfos-examples.yaml b/test/e2e/setup/demo-only/examples/podinfos-examples.yaml new file mode 100644 index 000000000..ae025840f --- /dev/null +++ b/test/e2e/setup/demo-only/examples/podinfos-examples.yaml @@ -0,0 +1,32 @@ +apiVersion: kro.run/v1alpha1 +kind: PodInfoApp +metadata: + name: fiets + namespace: podinfos-intent +spec: + replicas: 1 + message: The Dutch word for bicycle is "fiets". We have a lot of them. 🚲 + color: "#115711" + logoURL: https://demo.configbutler.ai/podinfo-assets/podinfos-fiets.jpg +--- +apiVersion: kro.run/v1alpha1 +kind: PodInfoApp +metadata: + name: gevel + namespace: podinfos-intent +spec: + replicas: 1 + message: Amsterdam is full of beautiful narrow facades, called "gevels". 🏠 + color: "#8B5E3C" + logoURL: https://demo.configbutler.ai/podinfo-assets/podinfos-begijnhof.jpg +--- +apiVersion: kro.run/v1alpha1 +kind: PodInfoApp +metadata: + name: paal + namespace: podinfos-intent +spec: + replicas: 1 + message: These iconic Amsterdam bollards are called "Amsterdammertjes". They are everywhere. 🚧 + color: "#C1272D" + logoURL: https://demo.configbutler.ai/podinfo-assets/podinfos-xxx.jpg diff --git a/test/e2e/setup/demo-only/podinfo-kro-template.yaml b/test/e2e/setup/demo-only/podinfo-kro-template.yaml index ea5b80c3c..d9c58c141 100644 --- a/test/e2e/setup/demo-only/podinfo-kro-template.yaml +++ b/test/e2e/setup/demo-only/podinfo-kro-template.yaml @@ -9,6 +9,7 @@ spec: spec: message: string | default="hello from kro" color: string | default="#34577c" + logoURL: string | default="" replicas: integer | default=1 minimum=1 maximum=1 description="Number of replicas β€” keep at 1 during demo to avoid exhausting cluster resources" status: availableReplicas: ${deployment.status.availableReplicas} @@ -57,6 +58,8 @@ spec: value: ${schema.spec.message} - name: PODINFO_UI_COLOR value: ${schema.spec.color} + - name: PODINFO_UI_LOGO + value: ${schema.spec.logoURL} ports: - name: http containerPort: 9898 diff --git a/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml b/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml index 793027ef0..a07529860 100644 --- a/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/2-middlewares.yaml @@ -4,8 +4,8 @@ metadata: name: ratelimit spec: rateLimit: - average: 10 - burst: 5 + average: 200 + burst: 300 --- apiVersion: traefik.io/v1alpha1 kind: Middleware diff --git a/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml b/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml deleted file mode 100644 index a4a5ae923..000000000 --- a/test/e2e/setup/demo-only/podinfos-base/3-example-instance.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kro.run/v1alpha1 -kind: PodInfoApp -metadata: - name: fiets -spec: - replicas: 1 - message: The Dutch word for bicycle is "fiets". We have a lot of them. - color: "#115711" diff --git a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml index ddcebcbd6..2fe157a22 100644 --- a/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-base/kustomization.yaml @@ -4,5 +4,4 @@ kind: Kustomization resources: - 1-ingress-specific.yaml - 2-middlewares.yaml - #- 3-example-instance.yaml - ../podinfos-gitops diff --git a/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml index 98e8b47d1..6c485f930 100644 --- a/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-gitops/1-gitops.yaml @@ -16,11 +16,11 @@ spec: apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization metadata: - name: demo + name: demo-podinfos spec: interval: 24h # In practice I feel that we should turn it off, and then only request a sync when something is changed timeout: 5m - path: /podinfos/kro.run/v1alpha1/podinfoapps/podinfos-intent/ + path: /podinfos/kro.run/v1alpha1/podinfoapps/podinfos-intent/ # In the current setup this is how you do it, every environment looks at intent targetNamespace: podinfos-production prune: true suspend: false diff --git a/test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml b/test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml deleted file mode 100644 index bd62cf2ca..000000000 --- a/test/e2e/setup/demo-only/podinfos-intent/1-example-instance.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: kro.run/v1alpha1 -kind: PodInfoApp -metadata: - name: fiets -spec: - replicas: 1 - name: fiets - message: The Dutch word for bicycle is "fiets". We have a lot of them. - color: "#115711" diff --git a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index a269be51f..477aca2a2 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -6,7 +6,7 @@ spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git allowedBranches: - main - - monster + - holland-stuff push: interval: "5s" maxCommits: 30 @@ -16,22 +16,22 @@ spec: apiVersion: configbutler.ai/v1alpha1 kind: GitTarget metadata: - name: demo + name: demo-podinfos spec: providerRef: kind: GitProvider name: demo - branch: monster + branch: holland-stuff path: podinfos --- apiVersion: configbutler.ai/v1alpha1 kind: WatchRule metadata: - name: demo + name: demo-podinfos spec: targetRef: kind: GitTarget - name: demo + name: demo-podinfos rules: - operations: [CREATE, UPDATE, DELETE] apiGroups: ["kro.run"] diff --git a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml index 754ae9b50..9f560c584 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/kustomization.yaml @@ -5,7 +5,6 @@ namespace: podinfos-intent resources: - 0-ns.yaml - #- 1-example-instance.yaml - ../podinfos-gitops - 2-reverse-gitops.yaml @@ -18,12 +17,12 @@ patches: patch: |- - op: replace path: /spec/ref/branch - value: monster + value: holland-stuff - target: # Turn the normal GitOps off for now -> We can enable it for the demo, but it will create race conditions otherwise... group: kustomize.toolkit.fluxcd.io version: v1 kind: Kustomization - name: demo + name: demo-podinfos patch : |- - op: replace path: /spec/suspend diff --git a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml index 18d3478dc..067ad89e5 100644 --- a/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml +++ b/test/e2e/setup/demo-only/podinfos-preview/kustomization.yaml @@ -17,12 +17,12 @@ patches: patch: |- - op: replace path: /spec/ref/branch - value: monster + value: holland-stuff - target: # Turn the normal GitOps off for now -> We can enable it for the demo, but it will create race conditions otherwise... group: kustomize.toolkit.fluxcd.io version: v1 kind: Kustomization - name: demo + name: demo-podinfos patch : |- - op: replace path: /spec/targetNamespace diff --git a/test/e2e/setup/demo-only/podinfos-production/1-assets.yaml b/test/e2e/setup/demo-only/podinfos-production/1-assets.yaml new file mode 100644 index 000000000..fa436098d --- /dev/null +++ b/test/e2e/setup/demo-only/podinfos-production/1-assets.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: podinfos-assets +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: podinfos-assets + template: + metadata: + labels: + app.kubernetes.io/name: podinfos-assets + spec: + containers: + - name: nginx + image: nginx:1.29.1-alpine + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + volumeMounts: + - name: assets + mountPath: /usr/share/nginx/html + readOnly: true + volumes: + - name: assets + projected: + sources: + - configMap: + name: podinfos-asset-begijnhof + - configMap: + name: podinfos-asset-fiets + - configMap: + name: podinfos-asset-xxx +--- +apiVersion: v1 +kind: Service +metadata: + name: podinfos-assets +spec: + selector: + app.kubernetes.io/name: podinfos-assets + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-podinfo-assets-prefix +spec: + stripPrefix: + prefixes: + - /podinfo-assets +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: podinfos-assets +spec: + entryPoints: + - web + routes: + - match: Host(`demo.configbutler.ai`) && PathPrefix(`/podinfo-assets/`) + kind: Rule + middlewares: + - name: ratelimit + - name: strip-podinfo-assets-prefix + services: + - name: podinfos-assets + port: 80 diff --git a/test/e2e/setup/demo-only/podinfos-production/assets/podinfos-begijnhof.jpg b/test/e2e/setup/demo-only/podinfos-production/assets/podinfos-begijnhof.jpg new file mode 100644 index 0000000000000000000000000000000000000000..527351030766b369dd5a4ae58cf4716c1c1d4093 GIT binary patch literal 124515 zcmbTdbyOQ|^fj8`PNBhFibJ8eLtDJKTcLQM!QG`uLvW`!1Zj~{T#JR!QlPlI6(?A+ z!p-};cYSxQ``7)xNwSianP=9sXV#pv&p!KkoO@gYJW*FxQw9KmK!7Ux1$bNpyaHfi zVEp$+Z&>IzHXb%M78W)EE-nrp5djeqAps#FF$wt-ViGbELc%AMPspBvC@3h1NU5kP zLDb|R3ebNi0b-)ZU}589W8;H}35h}f+uLIgfD8{fj%kDmWCdW50Wrydj{^WE004-M zp6!1V{(m+g26~P-xOn&kgy;jBo&Yd_n3xz?nE%ZheRK$VKLCpio18^R5$CDyJ6u)| z(2IzaVm!8&^}Q5&eTa^Ujr#~qO-s+n%*xKmEh#O7mseC)eg4+a*woz8 z+ScCJKY$oS4*mE!F*!9oGdnlGu)eXmwY{_ZXKx>Mc7Abr_4oSb_CH)e0OtP<>wh8p z|KK7+tx1kbD3r(D}_>Et+14Oe4n#EZR| zx3lzUxmaeJWmLI*xnZ};F*+*rb2(pa?P(Gf zQ(`ayDS3{p(q85M4KHH{7#ST4wQ`zeUT~S#z)wJ*;)+AG8Das4h zEX+*mF9%S$zQ1zCeq-XxwU%$nj%a*KuH}wkl>Ni22PuW{MlddDb{ceW@yOE(V7=B& z{gbtc zjRFI`=$-iJxStydIaR(k39WE2n0bz#<(CfQMQmbFiX^g2xI_`!w%~58m15AKYLcR<4(!vC-wZaFvp8Rs`k{}}R zl^##zVu;yDyw@MJB1X zi4R|C^sTVTj1M12fDXNsr3nZ)NaM6>W0D$xo8}%?Z7L9&C`#!>szIvn6X#@*%#%A*0YXDmld z=ArUt&F@66JB+j!qI#u>0&N!djJf@S#gYn##ae(Gyw=4_wwv@sn4?Obv&LpMh}t>| zn30)Mr!L1Qy3r$ck8ru$uM5SYCs;(AE$X+!Yx%1Q)%TK&h3mW_Vw$2H*q1IDIHm^S z${ykUZf^oFDQ<8mR=(-YR^7%(dcOM^|&jA4^) zxbcsTmC^pG~n>f0NC=UbSy5PI=p0SiV2=TMf)u3M@RFl<@o5LcwFglx?qt-#hpMI7A^fYg&3Q zj(XEhnNguq((l^*q>&Z!@=s6f~=4@oEWr{V1caGf&ZbJRd)rbvt zzE~lzZxxVfvu>6S=52dcb#C%m;Hl@Il@BJR5RK-9yxA77m;rOAMZKdF=*zCXB|eoF zSuPCt;s}enF(DbGsA5xZBN;Z?YLWz@3#d|Go3Q!siM$80H2$r8F;~oM5^79FBCOGJRwZ`IHLo=I+Bg?us#mtk*8@S{ z^Qwg-%Z-9#!Lkh<8T62h(p7S^96=pUtz_@vcsx4Y=%>4t40}(b z1)AlG0~S(Ww~_7q2Y$IzK(&yk%x%G^(i|>Xg?FT&Zp;v4lTM_6HU2Fg=CB|(iBbU7 zVv0>fOcYS2KM&N5U|gB%P@rI~;Z`@speJ}^02fkT(#Mc(EVC(u0*Y{shT!ouSPm@m&Y z8ZrM1FKs(zc+KY;&@>43WyzW#frUdJ4z9Y``9ys9@6L&h{6r_Dh=ngLwgC6sPBy0o#D-LnpYj@}1zAxwxD)RMb&jpw=Ue=8PW5b2AIQ z2=z5_2p#x()CnDRtwF`$>pdmj@Z~o6A-8rQpk$2$$7zroEH%*zI8ef@Dv6IkIj=6f z3bMDMg#;OPPOY4oGl{U4^l9MJkp5&-QJnk|+?jjFdnIt{7}qFId>yNU!rru4+&8uf z62xT2AJv*MWWE?x_BycQzjkI&cR7$Y=asm)n5-#nENh<`w=9 zBN1(hc%bQ*`NPsw|5cov>-~o4OJu>WQ{7L`#kNsRUwM_0tqci8xko_k{nNhSSC#vr zqW>x&H5VOoH4ncU=FFGA(df|_Ezh?AR6GsVqJGr+>0kG6gh*>n@OP4Uk8tn~zOYiL zPpIpRe%i~@_kmCO6&JxOFNCk7DwrHabc-DP$9T2rHRk0b@{MfVbmHY|jLTRje(8QF zTkNaTPM)_vAEpp!FetpCAty}ruH`N*$oYadI`$5e) zUz`I^qWD}zZ|Yp@99-fAYL2(vOI`=RD_K{3n~ij?Yhb{^Eh-PDonD`Qpz&|e{?6!I zTzcRqwzHvAqO+lek6FGZi3v;lO~RLs2uU>9Jmkx)n!hPv6p{yr!eSxl+}Y%)`TMw2 zsmg3`ChHL}UniVDd4v^>HxRD6S+r%-Wi}m>A!*QbSnK&x=1likmyGh|x|)u=i8oG~ zTj0LvY6NY;C)=xKHiTIwyo%3W=F69?oM&42Iy7+-g9@9cwa?Y3o4?w4MNctz<<+g!WEi-AZ zJdj4nMn+G(E4SDbBB0cs3WK2$QuQ)Ow>ch75-_Gdd5usbs4!$ZFG}4+IfT`z7UIK4 z8f^-wpq5SISW3;|od(hVa6%;{I>{epu)+maz!rSUPoZK49Zri86A^Q9%s2^6=or$7 z9S8kQg{l&ph~CKW8?X;3rJsuSxP%iXPFe5?L&RyS)F-gf4_-^I*i=Lqt(YE$Y}+61 zmy@Uibt(~bgcxt35Lj?s0Rw*7L~?KY7m;uQ!-MoQz`g!E;f)ryz7HaB@En4L2JTMX zpQ|8zQH58A6gg%o_E4w%FL2n9a3PzdsgG3L)N#Z?=xBumWt+UA)#=m^zpt7@s1Dks zlU!^Ft6kfKgMhg&xqG}6L4f7XiOq93MiwRly{AQ}yrB55 z!%JB{Lz-rUddyjXl9t;;18SlBA9Bl9IO6%+i&~*<^!J;I%|xzNqCu`f@G=;1U)dT3 zvdx>=Tu8i#;ov$J+S5XCZ~?hh;TSNzRy;~HbBM^bKog4Aoh(qNPf0R9lshbggA0IU za02C57Gc1=EK~+g;upmGAaaDuHp3=uEva_VWs?{4&l34>2*$Vx$?tmvmhV2=_vOvh z)D378tyqD@bPpChx~EG5s%t{03f}LR3Q&0cc&&7VH z`^ItP5rDNhc}YrVzP#WpxV-Q2mZp}y+ru@DdI{UeXn_ES@aN?&R_<-bRTOH#cNOo6 zshZo)z9z#b{?72hR=P3*6~i0fupUIf^Fyz7Q%)4$xy9bF8%eb5*S_E18nK6X3QX7I z@H;-&NnDAR-dXs6ykw%@laf+3e1EjCcFZKO!^SDEglu*>=X-HwAlXsq2Fq$5sPuiv z=?$hzo(ncQZO|Y~@;QqXB;{{YSreY}u>9NGF;p@893nII9K2!szVZ*bPW|$ryEbam z?`==G%Sb5o!Vla5Qi2gS;xAPTN1n=f!bL-pCu4iL7go6DuUt@cgOt9~Z>eR}yn@&h z1N6QrjI=P2c(yU%xQzEmPgZu3&<8fI-RF=-dVdtDgRN4DjI-9%R>-t}cp@M_GuS(q zcw>EXWBl5-Iz;OzSoZEs*W{D0&6QtOW3ec5MPX@waC25C_uj(f7+3L|rF)4W;%W3r z&-q7jhg-fg+|71n*xG7|vKSsd0_uj&P>pmR=~AUhX9sS*VyLl=kr$GF0T6Ee`gzi2 z*SXX&E(p%zk(NGAK*ClB>konMoe~DR(H1Gc8km=ri+IWg`Lklxn-@ zy#rKEwq*DHkzx(xK!$I`em24u(jTfSzG#Nx%JNaTZI~-nksc|nFRqe0<>GX#EoOI$ zZxHe}3oR>A6G~;!sKv~#ArO_jBSW4g zwHk3D9aG;nxphY0)LmAxVsg&17At>b%bXnK2)$XV^)NZmysHZ3{1RWDNv7r&)|2MS zg)l3a>t|Lzh_*e-z{Op1#9cBfPgxtWGLO>)+l>L&)VFvF%eCdBL+(HK3GJkPle1T9 zv4atpM`pBC`aA4uz4+3p*J%}EW8-llv3q}6ou^k}L}9qs@u8SYKENd2o}*VEQZ{d9 z@rmqxsaumqb7Wh|+G_(nNR{tzs$4%pvct_9uD9HGffFA})>N9&QE`be9fDwHTSRb~ zrMWs07EA$tH+Fc9$nbNU8DsD_b~r0!mx3(tuEtwt%GmOBjAeA)Sr-uk3uzF6C6aUt zyf_*-LJb`-Coo=c5$ZukxTrzLSV19N2(nB++08*jfE_L-GmNMBaxdsq!K7uO0RcO z5>5xxQ3fwNEh-Sh;W7kofC>PbJ!K(<3Y+pZaZoElfLmdNnf8A$0#+LJ>(!tDttN&6 zlvywPq!fb4*>rgnBVsB|Z}Ysa;Jb5S(qAURYJTr!f^)5qw~mOB&i;`C_J!J-hU6zv zkybQAiyg!*QW2AbXeWi)Nx0+A;+qLHl3{+wbyHgBQt0|^8Eshox2i0VQ7IDH&7LnDKQ4WtP{*-Cbtd8`HNl5L&B-8_(w7->jd| zSDs5*H?O?_NN(3Yu+96M4I(<9f8HlQ$P^y-A*pp^hgEv>>?dPj;7Hi!r5fk=bqzw7ur-tQ`CBezW%%FNzi*LiD zH5>TAv5$9xi+lu7znzPhH8yLVJ#4udhE$iVeEu;+LR6F&ZbHNeQ=&kst%YdKJe9c( zxizC)NK+%^A@}Ox1Qd`mP8k)?{TsQ)U6;JaJzWhg#YBb)+HOLJ0Yv3? zCYPA7Z{Wl3-A+TR^~JVQLP*0Uz)e0A6z<#JY&;i83J-a;{vFTX)-uEq5eiEG=wpyy z%j~H@MFPt2c?5h3u9I9#OLp0g|I)YGX8fw_$B>>-L_f*HckP<@{=$Q_Ez#`lT@$q{ zD_-vjGNd!6O-*7g-o+q7@zxmeMm*Q+NnmB|!VMI$SL1s7iT#^HF4|D3t@<3y&dQ-b z^6@q>%!0PP6N?ih)04NF8$%3Dsu1rHNwzBbyd{a)!yVi_v%xTj#;LMb5ma*_z6=5Mn^L+t@R#RE`7z(J@YTm`?`@S>PlR1nSVeW$ECqzkieChJ|a@A z*6YM%ovHD2q01w{>f>y-Sq3iXB;!^ z)@e6JR{acmbVoS16DwA7ya5wdjU?E;-jZoFG7N1D{_lPqn0on}c5{H+GvSWz->zf! zgT6GtTUm}-*}H8X&IPoGr;=u+*XP?CP@0zs-E*`LqspsN$M~#*Dcu%H+2N0XkGHd> z;h*J2@n@SkU6OzTFzvD5O@&%9$x8ZSQ-yuaK4N$3 z(nYm2lly2+wr0vjMnH;!O(v}1a_xa0C48;rqDg0V?ka-ob|F0!ds01@cOO;9;0qix z+_LFT3y~wN1I{lmYPtkoj*o-?#WhbrHp&;c7T*}L@G7XM*U)jJ)sb)iQ){%ANt3*%yp zVGH$LRo83Vh=(P)O6x%oswyJbBUHc$59Nhq60XH$bRL5oDKCUdjsS&cGJ34(b)jjX zXmA9LnP&GihtxQXz!*cedkRujKX))-DHN1lv6(A2?oQX7 z-y%{9)e%PTrxrmEli+8WbA18!oeYN)-b}O(MvrnU?Br3VqsxN<1U2Hh^BX-t8GIMi zik2nem>|8i-7+E-3aKJNA1)O>Wz`<1tvh4zZY_b@qY~cXT_j=a)t)-w^3>%n3 zW@4dP@)Htl>I$1Qh#;-9Q!he3G2K?S3h==OruSP30PxaKmMB4IbgnUxRs{73^H(yp z2zALZXB{rG;rlnJ*P(=`j7`<5vs{p)A=v!DvroTlD8yLqkY1R3;c9EN_f0cC%PPnf z?#!UYs!L)U)Kz@`ONDAWnc(9&0B|W9;EVCyBv`S+a=%>(QIlA5YxpZZx;;^djk6A? zTX`nTW?A1L$#|yrd1sZ8BymAHI~eW>WL2}HCOY)=wK|PdS#GM)kIZ= zYFa587DoCAWfVg`|1vziVW5-vJ2a*^Q|<4fIMr!iSDsa- zjb%QC5*4ZF56vhnqVW4IAnXVAkZW!0UEi1MQj1Bq1-kFV7Q^mWDE5|?%v<+{#HB1H zLuwm~82NxtoU2%~e>ewL!&eC}l1QATc8W{q!3!ZUz2&a@l#<3&H{Rid987?TBLNUA z6B&3@c;)ji*Jm}(DApv$TUCZFbian9xI!i?f;AM2F2lf$2IsXO^^oM2p$t*g4}G&$ zE72R=COzZ&bw*~?v#sXP_uZmaWSQC6O#yB+(=QA2ZazPOj4t8Gv|zz-=L5^dV9k2+r45^>dorFQb*kxsWu+QjTzn=}UrT+Pirf6q6h3=|CU}^nq#SSU^Ul zWdE5Fzxt%JW+%h&RrYx#P&X$n8I*~Y7{Esu;yhwXJ$N;N+jnWmCBOpd?q@()z+YM`V}rGkN5X^xFAKeW=K_z~IlnuV<_ITaYI}D-(ovSlKfY^ zVdN>JCu7OrtH}`z1pE8Cv#(3~oJ6pSB%K#t4`XlEjGG<-ef{H|CUqtLt124*q!tfR z=KEqUpWF5$C@wryuBwkA5JFI48l8 z9OUI`HJ$jIS@{PZUtzwK2|K?7zi|Y=FTPPEKqkw!UyM%vAW$(oyol$L(LJ3VXg8SA ztg--$8{KwweWO?zIF26TGqC1uuuGYgRjB1XvLO%mYQ1mU+GUQEF31lFICN>c7WYrR z9%P_Y2?}C*V!E`a_ogEKob?$&p;Z>N z>#BtsKXO5U?pJ<4@#jzBYw5P>fYBOr*db5^YvSEO69K&(B!h7T=pt8 zjNp2;NB2)C1L~+8zA^Ha|Ec!r42JFRPuOy<+U~K zF~-x~WYP?a>V&+flQrhohU=e_$o&ZizRn|ete6QITIt(xI59aRqL^q7a`zvkz$ef9 z>hK1Cp8l-oZ^L?*0t0H}CM%O}TZ&m3!)9(D9G{|_jvhhsXELROj{r*2sFRZ#eYHE) z#{A-dopv(LpQ;Xb6^YU*2;XipRP;{Mo%HJUAh8$&hpqcJzRxC~A~{vhUIkZ2Qx`M5 zAgy4c37z@qRjKr^bz(K7Bq+%A3Dz^;c5HVGmN)B zwTT6Di49>cGN6=Zc!XEsisTQs_r_guzZeA~jau%7w)?R!owHp3$`XVSzPW~%Sge?s z_TyAm+I3AV9ybTP;0PjDl>FnBHZ^`asoZ&b^(oBmHeqTeW-X(km70vP>t>8o@?bS@ zHq)7QWH53;;0tBZ!kc7Ew>#g>Pd;ZH?1k?d?LtEiraB2fUndPTd+gtSQlJcqIk%-e zz=B4~r+HRx|KKsPgAC%h6mq*~atN3o{QWD2tWr+Xu($ont=3$I;1IG34*o&;lVGE4 zQF*}+L$D1#pX+aR#{1>eDvD#q=gF*Z13b@;@@G1qA28A5BrniN)a512Vrv*?v7GV) zt7pvlWoum#oqFnYvw47WuTOz;k(q>(_tIo=tJdly7K85y;XpG!zsx z?Y=vwo+)H6bvg7l=xw1PU6xqCeRYd$2|Q&;(-CL$Ya zv825;62W)wT#H(3%6$o~j2CB;aJGf`2x@wrerOg%1S){#{{zko4TOy+I<*q9X;uGNqqFW*r5V-r--;KrKxeavX4MVG{3KiJBaD?WdAc4?`G^n*? zO_*`&sIds0+N}kl+(?duMhlz`s6WUiK72`!AXE}9OW@eRyel@+&_6cepp!nBO){P` zF{rCu7p)FhkGHUitg8g0=M9^%ohekJBE*Nj@lwYVFqR3&zxVo2CqS#Gx0B7ViH+2Z zo;L(ili@>UY#=C+hng0;LsNuvt2c3kCQde~FzbmFDBz7wpoa<^BOD-6bai6jlkfND zbUSKge<8Xy$01J?v2{R>IZI=M<1gbQQ5P@ol(fiXGKF<(95Jxq7Q4gow6@U=dt?-! z^vWx*^6tAFCeeAMl9%y-em^qzJy^W{^83J-6M{bv6oq;y_<-00W3!!0gtujp=ji81 z)mb^GCF5#cNNttv9%88%0SdAV?u|Yn-y27pTZrH#h zAgP-fRq=tZQ`W^J2sg$$QY1~QwOyQCn^o@UT)p2bZ@oSb1DA%TR)63vuNwuJ)_cZC z?A9kCq0`;su%+eWeV-~{HMDv?Tz_;!&b423+u+wm?cNu3C6n^G?6$Cc6(f}Y`=A@4 z<$=|F;e3js?^XPHrm1?k#&ZV2pYkijN}(QzJ8l0*K=0RQwGkUClZ^-<+t+WAOsvfE z?cHyIGO0mnX(JTg=d>181|6Ysb#`MDO!IeoldFTMoMQoT(?#~!L-V}Imz(~&KhG4Y z_kj=ei6>W`XO6M`o&%pEJAYi2C4)wd1D{AP2^EGaY|W0+`ql)2tF2TV!~EW{E%p>T zHMIKY6Jiwd#V5K)g@<;!)76mUcvVa$@~R?#^_5$0zB=|)E}IU; ztzU&awPeU;=7R7$FUt?K2JNeTVdP4cA`lm!orJ6Ao9u6G!!IH+$rWU*3xa0bXvFcj zA;lXbW}YoM+gY?sA0Bw4=vhe#{S)5^#?hNDOEbGAqhgr1>fUw=?sUeRew_-&-h@bv zuJL!nZ9m7}nDeOjihPWrca)}fT!dbJ#|{>wLKl(u<38(GX<9h~tXTb?WUNrtIYxu~ z4;j{Y)ElknuHKtFc|?4@tC1+w-}mZl_VBPU4w$qbWc&$Qs+E_%K6UZ6>9aw6g>b_4 z`TFyx{ix>yv~3M-O{aD>|B79{;M>lNq-Fblu9QZds|KvuO457|C24%JSHEo5VP-Lm zfe4)W_C9fo10x6Ij_~MbR2ag=#oZf`e$|31>>+Kms6j5iZ`>sKXlGAO=(|$H4T+M| zOXz1ZyZNqR*fK-8{7S|3>LYV(5tFol|FQ2mbglYde!91|es|PW?fG;KDg3*=ADTH` ztI`3JJU0_RgsJa+2b`Yc3Oznuj|}(aHg(<}{vf8O&rEPJ9F)J5&zb==s-=VaH>7d4^J3PcS$TVVA%$2Czy zTmPmd#!l~yyPO&6CmOY53s@)bB`eJLe97lmR9Tv$ZM8*9tOqIq$NWCo9Ey;j@p2z}={x zBSc`*RS-DvBL?&NugbFFNA;=iqas^*FRwa zSnBGm+1TM>73Q@=M)GU?8=O#!8|p`vR=an#keLwV;=jmm_whqbD;FUwRjmifaDDmZ zt?W#lj1bd3TP|d+W8%5UwOy($uhw6lz4IrByDZJS+xyhJP{`%&D;eJ>m2K0mhS5z> zDSVFi?l@;4B2*@`@2E2qOzdz);+qw78M-stT%_<_j*duB5U=NQZ=%~)D)ld=qRctc%45&1U(l@I`uUoLnnv+?Ht27^ zwO!DfK88(=<)GfN`_IZgWulMcTH2TkCA?`?a89Sv&4nr0a|(Eo(^$V^BG< zNAv#RtWZC%pKFPU$=9luWrFZB4s%8wc@N3NTM8`Pvf@7FdY&9q&v0|=f$-=SKZWk? z&t@hE2*nH!e)x7I6T>w0K#{dZ8W;LJ_7(UM@C}Y)NY~!}_&Pg7D3SWiWbKlT6Ekn7 zBDT{_KHk;s$VcNJxpZ=M@?KT&7XdoyD1cU|9!@|nx}~+tWy^_QwR}^MVIj5c;VTi@ zi2Wt2ejtq^k$a6fd3@KA zG1?=tQHXAl=HD4C;h~fzs>Ez(63}{s%?rs=r=0?05lG4A*I&9xH8N;%Mt%KVG*}3l zT*D!()Z@A|-dsQl({D9#5Cp4MM5DQj5nW4hDMUs9{Bml^R_v2I+^?S+-#ta{N$`BGs6EKGe6IU+Q zJYyW_tFPIh$ov*iqLGNB4F$-qQ1Ois*;nCF+^Sm4fmK?OB$DgT!>=^>H4Fl%2|GsQ z#&=rk^?=K4m9jZ%m_?Fu8C#D4`flE(g&$Hm{0^sGe@yrdY43YJV18hS|^@o2g;{QnnEr1Yny`u?tyk zEG$VdhF#V8_=I*fT|A`xK22V_YcQF8Un4SN`LKw^Z;L5&roow+oKSY`fW)NVa-`T@ z#h2smt#)FzSYFzfg0y_<7-(4R6st8j?iY#me5Xy!te{{)ZjRY^8TN5TN+nK)#h$T} z-`mpi*X*qCnCw_CO#Rnu>AcdXjTnmWa)gH{tgKKZQ6%|3W9Q-{mabe_0qcp7<)N(Lco)Gc~i1>Lrp`E!{_}b|aKx2SmHwL?s7}JKRE-eydnd7BzA)kg%rWUYr{`{DxG=k$Lc*ZHVB^NF1}4cl=)b?w#qu?NN6=V-~~s-HT!O_jceno@9U3{uHGOdiJy9 z@JxD@PM1uY6g`horQN&@ECcO|ib5J(bLiNy>2P>Uo zpTrojAS+vl0)Xj0NggW1Rwo@$7^+j#nigY>ich>fqSZD9cmu<}T0`%eT;i+>H`t!lI z(=x^z0f*I1CvnWz0V3BWX(kb-N1QMwVXy4vjOBT8W(gu=0zZz0Tm3ch!LisGEBq6- zPskT8fuSE#s?QHRUJ*g71yhhufH|HY1dQ2lXGy2`3_t0&pSy)+*-Yj6%FBAo@=%LD z0uH~EAwA$_)%8>b;->uKVPx7_9)l#(n_UopUbJnvc&2=kPh|0cm^*lJ@t@7UL{TJ$ zp^*Z-cdPjYW?nl$oC0^h?&Ga*{+*(g+4Z6Y_?{C^{V=><9-VujH%o6{at6ux_Ogu= z4|i2M4%5fyf#6@(r4^08*}5FZzVLEL`Ico(YC4tvi((VATkjoXgungVZaIK(SxYJ-a$_x z8QO?oId8h~FQJ}x&z(=`TLH`+XEUMmEQnDNWSn6tVUTW@KiXP8J$|*^z?VDn!_V@I zJ-+luiGTOu*0!mBTuA*?>FJdBX950t2zT8lc2Q|fL5W3PlMGy~N#umWsp zj)c&zA{Z$@;A5AOy!+m=|0YE>)Km^7ZH^T+n|u3eZ56?UcdvHZ{CS0LI$>$Y)UIu^ zw)D)6Z_86H(IkmSQDW@XT<%}4X;{@D_kMNoKWEPbHSwvP_om2g^ktE~v?sujs^Lxih0ecP!!|J7>SZoD%wJT-}m2O@c&%gQ? zqP_9~bsYA=s*_D&v4V6bc)xOMqX^aB1+91Kd`PAwGG%MkJxc{rLxu|#M*^(EJH@FI zIz6_WW*YvCuYh}x4x7~E{PE5iN^M8+ZeoLjysUH{e*0#MJpxD@@8UMV&C40Ws*Co^ z+s%ITA)5^}5_=bQg%=eG0{4pk%|8$ML=A-AWE<&2+3Xt(G zL{`)95kP$Z2!NV-U->of>NHJOmwY<%drIcxz}`asxjHY$cFFh1RmEbq&`HYh_jv6| zD%BnSl;cMS9)cK0z9%UMdFNBJ6u~l;YKVduCJOqO+O|Oo?bD?*B4xjS5qr<-BB^pQ zqEj9Le))H5)I^?wiZp)$>ZOalNpqe)E`u|2|S!S1|X&TM;gn@}`HrF4hSIVD)71ZFJ4&;j#@0NDK`*Jyd5x#*J)TkC%tUPza zC+baXf=58KhZPZjh8+L3-bSy$vk5G5Bb4RvQ!clRdBYBzUR~Kl+pl zItwsnv;BuRWGkYlGgZhWbQVqh-Y5==As3mqC6%COc zQt=0iA!4f|ZRxK+M8AD8iY)n@XqsdXNSiaxx?bE&%wJzL&ssudPdE2{$+5!Zlo+yb ztYBxPdpA@kg320zXunNee?8*Vq5eU;z)x9Pf%Doh-WH3?)sr}2H`chf+o9WwXKa4G(Q@@q$TRw4-K55W{k!K=f4Z@q z`R|lNCv#<+E2?B;lB|(B8X}zH4y>&z?3bZ8oUqD`?xuf5X1rlcKfHJ%Xy;yQ8T*Dh z(D^+8F5-H3~dRb%8c5`S`KE&??zBGLk9PonV<3X#C*{ z$#HDSR!ynm9QK4&_%8S43E|T0s{}4x+ZZv?JMGZ0Wt4t@og#d$()R6zw7WlJ_9k?0 zN`^v2D8kFOTH)ax3tJ3t>ujy3K{4i%k!AVEfc4dna^zDi^D^SI$$Uq|4GMOzSg}15 zmbGvcC5>e6n+EqrnaIYExJU0R{OaEB>G@}wG)|t_vS|AHBDY>_61zsXlw!U{|=m2b>n*bAe`4bmi^U@!~fN_Hk+ z{4y*+o%|(S!-vX^ZRDGFehxbdexcnF&9XCev4uD;j5ZYV)i zIct`MG#@`p)1kS+#AczwdY5A!fmGo<&U*+C4v8FApS@?Z{TbUL_BBiAD1ZLh$E_{Q zLcK8BoebycKj(&7!Vk>^jg{lI#p+^KNxx7GX1kWqHS=f7OSw{|vV$4eJ+EYPbVz1+ z=m59L@<8Kz?#<^%bp!!oXzOmE;UU>uq)7djp^Cz^ZoIVudD<~lX7L)tVb*Y*^E$SC z-h!|I}o~sZ5QbX(lIxVu`3H)81H_ zQ_@g6$^7_`O~xKa)vN zMdYpOw!Pk|*|1kzBA{=edX65$BO=kmV(BT;i(K{7w0TjcSVH?IT0*;53VC2GbJXIN zeV>$BMOoHQ|656>P6X~!`3ok1EmG^UE1&qi5po>AF?Rh*Mo{2T)FmKO`rt>+q|KMl z-)`(bjuDZlo*--d9tcssO)k~T;9Erh{sCV-e`hb-hobjHPlO8aBpEMv$CzGbSlNqL zP11{OlXwTZVhbz;=oX@#e!RcieyX%U>%n%eHXYXZPNk#~g(m>*16^AR7wsvz9)T$q zhXPDWuY{tM!a9nCx2{%RL3LwRJ#d={8WCIisZU@rl=)H$+5-3wZ3Q$3ehS!uF;4w! zw8lr1G38yGEk}C|M{bHLxoP1g%yaH^=t}^5*lkU6*jU!+thFb6|mTe zR~x|vLfXidXF=p^fJ&Nu{b(chMMaXcP9xfW?FwG>&<5zCy$=rDy%OWK9I73-u*NI~(W{8_6QH5Lk9- zlY@o^?5gy%1ns;-uiAKH5WeI9({G*<_w&7al2cpD+VsJihSMEN`YzSumG2ThS1slS zE>asWSgOgii9yZboQeyg-d@kr9F7S0RR1@{*U$Wwz|TOA2BiY+)To+w(VOSUT4!Pv zXY^%FUAI^0)B8p|^yD6{H93{A1|44L?cnq^%$>S0J3UGK{-iUkNIMKS=3XdJBt%Za zuFxbpqu|evh;2niLUKY+kHN{KLf7W@$g^jeEB>{tlKp+_Q=5sUP*mN5*T!o61I^xB zriN4F*h0e@pW%uY=$R7E!cvjZ+*UHC1fQ`7CRy7qE{zSyQE5d};Lm4B6)$fgssASg zBV(+7;{L|G_ou~xWr4c=QJ(dI~iWNze`_RSCc=f zWbZ4Jpnk3uV5)VuXu6{7BK_d{?`h96%3kddP#3m}AFh!rNba^$oJzn%*yPN+&`UFN zEwTn2>YT` zTJrWY7yJv~`KK8B-0TTm&U}fRzDHhzY zy$`9-j@0^}$PHq;ey@FB+RM+2%X1_2+HQhBSNfTgEd;6d%YO7^1EbqHf*2IoL#_aYkg;hw(Q9ek525sC{w*7{#I|N%^_{#c zf2z9ujiBe5s5BO!P~_w!NQme`ruYka*TtK44j0e=SplLLit)?Aw~&Dp(Th}}8yiP! z@#m7goFsy+#FoQ=brE=JkU;Z&t%o+5kNxirIV%5KOJ2jTjZsLIxng{)`Amm1=fzLK-9Rx_ zP6;eW_TWdrhFjz#;7K>l^@qKM?TnG!_!OF{^r7F#wmH5P=S0l+$#tkFFb_ldmdodu z`?G;ovB3&xR~=ppRUl|qD3)Og*Wme}K|%nNG@ia1rI4jSdefI`cVrM5(KcU0vY}jNvYN__0Sdo3&5BQU! z3a@B2ouSE}a$~Vyj>e>?U`eX=uS%HH97$@u19j_`*Z~3!TQ-nl8Yb46h!y zs>6UUnPOe4o)vDtD5xu{{&bVg@XNSuOwR-xZh|i?HQgYkQqcqC7RN8uf3Sb6S`w@M zWtXa~LQSzBM*au@KPOr8=aykxw`4j(@%+oR)X+)ElrDGIvus{FBPhMp&)_ zrXRI162D7aBL@PXLmN+;%Hv;L=luLsV-%FI;4zP3A}&ur}BLxxa+rkxsbHs z$^b&=+x+zx%QET`Nxx*OZ5v&p88fEO0&TvFR6ht^o61~i@;n0Guqd`@Fz6|PWdkXY zuHpZ9|E!WcEHaTqXJq6x7d(sl*`PB^8ti~$4VaI0kdQgQ^+GoBX>vXH(tj3P`S8s4 z=ObXPICsi+Lp$c^-M`BLS_Se!^r91C)t`z;wt{)U7NdVjr`6`_z`t}S5-yNQzADUu!ra+`ny9jfWUORbrW zX}GuWzbpb4BE_g^!s8(XcF%L_l<5mHAi$oNBY9HXL;!gtjfw`{)v!Uq?Tqvl zrCHQb<(U*AP0yN}`4a0MF7cJFh5eVSc!F(bL>IP#$?t~yn72%^y1dyYP!>kPAht(( z==>k0jYC_C$=TtK@(E$cUFAfU<$7n6^K|d+Tvv>v`)`LOE5gU+OTA7CsyaS%{{R65 zcRY7B=w1unzBcgsvla6$Z4eCSXl0FY`r^5zCgmCVqpC1bgrK`K*}eeyPTxfE?Z~y7 zE5*3eqJ~?jH*9xA+0cxsz!_2j72`qzbgB)GS+j^U=fTWO4Q z+uMmUs`9)e1Ajy~POXmh>r|4KDh_^sk>k=LF8wi!~N;0nQGq(VoeMsr+UqSxR8d@)dybBe? zUP*|CbVLeO*UN4PZjt=vaZmW#Yz3{J$#m=9Fs63!&W?DN=;q zxBmb$(ljp~>mCA?FDx#C+wRPk*9UZO?~LSqN9$gt;LnM69wPDE>vwWTr5Wtwv-0O~ z1I@nxDL4l_U>18uHmtS&s9^PQQovbgDY_iX8&(GK0$@SgYgHzt=T!n~9vD zq?TG4!kDY?-;R>9ly$B@c)j-aole7E9X5w4MACZ%yL)#THe9i~fyUQ%-9<)1m) zGFa^(4{X-I>}T=He+hg})pcu~INn=JXZByS!iFS?a-?h+J9+1xy!~s%!z}p1)4TKSD6Mk zDPQ?`LC}Cf`qO0nnlJ5qWPC|s;%0l-)V|*#xqvV#avCwTj18oy^ceJ}d~Fi=V_5Lb z-r&+hC~zBqaXPj$?Zt12<#D`UV=Oyre(~IR+u?kg-;Oko6xm89zO|YN+6$5lLIA|M zKvdzfK?jbVMSR2Y+dzANjM|h>;q-z#>6MZgAZ8cjpv!PdNm20H3zrMpna( zrLqUi27a98tKG&v*=sYR?*-8qu1G91!Q=a;xhvsgSy^eV&ipPmczIo`>1C~~c)qpp zPfx$sd>Qr+2HQtq_b}e-mk?YFU$VS#osJcGa!wS1*S>0x?KWQ5UhtK`c2bN9$3l03 zgZbCcNiqULu=T(d;~%tDv7NN5kPAkU$cAMcDI^dxf-}eITUAOl+FcG>P0p%vdY=OR z(0(7f(DglO@2sx0&Hg!`$-0s!fu)4YApP5r3n?Cl2EK;WBL2_#N#$EaBU>LFYTA4V zhiHx%b(3!pA4sD?_*aDdebn`@3G1H{Wzz4jbeUVlmvcxhW_V1f(@Mj3(l;p>Jmebs zQf)bId@Zb5%t($u9BY#R2PFNyt``TlBpU0+_kEFu7e^KSbUN?aSK=1A;R~M%HkGK) zVzXN`vK8edVYdM1Am=1w+#2!&@%Q2mg)AcW@g{=i)h3BGD=@6_6~K^XS0LaV^dvQX zPvcL8RzDQ{Eu%cwb6VZOXol89hmBZ;B(!bAWDHl6e$>7b)#K1K8Kl! zNR+IcC=5m4CcZ56k|`RpilR(tKTKb$f3$sB5TU4KyY-EDGUy9Gs3T z$}~wX;hk?_xR>pAlSg+9>mK|`ki!_~1pff&ObvZ+@N?tOhrS_Q+Ud_9k$)|>ojtqC z%^Up0F#(QLsa)gxyyT8+o}yJ`q~59A+7firT?b>D0_M6u75G|x7gU*Hf>|us?}|l2 zR0G(bQC#@skUEaF_7JHWky44_N-i$UX5K3&!`fZOzv69m^uH?cU>5#+qNIf|C02<~ zRUm*!;PZ;v#%s>L7Wkg+Zv1GH-l>}3#M7daGKUclBMLrT6&ufd@_lQY6*mfZUD2f# z%_|;)Q=Y%ACNWb@35HjU=P1QUZoGQpnTqRDx=hN%dvQkEekdZH#4{YyVxC7dn4;sb z+;K~aTvi^p<82GWm$2FDTBH_t5{#-yPXpD67(wV;8K{($RpjgmHf?sGijLCi3;TGk zEoX}6-c=7A(71GUJqobkk8x9Co$s(AxZ;8-ae^sAiUu@*DJkS)kkA8Z!jl-M3Sblg zMk&J-0GJfG6rgma0+qmS+*0F;aORi2D}dg#>~Ts3C|WK8W10m!Z3I#nl!OXhv~4DU z8-YgJa9mJnTn6o+tttANIilmRn|PybI+s1ETw^xU6#}1+N->%)D+~gRVxPA(72=Ly zu^1F%oYb3W+lnqRn55fJ#wa5+t|7Z?kNBf~p;&lU>Ro&75?a8%f7z_&Xs4TQaH>_5 z^Yt0#v_2u~8n%<*U3&XNSYKDRvX*OMKiuA`Pd}A@Zv02pz9x9b!Y|@IO2@@3rC;AG z4MJOK1)3Q_<`6()t-^;NC>T-K73Jfr%A7f28{fXxIw?g--Mb#&;y;gm2G_3jZ686s zn@2j0y4X1Z<%R@Ng%L)9Mp@)=>MG z18+Z(~DuxFP6l61F2Z9Dk$*&r1O6pd3?V;5e z=zS;fXZEGP_(`SQXcyXaT9u!c?Q62*d&53X74UJls46~g7p-XgU-+4I@$2EmoyM=E z%VVNJb>_z;QLI~>28`~OM&LF{+M9+x^Kr+?nmSl&*U?zpPb?OTy<&0}Spsr%#|lXY zt`0fqMS4VpI_jENkv$c*x|p?5e~!V*F@7mTYJ0nQgSso`IQRx;Q= zvt|B@Bqk_L<8QWEpeV92*iPWGI%M((m_a`&$1kJ!x@`(Qe#=3zx*CnhFW09LOa?wR?AUZW+cF-FT~Mf6(r{PvC8L#xq~(+THJltgkj=Mzy?HRsg$Qe8h0F6(A`RZDEd# zLlv(yveNt?Z8Wh>Eyez+GQ}N~t1MnrZl?|V#Bx9wZ+v5c-;MZzPMU1S4XK>{7Jf&%F{-M^7b2eW@50mq$9K|v;q6KVYUAE z%v(D`j&^}u=8vyjORU1-XO={etU}sC?6L1=V7)>4gJgrr^%aY2r^9pN!=~xFv`weH zzFb##q{Y;bP#+Evn18Jj7_k zUScU5xs;N*-&q&4;Zt&POdu_+zV&e)FS z?l>n07~t|Nnp3GCc9fd*Y@TH&c_h!bJUOrHem2qW;n#dysA$>+&5T=bAiozAj7p{V zMw^|tA=pU(4d{Muae?uk>%d+G@V1FJ!n=Jac$&)bV2@b4j^QSain5kQmN5&ESg~^0 zO^c2~f%2Y2&`95Sa=cJlTmuTa*lBc{g#AP8VtWnKq?YsI;YZyS>hNs&jnXc^^}JDE*cE ze>a2Um&6|wwQFIkM#l3;f?;=RVUV5c^D{=wROC923E-OYPmh1I2BYwI6lA1oS)* zNgP+ZR(M!bjAZqGcYle=7|!=gL+4FK!uj=mPG7JsoW@XE6=JCg{^$pwA6n<8)F=Bv zx0QbL;Kv#$ z(~fJw=2v}=g3XKD+Nf}liPsxd&RZk={{SlO<@m$m)1~UVO#0K_Y13QB6K`W82-Jav z4S=WSK&$xv8uauoH)Lq@4Ot%vU9w({q7CLnUaa58bz=~t|GI-^onyJt@h zqduc(^DIzGjN2AP03!qZCpgd2sm&yI&4C0AvAz`t1x`i?>;+`nTYqD!H<<|xa;nXh z9f|#FlV3_2MQzc_Twy^m{wMOJrtP6V)=gcVO1yWoGkI!^2h32MXVW7m){3C%Yi8=` zmy*dSCpOO6#~I{fBcIla&zbfz)#`qYOdd;}0$8!NH8z)XU=A#f(Lcagy$E zzZ*0=O&iA^AJeqx=ZY27lHp*uQ@I0J-9FR1kVX$3SDe;nx8Zxs8oo&5YTao+^l6K>cd8kw!SxPbK{$`H9vV>(VZ~jQ~ts6kT@U8vD z&xiGE-w)kLB{ExE$-2f@KYB@RbLBf=oz3^T>J4aG>qk}>7q@~mca~El<6@KM5J~O< zTz&@~>(uoh2>4sYI(DI}cwYUWn#v})y3zFo+cK+1KM&eVa}KaJ z?pQA$XfKx>1C7T6B$9E{y>rxz(&drUN>t?eplROPE~BPNZ9kZ+A@ZaQrCoABAdY#- z;~4|+uA{)yDtM>E(vmVStqxS>cxdyF$k!dOOFgESbtD_a>2B{10o}U?Zrt}cI0w|4 z==9s+XQ^rT5x8YXj#c2OAzLGZ$l-I0WO`RDoswr*FFZAzNf1rMq^>+G2N0$ zD~`G6r&{n&3Qhf)rFi2ASuCP?9xw<5AdurDrwLxi@VCc$7mIJN?kuia-ttJ(%+qdw zS)vP(o(T-6KY2+Vvz&qCx=qEcg#Q2$tghB&wY?KrJg_I5+SqDs$fsxavps$~8G?hLo zS!2Lhp8IL{^AjC?$MUbF{xsd%>wgUNOUnzWZ>^2mg_;K3jq*QFemWkN;2TbETrScs z<&|4FJd#E~I`iu3S-&H?ll{ben;wt*ricNxM*yBU?fm}$3NGADrd>qH$OVEc}0!{C z4b{skGO>yh5leL>@qvspO1v#~jy!_@`Oc{Bx>U-gt{sn^Aqt zU`a_4MA$!Z0NTG?XWZAN{3Wzc?Q6SLQ!Ec-atMrL_)j=I^v}|+N}VcPkXjASrFrws zpG`+?XC|((TU`m|g-SF?;IJT`G1oi}wa`anADbj4iULXbh6d6GbI&!=c%ti7(YzPo z`|0Ab)U>&D%W(y*<&;+_pdbtgbw1tc-Zq0w@Q$eVCq~mA71LtW8SNvM$YI@6Xhg=#6eHXa3H4rSd%6DNV6v`Hxj9e}wcuN|VJpjN#@ja}?ZuK_Qq7al3UVwWQI6N>oCy;aN&0E($Xg?U~i=?%*`dEo?B5Q+j0RX|# z)N(M`Y>##H&2!(`S8*({+H2Yc>`JX0{k}_$D6jW`S>hu(&jp2f2ki~uJC7CIoj&dh zjT-k(Yj&Opq(pEH!XXOr5(q0GAm=|r#cc;t5?uQj$y1zLhUeBk68PzPccxq}zZCLb z+sPscE#gEDRfyWrob5RbIL~ecXM9TdiDTo3{sc9C7P6Db%{4;lKA_?q$G z0C;ZazkcU54Z=QuBK6OVp8Jn-qr1{Nip2o zPG(cL8`SPZbzlhKuY6~<6=hP-O#+l@^DT8f3sd-_-XgGVKg50*(ELZIg&Xaz;NGq7 zLxx^{m6fmfn@H7k&nCyi?WE5(p(Xsw1pa(-NhdDE;1wj`@GBU+uYn+{L)*+IB8?gA2Mfh^Xdh2%B@RUM?`5; zz2bdk;ajr44biS-DHLB}h08NwDDuJnRafE_ifdi~@eQH5iryJ4=2{6ie)x*uASD16z9 z!3#)(bZn+A%m_HzJqMxpT7hS@c+JFWB)7`Ek2G0e7-xUJI*<=g*r{%{DD>-wg`$Ze zU)`%kF<+Qq0iVDx{qtR|_BnMV)MS#|X|;4h89;WEXC&t(Jq~%}jmI@7hrCNaf_zV> z>$Xly+R7#=xD}B}+bSPo3D5irs!Y>fm}Ih8RPHz+)w-H#*JcY0%I=r*I`ZAVu4a@x0tZUbDy3SPuzgve%quI;6I?gJo> zxnFwlyFc24SDZ$=$A`4*rchPP@v<)9xX2_Cf<{NP4^fx?$KMf}+AH4(-rOv&rV}-} zGHyR-WAmrB#^)aA*17MGzYo9R0`Zm7Si>OGbtU^npp5*nl1}*W2o1Al;1Z9)kq>bDUt|x=y9=s=#Wq$Ej-C3Tn3| z^5kB`e6ces79)ZI0x&-VUpgvBs2S~~-z4CX7+}kX3JwP4``P4k&Ozwh>S+2_rL9Po zwqY;lx{ONzwRKf~2?OsBn*#uPfCf)j?W#3rAog)i=^t0aW#PSAR!u*}T6;;m9$c)k zoaYz|#yJO|s+xy~uJqVE`4BuiiusX{$~_K8VhAJaUn%LbTZvM3g=A?XErnqt5Id@X zatY~!#d_z5^uLH-3$A2)Ef)SgK0=_`NETRKMgjfWxxvDLkTZ;ZE4H3CtS!x|IpsnR zW4$7fjPn@v$EfQ1cCDprpEsMM+t|An1FVW4ZW9>f@Cm`LbHN|9Ux$2O z;mLK0ZSQpG7H=gZbXg>0kuh*!?DpHk`?s5AHmuW4JRU*01A9%HV z72)YM0~nS=B10H<+Zr>R2axp$Gm=0jpa7BuUGT?>Y`j~i8|wv^?AwZlnL-jlApE(= z>D&y02*@Cxn?Gsai{1!p-7e8@zqY6O(pS@E^8`S3jy3~nb}qRJ#fB4)SjFPiEHtZA zchgTYX~UjsEa|mB+HBeQYWfR!t#v;Vn3b)b&DJ|*GC;C|=2iiAZX=VmjBGLpBD@b; z@z;zlyhT0UwW>{X743%chD?b9x;$YNj0HJ31E~#;)x_HTOVzCrd@Xf-6}i5|cUHGB z>~)cx`Ce&|AdG>vgAbG|1DqTle(zh=Z`Z)I1$q(}@lZ=Cc zeQV>f7`pg~MY%QoX{qW{r5HD%_P@j5+9ODZK+`l)q|0r0YYbL!+x?<2&9x9G$Z$Yl zMmn5xj_1*JFNr!D_))L#^+_}oxDv#-5h%EL-7~zWmK|Si4tnDR91uYGBGs%``$lQk zQq<>y)WI#(@T$cYREX5R-0cXQZ9925IV?|cYvM@!8L2SWZ?yeSPIhZ4Z7zX!i9E25 zzq?>b+KclZPF1+i8sYPb@U=O+{J%x3GoBSaVtsw^*I1Lpc4??uc&^vPdQ(KWREd1} z#VBf)in$;Q$E_~W zQRfDfH6YNFV5Hei-H!C&^tBij;*%c6ls;*5%`1S=dm3B{GfO}aUENz)z|-AN6|@WY zrJ2Gg`jfz{&0EF#E{Uf-_12>T+o=FDNF1>vAOJe|{Ecvbw9bzK&UR0!_ zk^%g#0_z*-9cvn`P{?K)gH1o03SeW>(AJX==L^#GVsTT zd_yLqr&?P_b#n^4PXaSCtNz_ZJfH!$RAk8&@rve&+yk0VH=75|m)CCt{Lv*y;xq znX0z>&YyJ3kzT;=coFXk{{T&=2lT6X1Y~kOMI|^(W>bWtx)Q4DnysFRr(9p^7g1SR zTcMI`iQFPaqp2kHt879Kn}P3GJ}0xW@n(mrXfsZW72T|nO(qq*pg|cR@sY{r(yiXw z5P7z*`&xJc!&9=dvc8*Emgt?0X%Pbe;G8xXbDl^5fJh_?`A7Df`0qc&eM~Kl#@#*_ z;==LvwTy}iNx&aI;#8B%Wwx$B$-(%kr2IMX_4vB@XL)_8>RL!K%c$MVrrPG`a<=3R z_ktj0IRSoU19o#v*Q-{&?0q{Y1PXI7V5FXsiOcV-`yj z!2=7Eo&|La8TC6zTVya#I4q5lkcR3>Bc3pQ@y#0UlY47*n!B<-*KP zG_L)!iuUrix{^u+RG~zDH{Wvu>hT+1`o;y%y`@hv*O{bcpma7 zbOxT*>e4vbr+MDWL?bC7j7a%)k-5ZVs0+?;#9>Zu60&PD1j*QdVg4yW3T|A7Kk6#4tG}ar?;tP;wWr#_VzHn(Z`y z5X9aY7FPD7>;Obanlzp%kVs1zBPoJT-WUMHr*oRXlfs(Srn{tGTU^9+i@?z({Ae7i z?iook3EZK91%?1Bz~gOq{{R&H8>0L_@dP&(H%o!s_41QH^ zNIkZYe5rgTqh9KNY#POtwX5DNHsO>@GNH+NUObb5kUnk+I0W;H9zOAenw{HROqTNf zpd`<*s)m&nl0r~0W+a&TJ+~f5PIFC)!dp0@d&%IpnI@6$S_XAlRyAxY=Z*mdxHus2 zGAq-(`4V#c4rj{UlugYh>c_+PH}JyqZVX{|S@wC!xG)bGJdPCOv0BW#@g||J8`vz; zd&_8;dDBb_#Sm78u4wlD8IwZ^u3jaz@0%nh3qb8Y;xe*G56a2G`IM8; z5zTeJ9vU{aXQydvcRjt;)$3YZJbHLo?cnn8pluAN_kuH$K^;gq&0{Gj>ua$pZ@!0{ zO{MuQ40(mGWp-VtS(y|LyGY=0PIJ>iY5CHHson-9^q$ zFngYa;8)P!3BDER7Z+Y6@N^oCRvsc)!})h@=0U8&GRW%d^P{0`GN~i@u-jX2N#QL$ z^!uL{Y17*vcwl&4F~4@xf)09)eJj<$Q-&hzsm)eC&0P75;4Ox$C&Z0CH2XN7bx9?W zZkG$>ZTX)&TLG&UKw^Ry2@qmc0SpcR$X?!+@|VQRQl2~4N~)4?r8s8$-#;W*eOeBT zId6STp!x4()^(4B8da(r9}h06;te)ySgzsJ?js4OG>!6x45w>24cHPhgN)aXYab15 zhlnloyRBDBhg7(7rVTx3xbpCDe8rA7Aoc8c8T8L*;Meh|jHjAtJYRjLSw#@Gnn>?k z2#TPA_H_Hpz{%PH=O;O>&xoHB(?+wj)9kOT^=tU;#80h&vdbPoS0JD!GH`Mi=EtUL z=IP2Qa@xa>JTA99H(Ss(FArbMX?q%fqRdf>!pcEYf_`P``5$VfC4$Sk= z10Qq71$BNU*6ix+*M5JU6kLm+sur-cQ%jX#ue7sD zzwKlJ)SjIPz#N_hd)LF*byv~+A1##Dm$MgSx3{}YMH8=>#EBQ?X*K-HlYx*#G@p_|MZR0Iq+xvv%QmAn@E+S{~c zQ_rz!6~F^*clMG*-AMDZK zjoXotE!DR5Q{}nfcRX?Liuv1CuohZxhf-BWYZ)M2sz6=981g?C<3E*oxTvT}#Po2I zbBdEXXOZCj>WOV{VP+{RF^*W99F<^0DZs(O9Aq9Vq_y!3Zn}eNcK3R^&SEmlZ!~Fe zgk%W-ZK%iQ&Iv$s!Oj6qd*RgRb!mJ*bq|QFtgas3QR=x4(Tdbi2JW|+$Oux^*@4329c=8swK9hvQ7K8 zj##$>2b~)L$lQ(?bC5D0RtJIR)O92A4X!RXTS7HN+qnMm(n(Zr9S#?64_+~xS3&T%QPc|P zc81JN0gO6ed2X0A+GsbX#1N^Jhz6;3F{6MfpUL6>G&GQ50vFY{i->-V}n4}@&iAyr`k`6KJ zpYS#4Uk8;_;s%Hqs($i8_l{H;q#)H)e&Z^d_mTIXj-k|M_%Y#!(=6q^m0)Ph5ZQ}{ z7LM>U)>%P)s5ot8jV{{YuTk&KLE>Ds#OU*Vpq@i*Y6g=eH2 zPdYI=u)2_DEV z+q_qr)IvEUS5k6tcLo?ACP3PMt#;oMw9ESshW-VIQHAaG81&;5(MXtOWkduJDqjou zcU8FY=Ci2B;pslW`o^xQd2FwOKYW=uCz8V*xjYKxjNB(tFC(r|QiV6``J>b#@Lz|m zE@Qif+7!QwRo-VH?%vWBVa9nRpZ>LYC&rsn{{R#`M{ppLXp8CT`=B>&-D*JR9Zue- zy?f=CjqG(zX75NASGLze`Z=+6Z7c=X?IY%0=mF=R4@&Xhi(nrfYG9SgyRd9$pOjlM zKKv=AT54)7JL-(^%Fav4o_FyYCW`dP$k9P2hi=MT3>-+Q*P%a3^nFo7d@1;ICO3b? zwPVQjTWAOLu4l$c;wUv3q|>bR(PJlv;*8zt&|G;QXWKw4<|6Igw499R8SPz`u+m(7 zHt;}_SC&OdSA2`}ACUmh;5*e6_%%z7+URS-zfYy+TW=*XOwyc*vQtlR#y`nR2Gv=(ppC=v&$J)Vpy>xl1VkQ@mIo8 zcuP$CJ+sNS=S`j21kB2e9oP3|%Q(o#Ry@_W(lqP+Z^UmrvLvmdS_b{tWe4mEoE(GG zwkuoXvmdePQYQIV`yysmKm#$|k&N^wKdoV2F;t?`?fM%~llHQUeg6Pc=6?%m7ak(l zwEaI!NbYW~V0L5^BxxY!i+tn^a#)VMz6S=qA|Kh3>r}Q=tLkxSpy2sXF?Ugn0iFQk zH>%6-!%bF79KJZ5rn0HI6}XuojJEJcV!+<2c9CysP3T#P1yG`i0$% z{p#7z0=`l>+mp8-5a)x891+}cUG(CluFfe*r(}I&@c#hzU+}+(^jo=Sy12SkV{eS? zLU0NBR|BhY>6-IjjGqOyp8?s!CX=YgC5&n(y7J-Nu!rFTZ(@1D$PfAC4}5Ia zehqvp(ig+_7W)0>x8iowV*7LXOTIxGA^AW66OYW-eRc5y`@~)wgTuP7hal1r<1h(;5ra?jucmeN8_37 zW|!?qjU#!1fM&|KB#dMnXD9yvubgDno3WzR9)aWS7R$#z8k5ErgU^{~X!To3X5AV{ zz!jZSI8EaR7~!{p(yr@Q7kUT9FBIJAce<9ElHY35pRnG%O4&)6Hq~G=xPm>!VR(DT zl4$mquXAA@sif+5<_oA+Od|rhQ=Ae{&77R%7VJfH{w(;4`py2SbD`R#TCDFcmur1+ z!ZKfSx|6{KWwFOv?Wa{WX`HjB@9v(N;T!!|;!cO+UkCV$#Ttd(o~frt90{lu1WfEU zk1&k#aJ-T-MSSC^d@u2rhAp)PGyec&>6&J%E5U3fQFC%uMG^l12?q)kg>Ux39VzgOmZe6X>Bbs)KCPf3LLNVb$#s+)(X?%6~rFr6u+kI;8*52b#wLs|% zamZGAa8$bhHu-=i8=>GZA2F{N7mBA>nv@p3I~{c3;b!$c3-)pGUX$RxPgwrZ)zV!q z>e*DrCi7C#ZRs(=!3 zPSx5@2g`xdr?>G3m9N~jzOZd}GCJDds_z{cmO=?Bw>TlW+)DCl{{V}1JyIP4`%lpC z1X}frdAFK^-^`ONYDg@NkOtNg4j&3xmjI4@=wj+}!wXR)lkK(t005L%Gn{v3Ux)R1 z?UQjzK6_b0AtG_czx4KYm=UDvvrp^FmIo{j>=yS=hLe%2Blf?H!NYi1}tW_G_XKSeK z?tJAqWD4x0GWlSIWdy3GGk|N(bX_z>N-4Xo1&815JLR8-R8H+w2%qfbJPLA z&p~f>AB^-JQqny>(^c2)n|RgPcQQ5sJLC(Epp0?%M+c@xl_0OowkG%7#L@g!X$HG4 zmvL!s_VL`5aU6&iMrH#n$jlsoU*}#%bI1a{FX2avBZI|b$ksIoFQmE1x6{nAwDFuO zh}!|194^u^n8-acJpTYj@Rh~BoNlhItmL?B*0y`MQ!FJ&-JIj)+Enx)j{MYmeeIXS z?P+Xfyi3HHW=n-u42`}pHkl6GsRhO})VDYwk}`)sG;ekK5h%8-&(jYHc;iL!d`h}? z<4JHJj&_E0G0Ww+$~jZnhaigR!q>%Lv%iV7i|-of@gx@(7FM%)HWQ1Vv4aRrsl6A8 z6^=$vU8*-WE9{6oMWo$oO{c@ESxtVwdfGdbX$K^I&~w#K9@Y0)EONwCy+)J0yMAYr zQj>(NUB)QeLd_dC(IjO01{KNw0K(VsviPoV4auskI>dJZ*T|MPQ1Y0TU8=-j{EVkL z!OsNOqwOKx8S+W&jn^*HKr->zwK-;&g071@9c*RnbUd)>{xoaF!8gQh=Rm3G3#XD^w12kL(=8zLg zIieT0TgYR)x`R)&Wn$Nt4iylAkPdj~o^#rwQczcAE4O5G9}TtLU&UV$Z8W_~8(VES zyom8aOEYZ;2XX1huSW3?hi)vjYnZL=SX(Lm+1xn*44%IA;xpDTiF=Vh&=lSqAn zpd{DLgX8auG~0ha`(7szN-f98DiT! zvBrK~#QiJfpNJnCwXY5Mmsrp=+Y4I*rrpbD=e%X*{o9nz*5OYcoLAIQ{@0#3vw~>6 zogv+13XYb{syr<0i3ndv1ySsjkY>!lNNfw+CmE4Pyj?uQSJf`B#8MBEX$eI;LC85Jcw@reBev4CU$R}=G;j8)Bre4Lr^#Rk0YM3$ z#=S@W3FE5G7mj>ep+wOvTF#o0%f8@5aIDHU0uJWP+%IrVVOlbRv)tYjQddW)TKq)u z6kAEut|X0@au2d^B!DLt^{9M<>(nR-Bp}}9?TjS;fBytB;=k%<|;`48I z&l~SqT^xO$WM!R3)5zp*K-_idR_}<46o<$#NbiNYmEQ#8axr2^_s4pJczeNm-PBj$#}%s>k!Mz88+@Z5E=g8w zV>rVOmGIOT4IZy_#pPGq8}4ohIewV>ewFXwt5w88PNduIbHc|`r$TXzAd&h7ai{5a zx6(YRq)CFwryM$-cJN5YYJhluPP>vxA+^(>w|FC(HHp>BmZA-sGgNZf%?~V`vx{ zq{rcfY#f7*Mh$$nC3`B;O!|B*-C8kg(8Q6hS~#P(^KEXRnWJHEaLS;8@{Tr)k5k+Y)rB&fbK+G=XoLhIOLoP zaH!i}&i54Hlx6KLW9P5Bwn7#9l1M#1TA|`E7;9SRh`-?=#o}8` z61K2$-8+Jilc{$n}MHD|-$4=z3<{k~g$R%@*zOhu9{B0G)K10a)jH;{?}KwX!N z%DBlV9=n5z=Bm$+a!s`aRI~ACh9c71(CGIQUA6Q9BTJQxECM(4Lm%%KG41LG*UmG} zM4t|3xYM5EJwnX1KfaROz8O`C9Fu@sfJgxFMRiZ%Ch)X3FxXFTXFNN;*&LUT9Et&4 z#na}-xr*7S%wsc6z`7gmfc-%~)uf>l6_4i}NPIp;a#42)hQPD#gBH5)sp zW6CFs#v0d+Eo@=7iuYS69ve*>803GvSO8EQoU!C*jx%1TsX=>rrb}S(&oWp_mN#&{ zye%2K9$^qGq02J}iP?e&mU-!3HR2zMz9!P`(@wFteFYL-lYN2_Z7so5l`i8ha6lnT zl?Ma?ilZOI={&i#$fBMb3+W@8`rprXibo7jo3lTCK{$WBJ$bEVhI6yB&=itwHhbE5 zdfe($OL5`ICHquz%r7rs4Jjj@eAESjBRh%4ayiEw-mP)0Po`Q}38K^g0I;Wfn|LIS zG)HGSAPlrw2-;us?@aR&SkmNxlSIOH}4Fgs$h^dA?&XArq%Rk^h;iqSUiOMI?X zSO;;rfecT|Pi$9TY4HC5#Mkz2>De2#Dgn<4o9tWz9A5_QE8C1(hEj&ExeZu@xojU#x)-(QdM$B zbH+~`_1c%h4RYe|P}OhslKZzuERz`}kjO%njfuzblsP{r-lY4DpsoLvTKWItx*j`EewE<;Wrg6D8keKX9 z>Hxv$wBHtg!e?Qr!6k&&*7w$ItYflf7LLluG6I~VDk%pz8wo3rtz6%N^vzGhk=&$_ zST~1lQEIXWh$})`1y!K@`A(!at1PM8CR2blC_QLB?!AM{{R7&9|)+MCfnv%qc34eS|4@G@|ufVn$!$I3-@!xx83-;e5Gsmq&ly7+maL#=!phHGn2 zwQH7_4<($|s(#TMdFMMyeBIdNr&HRy-w8IOZ!W1Wu{#NF;Aqulo6V5QNGB+{Vc$Gg zh-v=-5%sTy`WVn#!!2^ZX^_Qre+H*W?;Y9;cm{saI9QNle^{7`(mg;AYBvx#J z^ENi*_x7jUN+YCG2Rk{=J){{U*iZn~eAzHr*4gN~WW{SGLu)r8Y= zb0g``6(U&KUtEO)?9hFo$s7fZyuve_{{Rm`)ba0MMf@|mXYi(kW=3a&UWsI8IRKYD zenb44`On4nZFS*)5W<{EHjQ^2P3TN4p!x;pucmQc&+x$ROsts5Gh`)kNJp9hdE8Zl+%gOcXqxWa<+*Bz&2{{t<(M z+Hc}IGa8zA@;xjIy0@9?{u}Tfp?&c##^P(aVTSO} zAk?iSDR7Ea9Z)x??+403Uc6QZi*)TUd|C0S)a~x(w@8v>_7I+EV=5paSb+Zk>od1G zJ+g2QruZAi8am6OM|X4mpK%;^#L0TWBb3PmCkWoWV-1X+0X4~Z-^1{0Ule>@r%NM4 z9qdZ1$-x7350U=>d{=#bT8^F8_Du8e_owYSUdhweZ%dd8FJ&t!X7*Ib?*G zr-DKV*)$J^MkM2>UrNUC{O3>>w{N&Kw(!l(8xc z_-DkL=fhtP=`vht3#<5Lt=8mR*?+IK5`4#U9BvtIqZQY9Us2HY$aURIN7UlEOUrUz z3~iDYJKN?{f-+q71M#m$4RR=+T+`=mcRmOGr5MZMJvqYeVRYD2%YD}GkHhn?oh}h$ zLNU1R&ea(>#~-E+E9k%4NsxF#&@f@0KxIJAa*xOV0It4lZA>!xa1Et7e6|L8>+934 zV@KgTC%IzX8Btl3?EqkPKU(d66UdhT026#Tfs`qo!9&IYjfP0=*Z%;oUTGxE<`~hD zgpNUA02m5S2Om$$y1#}Rs=@JJMDpA!`KW`+I2~6XpB1FFgY+gp5=Yz~6Y=K128X6x z>6*pvnQwCxbG57tu8Za_@&I5!?0-1ZI4FNel#6Ec-~E0#8bw3BI?r8NdEvZ z$k_ZVhxoDMRPk1~r)pLXlgH;r8EI|IBtF;s!#E^y$UKV6)FX;l7=96PnE>ambDHM- zNY4(TqC$)wH=VN#Wb#JT5e93rt(~FWlpGEQY#*3$0Sw$wH0FOU4tg8TE%wS zWu%{Hmf|rmEfStowtMki46q3EBVjlW9ft?}{{Tu(q?CDj0;LXqb?SGTY4BE~6m5B7 zqTF7|6k_6Q<(|q?;3*)?Q)^&_83!B+o5WVW9Jly%_F7$vM{8?vvqW_LN-MZyVn`*9 z?b~w9Ivz;%HRjR}^n$A3IuNW$>yG?(r>q`(Bg~OBk+xOe0RFtyy6wl6kg&=!=1lB# ze+%hqB-L-Ob*n$OSwn6b-%NYEW<`NekkO+MLEbs^tbdO2>2KkiDfI1X3(q4>n%m1y zmUY5{9HIG_k=%8xt9#r19^qQf>iWSLe93KdBnVHg)nEzw)=$JA6<%NXZd0r3QtA<` z6D_}-!zF}lN{7bm?NiCmPr|9z=8q?#5uo8yoq3-*XxfgX_wlu@^srsP;^N*w{HuoC zrC9X!9?W|R?|votYX1Pn9}P608{N8IYHdBmwyyA|&?NJjgJ5*|N)kWbKTCcT_#+RC zG_5}W07>x$o&Egqq_NsqJU4`H8N)PkV0FP9^&-7b;m5%hk`xF*{^=RyaCj!YDplhbt32t!wV~zO!kPX9coOj&b0>}MU08bl?`i!j-tBFz zuQi_tv%7?SJr!97Sdn|RN7F1n4A(ao!z&ql5vBNA@=0PgTHUs%6k2q!NcoA61zCX# zah{pS93CsG@r|VZ1HaNPF0{+IG<_o3Bx|;K+_Y(R9L#=J;{a_L>PYveDJLx-a#GOw z5vG-3j`~?8g&g3PUB_|boMZwB$2iUhP)Vt@4;9|{Ygn+>H0M>nvv}^U93crR?F695 z@UU*&VX@R2uWK}Vlylp(OB#*>?!eo?3VMu!HvS9iQ`^Au$gsz|Y#%gb58Y5PfH9Nl zft+LxcGV|zH|Bk{EuNk6)8OPwqdN`u<51E<$+e? zLtT#u(d9Dh2G3BmZ8B#DKrSL>Xo<;GV18iAah&z}anx1l?Bj;&dpV$uX6qYyq-`T3 zfO}<1o;w`#n&u?cv|T3F5o38eTR>W0HDq~ry8vIy1ZNpgbB~v6aynIgZ^u_!#i6>g zk?$?7K52yEBa%c?z_179Bq$#!IXq^(_+r#O+1$QebTqDfHf?Y3bqPe5SNDg@iY7v0 z$zPDODakoFB=Nw;I2FoYXgYQD(k83sTwdEtB+8dtr1QLjL!U9h6Aj4fJCF}iimk4E zM2}s2N%aY4v4UX1JeN>icWvGXB!hO z`0~?p;1Cqx0pFg6u&SdfoY!m2X|$|Mu6UEfTAi)U>`5k{E#11u<;I3ax0MFg4$PxF zh}?FQjiVEmhOu*P7?o~r!yS$z5s~s9RC!=`X4#%HYl76RpwXUISH7n+qu=UY1J$Omzqx%D{w9t=deXMolX4Bw1;~B} zBm=_WX9QDpuZh~y>UNrxnv}ZAvgNJrW85wRw+iaJOoJ-ioa6AT-x48%#{2EG-4^oW zRkyfXSZ9(@H*iCIq$t|OS7FaRIp{HT4~CkKmmHUJ>K1xM{gmf$l2I+hJ2CTZKtLJ( zFKpE>XDIVk(Y?p2(kDfr>3{H^c$!}i>OxVjM|&#CX?G{t;E9B+Tt4HS`=pd`0r`kL zgIPZk^aS{ItXZvu`lf}hTY_3O-Q=Oc8{wOSwVX($NXoLho`#EU3e(2EEiYxM-)oL= zlT4_w$O?oRB}Uu;d1KdUIc((gQv5OStQugv)%7RSH5O83vbd8s8^4X&Pc6B(5Kmq? zH0nw;YFhVQfqTa&x?ZdC1I*MeJWuEA0x)K?Xtpv0QUPTc9WnEJgOgA2j;|h-shI6_ zN)JaV*Qm_#;k-zdWjGaQ_f+3#Mn@Ou8o zLwWAuhS;VOH?z}i7E`qR-{vtUZnd2GI5k#NO= zs;=zk01kq^YCC%!R^B;eb{6gms!tSW5`Il~|%$_arpN=d%U3sPHJ|yuU znH-`Bts~T~ox_O4!2RRH8-Rb{C!iItXW~DKGhW+iQ~2WdOOp0aBHAnO6S7IUCrE1TNl3Ve4FX!+(Ol z716v!rs@{9Z>8#X_tw{uNg|OM*)lYXo|)Pk1Fu0{>mNx?QS0BQ-f&AUtL&Y0wC%0e z%EuAnPl(?V{uub|>AH`K^$V-}NB!grs>y$Cf?eZR1hTjMd|-kzh}WS4zN7e2<2^6p zAB9s^(O+yoC)#w@-R5c2G6pf6j$=PDM(99g;Hd-UU)u*yxV8PDyj*Ah07pxUh@`fZ z@Fa>zP{$&iayPuABfMmF70i4J_}_K#o5l-vjU<{y*+}&(J<5E`a_qzJAHNwh0DsuV zeq&zMIk=@Yx#G?V=zf6G=Zb7PhMleH_L`=RdlsXm-bd#_aSLT!WUFJ)h77C^D!_sV z8BlB3gw)qEI3*VsW*ez$3#+IsZKSxlnmc$S8=hI5A|Ul7@GHaqD}L9W4ft^r#TCc) zh5j5;K z4ja=8p1k6|4EUw{X86`X<8ITTB9sWb;Eaz69RfrSD^NjsHYoPdh;}-DOk7L!KE#yHB zxpgX4nOWyq1_;mH0=zjS{ZCU?cxR0)t~?du&09;T{oE$7|?As7-l455KN zz+YO<8l-H~-P^P?+Q)3NM+v;*-<+swd}EN z!9p_>gp>Ry13ktEL0>K0>ep9tK^>%X$3DhsQb^O~%VC0&+~M-xpE0Z@MM^ttCTlEgo=La50{PVN}u+Z72paeHrnWOuf_mKjMqKs8$Orr;aOUZK0)qw>$6$%zi=+PaN@Fuk4$vL1Xc! zO1GYSduXqtdqXYELPS#7W#FDN$OhrlCcNI;N7lS&e$raoUu&1=@5-U)lOC++I3DEH zTT6?-hu%0BGQxvV(btd2@IP)B2oj!kP)ZmZ^KQHrM?Tc4o+01`BNPY?Ll zQ@2NS)Qrg^k3MG-Gbm`}4iC&ZWy$H&JXbZT-yht_Zk9OM1yzz$9?;yV$r&sdHyv@D zS9Rk3V?+3-;B61a+I_{k>X#8k9l(!uqO^Mh1`2p)M`c{8I0G0M^lZuHK3*TQ1yx)quB*ea_LK`p=F9!vX8c#G`c4(%<)%01She2L_( z$ajw%${AD-l(NUk{`a+f(RUnrYr`40isEOtf@3VhcQQhfU@uGpI%S zX^|v`V{B$q6oVjS0!9f06OOnCsIMnDrK+lrS179P={~1DuXvq&C#BzN`n0z;*0GY( zznX<&mNU0#Bmc;otQI8a?g8@>eq8Ow zR|J5y>Gz)jF8o>ITZ``*_@3ih(^loAhfRh*A~wO=rPO0Bv;ar|v+LBlvSNR@pKOXB#*+p3|%=j-^*X^|k zu4UD=Yu3Mvn1#9xs>(BkXBjPyMcw2#8NeYcYwlC{&D5e!o^6e_@sNt}k{f!7`D$!64U zbu&It2bbkV+}mPaauhM(9IsQ@)YPP=?9Vk##J%Eg6GHl(%o=2ypjlhXSA!BZcw$(R zLF1>Vern~7>rD)LY4GAreFJ6KEQ>UY$z@@jV+09_lda7bf=6mJ0?cb%|A1 zAcY`FlBK!e0!hKZ;Mbtod?U2htRcCE?k!5*+`Q9?c8iONa*DAuyM`k>NmgPx-Z|qL z)TvGpNw7*!Q<%2#28KL61+M)&0Pqdcy7|OwEs$NRI zYLEuww71GLjjBf=XYW@9;%|id?}i}1zn%*nv6hsrw53`FS5-R)UK!O$#XZ^=07?SwPL5iAtxgqoI z?lr5MnPu`t&~Gd`5$9=jX2U5ZNj-jFdg7^46D@S zl1UTGG-bCj$ry2z3P=ibf=L@|$hA^s zut5XMZx-bWNm4x3Y;?v!Qcg3PrK@~VW_z2v7y@cn!aG>wvsZ#ubUAs0AsNpDY3%%r zVAg%Mv~{SXlS{RM0C0vWCTT9AOfUrQBb<^lyk}rM;<%glo)}~fc0n`kVJRDwu5i11 z@T-C`jz_L~DZ-nRZZ=`e*Hf;a#r`7HZ{~_a0VWw?6JFgA_Ure_SIEH%rwR!lJ^(Dk zG``fDtLjp{#JA?%7U0H6jx&PFTMSD#@SHC^lZ-A!Z7af3_=ev908rC)jVc>)Bv6>H zE+V&*P|0!B0yu6ND6`Xf;j7ry=of`PV-ewFIb;XgH9TV zXe=j+BOKRIhave=3NKI_jFn&s0jtmSXm!g6ZBE&Sqh>x!k0MYW;siXTv7s!e!;jtp zkU;JQRqJwl_fi2T(D#scOZBn^h4!rx+dkEg*bJFbk2g4r_ zW!H2o#L^FuJ-RA9dc@6fXNElIA<4;A80+Y9n&vE0^TvAB^|MQ*S-h@cNXqYx$_3nG zRw@qwagcso{{RTCC%{tpp5Ii{Y%I1~!+kWc?7Nj?g6ZQ=F{5Wu_ps`{bC*$3X=7KD zQ@pa%^ts;lE2c^9i+u3{K~>$H<15tQ6Y5QKDm;^XmhNA)ZEAPt;P#ONz0RlNRfZW3 z3^sQBA7b0x2R*lbHD^Ka$HQy=NGUN&1~ z)pVK#|2Ta)x8Oa}FD( zap~94SF)y=B#J!i$)7z!oul-xBls{*+mNIv^L;ZGQRm?wjNY(C*I!ZhhZv4cQTTrfO^+a;LnIrcr#7E)9281 z?+|Hovl$_~ki^Ry4Vg#U+df?9jGE!%AQB1rBO99;z(vO-ee7g==LfcG^{v9kBNM%L zXLA4xD|>;}y8+u3oGQvPQd^@+l%puP_dbF6Rq>zUMw_g{f@!=Ds5BpBT|ZtT7~d_G z2|F>3$?T#{#1Zm}#qq++qyEsgmu522Te&GZ+~eewD3na zo0#4q^8=r}q;%<=k~r;BczVjy!*Uq(n9cNe){(ena3yHDC#b>tVDLw= z73wnlc)pWF)HDrqNBeG_eG^;#yGfKqA_ndl?gnKFfvEsSz{!oxsk34E}P|R`myyN!uuO*vEhs6w_mmhBaNlba=&Lk)kSh& z5N_ttzAozb7V8v~8+I|Qh+GEp4l)NMaof_mzk`P1w5<|fJiHO4OXax8T!Nn|?f(GR ztlt;Jv3z~Ec@Q*j1_&#=tP&2Dy+?RclkXqU<)*)RRx7lP4Od%l5_}ogH7P8_5nQwv z8I!hp(peiiaxi(qT71?zV4D;XxzCxr%hB*yBXdi5PStn2>(6<@J*KY;yzL(z2a zi+c8rrc0#X*~Nci@jb1y@v(wicyW$LB0w?hPdxUm?}b_hhvF5UN4z%vAhvZD&1ZAA ze=Ci^BA=N#=YlIY!hRy2;8^HaT0N(TEo7VbFija*B9XUplK%h+IL1x~zcqJS_+8;$ zV^p6@vDLq_b-OoZOHEgtJ~5bOIM3cAa5?1r)yC~6^+s}WPElGrpFMupT69`QnI)~e z$njh15$!HixKk51IO*5vUn9g~TkGA){;vd;CzFo3_c=MQq(5nGQ~N7I*5$pPX+#&+ zJEpj2o<}A*SLS9UvE!*GzI%ccn%G>yvuogoPzHN~e>&0{E1}D!_adIbrdvQ(INmu` zin#!7$j={4*7t)fmdnR}89`$iavRQZNvU**p=pJo{x$hHGkHnev?yqgAX;+>HT_3~`rOEb*p=)pL$n4&D z@CVBi$gM6n54sx|70cZC!{aZ5yg}iHvAFRLsis_BT$`!B#`kcd7C?&fY+x~#$0LrF zd*VfhhwpU{0p038CW}D1)34>Z@};q!^M!2w&StRII^X>T`#-%viZ+1=E=cn0t?(gGI!Wgwn$meZtE4!-& z`(z$mu`z{NQ1E_S;OF$OEb*`H7ir+X5%_~hw)k=3t#0p3dn8#_$IG5?HXZFFym)3~ z!Owlw?A{JZqWDi>8(Fi|+xuLFn@`l@zD=eU$PE*)$IH|7uRg!<*NCR^kBg>n4?F2I zMW@Mce)=SAlZCd0TWsbdzYJJ##~34wR68oJFHx;vn$VxZ9~UkBYpCm1AMmTS2ENm5 ztnXSY9d6<>Bbp%Y+Z~xeLO{kl*58jTEp&Zr!dJE}H`(tr`+ZR%)EQ-Xyyk?I&d?4r z2TJuHhv+W;AY25}ts=Uc!q!qrE5uQsbZB<)ae6#!ancAR8 zo3YonKY7iBd=@-i;oVm5?@dinw8;|cBtB#LJK2K(2m>lJ-=A#w z&yKG&8()n6Iv099*}Z=SZkpx`{i0a@#cvS-3YAs>3?7+YoY$m&&2Z@>_G7s^GT(ia z_=)E$sLGN^ujL|(#d@i zq;UquGb2vD&!>99_>#A_nm&zpGT7=iciV3*F0E#1W4MRPe6S3FVU>Xd7FGlt;<#^% z-WRad{9&j~bK-k#GHaa*HTx~iwB<%FwudbeK0)%_V;LE*Y1O zzL=KxK{8I!EI>BkG0sOc=So$i$25r-mvWYu zIWEBl;zG7Ez+kmqv+@1qiZn5JmqlIqM~?eRp3+9_RoWf@08Y53{9gEBW8r;6L$=bI z;@K|j(($AFOUe<}VBaZnTOB%|dZGUS3g<>$K0Dn?<6X59TElZB);e*xQqLN(+qOP% zNgsPW6I_W&%aLx5nMRKysP2<5%=Y?=@3`>4gf(5~asJKKt?lP>c+PGY%qM}<&{2Eg zttRFuq`&xm;+;JrU{+l&546RR>ng~5al-Vf8V|zT9~ElyX`UVNG&h=wfiC{Tso998 z@|^^T#G|&=D#~(KslmbGxsM3NtZSD_nq|8)8%S=UmOF%n&<*>*mPSAZ89I@kD(^UG z$W*D#UJ~26c7G21KaWsLi(e9I9vRf4luZ&zZ!|JRB9KEFC5#~CXM^0;e~cdvJO$%l z60eF+inQ%MT}>kw*7|+qS0Z_V`Q#g0Y)r#B&wQS_Cx2Dc{w`SP7tNsrId5h)T} zTk3$2c>*}5l>QrQEhj>sP?AV3VP7#IE7z~JEUK+gvkQlzl4R#*IpNlBhneenZT@fF&6=Xoi?+mO{h;FpK6TPmkIWPAx7F8FP+#2>(ixoI)&eY zG`|wxAGNQA_3h6zv7jou!zkOF0uZSU!*kEwAb{VCo)^2+V9>P9B}8!x6`pI0Ym(5G zSk$2^&48em#{}aYYoFV~c7um1eg6QhO%yGz=L_NOY5Xe^YZh16)>jVTqphSUg*?p2 z$_@s8WXQzSZ?zQfqxLP}Ka`E;ik(M5%}K7w-}CHPD@>{7r!L_}f-pa$nsHQ2Il2BuEg`-o43(<%SGC;;b8S7n5pN2I(UrB>o z@XQiwmq>vXrAJp-_k7B-sM6?Y7baC!VV;<{NbqSfKIlI9sE^Ajs9AtX`Q zw>ab;qP&yg@5PBVX&UoY)g)_a_a(wP*p}NBPD8NUwRa5j^06bht&L;ijgo4Oq+44_ z{izFmnn>oh60(+2<+`GRN|Ne0JweU~Cn&~OwU4Hui^IZ-*2!tEtnFdGhC5jzwhYM^ zE*jYF0gDjBZzLRX^gZ)h5lUpWiJIx;w4JxCjM7Dno&nv*Lyy3Y)#Ex3j%JTkytmc7 zp(M8=-Yby+fhHvcis}>Zt0N=lkVJ|>JXfp5s6^HQpk=p!s(juPw?i#(kmZ`9vicbG<{NXZ){Q_w#xO?I|Unm)yM#K2N*IDft(Mq%Q1f1pCq2B z`5em#@b!73z1C6vm_9g7;B8d+YhAbt;hV{tLoBFqX>o#&AU%+n)Uf{mrhxErPp|&c zAGDR1z-aAY`vkZ5SIA!0R2!j_0ZH29m@T12!P{4^AL*dO{L?&2dULuK~1A>xAvp%f3=Dv!ZS54oc z;7$ri`NQH@?Nj5gi1RYbs!L&Ol2#jLE3-a?bU5xX-u2^>&eFR`?9T2>GqK5Hdz0MP zU-4(*Ucc~j#ur{5@dcw>T&7wXz&>5fSs+OnJwYIkP(cI&2pmfs=L4TiR`Q#ct(YjN z>^Qgpvm61CbSUhoI?Qo}SBzlt`>Y^o@gKf!%Xl%_y~FXtvg;tm%F- z(L6`+k6G|?)YJXLmSI+Wh_5w zJn@GCTlZmb+3KWquc!5E3rn`QH&Ji>;8fgyh}VJs(?1U{JX5H6M@{hj0cV<9kt)2* zBm1$HcI(gw83VZ;=_+bk1Cxun<~}&nJUe-(Tx)uB!*ws3w(epjRiuy>4gupk6^|Vb zdU2ia+2c#nu5Y|V+P0ZzsjaP(-9RoQ4yhrN3Qq0_%Z%sKwRv{4q)*{nwD9cjZE<<0 zI~%K;Vpu$ke&*rjUmV>30K(4kHc3C}t=7F9XnMbj z^^Ix`I?L^M5bD~5ZX_XmoWW`lPU1Pj;~ezqUU~7&0QjHc9O1WIgOS+aXOFIHJ$adG z%_F_|0rBeV!uCtxeP2|%kHhy;w3e}~q(ZX310#-`Sb#^PWd8sW$Dn*g_?b6_G~(vQ zYmGsqLK()`TRb*M9Z1i&J)$OlsRXo^pw|r{joc1}YJ|XdLvEm;QY`(qo zRzRVX%T-f>=l~y;OzBdq8%`;e3biQ8@RgC$_@`cz#l9xL)-IR{uN(}!XJV)dfS+Xw zGwoZm=>7wV?XGoAQ&HA#mfjei*GrGfgK|WOe|sQOLED_Fp0&kCD3i{NA^p>AC$~U< ztyr{zeMaGJts#=?<+&F2@wAd3qNpU+kxB_gbS6#Bxjo9a&k{`XMy!zJl~!%U4&3_J z(w_i+ENMURiCg$@S=4^VeGI172_4xLpmCH1@K@zxMh~L>N6#KB@P@VFyLcN})92Hg z=?hz=+jqLDT<{J?Hx9Tw({$}pO)d7tYiwo*cb__MU=K_K!2098bJwF9^xP%X+KpLI zsH#VFebp5E;{=p1%CL@4Fk})w_6B-al77>^C(^WkhZ5=fi{@=cR9J1m%g7Oxob~xY z82P&v92(pBJFKwqJxNa6v-3jhLcYC+bDtu9z{{Y4sv=+Lgnx2$L zlM8vfVr3Z4RAEY&%V6YZp7p^`<9$&@ko$DPOL_j(tXPsI&kl3JH~@M8JWOo@y>&*czZ$9d{29+CHnW*vQ%&A9(Rpv`>om8Utyz z^7w8UMXk`fy?J6&EZ;ajRv9vh<)~5zvCmOllX(9CU%T*LrKMXSwD8Tv^IS9$^%J*} zT&X8lY{JQ&vf*6 zS2R=AYIc7PJbwrFpNO>kd;4uYCx7h$E&gL-5IFMV`Sy}Tn~324?Ql;bxUY*p5H%f7 z#5NQCqfeDB?4uU)*vw{E4VE%9NWXvP0D=KspmwgK$C`JAE_@dy?T3Z6tsX0R#Ku`I zV*vSq7wZ`<2$$&;!&sDFGP0G%QLWSffh+Y$U8?p22DB;jO6B#Gj1!CwR^8! zc#1%`LTjtDA};k}?;*xI_c{J_x0jPaBvMKA4$HfXI<3wa}L zn2S4trUEhCx#7=ly%l8X$Kkpr5$4_4$+Gy5KCz)SwcW4TwaZskEwPgkc4EN)01FT^ z!5f#bBx&go!>?FeYIo6_goG3qcH_?}!N`&@OOcis;3@oShl{=#Uiex|tE)+o?R=lL zAiC6TCAPDcIf^{mmyjTrg2l&^l2OUP84Jd8ITg*@c$PM} zxRwj4t!?tL!mSF0Ah9Y79Dqm6K{(@f4^dr~z4osdY5J?`8eA4Sm4s_7*7t7lT!33{ zRyI8e#&8ELD~!AFoblOBKiH>P8P^fA?raU%AweVqhsH)eUbWp8oPFh~4|}5B!d=65 z$!&RQbc)ONcZNp9xMul~vVbsm3}h475mj4LiuzlTdpr+wF4cFD3oto3Wqgi7Bd&8y zw6eXw)5gn7pR-1e@=fG7maL>;?f~uJob>1_3!D9R(dEMZ8A%$A( zEDGgHsgVfA0q3YZbH*!|)2}Xd3(Htu25hqVa3NqEjzJ5K`071tXIHzmhQsXfSw{B~ z$AfUlz@ZPw8O}yA$N=(q^;3jotXyT(=&Y`E4PMsn@_XjcPuccMYaA^xl2A8qUzh=x z0}G4-SFj{@zuCH8k6^M~NY-~30WPg2@l_C_4(Ph9}G5ZX1CFSCVPVVKuZc zwW~eb?pWF<4CT7ufH!p`AI`Nb&Y`4>pAPERa@lAs;_l)T2c9kFWZIGy>c4y`{{VZr z0=Z>Lwvv&zI$NWq_<^tK-Y34&G-0h9yDL|cSuP-fVsj_mjrW|fJ3<4IpOg-8F_QM$ zCYi0-UEEK7aJQ)Ia@vA1nIy>s?>h!a=aPNsyYSYR6X=m>vs>I>v>2ZDp64Fv!J<It1#*+yqwKHZCOsxZ;{r7mHN0hBF_V4(~bRQpzxe_P*?{ z03czq0X56`cG-0gi=W#t1cu6a?l#8RMpkQiaD@*b;5WAin(oUh>gl)I-{fUlJ#2Zi z1(nMNV87nzeuQ_b+J(QDX&?+2BOq`?4trLO(*(E&J3u@#{ln>#RCP4lZ6h98$K7|x zI326j>TK;}s`xt?U$iM~4>Koe_7%7Bddl|hFApuRnr)TlGJN|Y&zdo~k{3T(=l&7x z65N>;PF!I?$?54`ufz`sUwD?=NV&92cq%q=X{Bna7yC7|X!G?j(;bS8H`6fI4&-o}ES~Y0gh9ER~+DPN4IPY9@6C_qeima#*fXLgA@Qklf$<9w1#}uC&J|2CaLC}0$ zZzP)5=S|f#xis7#EXul8p>YI+X9TQ}sTmwFQZb78xJ6D@YWg14Nw`Mo+fwGU@R!DZ z5N0~BhNio>Qm(h!8ny7xv}(R+Z+|SR#l%-pNg$ReV~xn%vA4=LuRBLvSJNM~E{ebK zmuOmj+b!I!YMy+mrCxKt4BJ;2`@Xzpv#Uxmk1{l)T2hxRj}+6KM?6rvkjF9XP*ji) zLD22vmhW0=Dm%F|LgAyxW_$)vI-KCD4snhSGw)lL_FoJ9OC;afekavDDJYPJw$@_v z?Q$D#47`9d-QPLuQtICe^}Q2MT~yp?+V+IOQMC;s@T)$0e3&C?zz65(dK!mDQN7J$ zUK4gke%~cZvygG~H{Bx`48(m-2c`{V*+(QI=>!E2+L}?uFc}l|`A6$oCL5=MR)W@h zh~mRc@F95D9ouNYKtUYUQ7TJlvc}CLO2hY@eAy05emKo?)YdH>RQ@E-qkatPs}6yq zTFGrVGPqTpM)eq(0|ilE4rqXAp2PJX@9&_U*bD|702iNH}QhI zZz9^^`hT!ke<5EiJ-PBDUCQC3ae_L4c?a7B@n2c$cJj^OZxhDxd46q#p?4ha%qOX@ zl~{nbv1Fr!Zq@-ortNrx-SfWV)i{VTDs z@CKa+#4STsF$rGaNh3!f1q6p+-Us)vxX)fqc)!CaVw1%_ABkm>KeROPypT#`lNk)4 zGMwZ80Iy#2rd?`pzkTzH_&;dp#~2beHB>ls3`^PCI*Kn)=7~khT!`A68Z;d~-+h zbpHUBfpMS4zEp*m1nu04K*1e;zs|9zE5^r6qiI}hY@m?EWp@WB8O}3~e!u-{*V66; zJ}U6*yo&hL;ASk~=l6?}bC2`&tT^skW-QRC$RGyK`Qog3?P;p%Z^DSLB4f~F4U&GG zb?I9465RUp<1Ue=YMu@7l)98RO9JY*F!|QSKjow=A0YMn{@+^pKf;!N7rE5F97hj` zwHT)U)wqok<4gruJdmjc#KezI6!q<2pgtadhkEaa{{XOja`$n(H<903n`kGHpV<%ge7DqmJE-ZtDZH@$#=nnHfp6}_Ya=X(`&-OK`ALilx-wqQD&{rQ z_L6BFAMCqjYis*7Lj|6vZ>z$5QtT#Fk}x+xDdaMYAIy8vqiK4Lr-;03b!Vts+q2)n zC_mb9Tgsoi>jFW&sWQ%+>5{Qm$$q3|7# zjP>sp8++Y$^3P8G#Iv5-OFOeRSfSh>ab+asPDfmItnY~S)BgZ%_+e)c=CnFckV>RF zx1NN8J9Ru`iu9ib{2RW|d{t|yY7<-ETg9SU>Dx)^EJ~_a40JnvbIp0b#Z5YI3tsqA z+UZrNv$4~okVJF2qLwh*dS~vMhZ#mXuD_^4NzYE-q4M|bFRE*LZolC>i$L>e{{Yw; z_nqe2toHt5*vezv9ZF=9I`!|G^!%&o#is*Csg z3iRIyd{ouEBcqF5Dmy!iZ#l!I&n09BV}phVA(VnWL8t5g02J>0N231#X5DBOcIfjg z-)XWf3Zrs26(H`;PjT4M?PKhe8`i^gYj5;2yErhDsJC6>$ z=k>0K!y5LLrT9ZkTdVl(Cl+L83;5?La1 z{LOeD#h;9y68sl=ai#ckP|@EH$_lbIc zk=IxK)*ip+eV^g`iyPk;Y13L?SluL6<}BOYM|bEVWxm?635( zs#{5?D!rq~SAGm_gELIYj=07s0%G_fB&pYpJ`UZG{y|qe-_Q02_gv6V7Y%*Y=>-yjdrU zY{!bc2M>oH($3o29YVs)pSFJP6Xu(ZfhLdz+qr)00s+A1*FGfv+Pb%h^~;6uZoRJP zx)eH0O*&qPrkN!u&mkMjk^x z0^BItPURyQ2Eli-qzyAKvqch0tv=^z#Rs64mUP)&3UiHT{Uz+ z6x>a1r%k45+M=b!sxui=$deADHpk2WWf&dC4>`qKkBQLR*~?{Vq+7f^d0%dJl*Yd{ zeo}BZZ!LfaC*0SWJUeYTe9Lm>c4x20aiv=5R)#jxwb|VK^G5O8`FY$*n85qYRz>Fo zh8;nw+QpTHy|#&Sra}Fgs14C=(o2JAPvVoGC{6u;kU#KtC(f7)uEd5 zEBICmD@$+mc)(EKDOD;83@^-j06^qdORQMicxv6CxH0&DS5)0^d0~Z?Tt3mqUE6Vz zI_~3xTvCLoPS@A^{;9J zn~PfcxBg7dNgq7J@TbKZpNDPawbP&z*&&>mu4D7uH_w29w{iD|2PK&Isfz zO|wRXNC^NHBy*AKN1XV};yk*?hIK7B>=?DxBNAK9Ez3w_RB{K)l2?UHFWt`K2Q^Tu zh;rqr1#ABRT}IMc+~m9u@ZNn!8Lnlry7JJMh2Ut=yDoSH^4$h74?Xy+pYW3>hIDHf zv^&@PSS9Z+5IV4vBq}-RKwdG^0~pS0fN3r*)otzPxcM+c2@VLyAYkACOJknry?5Re z@X*xs>xht*kEZq45T<4wv?2^P{zu{_+o-Y>LEVb7L9m7!j4`y3Z6^T*ctad31dlrLhqS z*gTCoTSu@QLn6N_Nn?Y;oP&ZfUPV<-R*V*(fxK>?yiMXQKIcHvZ77gmqwTdOoS z=p|=hl1Q!wH+9JS#I|sERoh)k=52QGMe#k=tQL?YcN0x_JNa+902tabtNWqz91LNz zg0;G95F$ln42mM(wm-7QmQ*M( zK2d@=DnRBu5#h}v!hSaJPNU)(uA%Vd+AY%RQKB1p)#Q@gY}?8J(Sf@FoDw*0eL84i zpI@Hd))@tyvcHo6k!A@VPScVGO8m-r=jP*@<*f#_dVgDDSM4=A~x0 zW>@|8_H36ZrPPtf%%>-*_U5`j16*mhO?ly85W;ReJE}$Ybt2wU@wW^}W00j=E(aWw z-Hv$ezhH_>WVbIglNI0gXHo$?oy&~l9OV0oCAE>Dc_X@#Iezr2R#?g(ZO7cT}#8-s|KC|9?nsXw=iV+Qz!c@qA+pM zk(a8Pl%EHts$4FJJMmsvu%e<*{Eh{?C3Bhr{yf!rtYlY1-aa zVZ!`{@q#!%f3Y>{KN4&;OA9SB>qv&@QI_)SWU!dtSgnGf=^IM$4a=~DYCM+GGT4tA1?cs5UBP!m;~Wnl9=G{IdMA2}Dxr$K@SzReJh+*Bz!un`53AIV9uM*1nky-OcPV*u^c? z%D7~T+A|}P4n`CY(y)57saYe~{ADMN^luAIJNTem!2wk^8ay9njy$j*E3OFxJb}o_ z;|De1x}@`5+&jxF%Okc~rE<(qxjdTmTYYQAe-iZ}pA3UK;V(~dpPpsfC<_df_NbCr>LrKE69|d$BJEnAyD0qJXM(V%^5Bp z=?&e*!H*G{3dV@o>J((2oikhC67(%AMAd9ghj}&Tn|Wrp2{hoVF%rugWDp4EBigw% z+zA=vj2!b#KE&AQya%V--FVYTg5^cUwaxruNFS77a0-#!abD@-Ul(c~7ME4hboRB= zE$pFLRy{th1Byf=R(!*p3hT-~PJw&xm@RrN4{yku9pc z^Vo)H-bP|$2OxkIM^xxqiLPiihZ`x{N1FUq)$VWa;#s7cM}m8V4=SqU5fS-9sNi~> zpL*gWW|8>GBz5Ate;jHfTDZ0`#)~UEvk2lTmSu?aEZpf&KRE^Slo(CCdPQM(?e@JeW*_+O-7-pZD+*qeru zSy(D8d!Hnda0f$QIs9n&<*WGTTGce`Tj>|Vs{a7FiUr!TqMQd~v@-%maCqs)Mr-K5 z2zV#$zYQ{>+F4|a?f|f5M>|73W zlgH;*Gz$w$o2g^Jx=UaqGDbGBYnIQ-iNOH2M=gcNIp_~paq#~DL5ES+tXwgmLd(_eN`@KRaA$gJ;n^yBxKqUsi#F9?j zXFp2fe)6(*AE}3{ne49IJiGAQK!9R3Wl}NF0>gRd9s2WB^zB|PeW8*5qQxd0ht zj5|W3ISZbI@y}idy>vR)hh@{CyN^|hdwa;O;t@vis;bP)3r5>`4YWzu<|Ph!1P<2o zz+MyZZk=j%4N7f8QkMn3$FU+%-AKqFU;+ktFkHxXRjpys0 zea@F^A+}{ARgyQ{Rb01os|9ke%^ zObc(RTE-)}()8PuPbBX7WML=Dgp%1%M<9?ZmN<%3{q9?Nn>bDNK7NoJV=OBs-P={< z1QJF;!6T>`?d|DJa<>-@?I}{@=ayCHJY@9s>0e*!p9%C?=diidCN`SH)~ja3Tdc-X z9azXrVE+3Is!lM&AZI4L+s8Vlg9eEw#y08cRsg#aid90P&HdAiz!j>;`Lt>L;-%l4+ajU9#vBXbe79(v&O z#ckdEO7RzsZ8R+^;nL!5DfdNq#P2GhMkG1p3;-DO@5!y|b6@v%UHg)rnx3QMSX)ZB z(<8LLc(gQ$3yZ9^b}hVfENv>1S9i?7 z@#b(4an$#v5l3UD=rQW^PaUL!S*)OuL$pEMk+py&cM>oTMhNFZ=4L0P|__0-uXCw`%<_;a2*R*zhmc@5$fS-AdlTaRDe%3XgDXpMsLM6gz%rG4e=UoX$aO9mNaw2e z?_R&8`1?%o`o*Ma*EZ8%7^7LrB1aUi!+(+rlp8}a&OrIS#dJRqG|e00RF+Y+dS0Qd zTfo9=KRGS$hSU$tgy4k?=jPz;Ba$gXw5ZC`S`E|YT5f#6AI{m9Ur)wmzOQyUCu;N%QTX-%XC5AUB{o9tvz~GJuC#}-H z7)7S(aNI|)K|Ff1JOIpzAKF;P9b-pxl@bluVS;cwkSX3Jv+ygVm(eYz%lTT~I7ybw zDu*SXaUNRZ5&VP>9Zoo{UkUgN!X7BIv^RSGsc6>s3~jV&QRa=%FdL%Eksu=^l^6gV z*F3Acb33KzYs%k4ht^|)4-)9wWZGr!n2fAuf@#5-L6ntk&4HX_2l#*(>s0lNiz}^0 zJ7X{jh=LckDrfxkFB z1R!&do27G*{i2bbTAm))udlu)+Sy!OJ;l1uE2x(Q zt9_luLmqpcGmdL!aU)t=rN)t?Pkn8192ZcjQ!>O0eC9ARKJy$V2rS)e(>xd8O%qbQ zw@o_M%IjK?n_+z`$sCPyMc%*N{HXxqx?cYY0b-XZXW{xi|;tf0EIv~|9Q|FkFki|9hRYU1!;H4k&*py$^iLUUo*aLK zwmM&mZtnD?f=i3jc^#pdVu-^vyR)b}wuPCqjGmlVp4me1+dNqNj9+9#Wk@;B0mtTl zm3RI-(Cu~K*?Y%w8)(JUT2`wlcPh?=Sj#B|@^O-bJmaN&^#eI8cneq zCnZZCn1?)V9I@x8L*BkN{gSk+Ekoi@!%Iy<(i^);?IlTIxr{Q&vhx5HJQ0@kueCpE zs0F3=g&?*MZRAS>YynCPf&gx->;+YQY0KnA4J#wZehB;!vG|#x-rvuAHKqNQwc-s5 zJ)Ep!MV$ngKg6Z7J;1MM)_f6f;E&nP3wT{+zf`sRW};IPT|QAuu{k**i07tjhWJhK zS5&g`2Z^-%KMUACp9Z&kbk`bmFAy!~PK2--20{~YJZCIx*uEjX#dWexWOQiT=$LHzBK$szkL#Dr5jRfb2(^4u_BC+O7MPe_|82IaLsd4OR3*Z zR;?*?6TsgXJVD^Q2<-IvE#|XV{{W9`h>HSP@^T0ypUSsL>{0X& z5L=l?lJcQx7w(1I&*6&Zrs_LVmd4eUK37_v=dJ0UAkjQy;amRz>}^)lRkxN0gMF;Y zC=5`Iq^To!wsT)4%?bNGqR2SJoxX>kdds%Oz2sN%E8E?u0A5?Mis57Q5~&Oaup+ee z3x=I6{Kd$2d@(!{aqHC8&bqB7b!LYQJD~@y)3NA33ZB{<-2wc+GEuH12~OP1R^%uCfOp(obApwpzsHVJRx%(t-*r!;Up&5!cNF`%HRjU1dMGU^&Jg*U7P6l z8f)Im;;Zc&XZpystp-!H9mj(KWnqM4l_w(}wZZIW;-KF6hrL;23 z8Mq;gdsjVr_N&@IhIRdR)K8`9x@M;tXWE)|^fI_A#aLm2;D!tidK}g#j`V$FNYFK# z9aBfV(eAD+C;K|w7+6d475Pj~{Pf`Bv}&V``UG7j^k~)a)}1rhYHt$T#T;7ZsS0_w za6(;$^yFjAWzPh3=rLLsdL8wID|L6JO%1pz4ay*z9#0vPCUsn#4o_SP;l3tm-Y+_C zn|0vHZ`O0-JB!=6?C%6FR#IGyZ&J&Wcp2;OT?fG}C&o5j0r;SvGP2cDQK;K3oyM~a zfXfe@Gv&Me_2_sw>0M5oWSqT!Q;*upGEwRO01f_Rmp4{+j$UbFw_Uq|nm~l{&r%0o zKPq*l#jW69vd)(YoC6!HW3WFif5@&k;xCHsw2v5gH$k!S&xm{{d#lJTE#cEcy`xC? z0Ry-bARjb?Cuukzg=>5wzqs+lJ~nMv#$WK7Nuk-Z`FEOPq_9Yi2z7Q+*##JI^_r!0 znQpb4jg1?q1kNT_WM=2)JPdX`{{T)ZwbjCDT6CAPFPvg@Q;+Vm1w8v_H3x~jb7A3) zSHt>K_U4f_MQ>=ppq_1-&)w*mMRX+w9!Pwf_j$7$>z1ZD{~#b zk*ZqHXARN~V*db(h=ZriFn&Q8JU)GSt2$&lwv2@7TGjrY8sHf0ZsoUM$~I6(*0gwq zP4czHMrE7xH0ABc5}IbhcH zn~h2c51ABqn`8^eCwWrCxA9iBkH+5+_zv3I!$-J)>4kjMjjhA-$2kq?YbaHWYV*F| zUcd+<6-&d=V0S@5%v%8Lv=}_OjR2gmPJFbD%4`dba-n zrUQYR^B;)X>`S0)Ib*zieE2^}foD?zn5^fIGLbkx+= z==s{);qINT-s>8lf&MJ$`jy098nn}7jkLSlTZfL;#@^OYrdX69VGA+CbT}Z4*D3K6 z!MB$l4AVSWt!tB9vzc_5boc}^-P%Zz?MJvx&Hl1AS#k#B<~9!8S7#oFYp1o5p4!t) zzn)f&A&z(2K6VL>&H*jC9DfS2Tpn>VUfkRGekl;w2+q+- z$vpXh@^C@vn&*}+$>|f8G~LzL_4pqj>GNq(>+c4)c|NhEMx_zaBrM~e5Izd7;!ZvJ z&nC1rNcO9z`tbxB!n5&Y=f#+%ULzHBgK&0@~(CBW1O^xRu^_Q=j#r+=d@0PrRc8s_|S8f&5QDhBaUKmPHTSWE*_@o@RT%o$1 z#mmm1G{GHDpCKbI8JWf#?-<;5;->INhNAJVfqARn-_1LD(X5a{l1UT^A>85eK0q6e zK^f>yYpL;9!TTLAMX|P?OI;%R*vdZd1#_k;v zs9!NmZW=PD7$YMa7#y$juA5HyJ>p$f<~yxKK@Fo};J&-NOrM*8GT9hDxW@;f8RPtE zq3XX6?`>kf)0!wQ5KA2LeDTVMoyx=v_5AZ)wChr-E=#8)878&pLt(n&XvCw+5it$s zL5EBBoVZOjx+h|E}^bn>5*A5xt$@99fdG*xyJkqhR#^z@J3G~HC1CN zTb0<9dQ_eohe)-W=To&B1E{jQZ3kdjyFczNn{6xQO6k18Lt&lsXB>8 z-Twex4uxc{%xeRqXg z%(Ah#TT8pS5;FS|-(o)K$=XiV2_orHdI^#aI&gvF%!9Ba_-VrZwvqckMn`diVoJ6l8sgM9p2pHg!NjTz^u+nmT z*DcN3>qC8fX=ABd>J~Bhf(tuY%wJ~nVg@KesLW2lh8sx$gUB0r%~J5jx@`W}E#>r@ zJh4r+Z=$&owaltjR!qh|=)+@gP&m&S)zu!@s!DJNaAPaN@*yOigU^8AhgP)>LN*3_b- z6&`DA*QeY><7+jk?O(I(FE#%F{3E*DmYSBGb9-**?Y3g!E)r>?+`G0gRepY41uR(P zl1*=45A3O@iVZYEwvNI!NYn_xJSf=IsX8FxH<2;Jm$Lb zG%9;4P?Nj8zWt9=E2$b%t0&t30CmnY;y=dt?Pr@$(80JUL6YK07)Fi&$Wgo3sm=~7 zgKvwTF_HY`)}&9DjjFvpI%M_lT%Ma_1;nUsZdMe|2;{bX26+8_YpF{=58BF+OLKCu zU@w|V?FY7SE8d}krAjGAE7k6KHSzW9Ny<`d$!LVpz9-*V_@>ufypg26ibZC+fwnoo z0}Q8wz#L#_-o3BlcDnvI@TISh^a-Q6QtNH3K_O>oCppFV9>rXNl(sfwPwdJdvq>4!6>z+aN=DPW8xfX{P zKQrc*r5pbM80v5hN4DZDu$p9Os&(=jg~j4)}9S@X|E4T6Kn>yCI(1+#@k!xyyAqA#?N|mDa@^&G)Y6#xNCI zXve1<)^2u{(OkDk@gLcz!|S46>Ow1habh+|pJ>%Icd>>;_L=4V*>)w*@A1%d&wA)S zF==z_S}wI^07iBS>Aix0Kvu?m`VXyoUEqsPw36U1@|=?+7C)JAK_`*vk9S)9 zYw%$5$W>c0QD!2QF_lOJ5M$w9>G&)1Kzte8M&| zx^&005%|`c>Q)xl(JcBMycYMGa?f$+#@=CNW<|lsJ3x$M9FTsP@@G=K6{Q>br}}@x zo`fR@CujGVzYil{_+9G}`;B*U2<&)j$I(1Bs9kBcji+hzURtH)=IQO>W{oox7%by~ z&U%qvf#A;%L2>bF_Iq3FNzJ{~vJWCgSwfr!%C|o*@-hh}k-KwPI+m%X$KriX-s|m} zo!nZY2%hdne<+4<(URG~8@TnyPC2@mm^k0F2t~qHeNGFMM+}0h{SV(uZGXdoB`C1I({CRRbkG?b>#F@^B#ov2C{YE63u4%t@GZM zl%#v3kxLmn?Z$rbB=eKPs$ zdS)nO6U^M0umCH74oTyuCpEsF zAhWgjeD_V__@UM&mv8o55VOcsxKkvuvhsLh12M)_f-{QqJyP~vR(GCTiEdv!29tL< z3z3uPpne0ett}$T*JZ%ATRr4=23vI8Oe(T@gI(&p9ejPI>|BSr_-#*K?)Cv_4+UX5Ki+ zjW{_QixPNM>+k7YvWuTqQj&@2pWC{9u9a{0IIi_cVYxrq=__tn6y(2`5euithYco+9w>o*MpAx?@IW)9xa2$V7?$tVy?U z%8YC*2M2-$aXMTpcdS}nSZP1#M&!n@Ttv4l#{-R{1+X^pz~|i6(Tr4Y754<&xtA`L zs9M`4)$9))vXdG%K=T`^1G(+fryjLsd}XUgqn#QFS}itmn1MpDQVS7}f1QV~IQFie zUGWaDec{bR%hv8vTRljm_OV;63L}^~NlPa=-X%g9@EKHaNUSXzN}E!;hR05~xz;3i z^DZspwd4>oa6u=4GFv0PX|1m`V`jD_zST7!v1xa@#r?*kZjrsUy|<*~qv@8~bFE7wzG>XrZH$>l5)-F zv&A3Et+WuNsA6O}8RopVRjVkw`tA7jH&OW+9yR!Nu1_YBu4+CZ)Aal6J2fHg?eig# z46TU3OoUv5S%L5OanFkQpT+vei*=EAsa_pI-6Yu)s{&VrJCZQWM&PTK1Dp;`d-j9z zKg3P;9C&TuwU*>tMAuIm238;sDT|Hc1Ml?vtIc?4#E%TbzB||7Of%gqbNNmpv@5mB zuw~j5Wq}8if^pxP>%z)Wn~g+*qSKiX%dYE8Zf1*Fx*};-;2B7Gm5{awRs;e!H$UxF zGvXa$$pyrBGD@SLw8%@8c3r!&c=?x(=NZRCRj;)>rqk^9&;6@6l;{pz%7bwG;P(W9 z?~&HI{{Z+%Pwv)O4r0h8g4p8#?I#(pVJdDqD>D}^=eudY72is=n$q)BW2xRkWRp>{ zQy>@#`^N(ejEbZw+l(>vu47O5gQ~-Ce{&VY$S07MOL#&??B9KfYz1NuP&4U@$@qDs zLE=4X&fia!?r*M@;qx|(M69ooD3J;bsyhv=cpY*$^cZ|2r0B81ZknWalE{*$_HLtW z%l4aWGLpCqWy>y5rdKLR$>8|&RO!j9YR;&+wSI;d#4m`}y1mASr^yty#@fw5lI|9Q zc;qFrLH;06AkKNf$4pjEx#E3uz_#;RL8!5Y@S~)*x2rO?#@>aBu`0o^cAOAY9-tcO zJXPSY5$khU>rv^p&lS6gEu|2THaN)yEb+HH1_=#paI4q74n8!3_x92z@@sX7$%QYCmvn=_~Cw_XI9-^ebut&XRShfhIEH>Z)jE<2EBee3u2+(C1%IoVbAAYKk&FH zx}3`(Iod}!9sTRqA$xUDRYBvbAwQ@U?b7%W<lHaY%dh}@5$qOQar zQ%P$aYp7c~fU!KPRZ8szPX~-IUtenVSbo*0d=c@lkt{OAEUyu*rHnet9@UU5Dd;lY zJB~Q7KJgW!MdDu&+cNygcW^e~F$!6*NWd7!u5(_8@UnQdPXYLgPMTDO?^+9Vc_c0i zJ``m4KEM*VOWRgcW~>uX9+(D_^TP4S(kv*Z5&hB8f{+S=VSPZ~{WEQqQ8oe2A- zn>pQ{ze@Xe_La4cU+hU7V63vJJ4quUM;PhXum1pEz7YMEd>?bBc#Gl1-JYwXYS!>y z+yo9uHr`RT8N9ihg(TuWGBb>d`_J~Afxol#X_QFbT+-)Z0DaJMNj}}HTHnKw zg&*Qa=5c?s%y8Ivt6mLvV0e6t{?SjvFAzW+#y-Pv zOS6%K<*~_barLbd(!$sD`;zcc=+J($AIaEMScPLpGj1$6v zj`iyQ00z8Ur}*9F~Cu}<-7(^#x%&Zs|%CL&HsUi|aVjKnD7I4UM^Tcer*d;x z!Z(6t&DwHDj`*4n0O@`%)UxV6)yO8{V15y%}}Z6I(8mlz%S?NKUCJq(oWk0RDQFXI0I z3Whq5g|C{>0u9cU{+$qBnZo@40GLr(P2T#m;iq@Ud zWlhFSdY?3We$+qVBk;$HbtGRbT&SOBNjLx30OBadCD^RJ119e7X92S|?PEtKcVS$Pt^LWLal zY@f=eGf;%5Cup8`^*H2fap$B;Q9#-WZTK9N=Q!KX8O3Mn zJ{+^~4~lJLo+XP+)#h7SWJmcUStL+neZ78CP8%bk73NUQVvJ~ zh+h{|;%)3LsodJdqiLYQ3LA~c3n*50EPnH2*MY@-mGBqi)vv|BhF4mCr6gL8ui>j@ z3ii$q+bxlJaHHieHi3hI&urI>T6kl_`bGAI2DRbK8_6YR5uu32(jb`z)nvz21Y@@D zYoOHp8R12=PP65s<(!}EE#|bEMXpT-< zdroWWbl(n_NxZr7y6G&}EG;bOoa{)%XoeXTxHvcp6yWD1``0`11He|EDENsUwQ(?6 zZ1mfxe5bl-$XB+WGm(M-B1LTT_jw(vUkD2?3F;cAt7mU;;{O0hZ9R%wfJc;ws5t~G zs>FaE6`oK*w&#$t3>(2w~J<{G4%B91_Fc zS8sLxT@OXI%V*?$8}M`CUx&P7;Qbd=)YDJZW0|h)AIP~&h};#H-bM3N{NKjB9stL^ zXL#Gdc76@;>>4hWXttVkcXyG&Z5!@XR)z*39G>iY(?4b{f5F->fV4d)Q1K3nsb1P# zT~60Z?EFh{B#*SZD*&MK2pd4>ftsmx;zN63XyId$7t|&;?`{#1$CVm36?o22sr4f` zucmb<;jq!CPgeZ;t8Q{q=9PYFYHHE>pyg|CkbdX_oZc4>s-Qi3*Q9>N_WEt6hw<*t$5FbzlHXCfGOg8+w-GXVxm6-Z-ab># z7wKNK7r6d^sS|&^U)S=Rz9rW0EWROpG_*}3{{T?3y43_u@ms?sv~FgEDRz<k=b2MWjjirVzsxC14ko-W`;E1^yP-2VKMlX z@S|0Rdsxk`yAXZ%DH9d@MY}K?m}h~|a((k%f9%O^t--7OP1AKfSMBzZr18TD)#AKZ z{{Yu1`4U&yco_r(>r+oyB5G+iV|epZwb8sm;fv|CYkf-M+xtRTr?`?tbYy_O(Y1j2 zieQordUL3s;Z5h`4 z;cmXp@XGNp5^V*(XxpB1pT@aF)1lL}eFob8+r%=uSRK=8nrOIz8W=-5v-WZn;CJ8- zwd&gF3-E~vgj<;WU#Tl1zVHiN+_~g|kbaf1@W$pC^e9!Mk%7OPYs8trF(xuP`u47G zWeHdIZD@8XwH6!NC#m9k0nofrt7*C}k*&^}o}$jS_S&|MBrN3tE;k3l;|Hki(A1iZ zjnrNxeJ=4V;+D)^TgYdZFeAYuA$`SiqYB43;2d*a^{8ChT z%%=87hWuaA^-CWK+uUh7zJU|mOd^JTHv7z<%ja;6nAc`V!G445Up}|k?R5=SEnYcn zq?y8*USl+u0f;#)2@Rc?Ap7*sA9H+5_-pXxJQ1l&9fyZ(ub{HjTG^AyRz>;1D;`FA zkT40!z~>e5P0z!NO+&zuYtrghGwQ2-_B7GvQEnzjV2JP+lc?InsR~B*7{&?b#NoLm zZfy=PZ94mvbdMTn{vw`39o@vY7dvFRwUjF%VU5n>Ko^`Z%yW=C^se{C&v&h8R(>3^ z(k*n42FYy|83^uvL_wD$yZ68rL-2EpJAXPuJD_xQ+M; z5YO)xG$e&pD(4Km4a#~~WuwJBrY$^L8#M&nMGQ)+f`2j>vC@9C%~Ev1!&;zuHil!z)L0A$etD90K4lJHAzA0AvAzYdcc-qXvv@Ev?`h z&xkJW_uM_it9JJjyn#j;3pw1Vn}UP5vvtbXQGen1HP~(Rsi(4QjW$Lr_DISwg3H0;78+cLv8tT_iq~9)!aU5+LUD{6Z$uldlY!KmqZ@HB`spl1?FK1Qq z)P8=yFn3y{Yigb@)x2He8T2_X>@^6lkJ*-cXF}@A*-xvHp zZ=u0-XSchzlq66aO=1&kD=1x}OJ(EDKv(Y^;BD(%(^z?EikIZ;k*bFlN0IPmT%c%{>YUjLh;_Z9HS2i}DBD&NsF6{FI$$cxyG6iBb{Kp`J zoPv7wuTq^DDXvwqoTRO(m80C*t%cgQ=9m?Z3pI?HrrnW{;BmK(I+8%a=C|~Z48axb zH_dTrJ(CuN^!fD|n(1x=5Hq(QD@Z<4po|bRp0Vk^AA?MXN7irb*|gh;m?U0$31($R z4nly;M)h-&xEuqHr_%g2@teZF3;1huqFCwct6yqDDX!zztWtQEMnWXo+lj)SRNxc1 zo(@^IsU@;kxiL%aI}h=>va|3WgQwhhQufbPvW6uW5=zaq1YM*M0)>62BRO8U1du9O zc)wckzl5yym@h6`!tltj%|0J59?vuaSQ2q8dBNj6j+ye;zr1)YA=F*3?1Ve4V_Cf4 zv##QU2pH@+Ozb0}&M;QGj{x{`_gubcY`o1%-U4uu$%D0y*)}T`z$3G5$l|h`oMU-C zg{yW)yUF2?5qR6e-WTzAinVKIwz!%|v^gT0DW;!k4pgpu%tq|U+s;*#k&yXlAiCDP zO9rW7qg~51b|=iamP|uvsM8RxAN$N5P-KQ7I9!ZYMW2B#{u$g@-!0s2WhU#dBV8a_ z`65X+`H}p=&Uf}Kr#xet_FsZt3DmqX zf?hDy{vR8uEi@J}&u?cg=XG&7|FoT1LYOWYnpzk@lIV+#eN|18m^%%t9L9{ab2@93K>B_ z0>%IffO>5o!lQ4l{>XkZvG_Uh1`BJX&|mm9ROB(>?IYU0mj3{Pq3Sv?{h_=v(b?N9*1AkDdwCj#X-ml=DJ z;e0oIm!3P-KG)_)rRq%>kr3g6bO+e{wffcJuLpcP@Q1)355w^O-aqWi%aJ|A(g4>o z&Lbhb;yOC;xabBk!Q|FWAdZWe_*Yfp@fKM>CAoQo-njQ0zl>0eMkh$7J}W4gSJQq%#PnWYh; zvB<{m$NWWnr|?%T<0w^-yk;p8_~Qj}&+@OSyh#PU=fg|b?jw>_S%LE6`|Jk8fsysD zdD+3Iv9r;pS+n?~r1*jftn4m6({Pz4IF*S8GKKI+0kM_o>r?CB5jCwk-qE808sgx^ zv+VAT67n*|l#Z*8xvw?Qbog{DrPMV0d&_UM%{v(wf2s3Pc~O8E0}vb&gOS&Y={$3H ze?Nw1iWf~^S&m}%>DMWfxwfGhBP=@eocA^J6>&VX#I3ZM7Xz2zc2)VarkkA z_>WOsZ-MX9>rg>InE;A;AWM1CfP4-VDd2xG@0z!)UFo{C_Hx6h-lPTgh;J~_G>U*6 zyY>Jm2X_IFOq%7VnAD*uMta+!tHUaZHKD}#7R~%UaemV3NoDq{V{U-XtLBy};d7pI zgOTmeZ0tv2HRO$V5?kE)AgjDH1D;uxHjk8!LHT&W7(Hsm=ZdUs<{Q-ppB!ooV%1{9 zwi|iLJU$d;9yWFuEVte_(e*t({6jQ$s~i9bRgp8ebB+e%gU{F4*EI1}Dz!F^eVU7E z1wB&i8_ zLlup(z=Mp8xFq!T>yS96m1>gbZpri^)KOfoQ>E9hEuo6c>JY5Zw0MBT;o3OEHq}xy zumVpUbv^S{u6#`-x@*9<5|~j#sFnBfbJ4O$1B2V~>s&^sc>uU$_Oj)1^Eoe$NdSSI zH&RI>uhywtYOu~GU+oVq6;GEZIdVGz?kl(0wzX`*RJO+4`pa0wBE@ke499l(u77993sJDTD(7_Tqnnn$@-5imnN!xaoUz)bWQ8LelMM{y0CPDxPZQ3-xBH~@aW z^vk1i;shxpIpc2B9A^ZcK{)+571sr>hHaU#=~sf{3Fq5sln?Tcln!vjlaF3~1sa@I z)7{*$yf-qcZbH!p%VZMG&pWV3^cbttM;@At30aqUU9rj=pIl_A9S0fdRAJVWNzs1B zXPPUCB{EBGoJTh;i8wr*5C^6)+PaiEjN871dNqR_T+eP=*^4VAaIVE5f(Cid2bIV0 zjO61P8mE~h?2@z*!5M5v8tqO700DUIkLO!)_?mC*>&TKh*$VBJWOAwj`=PlY@;ZBQ zSTHPi7IMjRIfx9bES$WL#z7vP^Zjd1TN2tu3yB`yeY*mY)m!f`83*;MhU+TGNnDP( z!0Vnzdew5lrngKYow5-tV7LP~&Uns71$2kPx^AbSUh8)LWF=16^yFC>XOdN0%OQ%4 z6Z3E~NvCIfGbeYk=Soh(HYi|l2o<4Xkvt+hm-6AOkzLz!-v_=CV z!sHXXuifL=)R6dcdEvZ!fY3*3yJcwz1st3zvYvX6PBHYMu_Vm|q$=3^*YF04mwFzSMN>BKFoBsS@7i5%#$4(+W^HWmY_KyJ_^u9dTGQSX^4N`H6*N zwOAnmy$ItUm0r{CZ*-f~nU&*`JY^;uLn$2@dX*!$LMmEMKuxA>>OT=RZF5J9O1;%) z)GT5!xQ^g&Xm%?FK*#0)Zefh#uIakV_-9$Nit6L-T6|tyo8c=f#3Vq)ROEn1pg9B{ z`K-&!tv~(}3tdL~(Wimk{?aX^07|$4N{n%|=dWxI+~(R}EFxB1%K>fQXKav9MQ#WI zfMd-HDjLH=n{Db|zLLRj?jn{egK7&t7n2!024H*i2S4FmHj(4F^c^MPv%0#8?s9G% zH_IXN$K4+=&jnes2?cliG6+-zaJ9l)>i2qXs&DNj`wiwC%z9nVyo&J ztXhr!=rqfHHVGk&JZPL8C^;F{#>1Q+^z7go0G_LRAIE{`7d zR#jnwHoJ|;aDRE6bDFcF>wX=w)S$R=bu2MPQU{0kX&EjYCRAjMv*fl(Ov)-uHrVH2S{n{fGqC@YQ$+b`$S>mL{8va~Q-T?t{^5@ld`5N&{ ztrFG~ERdr{je(CaWOJN-I2ElrGOU+8Y$v1lS-q@kTJ^q>f1pY%by%cUad;*TwSnE0 zW;onN?s#Lll=a1MR$6ScMRH}dnnoxDc-#qn>-RkkIWEl$SIGy-N(IM z(sWC$e_qqH`#ZB|=9W-FDW8=5yO)p%$T;k4(2g20*i+Y5a~>X=-@-cW%oq1pR@U&y zQsUmsZk5I}_p&x}03;mpM{{14px)^h8g0YN8povR_fh$5mnol9xDK7sK^h%6IVw$%RswEqBl+em_I<+iwin+=epmLqp{2R%XH ziWK)5Go=;JM*A=&8@efm%;x>h%LE;Ph%Mo)Naw5oLg_9q5%K!{?;~1@Qw5>g4 z?!1ef?;VlJUfc$}xLv`sBkIuw@GC9X5-m^6) z0^DpMVMsiVy#E08>(c61L!|guE-!BIrH)si`q!cBek_B-aoauWOoHIL#377GPTioB zmRx<|>t1#6o=5v#-qDW1sJO(OhrC8@pJ(A!_>|l~_YCCP@JVd9m~W z4@xOUO|4m0is*id>Ke|ctVM2heMD;W#+xH?5DufR2t%~;dgJsJ-h3I=ZFIdV{&{x4 zmp+#+*$R@3*Fg!u>DR4!&C>btg?Ni_0w|1-3C~W0^ZHiz!~Xyr*!(-Uo@>7hP2yW? z_m1D}Hp_3F$jvISGP)t#w4b~=#yPK=Qnb@-_T?YlxTJh{`#A2>?>qpG+oiSfCZ!8) zY@98XyWsvSU#Gvc+)BO>x43o*<=lV?8T-Kgd;_0s*N*7^F#VpJTZdM@@UExfeNXK& z-YmDeZQDoZOBa&vWQ)y!e53$(U~olxm&BimUK9AI;A^4bKNM*i&E=bYsz@v^;hJPr zRxOkaN}L{q16?(3C`qG|mEkzt<3DFRi|hXY5bLrFYj=v)dq@?dhB#mTJ>xlIstX*R z`8@k#zJu2h^$!GV5zc`cOIeka@&;r70PEM2d;|D#cj5JuUH;rjEj39N;Mm}(QmY(% zvHQpOP3iZ$*RUL3* zF~dYaANcsMt$q}EZ1}^#p8)(uT|A3RIpUf!g_=<$3=m{}&(h35dAXB^_Z`^P^PG)s*eUN+F{=1FvWq>nxs)W^-U0P}Ctp1lQm zhs1x{j_mwhvhenY1HpvcGaPG_wGn^5SGm7i{S@AbUZ-mXQ6>Ga|eW(ltSTuo( zw%Ieac9K-F$0KOTARmi7A@M3-1l!%(>RK1sw41{Ph05OBM(=8^GM5Mf=5w^MC~~Y6 zXE`AIc8&1!Q~v;jGfT46EpK%=8sJVo%ED+IYYVf5JQC1=Zp} z50YG775@Nu4blVt@D;uAve(3VH-)sDE6=b`r&+I)_LOH6O2au~xWFZW&phWmbgwqm zz6$8JvFY1HZw;r&$uvQ?DV380(43;@sKCW^#+z}v+_ygCJ~Zk!{u%HlxvKc9!`gPW zW{~-FHJI|JkiH{TV!twiSPbnL&szNA(e$lK`O-UhyvdnbOot1A4hs@b$}^s)xW`dn zqMx?+hix<;+3@Ms2+(QP+J&HKi?d}uWx;02>Q|FrA^2u}BTtUrYjpCiY_2!j$1J3* za*)G?IXEq}3L;T+uRsgUGUL-+f*?! zh78yvh6HsDgR7|c;vEygS2j~Y4x$%ywn*}%(MSUhiVrFq=2CW^m;~@))?>B2@hrX| zLq3}{%-1%J3zb8+1CJ~fLaG7zNXHB-C&jk1$$O{juvkuD`(s)QfiKTFS0BTYRkMun zGuy8pFw}8X6zeuQWhLzxv1m0m~ZZZfe4tDTP1$KTA&~$%@@%dJI zwUj#4gUS~2T?I|i?k}*ZJxp`d3lZ{+Dvj73K>={X3Z(QH#bSIO@GpjC zziIVP6E>dW;x%3I?;c*(K!#R0P^LU{z&TdIt9~l@ZK?PJ#X5^d(cEb8U8S|0(W)RJ zmNru!+>&==7#ZA8VN;2QBBdzeBRv!QzFYU}*s4;DXUfs3;Gc)S4EU|#JHG??=Kfn9 zE*on(;kkleDZIrJq010PFn>{AweZv7H-h{Z55)Wb01WGXefNvn=IN}jZsOds%CayD zGB68EfDcSp&-x9dnzYhgGuufNnTWI@n3MA;T)zdkZX9Qj-0~~CvYO9w#6a?!3+20R z54H?)+%5Xs)4SfY0I49WSt#vkhkUkpg9}+Zg5B~sT zz_UrF>Q>hm4AKN;jDfv@Bxitnb6%0C_;*~=ei-0LP7lOJ@BS4u z%Y7BSwbXtjd1w0}a*XH9+`uu&1Q2oVY9ELC<^GYT#4NN4b*S3u$%Xi!M|VfG>?fU)b(!-L3!b=TS_r%#^6fNZ!}?+Cp%cInHdD&fNPlXJgI5% z-&Ku>!t1t;_%wF!)BXouiE$*d+*!2NK@~Mv!DYGKw<#Ko4TC>_)K{B)M3CG3X4a?Q zfY)|YV0Gfw*njM_V}2F=cP9h!wdMXt1LF^dzXp7N;t9M#dE#4f-X8FtmwTw`c6x=y9EJzmTjBXB;NhNF8q5o%VfZzrBsXAT3BS&PWek`E=H?+WoF6$syU&Z>&i6lN?g5puaExsy39G006Q#5~9{&L1 zW~Jfp7294-s#EdSir?)FY;H;WHQmGwADT1!#wa(OsbidH+c?E`LX8<(8eC3NTC?ALUGRrh z_<^Fm_4$`ezO#YJvylS-0BI_)EpY>YsWM@RE*X><$tMlC`Cp9w6KeK)oWeP*@3k54 znPs(tJB5HWNC}q9 zkjlu>$&x_995Kdg#{6BV=r_JP@olu1mb2R3>w@;vQHxWFUB7uLB?)hsF)SF42}93X z(p6!GjHI?(f50wKQjA|#a=M&aK7-&d1f51`#(;~`ING!M@?cKcrC0Gwd@ppqD)$DGh zvdVp;C{`v|x2wjGsaX<1*;p_bZm4j3)sKZ=1hw~y=UB3fPH1hVWuAM>U$semuIi;7 zvAr|PfKNVWE9=RGog8Ak8;c!Q<8G%Ds(3q1@bl=e;y(>XXJI@W?QSn_{K(`&6`NuL zPnHf?aezw>p@8mg^q&jslEY=ELkxoUGRqnf8ag0Ah=32?GLxL`JUHa`@4i2L1hPM~ zN5kE1V!814rKd+Ap5Ek2fsx4EhCCKlB~&N?0iJ+#@M{rkD@Chc>smMN zM!786d6Y%4$C(4t+g$xfQ9=IE8+&(AxXTUxX(sWI4N%(!tm-iRxt4(z*QvIbaB^wN8K_kc; z=G&fk`fe-o_P^sxeHY=yp0jVID7lT~(~hGg@+8s)nnHlbxNX9+?JO~cIN;#B;#Z7( zb>r(5Yp)UO)-kAMmcv*ZQ_Mx*xgb&Zm`k{wq@BQ=g$A;vIa6<&XSSPL)N<8ZvAN@) zj{Y?G#dmvStJyX7i3HJsr`p5{tbqV3@v4?2GP)|A#J3pqt*87x@i&NcxS$%2r3Iqu zUuTm@)FqM&$euhh%<+$rqe9F>I6uYs?|c~edk@9fY$T0rWwNknwEL^fqR8=)^0A06 zHl5NUmjgauKrjcV_>#^qfD&on@Q%k1imU`L=IZ)tN{^?;v9FkoEN9D5g?9j3a4tc@ z@^+LbCg)K?l0r4M=@888MiyHwo&yy0u!JI{I&$(vibMlT=OlnuI z??CbY0PMG*cm~^A@our=nQrs|UguD;ytYEquE2{FObFVX@s{#2Rr$!>$1(LyO)&&c`;&&E+}BH6s~=2*97RHzNeDs#p`$Kj7rUnzdj zei4JlpAzrIi$-+`BY~!QuA*3?+la{vyK~`VVBU;z?tMSOdh%JxHLb=?oyrw)oDuFT zjrfnMKzv1YcWMR2%38z(=-F3R1p?z~2OE3$>0UM^)}ek!bo}=F-luITLSGWpgTsCY zwD9JlmvcuVYLOsl>Zuwa1yMsb z8?HKg)%{9aNN<+mZ*+I^5Ek-@O{XMbm2i54#s>@Esq-x&($7vKWw;$TR1IMq+o6>ZgQvzB#;T@9tKaR(>!yb8BIn~=x+*_S|hX8 zd~bbvu;Sj(!tCd0aUqZeQ@AU1AQ8wTgM-f%4gUa+H4T1yYdFfsr$DhQGDHK$6p(N- zI02KVA2IjFZ-?zt()7g)a!Vr+hG`~$Ef;PMPbV25@vmcASNP4AZjNXrC%a^FYuM!XNZ*tSZO zgTNr3yk~0qEz}Uj1>|ttL?m?=GDxCMpk!q8zzv23;f6WQcOD#xt?nYbj`^5!=<=0-*{ku=(Vy?U0qCaVk%j-P3U(CmsN!sYXT03?u4Qo+IH1#`l; zEShgbDwOnH&o#C1W}w=Hx2_)fBO>A?NW8#M{LS){&;!XlRZUOAI)GqI z52|ZdMYp}Vw%AN^V2>{Wl#_*Z`CFg^pv87a87p%(taG{&x#BvOnRjO>v$=)srFSXh zh2!g<`N*c*9Q5m6nXhea@R;b?A==GGbm2p(#P-c*ww@P0=lF5g1sgf(jPh|_f8*;(Hw#Ny-|UID z=^`>2e6zxr-I4}!0XgmmrD)HpUU*kinrLEM3pwN6ape%p8Y9Sx>2|x;D4=$iXe)pfvBw}10RA3xQ%k2^Yt}bYOQ;Pt z$t6^q&PHhtPq#Zj#s@-k-n8vLA9!}|>gMLtSB6_gw^I$go8x&T-L!1N<9Eu(9ZuoL zdZ$srIY^FMyCW*=OHU#xbnPxl;_~9Rf=R$E*v~P>F}$~2;PM;a8TXfNQKHnLp62zq zx3rir36r}!K^Wki4*Ab~pGedE2X6y;SK5pTW9A5;QmE9P}-N)R1@_^s5^8fo<+WTiWUnOQ%b5G>>^K=u$aG14hM92LVVddJ&A1 z&u?#iapP-Q7hj6r>h$>**7nJ`T*j)WbEJWoI$(}YN6))}a%y;=;I+8ZV>(oqT1@Y# z3u(@*L?{%Ybznik2LNCKNmGs#n#tm=E?GN&TaS@i_PORhFw<^40j#c@r0TZvLvd*l zl1)0`ylxLaH}@MWk~7BdLtM?Thp#o8yMtn_aL~qOk&oR~=v=Y+MharOh~~BfVUsd{%Rzz^f zv_Hw{aq{3~gH*9linZ?{dp>7=hnRSG;pEfJ9put1vF>&&Z!*Z=BOu5qPg3A1=y2FS zDaMD6JRcW^ZLKwH2pZnYRfJDv4eI9OG(IeSA+g;9Na;i?{18M-L za-E}d1Gk<(1!17JwQ(5op9 z6pXGi8zYeL_N6_(pttDF=i4-Lt{5t)&Pd0uLgOCw>(g;@T)HLr9F1$K+UWWwonal0 zv<#M$z+H?JsB{Yf3cguJN}GHxI;6Co-?pkasc$$2iyz8Ct=Q!a9AD>JZ#&c1bW&u{>f|c!-e+$q0E07$2`S)rO2~ z#m1XfK~cE7$i?vUy_Nm5$E79UmOvwl;uT=YlDq;l_kkJqs{SBrNiL=q@W}$?6BNI~ z+2kMORG;l!%GKGDjP{=AP|Wf)`YT*Z^0C>3ll9OwN10GwA%@cK}` zHPLcO&Bs>j)1_2?Bx^5)o!`ezD)QxgKP9H0J^IEjH;7&ARnqFzr9 zo>n`1Sp}xwD(>niS$}x^e({0mMS92WDP?hUW8v?zK_$fME}RIEvj9AzTaJKNjO#_4 zOVOFwvzszXk;(n+nXT$1cMc(3;2>=MdRIr` z?*t1ki25d@KDVVSJdn>FqD%M0N?5X;1iS6 zw;9K%t(l@>@pr+*{HN^p2c9_*FZfX76(^@*-Pstrh*Y|`j0aKXu{b<0%xg0EEU?I} zxyK<+;g6+Xyx#ha$V@-)TL&kPnAI!qmerKy7-u7;b2X{lwmJ`mQL@}zfJ)$~!5>zc{%ZS=ZspMQ020J)h6!f)Bv13V6K zn&x%AA70e&-&MQvjPf=&Zt2GZ80m`bi}w;ZW$`n#Ux+#vhV`3^i!Tqs_L-M2B=?NO zhQnj9^rE?&ndgdFjm532Tr^5qvd7!hdQn+6@3_(WU!-_fQSe8IFZ@5R_`=7(Hf_<~tl>TA1+PYzfXW_CF|G1E_^}ZZxKtYTFarw4dt}f z*AF9XlqCLQh+idTZLuCv&UnXBUp+W&W80@R){OON+16%=299186&PI(SZ{*LH0KpAmcg4dFxnS9q~4c;*Sn2_7_mJTfn%sXv|()VSNlvFhP$WCId8G*_cpPy z8No3ms*RpGAd{NAZ{zP6=w)NJ@qVo_Krpu+OzM8F(F5zrYRa{>@}Vj%gPa_*K#9%| zA2&So{{Vqg2wZf&E1{;Zq|`fv7i)*r=Rvu1^PGY$~_BH9# zccpjd`kq}yYRxY%(DfgP5b9nRyjzTvi4eyuiRJ>YBOG~z7RE(=2k^efUbt;9Qor$SuDM~2 zhC6geDAFPMNybJPf%tP?T%e&V$Embc)z3@twx@SJjqQ`!NFsvnWfz0X5it@sVA%)i zE1ABZ$oqVEa!We>qA?o_XIx_KGBO4aBx9di-qNfixU`S#8npYZuOzu}u}YE3j!xuY z*j7B}JoC+S{{Rs6Bcf@u+_MOxjh1a$tO%;`SB2Q^xgSa$Nm)Xm4(m4=Gm0C2ek z_WF1Bm(lJtzuD`=vtF2zON~0%s{_1{XNzf713xKlaoNp$QG4S%zYF*l=SS0ZeLznH za<#ijZ-Eg3oB*wY6p@}m>`1RCF`-it7)BDeyszoy=50=DDcs!g7OLI?*J9IbTwZDE zGr!r*#$%CHaLOcBdKC+rZn{5#8+Bte2sV#Ewr=Oi&F zZ$<-ibXf1_TiC}5FrR{}MNle?YR7|E|0*5;kG z{Tb|HiglJ*S*&M|&XP4i%o%V;8(XgfAmCTj+AY4BfADVK#M+F>d93LYTVCJ1!blPb zjIqZm75S7l#$JP*54;Hn(yfP;Ts1iJC2nNBdU}~gN-f{W62W`X=QS->DDqb zUs~B+85%OD%Wdk4Sf&e|#B>q>ijvnt z)AbJ!+S=*Spt`xbM7fd+vm8;0zj+*FkPwnK5?Gw>`LI~ktuMh}YrDVGF03wf87-zu zWVE!oEhm>2=J|eLS&V785yk-ESA|h7RRtvD{{R6$)ap`eTWn_?e@}5eu9)bhB0=Ze zxJ0=O!bJv7;|FeX!x;dabJO@=P;0FsCe(GC-9piZKeMjxWNQa+ySHQI4l)Kx=a4|@ zRs2=(zQ;+D=1nHfZANHhfiGB2f(usJ9FjXaoxtWqAd%BKIpAw}XHSA{FI>B{w~`mL zRhn2QfUF=qo>$6$h!RgDAOZo&&2`hGJT;*Ot5w%Y2p z7tO0r0shGdk>Rj+F<`h*u2dDyLIckg=xgF%h7enXyViBBB|OH7P3E60i@0DAOi|zv zaxh8h`FStHfvM{bWfM5?vd`ygAtVhPvm9kTTXLZ!5(ak;Ng4I8jo02ByV7p_Kc`0( zmEEh5@?H&!GN+KoC5hnV_2lH&r-GDcB~oc8WUkh&>-R0rdY4J0blQ%C;V%taSX|ur z#@|It)h68DSbdSm2Lv74Sx7m^Y#J>7#eTnM(tavv7YIi&MXNC+W4ff+{VKZ9X)>py* z026pyNRLvB#Cnj@qIqJLDJLd1ETo=E7#+AcuNKj5O}+mB%^E8@5VA%~6W*@tURI{J zQ6Wi;Q zkd8NJ{#mVfuf|y|+sC)J`gQfa^`zF&$rSc;q@l`2sKY8w56U|Fit=HmqMfSBpYG!W z{5n@#b-rwQgt#LiPI-irNTWT;Vhd*c7kVTdB$COS+Fb#a* z{u0xUs`AgJD89(?>jTE)!1W zKN9#lEq^!GUS6B8tX@U7sVeV#R4tR`gOG>)pfkYq80_`Wfcp1?d`F;ZU)y%8;hS>b zWfs=WCF03~h{+z-3xEdZ$|~i3D;2M!)AYNWn8COf(8%IAj^gZ@I{+CwvN3=O_BH2z zFY(5s;!C}not^x8hMTbS$ND%REHe3kjBk!c3IGL|umc&cH0h}R@n8D#3`}Ls z?w-AE@Snq0zZ85iqFcOLmXQ^$v6&)Z(9LkS7bxs-Pa&e)f=L^8{{Rmif9)UPj|b>l z1;bg}m^?xQi!DCMasB=Shy3G<&9 z{1S`%eoMRid2McrM)F@-?TkAR37cmi;O^XWlFEH+>}%Mpb-xyBI`kHg3*BlH-ADG2 zJlNJl&q8*S+yh@ee$w_*4M$45MbuvA@w}IrcQiu=G*y$i%7sN!zykmr0a&UI58RcF zXA8%fn7UuaSTuQ7IcB-Oc8(wI8;c8eisI@s5~QkS0T@Xer^wkGkO9FJS4>+k4{93J zt*Pp-6pXIdTHU09Z#QgNA~8rZOl6&NaDH3@)#qLk_y^;E7&eVAn*`RnUFtNV@_8hV zAfploJPa#&pDy0KkSn@R1nb{sj@D_CD71^S4A5zp1!R%Js*=P3_hj9i1CmMS8LaVf zUd=)3nUZqqv@dv@TDP**G|d~r_V!X=zM}ENV+`s;&5(D4z+5mJl$AJTs1TeInx0?X1fJz(kTxhSZz9#}hNdh}&E#tHP_5&R(ip+9L)hw0**#u;j%$FE6A-dG%31DX*>7yB65?{q4S{d!uTJ-9u>US@3lQI!#*HO$@Iu9 z-8cULqmYH<=-Whv$c*q0m_|1e(CB(ir-*fJVjt~^ZZ!xXG0T3_l>OQri}EXy%%wUF z%btXc6X_Pe2)t=$rRrV});uF&ac-o*qUm80!#dBhNm}Jsd0+{~oOk~K+0XXL;rYTefblAWhi75;w~>RBU9ya2qiihFS4VlH z{4vmcUGNsuRyUJe>yp`A$v&8l@=a?vlIa)a1pff6TXZ1`=Pbm1_RBmys@&_kP4$UuA2SN_i!0w9_?Bz4r+9l%yhx#0QYoNV zN({Iyy9ijBl0p}rcVKm|LVt{2HhaAe&&04BEk@6q19_%FGh6bArCf(-%LR;WY%WxK zS1hME^h)0~^E9%uKA-qi@V~}>0M-#Sj|)5adr@6BSHG7~g63Vz0x3RXurC~FsTk?P zjiVLq`me+955AYG>fR%;oZL+nxw)Rz-G0-uH47J(4>=^R2sy~-)8#+-YQ1N|x&@w{ zqfO!c0vmgE5>I-!Fx!dSJL6Okg196TfO(K@1Yw(c!}hY*blXc-@ipD0{f)>Qbqx~e zs+p2TQa*E?s8x5X1Us@h1D?p&rsA)+-jT}VV$Pr8?JvO>5%}j+u(k0Xljq+~+B5A* ztfBK+QbilKglK-w3b4Zsxy};4W%%v!zT4slj_-BJrFo!?Ev<@7#Q9Jrc8*EplFUX= zE^Fyu+Um zHV3s{ z$B*sSQEN8B%4Skn;DrZ*O>h>mYZen-$qmfKpk@JCw*mnGfuHwb`!vGipO8cL0+r`Vk%=wQWw&&ZtzE|LiID2T> z{&<2)ujV$_Dn{(%87GbgPEB?&_^E}(#hd_s?Xe}a2(c7$8FJVmasVFx0IhH_-R`%6 zqPQaGZ|-4~GZo~FjPyS!9{4=*&eguDqu57gWLR#knHlFsMBIS8XLIx{Fb;iK_sw%+ zBNy+u-0Gt)RB5wV3%PCJy2NFfGK;-=*j!`+F_k&w_s@Lj-|8Aiigarhcjl`eukA}QxmMN~*D3{BSl15H?g&=MNBcA(J-DchSpNBB@bo zj3sbkE)-N2BvfI8@m1JN^ zIT-nUKMZxQj@!lBkB4kL$!8u_>N;Fmtia0~WP~k|(;&AQ1Qp5TS08V7{{U%?ZDI3B zkh3d4%vS@T=rBP)@FQN07)lGW?sG||b>1MF*IK@k=I>EtznU+cqy^gD9S-!o~r(rBYJ4vLBDS}KF3yywnK^Z^&VObY` zRisfEu9akRHzH%?>N@uajy{y7&gA)IC2MSIc(zB^W3t7>i*IaL6?ZH{U|^}w7$E+B z)n~)7NvrC1c5`Y)XOTz}Ow7#Zac()!uT$ITD~s0kwPNu@I>;G*Mj73_k@ya#sNK&D zmhoEx%yYaf%s}7Wv5vd}{{ZS0x3isC+H9Mo^g8S2w$}A2tsqO8rGo2xe{aG@BiJ_< zApEE0Cmi$xwQNUY;h3hexxLgD>&~C-td{Xe6&Z=fZTN(0vRf)g1Ts3vvP%*NU8-@GBRDzd920^;AYvUC zWVQ?2bJet{JU^$)HQk+*vguJ>h*nLF8%o0))PhvEDwqx5A$yaze74$E)|+=OmLiVv z#E@KBqAE85S9C$6{nLeSn{h9k z`fkBHSSvh1twrH&CVPz=NP=5Csn$!(+y-?`q#)X-dJW9qHzRk;kl6>#bor#Mc5izk z{{X}v5ksbF_O^f9(Op^*9P48Z{`R!E+xy7;u*9nXa2O1I@sX3Bs@KN$x}St}=`@+- zxo43QSZ(gMv{C@uzjPaaLYqSSQqatD)LPv9%KF7K8Ly9tDQVAAA}$gL@F^@_xB zn|g-ljt^?<8^#(>gfxg>!Vhd299>Or+eD747;@lcfHL$wLgS2cH7ID8E4m?0_Y+w7 zrw56=N2lpFQs2aowAS-UaVAUP5uJrNC$4e09R3vbO&aG>(&e_j)?qSxC?6)_hZjwSvY$FE}9!i0<0muMjj2g+CT6iaU2w77l z;S$OBM*&VTgN}3l_tv`MK_r}XGnDSnMet9EHJEH}tRl0D)+8wmcL=K+Il;h0h0^h}2hP9{398*m_yl#fz%95FP7h|{+oc-WKbps$^*D0oI(=nE0k%KWs ze<$x9z>b64x3x=itKMHjD@82Qx5}*0v2CS+!klufM(#b2rD*WY%b6rKQQFriL*g5) zKF?6n?;^R6Rk?_*w3g50vM6KbLn`Ec?2H|!jDe1(uyn5twZ5AUujL!-Ld7HqSqd<2 zMo7Why9pyW^9Q~JYg?#ILfh;26Kltr^gN3H07?Dzt;}}v&ub(>UP&Wt zD>E@%wlFdV0VkXsbU5@61x;@^g?wG*#XJoiq%lu#a;geANpdrQSRCXQ=bUr`ur)sc z&1G?IK8tbot7+|4Yhx=a%RFF|a)?jOjkzImKp@}(D@VeA5EeZvRne}kZJJcc`)q0z zr3kEiqI;4vw4CyBiqeg1R#UamI9@%D$M$?yRn;VdMuJ3=GZc~IF`cXlUcHGP;E_j& z(&Je1POWormdhZD;x9YQNc`is0oZ4$?s)v`L*Sk9)vxvI(xN{;bvp+b!v6sE>l*(6 zOS?MmxgoWf!Nmr!GfrB?(W zFQHO>I#-`+@*lR{f)5im4?KL;==vUqFWI!`d0>g7)HfKUl~zJ9oP{Tzhw&cbyvJ0P zR_^E2%G~3x%}Tz?#+07q_QaTD3a31B6R+c4AH&9Z{Bxuva7ihSaLiS55O*Z@9E_7( zq)sA%K*r)TgWi!fYpWSU+1=dPzBV+rvobMUfi+;6cn3xC z{k@!8jFPCfM^LcLtCIV2PhakSwU>49cfr_+t}UJ+6kbXZC_sz2wnh_!l6kL~ZuLvc z$q1KJyqQmF?-~C9n^i`zwPH-MB#wG80gvZf`%C?kl~QSLeMPVMbK$-FA=R!l)@b2a z0lB{@cBlYzxOP!apT<89^w<%t?=(YkZxD_tAb91NDvT%%xVHcdSIn^KamuQ(Te}m~ zBxKahq0FudSkAz32#nQxS@c2dqPJzKWP6LL$5Jyl1Ug zODo8i3u`PYc*`<@{OCG1r>I8h1-efl8;ZvwyW_vgzgokRPodM#71Y-FbcrN)(K_zk zmB>jpn^sR_*S2Z zbzMWlTGpR)4WzdkO4`h`yl3Ww7DhatI;wm9>gtMosN$BgXG3YH=~ocPBY1mHxOPST z^ue6C#|Q6Xiszr=G***~U+lZYm>_WrMB%pj<7vp`bMHl8G#2buXPE1HoyCl>#6gk( z>_8L0IS9ZD^2mdz+C~BDdUvajsa;$^h+>jE2trDVBxx|<1A&8)-<~on7T;S*-`#5#pKbd*J6J{|iTOnsC7V8-GAr~0;E#)b8~k$k zZ+qf|@Y%4q(=_I~)orXbc5@RV7Gzj_%wXY)-&P1=5&-r z<{)JwdmIj`hjAbcxhIU***~%H@I|-Ai}R}ZiF`4CsQ7++VlGu}bnRM8h{8N@nKqmU z=%fm*ur>7D_&zBa}6yPGDlbsBZK zl&eD%6>_HnMcFW8<+BhlP6st>$2Znm=ZxRtEqjgg+V{WbM>w72I^XG z!@mS-eiYHQX|JsBH!=RujpUFVaG|k|!>vOdo~IU=*Om%pH!gD>)I>72PB!t8*1VhJ z2gEIB!2TU37~UODWN6}fC4I5vgx&{s48VYR!ndVu2{x1w6Sp&&jpCn+c6!I{1*>?c zTDl9TYI;QET*8m$LvID@Dk_7)-R15ZM?1$oYtejLd~9@2f-}LVOR8!bKA|oCx)hDJ zuW$&I9po3anIq$In9mZ?k zJVD|Vv(Va0y+7@C-b+m)T1;b^!h%Yxj#Y@r4b&VS)wN3LIIibBY3R;(;!njt7;FCk z2fTUVE3Fb~?HtDrocd;;BR$AVm|i(lD*UUpHxPLkBd!H{kL-!4T*vVHOo&<~h@V@T zCyq!AQyCL25p4685f$hA-|W{1@N8&*8GJzcUWuo8=fn0w*j-Jj-fI!u+S{hn!7`?I ze57sb#(DxrtNaYII%bKkT1j;tt!-nj#6{_Nm`wuB9L7QrZ6^Vn0~jjU_vMs1Bdflr zLzg?cR@3@tqW;g??}+?Cr+B7c5!>kTJUX4lt+Q#66lab(q)pNj{6wcgz{ODbi=k=@ zt6$t$Yue4Mmr%G^67oj3Q<#SloTvnD7{)$o)%Xv;{wql28%%oUqH)*;zq)WgCKR6& zFP_rw2HJKZ-L+#f88eK62dGj%8tO@Ej2m`5NA`-cyVbrMUT8YK#3@KEz3=SiX$m8gWTarmoLwvwz1mln^z^O(1 zOUd3h(Ru#>Um~x{tc=eZeWS%*AF#iX1(>m%g|t)y$@4dC`AERwaB!m-9>9GQp!nBC zi{XXmhOD(XFEs1>F<|jZs-@mQ7v=$!9l&4&PO62pn9&?{wwb^_u(x1jK==za>-wzLI(cCPsvnh&nP$2|`BMPxLcMzc9vnM(1 zuSeTqX*jnYSv|GriBFcJUD53x1<~5hOKG(64wI=Rx?ajJ5;(qeb@Nf?X25@)fsmGV zC5g!_N_Z1fo8mk-T8tN09v-o}w31i5xR>nGNeBStfL6g!SnW6@{J9_u_5T18{B7aA zOW}aB&@^31bz2L+^lPDJ!qZT?o>yCt`>+{~ZXBZnf(}3@ZBp>;dV2VaU7FudxVjo{ zl?vNf$UMl$k!cWR3`i^ijl&Q*3Vnrg{Z16BsowH#>9@Z3Iw3p7qqy+T!c7K$0$uo< zT=6WIGQ)M}%J&y;hRBJ0!p!Um-Se3bA2EE4h6MG05qu-3N8wFE<)>S?PLO`hB24kL zjT@^ZWpXz<<7igF!Q^$W8{ub;B>0Th_MR(~OOwO11(_DelFZx2cd{I81nn%?>B-1A z_0JsqGw|k-9;+6YWRQPr=y$NRA-6;FLW+#4ask5*N1*Rsy(z3!H^`N(XW#BHe(Tin zKZHIBwec;_i|pXNZ9Snj*RagC;!sP8R1h(cr>+JL4>i{OW75@pEoUsv5V^UMJA1hs z?Hqi>MiD|WIr%{Yu6e-87(WAao2_HTy4-q2<7q$fFOB7r);u)Xdm~J;{Y!l7vK-ZxuXcehc#8pqW846op=8LF1^j? zes-7KV(8xwEp&f_cAAIwqFr6y#cajOIzl8c#_U(*4TixvU^`b^p-vmLGNBM`*Hk2@lV6g z+2cv^FNHP9be%zU#EwbejyVG)$s~kgMPNp8+P+Qwn?GzXiT*11+u+|1>;5b{mXE1w zG1}at>GrWRD`Wr}B2qqWwbjXW&ST84%6*mlM%Xm|JMhA|Y}{xJvTb4+l_rUpanC|) z<^KQ?{8O;-z23iXd+_lmT{l*jPb+ES8`M5pJ1AqmLNoGX9V_bZ+Hn1o#2yT|Wip@a z{Yun=Gq|smasL30*T=sWubsS6@hRrhtQ{X!7i*6&7xQJBOq0K^2s{DSx5HuS;wm`Q zSJ8fD&a~-Mi-WeW^gVOHvsnB>UxapO48x~oE#ww=I*P0gC1xH{WyiekhwwK}c#m&rj62e{wU$bp2q~zr8 zAY+ckqBweQ5eiFb6tNXu3Dj>@V$P%SgTXp&#PQzWc!zzp;j)mghs%K=&Q5o)A1V2| zbgsj}m-c=>xA8s4hjmG3{{Vz()ioP^Lri;C<@TMY%pm>NIRK72*NAxiw2AdE9W-M8 zHH%o5Roe_~FlUque(-U}wR?}jHq+tOe`q^0)x~ zG-=72590L0u~H<_EGa6z-F)?!W!bZ zX7K*9oql1aT;uDVoK~Og=kXiI9}_+ac#l-^Cx|X}9Z_y3vyWtP#1I6xC|6>GyE3;q z>^&>zFMu8`_>(t`{vCg9>)s)_hU>&Pa9u%raVsp4h^=-940!>a$j@?WT@Ggai&%#k zp3A4hJL{J=(Zwvu95FYNs!2x@O0F^Yf#E?1rYo8FpW&D^9}V4KjTLO0&$nlr?7mPr znFlR_>-pD3@!M3^d_VsH3pVe=cd}_3I&3x1GW8#m*om<1+ zBD2*zUNt=iTia6hqW;zM6GHPS3J&5oEN26rGt)JXuvH@8V@j2rbZc|nuQm^d787g; z;q>V?6OZ|Oi#YGzy&p{Yx2^b22C&n$gtWFtW-%6sjl}YK8LunxJ<@643~d^Az!2I< zbDnlh6O-@4lU%>VpW0vI&Vj4=q5MDMtEe=KWwUKQ+DMR?qicAajkwM}W7p8uSao_z zDcRhIH49x|PxC!rP}kp7*CM=?(s?iDnn~pZ5MKq!7~;9#7+b-7u?P|d)iR%Fa&anS zIAtIcfs@#enCF9Dzwu1jFUB7T%6?YVBeHnaP`8-PEQm{cZB9#iSCijsUMIVTYrR7D z!sTST`wFzWWF_4Qi)?B!g~K$Yx1p^gN19gbbV|Q0Ro3Q5gf*5LuAy;oj0Cbovri*} z428@=69oK#4l)5b&N;83u5=Fu{3P(Mr{f8muxIgDtTX^?V@D3Mevj4&&YlnUeY zD_suyFAukfZ*A>$rC5#Bk|~x+BM`_WYg+3c47BJ&+e0mmuW}8v5hm!$NpyfQ6x-!fnj`$Vk-wyPs^vy0mEdVZxSoG%^{amV}wSA29vOvv$8nHI65 zT}31yvqo{oa6sVKk?5Mw_P2=E+}pGnwlXotJ!{By?Iukd zSeo|K1%4$!dJ~R8`kelCGa6^1*jz2kczQ5%{)%6ZU;+MhziX!4>M>uvy2mHkqTa$~ z45V)8K{(_C*1Vg+I`*Ng>fdX4mI$GRpoS#L36|vl0G~luJb&X$&kyS_V`|D#t$~$Y zavLP$uckAfdgjD))T1cV-Hsfwl5#s;dqdSBf>ujQdzjHf7h7<18Bw<^6cBI^PCn`8 zxnZl%aV%?e@&YKXk<=RSUK*_^`}(u7NloZlw4C29y~dwy1a?t}jF}`@ z#l{cKj&j@))6jA23=>>xdZSy3X1=sDEN0=NY(iX}oRQ8kj(Y>{1zG+i#Mid6E!xI7 zPb$;*YY8WlxZrc&@jXedr$M^1@h$GHC&orrp4{GO=8x@>_={^_aczGNyr$_{0p~A;^owDTIq$5O zg2bLXk=yd-omTWwEk$)A@f%y)_QAi40uOzCVQP>V^PU}dwxzQoLj@l?r#U@l>6a#`r;y};LI?>^q z2(1fzLkIwsubzpohQtv**PU2HA3IekgxhWhGswTDkLpbHc%1ZWQd zagt9M>yk;&deb+vPo$V35UXxiXMr#Q35@mQA1JP$SMZZsSs0|dh0Lpn_69B{+NI-Q zK;sS@zusQ?t~*|{{>xcWVleFr!FkT(+xd=t4JNIjJ8Do^ZnYcOn&wj0q>;RqJ2~f% zZ)(}puQZr$B!b%85yKAUoG}HY$KN^R6Vn9XR(-vOsG5biwY7Qt)hZ*11Hj~*4xD8E zb+12y@8-M~+aikYU}aX7m3m|h_UI2!PC(6ETaL(^T+{I7v_2rcmKm;vjnvlm?O|}z zlF_+Azo{7|KwSF@==>9{=(ajUQD4n;(;K&rW{r$&?ovD!86|<*dC1N)>x@(32{g-F zX&&OzA1LmZ{U{Bi9k}-T^H=oWiIVC1q0*Y-&Un#Oi407vtJsnh7VJ7#Tx6fbtSdf; zH{-oeO}O~3F0`p6lEoyq3eyMN3dDhj&(Gcs{VUKuBHh@b}wvtx-~QYd@P5d&kTq+8Be3 zf$WbSSdYzW7fO*zBqW5;&w3GxLj@zmi}hE=fqMQjW$V2fbxyU z9Xi%^Wf@w=&`(>Qm1W_L8tcj1sOe8Mi@VE16phm)cRX|XR)jtn&`H7QC;>55j1 zv9%9|G>K+NlJ(h`;Y2Y>$@~Q{XqqNl{W9r-u?OUo430b18!r*~gn08rvVBR?$>&9$ z)uoMkwn*B3ZiEaEr&{PWj|m-L#u{*r+{lI~R81}q6-&kbF4b)8SQ~hZ8Rl4|R>c~lv2 zH!7(ko`iejr%KL;#n%vAAG9+(Yxfv|lsVw2?a!qW#5u;>5oH!@O|0HZ9yy%AjNW6% zBH-@l1-Rtko=p{pCawLV&nvuumd{Drc# ze%CWK^2SkJiORVc4hK%X&MT(cT8d|jIrlEO>px__BIJ|ZE#*h z{{UQLVjWK^Lkx016V|)CkA{9C)9mfFJFN;^fp+jN+d=0SGsa|x8wurtwmX81I;jF} zt}GKMRO$9fd=Pj*cES;b{^!8Smakf zapOx3W+)`L)MlGVMU^6s8)j%DJRPd4<`Ek0R7OQWdk%O$IB8nP#4m?`4)xEle`olO z^xJ!FS5CIDTY00JSiz97W+Fc*K_Z5KnM(k1^9uR*;oh6$EiJ7z8|TxbngnNV)N#Ks6xnWhM zbdNz{>EGU!_1p3_2EX515rL$Ppf;N|j;hUqR@eF7VCX ztG`~jzrNFiQ#G_LET6n_l)zHMa077KK_4*4$gc_3wXH9}o*=Tjv$DLqnSipM7-B6V zBCjWRY!w*=lN%Bg4s(JsYoYMJ zkGxfT9;PikI4^WtrgLw3X(B}PZen(ASs%ad&}O62r9;y!*jVcaO4UqjII>v+8?CLq zkpLl%?Vxe^SLdgVEx+PnqTVJXV`Qp~B?2dMgaQk&MPi|Fcp#2>2D^V7Yr6H;oq1xu zBf1y2H#Zjc&#JL~tDUTowp7LrSd1|QWZ;9E^2=RH=HE~M0Ew_Ed8J6PPV9{38#|6$ z0HUtk@9o7ZiBw$C`;JQ3Y0_GiS!cTOG}C>le%UOdHuHW+*+AOio2Mg<$LpNtqPx*0 zg3?ru)-5s8AXZsqQdNPJsoZ{Hji8TypLdCL+lys}>rsHLdjX0^bp=%h#WjD#Nc<wEJy7PY|*jKM;6Q)@w_fsdmQ%h=Sf# z2cD!S0|y}g00}wyC&t>Qp=sei5L?{Zy^EhJ@@N&fXK7S3H&T9P?s}Zp)E@~yXd4SZ zg5L&O2xtEQgquK`;Ue;2U?#RQOs^jDyLUDK89(0OoY%WbNnx*3`M*W_5SKLmN4NYg z_&s=^6Ys5CQju(YGiH;d*7FHBOEh0AD}@7ijIYc_aNT+u*VX(#XYlXim7n%R=SR}7 zadB|4M6AkZ^HrG%`@FV#U=zUYS^oeC{{U%U0eC;c`qjp(s-@qHwY^4JFD&h#mA1H0 zvD+)`36OymL2r~Y1B_QM@ss1%gD-qr;oVx&{ItGYCOavjyMiWYfo2|aMuEoCM&Z*W zkO-nm5vG(Lt5kK;Pnou~wU2Sod{y8d4S0(3!SwwqOQ~-ipH#D0q33@@Y4)-@z%nghY0!Ot8o-poAm?w<8%HtI570e0unLB)HRT zE~mV^x3_zR)o&+gZlaW7u&P29e1I2&xtQlWj(iVa_>rb~msYm8ns`>y#r&v}+2^`x zfx-zyj9YOnmH=_{pP}S<$y1w(cUS12=lY5il$u)~NdC`0InjI>;#=wUy-MD~+xsdA zrFmroY{l1Vk=c&n`TjND{7U$#@bAQa1k{;)L1!$s35gmR6V2Tlqm%%;?-}jPN%4#iPk6T^)0yN!_V?-di7|-w8fAcozQv;WOz!@Ri&D z0BJ_ey)Tf%Dlxo$q0=XBL!cQvfH_u1={Itt_5IjRl73Bt&IR&dRD= z3O5sgNZ7#R2OpLCy1_Q3aTb?)3f&naF~&~E0mxyV>Oz846SQz}2OJ&Nt*q!bkjrOv zJ+zP=_U^9AVJGGr2>YieJbi1non=v4D#rxi;@jN)Mg6hwbe|Xg!}s@^UHn>wy^Pvr z(oG(4*^VgkIL|xJUik-*Yx7^=Hmm;5Vidi$K+ewFp=-XtxkryCrBa%SJ99FnFCtqIP zol&K2DCytyXRv83QT7VxfuP4^9Xb$&7rssLP_a z=yYM^oe9D2*W_*g0Kq%%7ef7^ZmjNH7!8E2Ba!?Iv=V>sy7X@sTt#j0R84VYbtuKH ztnUI`c`bn%=Q~^1raRY<{=-_NmLIg0h3xE*LnK1Y+1vok(#G6ppW*t~v-rPKw4X)s z4u~OFOC)<)H=Um{OsRo^^AHYkSOoA49I}n;n2t9boO9H3Ug7XJ`#xV6d~CI~v7XZG*k0I3?{p>VdoZk~XZ?@d|xB7S5 zw#O#o;%0WwI2`$a=ia@F^;KF8Ci-vkJnBo9qS)vBVJDxXc5j(aO#c9$wZ(X`bjW@Q z_^J(4PHj@rZtSHN@yuZ%gE$9p2h`FZ9e=_{;rQnJP_ht}j^^FfWJ18?xR7KO!S%r4 zpIYX8MdG;iEfd807xs^mlf@6q94t;%_h)uM2ZAw?p4^J~`cvhou6;%(q$N$Hdzb8| zu4xl^GXDTb(6qsC_M1nN=Gx+NcIEB@Dlp*uqd3PKhhE+9?Sb)s!M_UA!S{YH_1to?lyfg65B8FYxBQr}#I( zjo|xxE1O%nqnlG}g?4$OQo)#UffDhY9D4QTv_JSLC&X_Mc&kj(V9>N-Z*_YGw9>bj z9wc{FX2Cef4d1xMeD(VvUoC~_k1Zbd;=%^DR?G>xnWB_F&#nizPh3}LplNz^T7~YZ z95UG6CD@s5*$|kJN@f8QfS__X=YhcKS=O4TH)5&#(N{i$_`j~%>iP$b{@TJaBv%WJFKYDpdbn2&2U zywgJr068WXekAhOles=z=cyIJOA%)%KQpGPQcINXzi16vEqnvvcxIeii;&RmCmXOh z9QOnRpRIC|{9E<#*BAu{eUEjgnbJ%0Gcs=%$bupT4$OMjnt_IQqh3*ay1e|e_a0W0rscf|(bm~i3 z?0vrk@eqIX6&J})Rj9^z6dGQBPxV$#@ z$*0K=+vRVzBRP#-5q7$b{AGtjfxyA93)cQ2+-lwu(6m4iWz#I$%_b_sS6?;UZf;3Y z<`4ntf_TkK;SU`ygL4hrB1}jTpFQ*CWFCI-#y1=<)6%+QSwdyXjGS!V$E5gQ#MZXo z4KD~-H0P67W3o%EF7!y*h*r+x%O|MYk4EF`AA;UIj`QJJvHM-j%5+;ey!&j4qTJIG z+wgF?3EsPe}LCD-V918Hy z27D{D)ZfDz#MXmP7O<@A11{#eljV#q6Dzmm+yTx(9CF0;qD*RX!k zy4AhjyQ|vib494>k!r$d<&Bs3n<9!JK^$WUdCm@N%C9dNxVGG~D8p<6=8rv_{5scs z98~0?u$AqpZ^G93(_$oJC@?F>pJUlwfM_g1il4iyB!oSuU#kFQEvSgyL9Z-!@%<6TMSd3>8?X&=bKzTa+n z+t`5bIT>g;*U7TQC``!yg7 z$m~^ZoZvcwqqk)>>RPJB{{RRgI=*B3BCvd8ckcK&?OruuaTfPqB>dlHKYafH&m0fu ziuIeR!~8Riz`*+w015v9WD3$&5T@z*_b{{Yvd)jUA= zIyHuzmZR*@85}bbOYUruoMRjh%DIxeS9Fs*FPntSeS0pr&T*o1ANuk{vAOvzYaBj z4C!{0*lSVUJ@ofc$!jb<0}qsBf54mb_O8#x{w?r@kBBbd)+{e&yb<|M6o7?#0y!R) z;Uw(&aXOs;0KkQGe;H)k<5MBPF9MVQ0DZk@6{X6PvG4vCme%UaN|w=NOH*ow z0Ou{`kT!-r&MW7CimvJ7EB%dtsGlhR0Cj8J{vXO`@V1J-G67=}?gt;hha>a-E64m} zWY>N%xw-^AEri+*OJHM+eFbMJ#u~^O=vUfWP4L%5m6;Hm8$>MQ1ZSzwuOs#Ks}@=w zmv5q8dA?IRn0%=S!;Pb9=re*adyJ1QI?OA>)x1Unb<+MwSIbQN5i7_f|hZsDQ?#y`fJ;1Gh z8S2yM(<~_fmN$=Ua-^`p2WsamM_!d~oL#Jnxx_&qk*z+Ha$FgHSgy9VQy>+_I^%$P z@P3unMWx?Gpx;|-I%Tl8Mgl!T*sf(c000Lhk}<|UoYxySiYHDao}Js=*3X6fS#{#w9w;LJ07kb-3gzv#0UmInfj>S+Z@b4N{o2pf^qUO}$J(TJ za3EQ9t75UPH%iXV%*X!u$vwX+(h>Ld2L024y0f^^v>5c+k_1DjwCM3VvoP2PAxYz& zKCNA+f;Fu-!;orI-%PM?MB2p|^i~I;KDFgKCI{|cnIO3($=At!)!khTFmynq|eS&2c@$ zTtra+0IMZq0QAo|amQkPYX-_W9z*0U%!W0?mU36r6WX5?bKXjAl1;O`gJxJFr~sDa z;~WhB6sJ+i>A9Z6#-$yzxsVm?qX(KBP?2LYOSUz9Y)t*&M;RYl%#vuK)EOgC7dUdv zSTlP2^NM}#?c{TdNl+mRvuRZv6T7JQJx{l#XQ*>NhQ+nf+4FH9LQij|X_~FiouZ6HWqk}3Z17JJw1Akqu#oUt80mV8d&P{-%UUEg~Y6F<-rJ0?9SuK zKQFIJ(idj2mqV)fN&f&No{`VXwoLy3--FGL*iUD7 zYdo{f63B#XPb>mPM?8*xgX>jqF(fKwx>!?p1#!6X?f6rYlC{8<4X@tV#Pl2xG58uP zrL4~zvn|>@WMDhD2k|6((MKTd_rbF}W;5UDpA79Vjy>mkN)2w_j_ZSLCM zK?Lw_V;<<4K~_@4@H3p?kWPG_-V-;5?vtit5TquOytw9Ec?OL*x(ckPbl|E9WoTy#yNfhNWH><4gg)&zXjM+*og3t!aIzAmLl&$p${M^^D{_t!>P?!M0(ocVJ}zZ-{6GEPTa_3PfJPl`8| zws%n_^UrR|sPIeyM;RSI6$hU|=qt_bwZmxTq+fOf@bh59W3rs+iuI#X7bjH!h!3P}I z2jLxB{uI@1^tqr*9ctB*^X*aNF(zASW*;y6*#iJ><11Z;m*f8c4{E*<*7R=^&e!wF zVR0lMXSpzWj+@*7#ExGJ@|?;Pmu=gB~%q*LA4wtYePOHn2$b zA2uw(a1{V|a7FD&?#~N(8ILA2 z_ekKJ9+mSl_`R*}C6+kLJB(%I&ehM|1Ex3w9)lI=S{>}Vx9p?i*`^_Rf_G!sG*8mao4u7YyrB@G5RNQ%YclnbR)b~C2$NvBl?x69d_L^C3 z6HA&~Fo_sUY?SU&xGRv`v%ne21CT3Y;P=M;S6J~q&5h)Cmk`ebxQ6Z&c$L=Qk~b@0 z4=mw8!5AD@3;RnXdUx#?@iGO#U-(G12>=`b8h3Gz!Kt4B?W`_-Bk23y8LzG{AhWfz zEQu+SXAvWzZs}PjDo9;g?Lfcyl>!b{zFdkgd(B{12Ijl5q3+oX}3N?+K+fJVy{H(EtQlJ&c+=0Yx$RL6}>feI2 z4M)Xa4LnJvX_jw&bTu0tut$x~WLc0X~rEl1&}i7oWI;(R}FVwKikwFwUZHRO|p>(Hf^L90C{8gZ7dd`BjazSMb@*uf3vI84Bl0!Hh@<7QLBanKU zU25-2@m2Nh^{(krnpRmPkhcA&CC{KB5;8gwT$Q$odvR}OvT3ef-+MLDIcHegY0gg@ zPZhDHcpt?&Rp;41v96Zp+!Hp{!^ROyusA#brE};yS1ZG$xs%lBg;mS!WVF5S@;RHh zqV7j^TX=usw~4Ibig;~Ky6gaKEF0%{V3^=#S#jI|I%2C@d~Da@ z@Y`5N;#<{?Z6}E#^Ih;IxZKJK>DcjIm&HGU{w?v3#a|U)*y%ByHsbrwg7N2#RlxwZ z7dwy5o}(RWl8g3U@$g-S-%o`=12SED9QIT7r0UX7;y|HN+9L~A_?@aqp`qv374K@fubo;D-0YEREqf1R7(^tFK?G%3%tv6lE>yd0(SlV z_#|}n>0b-Us@klx7QKPkua>ORs}&jSL9a~l&&CZq;xEG=hrSPSZ(}cpEp9D7-1kAm z>ny%!-2*F*ola|6d(Am&O3`RmzVR)Do?(wy)tSYz$h)l~LS$}Rb`Kn%sX3xg6KQv% z1e;X9xr#e;AoHwZ8J8F#h&&ECJxR$uYn0P;du?v~!5*b-vPkO*D#6qa=dZE<0P9zw zAA+`kmOd%53aDcnKx5aa`MIoQ@bGJz-*HieS!{g)`xW@b$|?pTa9Qf;5lB*5(%> z@)IC_8@p%u(v3<`cfFB*%A}UZxp>dw2mB$wv(Ljn5ox#Rbq!h_P78~vEX0j-vgc?m zxa4h7fWLHtc&`f7{w6k~0qj>j-V*x9G-)dUXS9P9?QjF2Rs+xHr4Gd zW$^6RlKr=Ou#{X$ye`b;cIO>?SDc@LHo;OT{8eOGdD>$fjQ08ck6MV-!o6hm8>Lxn zh+*;hO?Fpr5lG1Ui^(;X^D)UJusumXF(Y8k z?IA}8w?3w~qWB3BndNU5*lr-;731^Wz^XT&1mV(cr@W8EP+dzA+!i?c;EVzDP~H;F zt6ljE+1B0uXSDonlI6c={SNlccFP3eqsc;?#@w)Pl#ViU*dL{2e0#Uj^nZrG1@$lN z+gmBMiyO&qrnk3+B$7p)0v2UakgDnc=m6ORd!mZ93Y)RT(P z@CVdUb%M|{Ox8Q&PbmQ1xFN<)GmJB29*65pvbp;nwASf*vsg?TbV$zTiShHN9mYZW z5$VUNT6`Z%TbFtD^m5PpwQFPhunz%pbHL+{l|g<1+AgCd+iN&-{$Md#xB>H>%rnX8 zKMGvXcl0!T_flrZ#>)*h)5Z7NL9~lNx3#x{p|pE-l!cns00}|%zBxBY!BckT-ggjj zfrGRP=(TSUL*O6TYr) zb1LCL`=yUQ{{TwB(|$JimtFA|fn%!bf7+I-GFh1KB1zNBl5Ot0T}q#s$>bhu(3|#j z5;{jNuW=}1U5EB`gn}>!1EAoJJ-w?AZ-dr1o+7u^qSmi&t|PdPL2m||DL*b4W7`9^ zdgr}*F|CJN91^2doPWgaN-YE8mc45PvBM3Hp(Iel9kMDat9+wkfTxUSj%%Zi_B~n; zwMGDG5;-N?7nZx3RN+Ua4{kAxRsR5q{{RS}@fVA=J!(sxM%L$0neIiqSV{7F^R(yJ z@fBlRj`K{>G+R51cDMT-qFF}rBLi@%cwvw~y^q4WV_J-#xXk^Wq-}9Z-f+ zzxom4xZQ79k*{^wuIG$Pc8eP_uuL+QIU_&ppU$wJ)-KwWG}vRiyf6(*PqRqWZj$;& zjR`-(2vg4<-7C=b4**+V_&-3pgHrJQrNC)pn(E^8#RQmlRSM@Ik`!bdat})Kdl-ej zopB1!16)aS6w2j+17MIG{{Y`(@~*y3b{nsTu=sdxd+6mdEQcEjG8gOD_>XGKlTy+~ zzr@dncA8C$7nWBVF41Qd#OZ6Oqe#*`O601SEXa3~tOp~eY2OQU`|U~KkXuGA?=0l9 z-nS8gUC@R3H)Lh8&N1s;=ZWWCX5UB=E17L{=q%mH1Llq-Opj6WReuA$_l7k@yz?1e zFr^BSl_FGZfzzPxM3=Z!Ri$&b@XvsCi!0ggU&ByK3z59N?X2;s2P0|*Bpy3fj<0<* zHZy6_*d^k??2jBKC?in46KOwAz*iCRE5Z|abI1C$HV{a*vdWh>*$NxVl|-@hJe{t; zl`oEd&#B({t>6Y|?_`9+jzo=JZQQhD1l%;zBu3)K_T)t*Q8_mAw%y)RXFEO9!oQIjbArr3`iihGFJ+cl?jC3l=sOE^3eqUy0)$g@u7GB<8~<$>G?>HD*c ze|ovQXk~_IWDH}CUv}NcH@jr^J67x`&;7G+8+$lL_#{(Y2@~g88Bev9IvGG;R7goBoMIpCKX7gs- z&Ivdzf^nQ@o_WtTE~DWaO)J62UWWaqND=Kr30DqT+atK@$GveD{{Rs@M`u6ob(cO{ zZA?cNLkODV7k_)?GEYQnhj;SW%a)?F%Ic)LkTofKVZC&I0MvJ0C zA!2;HYZZ@w7~~EQCb_D^odMlxUK~wh!@8cLttgXEja}VY1Wcr3r+>UU`WohJW|z%X zX;L&8C9(l;px_gRuU7ak;uEdtULv=e!pz*O8miF>Vn$}|P@-XLtfxiPh<0Cx}v9s}&yc*_+f7O=f*7_@co+O!8#Sx5xH@` zJg7h3OD}zq(~K61M`E={0Bcu=2ylVtLs-9n7==5 zovBpOpzs|EH_>tj@`4mOq zOEs7746q|d9t z{ivm4x1Y)?%>ns|=R6|}R!iP)6++{nzCByW5>K#Zwx*r)-CPfMYKz%RfPut z1KY6w0P9yx70rbH0MN9Xwtp%)CuaLL8I&}Qj>L8!htyX^u6V=1@x?1?(P|L5LFK^8 zT_SMdw+zTgC!C*p;g;&w;!9iIM&|A-TcXkl;!W(|Ib8C6RjW4a+mBHw$sUK{v!eK0 zU(v2Do>Ck*4=l*bjf7;72lu*j&lTTmekHMy@Pa-vd!L~x*$^OoR(%Y)BK^LRWJ zX?bHE?wc;E(h^!RHL{0d0O&Uc?nwPBPr|w%g{`%nI_)*9xwS8~i2lnsRhP{N?#UVX z*BHqpVz;MKa9-*rOPSr9TJMM~^dAuEzI^k<11qdIR;D5p@w{_O$ zWxg%2#padB&Orcm2ir977hm{)#NI0+>rvIC))G-~3|6+=fWsUrDQ~W%1I$#9dqCWsj#DZa@^35*l?57v`R@Ya!Ytix?_uga1t~aP3{;J$M=Z0)-Cre)uMJ3{ZQCh%)-%SHao@q{;W z&zsArpLB951g_;ffFK-n>OE^X(@M;C=+8d!kA*bf2wr`aH92o4OcsWFK?5KlkWbFv zm%eM45v;CSAUw0idgi_B;zxvhJFa+&@5EY$vo@3VnMJVqGNF-n;BlUIXVSQ7d?Db! zFtlDch!Me7h!4+!`O=jqd!lXFdmU;^gp*UVu-o%&xn~6ZHmM)273dxr_;aIb z7c(xG;yCpwcJ5?~0L4an@WZ(jQ&D|ONbv~OBxt;>`N~T^K)?f>kH8w+w6^;#)6B9Y z;EnDxwODTL&*NPEwY-;CD;y+wrLdr!WarkkuOzs)K2^ypBC>;S@qoY{at&0w4d0>J zwat`zHn(LBlN+nug<0|*Agryx?g$6Jr=@XMiRA?#whvHEcb)<8&-^6%7PofS5<_vN z!6d#}P5Y4}XV`h*@ql`BTxO{hYG2A?>DZdunrUh+K4;dlU0HaS!MY8Whpb6t_OX4g z;95e65L_acLS z7SSLW!z_3i^&wOb_z|v`_ML~$_;=x1$5<|>kN)~lfA(5*HH?s^{q%g@3;f6l{_phv z01Eg20NE)0vi|_ZRww>mH1$kuREne)U%x@}=o?kowdw2D!*Y=-czn<9t0Lv*%f06$H>s7rX z#LI0h$(9(3a;9bI+2aQUW9wN@L|k<)+G!G5>jKP{X1a-59iDau!eF$Q>5 zRULEBuU}fyP@HNfr!V7k7)m^^d-VSRf?<3koZ?`=Kjuq8zW`Nj^9g|4}Z3*=k>1(7A-rX!x@pBWCaAAbN(5w*iCA4wXw=; z2vbd*zX0ve<|(=o#c^eHqb@)oGB7wN*A=&|#Mbuj8MAw@Fl_SUO~e8ZIKk74$Rw>P?FZ+hpb{?id$T>NwKMa~4l&moj%&`U~h(XQ)EJ?uRl4@N`Ha#qn z-+T)2U;HFmSBdodo4c(Z-gUINis~T9S>lnfs@U|9^scv7{h#cb6o${l`m@OG`^0;l zp-{f;uN;tbf!Lhpyjx1~F16w5qn5@w@r{VT}C)~nCW8ENEo(UlHcOrd-GC~5ao%J%*y)fUo5F3=SRB%G1g1D{^} z*6xw;7s4JML8)Hq`kek@A+9cU7KK<5f~0~wa7KCzcBkBYRMK79C)Tg!Qb+obVI4;w zF4hN+UiA*0*#Uf4X#&Og>OvFZNTBDkfDtd{MX%BC7R#T{0g;lB!L zu|(Q-g!ibp@~3#3MfpJhtg{>tIUg$;+Dq%nQ)*ksYN`MVeq)2v(SIg2$xZQt!_6Fi zWyQp*oT``y9=HVHeid1MHF#r_ZdP4__;k1|WC7ykff9`(Qvk2)ea7S=Lga0?X#kETsmljF^l zk`wkhq#4FRTz@Lza>h5<&EjVDZ0=;x?TOsdNQ=>lnqGdNJ!9%$4m>}tOz&}PD@fSh z?=RjM0B3f1;B#D^&&Rbg6CRjKk$_}y#Qy-}wORf;#8l@_ktF^QBmqz4wKm2rQ-67z zuP39C*WG*|@O|6DE_7Ramx+rajxn5nbdN#wt2ciQydc4rT_PrD!x&sREL34h0F010 z2Dmo(;!5q4NCk7r^Fcq#nBN&-m6U&HyDIU6FHh6?(p2$}cK-hW_$E%J`ki0=Do+Qs zyE5pO@h~I+RE{&(CYfvS&%)3ZR?(nNe*2=F*DT)|p;3dUIxl^)e_U0F{xw+z8PlfQ z>XIq`lsMx%zr2oPyVJZ6qv&@r+*(^jX>zA4EKjkNdhvoQadgX*8I)Yi$=QYTIQ)xo zUQ|9c*e-m#JBYA;U9$mC9~)r}yX?0Nf4(IJhvSOL(ZxN}`^rh-BZMxjg6ftCRR|S+*g6we>*5`}dL@{{Z#6`d6C2#>**-G}?TPo_7fpAJY`; zZ;iGw5+Svi4?%H{{=FW{68B7{HQe+gre6z>ntRYj0+}u*I30)Hp{=zP$%{~&Xa0HJ zKOcZ<;vx9orLygw!r_PaM4`vzO+zonT@qXsv${e*fkKRaO;6ZjJ2CSv=WYK06J%T& zuVqr%R#%aNkEU=t)myl4bvW5BFJyT5%u>59aCpvZl|LEuaHla{=LZVtr#$+T=}^t_ zp27b8l+uI9E>w~F()zw8_eqn@=6XE7E%7|sP}43oL;aeED+F+=V}bl?1A2F?ap(DJ zf3$7_=kFW25RYt@u14SEE{_plmRC^%e(IIReukd^02wr$?~6-_)Q{pt`M$WtQ>BWR z#G5Ofz1h^Qt-)s7s$R1ma~;m?{*?>Cs~JgMa_vClX!6bn{qtO;-xhQ$p1ONG0r!!@ zxAUs5UNTW}wcIH>;s;crBW7A-t0;zG4B8#MqpnFt=hv66SI2g~Bk>)aH+qJN zRx?!6xAojgUT2?L$lttX=W38M+NaQ^^7L5nq?UGOKkyGr;@1#|GJ7~~%`#j&!AJ(9Q;-%~>fv4$b zj2zklpVq0#Eb8iqFt0eNL{)BPXvfo~}W0T?iKXK%hNB*x-PpU(56dfkr0O$ts{{XX^ z+B7Pys!Q)FO5IL^`@q@_y8+H5fxi#FDk)G%24e(z;Pd*`R->sdJW%S*0P`B z*1e`oB^q=POA*Sl$Pt0|KjB?jw|&Hku-M>%gZfkAxYeRNt@R6tLGFyoYek5}t=jv8 z+BA9O-V4+d22C#6n3I4fLVfD}s`#Tw)6I+)S1{R-#6X2u9mD3w9rIoC_=CjIV;c4E z9XC$j<4Sx};$~*utX#{-3S?uS!!@tgu^pG<9?_m(rCMsf8`M)#w9+rF?$dVJe2w3D zVD;mrL*hHj-w{g;+MS)`lEoN}7{nLl1dt9sroBjbi(QK>(?=sWQ7V@G38h~Y>nOkR z>!y>80&LLLtX*aHSKdEpc{Z=AomWiL^!wDAE$o-eg?y$59dLbX33RInWe*$NM(TG1 zgTef(-cO7*RRhe^^nyLC5L9pCtC>byM9_{plc%LMjjPt5;vDGn&j|cNlfm&`CBKy~ z+L;%ANw;68Yq-;KOOdE;G4Zk(mQ)Df&;ZUJ&nU#T_BCW|7oGszsIuqo5quhP8?=w2Ie z2mB+kq3^cksRxI=HOT(Lku%e6=^xUMu0^}Jzr*~=ybdGM~%u4bJ^*@z%KN1w53PWcZC7Sv<3G@pTp#K2qVzIQXDqSLDBw|^K zIsNG^(zJEUo85lJZC(=!tBC`m^U#j}09wDSLs4_Zo@aBWPMK(8*&`09*guc0HalC) zub&f0IAOJh0rwf^y7ln2)=3v~5BH;A@T6}H-fqI(bJr#zkLO3$DcGer_cgu@>h}5; zy{bv8+zUu1v@xSemJHrv9C4o6{Hu)dOlJ35zIfsDt=eZ~Dhb{)aDOV^Uk_d*jNYgR zxwiiR?5Z}SOpU`uBDfUfvzb=3UO4 zTk-Ax0Eb|YOwx5fw??YIRl^2mv|OnwUmrL*Ju*NQpWrQ8`&ambr{3yU25H*hAGOWA z$io{#FFz^~k;v)zR!FeaRDY_pW7CIk@TEF-qb7G+Kv&buKjByPn6gukb6esB_SQZl zmTRaQOGZFl?T8ZxEctyeo7M znPm}fq?QLf_2hF|yh?im^ks0y(*vfb-qgtG6n>UWGDDk))>)e6WLnHXK+DQme?XOQ;sq)NXQ&g_Uhx) zx!ZhKi6;1eeIpPyT?YjACQvi_pU%E|av_~S3iG!Mx0P^v=ePd=TD?b8*7Yq?$?Yaf zi;Gs}p^<@BInGGWJo8+%`WR^gwwzV5jI3YbTYF_7vRzKw_I|f}O?y;#U|icMBpyEh z0BS3g(J!?x4qQzxoYPsniV@i&-H8!j{_rc2_1|7aJ8y?4p83aG^)K1F_9#9eUb0{$ zwxFmTfMq!SE6#Om5oLXKXa>;~uNDvcB#P;dkw!1#Z0UX-Y7koMS}s(H7Hku=@wtd6 z(C|%q@5hCYQus4rG;Fd2)nf|Eaxlpt>-EKW-+?sPQu9sJ9hZHz;z_rrO1igS!o9EJ zhAw^%>WVS`k9P#Xf7?Z0{hZcReb*!@EmJ;Q^20Z%C;5^7HS2$~9niG+r3ubF>p9SV zjr}XkAhLN1VHvTE?F9S(07~ut6iEIS@wS<%-R2uuH;G%R!9p>C&sy0PxzBj+*=;;) z;uw^Cv1%7dkEE=>(xK5Uu5Fau5*7f!fX5v>k?2o5&Oa;B=+>;?H zbB1!<`e!v(&%;LC>=L|+UwBS~JqRP#v#A+LXbC%LRF$;%yMMJgh|1eaTVR#z&mR8( zr9p9{4X-t}=UDeJGGR$k>G)Q}It`?U=UmCc=2sZ} z%hjQu3yhi%k91qByhR9hX=cyJfTQ(pf~C`A`z_NSv-x)FrAF7z;sL?>X9pEw6lCof zW6&CGw-MXM8c7vbA9>pY^EH0Wbt_a_K@hq#ZQb&W1Kfk@+;y(YK=_lW_iO!WO|{@=W~QTA;;`0qE% zpTJbfQ~N}zTdC-JZ;mz1A4S$Q%PZ+DtTkngFJO!Sw*en|1|(;O=Yl#`Jd?>Rhjff& z>UTDC>-yGKn{Rz{vF_$rgA9~G{Z4CQ-r5LA!!xksE!MebiG*~DB2`yHC?R>Arf@;* zYeo$sHOHMH9k3Ww*xmO+fuE&K4Z0i$m65Z~NDW?nO7$hShFsE+==6vkfsGzq3}ZZ> zYIYLYc9?<^Gr;EqjMTGgHu1v9sW2OHxShbBl}=GCv2a=h9RNbO{VScxz1Z#Dk}dHJ zaPFu$z!VKi1asyRG1H2tF)>Ak2Wjb?{{ULgjSSKfq4xC1T81kdBU4!zm1YPBB#dBG za$EohA;x(K3FGss@kYfsSxC<#9qJ=8qnxN7#b~k(i-S-eSsy%O`=k?^j$KcBjFF#q z>rrlY8C(X=aA}D%nTxYJxFZCA4GzI_%4!%;3&=1zDn<=Tn!G^{U5V@S91qg7lpHfT zFP_`7XoMpvR#WwEMrkVr%ZsVSE4EoqPB1ymK0BB}+47+yr>$qlYYT4P1N{KaK{8vw z>cw0f^Tul3iEP|yKyAWIFzPZ*E8R_Up`*CL_G+MEhGojW?2bs{p^^ncpyUD21By-9 zo0gjHc;q155;2X9j8XlkC>dN5Jpez-t9_jqhTh#hY2Rmvi~`y8Iilzb!&JIe$xshp zn;+vyy2&f^s3+A8XTyjk+Tph6h9;iY@tp2f!5vBC^QmegHJ4J3ZgZb{dflqFR%YY6 zoQ{=-*6_rBSjaxiYE?0n0gE0w14k-*4(7AoNW(k#()1rvU9uv^%U_I+z?O?UNc3k2HCujC<0P_jtxil zjJD!%Pq7BFe7AA{d=t(HG_EI)92N(Vd8ARM(y#>JF~)mlfpL?8BO_yvoK{e0Dl@o# zC;Vx<-0`L)h-8+wrR+d6} zUYuaADj`2VcK{xsX0uJyiauok_uEP?iDgA4q;rK`yq`h!rDt$8br{4t6BamQfyaKQ z`qtcW`F2*Q-~zEOLG}WvYc@}|%E~|%Os@4EiRvptbuQQkg&g9(j-JO$BGeGIv|ug? z9GaIzK59uB<%)5SrCW~Gq|=xwK)_??JXU6veGt@k3`cPp#F6d<5$X?dSJVVHf_vM$ zXE--7l*m2E!2Xqnx`Hc}3<>`LYdqIotm(~faRtm{J6ovg0p_^9T}7UwJWmc6cHmEN zMo06nGZ`0TM9T|(fJO=W=7rSQ4WlR6fBNe!E^XQIzhMWi;wpbN4gx;x9=p2Nm182? zUo&v0+=_DD%2#m#zM1}&R2Tji{E4W|!vH#9ahy_0z-SBRUZ_W@=A7DskZ{L6PT);t z{iGIM zQcE!WR+c;1@qptVz|mkg8f>UL_b;VKEyw_Tp!dO2Yai`Vu}DWBs*7TA^#O z82t3ZcHrO!!RI;RjYe2D@jv{s&*4!Uc#}I~BQ+Y!BOU=ig-9HMlE)--fr>1|G$?H5 zGNc}Gb3u+Qj!5U9Q%-A_U@}#J>A8W&w_0bIt^gyP^;`;e7@kZ{K2<)v8b*Vs`oYKd z$fo(5TW;b{KX~SlmXn|a_rr|;06JCxE>z=taoahjv2qCo$JA7FA>2WYj~xqAD!gMj zWT(m@%47;kwoj?zgDitTI*@xBVDn0xNLcy~wHv#k0kh8;#%c0_E;4y5{{Vc7Jd=Tg z!9BRBjH!>fxgGY3Xn5R?27SjgSO>W0=HA`DRR*Il#`)$&Kkm|#8j(Q61+=0T1+;-N4m?j5-GAN^{3JXt>=0CgbZllN{i8HdxFcVILhC}Cw~^}!;O z&MXNtz6YxlO4wEzVlmu`k1WKU+4RVw;5o}VAH3b)=A}s3o(Ld%QnL~ODy07awAiMo zC_&hR+#J)o0mU4*ADPWG#CaqRdyWlCr)kMTr_>5O+#D9hM{`BSF&V$wBh&nfDNG+xLRb^fWbvB2ZKYgF6{3l@3>Mw;j=uHREQ=chwZ39O1F`G*QocdV zeRGa@&3zq_)KBor@?Qmbg*7XP_otH4n32A0k;k<^Qnj?y^_$z~2W{2FldO6ByO_2Q z(=}KtF9YQ{;*a*p>B${G&suG0Pg3&33@f%Ml}0{X4u1nzZZ&(yv^LV--AQRE-Q`I$ z6nr1r$;9hhuYo!1?*H&or^g zh2V|dcx5HN8&f9$maP*#0IYx9M8Y_d#cEslZXK%4kjutKPHPEb zS3!GJwmEq1wR_+L-N><@`R1X2F7=~f;Y($k&$*4JZ|_WZb6qI05QYoKs<%I-SdIrI zCNK{f#cE2FN7!{{Z#U ze`~N@ouDb>Z*Qezcg(q94{DKTjfV-_qc0+-QKrkr`X?o&EAo+m4**oLYABe)5#31U zvrOw71~GCzfc{k<+fneoSYG@uT3U@WVBBtf-eFLCVxlV}G2yY-b}E3KOAHLL8~{c! z_|(&1Nwee_V}ZAdK18-UTfrD%f)~DcHCidx%Q$ja`W}@W*OD;Kes5t@MG08c5=rVw zT=V$QkP`XdCKx%%;DP-qk)pF@Lxc4n<4r3#QL~ZPg{FCvM%iTvj-wRpfU_V7+1|`J z`EWQD7oGAIRRcbQkx2J4mlyyNdf;Y{w6NnFv7XozSu$BYyt0?r7~>sB6)VRyjDL7r zBfDo6l+QalzILeYPZe~@Ea)TwdVdKNni(_2JSn*4)b}(Y5@DNQ2B0!soaBwU@6wQ6 z+{cw=45zbVqeKOUfr$#7=cxy!Iv1AH?`LORk^U6{n__}uUAR1sPw7Kl?gwh*5C#Vo zRuN`Pvcr`OI~4}2HPYt>H&dQ?!Ki%pMcP|xp2w3+XGxcdcH^#knmI#q*V^NS#${zE zj4?mXhP{eHR7$Ma=%iM3h$83B>*#S%yQVPDp4~vBl~@g9FUyUEm)umb%!~jBXFU%Y zthjasK3j30;pa(Mr^|7g zg5id?n=;Gby2v`>#gP2P-f zrE}^IDw|reftgi^>Ivubq>?g7%IqGvrjQy6SpgfHap!3{sG3Pp+sy!h(~KIZJj75H zxAo(wH29{F@bD3iP6jE6vlOiItYG6L4Dvdf?{sLiy*W>m*C}z2nr*0C=sDn52Wb(S zbs-r_;jn9RO>jXbJc3Rz!*+4{SE+)e-@egtNa_q)V@$G%?wVoIo;4tQ@M=a&G>w>C z%g<5dG3L2Q{6}sTeDswV^$NKI@u^qDdVGL`B#ZS*^*LW+@8)&deDK>5y5cDiXD%BpKRcAKfg3&DL2o~0fSo?~L5_XB`%ezngHZo{>~3)t*$@TbSD*^+T|bL+8( z^r5XR2X_nETv^2x#r?Z5QI}lv`f*quA@GiwttXuNzHX6r#fCxesiSuyJo zBC4&Tya?Q(Ur*#Qe?x8Ka%!W%E^A9E|j#=U{)-0gvvk z4P4aVV|62#0FB(R!R$%nih-hxMjH-?Jpr$qoEh{VYaq- z`MIYkSvIcxuehkqt{;$cIUI3OxCm~Y_+>+mNdq|jDW>rB1CF?-Np13yWGlP-!l7Gb zC+`kBf@rc zz#!v2H&s(Bg>BeBQHn*GnQ);PXR)AmEeq&~2WtcCN^cY{H-2r3s9z(P@^RXtd>%Za zb48GiG-P005&8A0lI%pv>fT(6a;yo+_Xe?imtq1&+~8meYg}b4Km?t~V*%GR zSplW?l-d05Oc#c$+`NpL|o*@&Nm%-x#4F zMbA8a;|Lh)NT!*feC8x4+zO^8Q=ga+I5>Wk)|fHj8;^2mu#4p)^1RO|1t%HpnvXG@ z;3hHFs>K@5I3FTsg3=s*zvE82K?LLgst?QpxO@+&CZ8x0Pb5JJR|(r7W|!?FVM8HR zZ<(W9h0m@k3tN9RipG++d?J>`B+S4Bb@Fy`jf$lGPBMQQIY(jsR3Z1PkUcwoRP4q_ z%OJ;;O^A_t7UG2*|O@pGv8IO-9${)4S(Ti9M)D=ya!&pk0(c77BRx|vhv&pGOA z308_F%^b`+O{0?EytyNibHS|(JvKeNW3&m6y6iGdc2>R}g4Qs0bQtT5)X!}k3-T(U z&wgu_;v3k_$+MG97W!8Q%-Ip_zb-3U$HMYLnR1;nOP)<@t^%F0ZXom>>S&RM3n<&1 zoc63`R&q0Ky~_^V0J|;K_{>r;;V_-I_D>+J!)l^<$x%p0(imxRX1fMSdQGN z8&3dr#}y+=s}zGHarFYS&Ojxb91ag3prG9C+`s^RF_B1SsFFZI4zb{lbN&?sj)dXv z3C3^-@~r;=w>zJ@xCe}iRCy#BI955w6*gjJ(|nl3WS?*hRaeFC@ZIVS{*ki3Umr|9Uv)_kqu17;{eYlzsmIs2f1VLH4OWML#f6oRfq3 zQbh7h%p+6W9Dclt8t6Mje2E;H0FZOec*oY9)~JJOg8Aw|%{)M`aK(;)gs&p2%3}m= zX2|LYJXDjQ!yHhdeT>9>}3zNn=@tV6Hp1V&h-10ysnJtpwu745^XrW`zcIl-T0!OzLX7ps4J=HdRZhhtsLxuNShg_W9=^F1NJVcda0U>t z$35w7?v1?OTdxP6aw*Mm&JJAjjJ7_t6s(dEpat{<^FYf|UWRNaI6c7PqLCTK?pHl{ zrdwY&@;3+PrYg$Zf<^%cryNq`>I4h&s)Ac1q5NohS|wbu8TB-;aDbiaat9r11AMG`_r=#7nf#B=xAgxj7|64yPRRR53vfjK1PUIUiz-J`{EX^x~orDakPm z{Y5$Cjujwa6VE;9x)C9~DnJd_oHzBQTcD{djM#ke0M31Rs2WuuS@& ziS%tkG(j!ojCcfvIQ%O;{5pb1FEA^PwnT){Z_HpRh~BIl{}t3>8Dox;R=Lg za9500p51p9k+&9|s1;%_Vk!;^GmL(;C6=YuC~X+I>p=zw(i+UI4CeN^!aPfP1;*Z z9B{)qAbxdfKM=Gdha>wY2$lJxHv$;d7S!x#GEPKtT-c92ZR@ z4vfQYe+tR|-AU(ZZXC>50$jF9>)xr$te3eaH6joP%kR!jdDy24itBP6jkXz1t&aTD z6vn^a4UGJ!kyJmlM2NeTHhJT{0&ywmRQl%?<=L&tG@5y102y6){v%8`GXO|FVeA3M zYO)6>G04l$UEO~=d5U6ACqa+zEi2e|Een!Rhk=WF(AOKs2*Zx2HAu@mW0DRt%8GB> zh#xT}fbT$!2A_}#e3PHOPg%Lz44CxJYc(#n;1CJ)^r=nis_p{=)2$Y`7X0+jGCCi` z(9&by0_X9~K(}g7-f(+y#VcMmG9+$K z=l*d{>4nKdC}Hy}9A^b_>rQyd12?O<@%0@Oust*LdSm)b4DjlG37!m8&G~!*65RtGpK?9Na`cZMF%P>tGQ52Pl zIsX8bAzS&Hs%KSp3<*+69gaP~;-rMEdkzaYY>mhYpRG1URxHI&m`TsdF^|HVWnWE1 zTZoioAt%s(jTG1;e=+a?_uGmora-p-7J?s`kllFxb#g5-7?5EXfsdQ5TS#6~$9Rkk zoE8A$o^;ei4)exA#(r<{qVi=V*$izK-H58gJ-XD9w$cP(Mh`gWpKMghU{XRxN&Jm4 z-mUGf8y)A&#LQVC)`cN526MNCK^bTSzHF4N_OVtU}yQfZ`MGR1k|9<>_SiJdZvpq@)`ewAnYJ=gBZ<(y=L z#T$ykF`SR?ra!$wV;ZusS8y?tQm>g1+xN)tyEv*+%_3(jjGUfwDRO|bxo2386qDB+ z0aHV=7)k?@dV%X%vdH9vu(n5~Sx8z{$U(T0bGxq@G+B#M+f9H=dLAktII|tV`*FoU z5>Y55GLUj93~~&%_WVsQQ4@(>T}e}c`5KsNYbha+q4ZuWK3M`V4#$E{&^W6QNRuw` z0XWW7dv&U}KrX_V8S@+xIQOZKl$^AMc<92aA(50}hzAE9Xq8rF%c`+ck-N9O77LEN zkzZkq{RkB@#EB3sDU^ZG9AdL>CX!6;a69eWjDA%6CM=-4vpIlmDG~O zUHcsW0F6LyRFD}6{{U%EC-9=-a@3LT;Rq{^+-9r2n=#8aJqcWA@u}j08L_))9XP4{ zh<;!Kvv<#0qD<$7ty^#eueDf?2_(a01K-xBmOatoMo8*68h|wRY5T&ejClNLxCt~( z7s?|qo(ThkRod0mXUjP57n-vpIBc$6Pav-%nkSWs;GUT{pa=wP>xYeoPeb02Lg)a( zh|gb}rC4bgr+mc=m^+RPd|FBrQ;5E zF(;-DDf`0%x;6y$$>y856x&YN2su!C9@ORXA;5PEdm6jtG^1$wyMTGcRJ_xKh*C)S z{{Up4=}OE7`Rb=C!GZV1GDckFf;w^yLY9I800oC^_5EsN4iu0_JQ`f1A}#=7$pemk zDaPmJA1Le44k_${OaO>U>M_knC94+63NfA+Jbo26U^vK*2smJSo+>jOf5HYmsTBYm zF8({zh!t1nU8B@s(3fy4vtt290Q9CXk%uf-+up4h;|xN&jzKJOO?UxNLkBtPxZ;~Y zRArSzF+V8e=e09?GX2~S(ygjUiWd$E_Q;@ZVsnM(>BRt1Vq2~-ImRh~D<(>)$6O57 zx7pFygan^a%{8o9RQ#hJy=h&c+{;qAH~{h5mvTq}+5jGvf9%iQN$u0EA%Rzc5Cjf{ zQFg{>$rs9S4iDp+HaP=k0Q9Xf9C$9kXSl{G;M>TAsORg7u4mi~SB&mC#&PRNvXy>D z&^wCLmfcAErz8$~;-E31EC%fRjMKXUrcio$@ql7v&?2=cQX{NYBaF(~Qs| zk8VU{p17rbMPia%G-MDJzO^yCkb|~7aoCELV*$Vs?NR($rE4wgki>lrJ;h-X>A(So zexi@G?qh}8GuJ(8HnNl-yjXe~7SVvo1RT?~^a5cyDn>Zz^7o-ay7I?%dJ&oy)aU2I z`{#jHCbRNcRGbo_z{oiSewAE+A~xIrxjw@c9G+0*t0*4zAh%e)R}CIOATYrr){xtw zWx*#S0D;rmm4=3_u&7)XDhESCU3po;x`0Oqy+~q@%mG<}#y(+4Y^0GvNpeR8PI&<| zAIspP*9!`6)>E9e4;0<%NdWEy@H(0=EM~XbB#ag#sPyb=v~iHwF_j%b%}s<_4o4f# zGt(HSE6t4W9C{9Fy}ZRsZb2~GLauRx>raKyBdmedn2oX)`B$!TKhH|8P%M3^)fgX} zob>+yKGhtIuA54ZxZ|}%C4{LabtE6Tp5C;wtPWI1aprIf70v-Yxg65vkcO+2A9&;6 znvo_#IdZ3<&lMV~f)q#z$4<2|Eb5z;3ZTH+fE;znAanTAQG1k1w+1E0naA^|eW=I| z)>bDu%|jX!fRT^tKD3tsw{414C#hh$AE>8h7A0w1oUYO9K%L{@GL6{I)A>~)1ky@b zBxh1dz!?5qQ?^Q=aM<)Bm6$YN7Y>cYmVL?31ahst)2m^^f;THF-_7(8=|W=P<&f&TSKSdY7m3U^}E zVnyW=%FM0Vx?t3k-pzF|dFGOL#@xtrfICx5N^o`qkM@N%q+!WSzt*ndsJ>z;&Ise5 zdXeuTFo?ys&H?@Ek~7bx2`#ziLdci{3IlWb(KFkr%uHN$8C)87(&N<-?mPbGTO4MR zNeFD5XQnaQq!KF=^7Hhi^8sK10QUq`+*d0!+;9QMY*fN?kGi-uAud0S2e++6!zU$` zvFTHALKhjygcIxDq5DM3DZ=3gMd)hCfY4!u*u?(ZYPn)P;*690m|%I zN-lH5VbNq!=%ddhIq3;;a` zOp0*5C5{?C*&9a{UP(OQHvoN(Ii-p<%QC0UgWEXoOVBP^YnNvJAV*S81x2*548@p{ z$j`L^qK}=6V*mr|O-t0tT#(r6DD@S^R&>rIARd_ptwQLFoRWH+W~#>_3{L&|9SG)@ zNgp39VDXHODPF*f!u2E1B;)<6b83oM?E!wCwUnR$*v3!4826=QV7zSyzc|meA>En} za2ayLceXR_P)BjOLpmIG$5T|q(hrwB{{SkHbFp~^xX4&*vQNbWPG3z z+LK{a8Eg!21_;GPJf=bwoMiBLJX1-Es7G$SEgbRN_S!qmlDqb~J(@56i$AB!k|I-=QVNakJ%9fIgKl znP5U0kuxy>7KO77)Zr~4EG@PG@L+%Zl2hH!zeqNnmHat8%g>B(xSr1#4!wd@@dFg z&@(UG$2b(ZL})5+j5`v*nv?9P*<-sO-RI@1w%3MC6vj#PtAc5Ef)qARPT`;D_|&y7 zg>FSJlmdXQzZ?V8{{XK{c_WoF09GG|<5N0^Ol{hI0mf@STNeyUu_PP}ni64R%AJEO z7!%O1r8T7z92OyZ^HnxY7S(q=9N->*N=2E=;F3q6>spdoxLhdO;N)aufIm8*)3OtQ z26-fOtFYWg+f~z?a9Coj8(`r5#=z`HB9j?HE2793E0BKhsUGIxQwU>W{oGPbXq$i; zR|AoXVUK7bSQ5QC{{R{ixDGcn7QsL|{n1Qja)jdt87CDUz+Z_5LC?~sPnQMNNcQSz z&3`#ef=5CqRT2ZKUDE8I4Cbza>+ zDkM$olqyNacGOZs8Ewc@Xzm9zr6W9p?lLpYUY$S$o=+SzbkBN--Ax+}q^Rx1OpW{` zfS`0?)`yi;4a5=1;NVeY0Jp6mB<(zO#weC`+yfv7+l>3v4;f}7+t#9z+1TYrILX~g zz*`c;TLD7jzG)l>#veRzd(}jYL^41iegidST#kgNpzp12(;->?f6)w0M!$a-`Vmn*qy*(k76a0mCMwR~*o%%tJ0mJa8$lgezdCN9#-EgMqjCn1*@MF$Px*7v&q6@$4#8xIzyDKEo9f#uZLiEz^pUC58B9D&4Tz6!rqm zyTdB7$#V_6V<0*?E%<M^ArxLuP~c@P4D8dWEA{n~6XpsA_k19ob`W zToVeapS|+qKaDl#8w7q@=Q+lIT4qQ;GiQ!Drirnio34KH=kheJ7aGbUK5E1n@<$_q z#X``KK~hv>q3u?kQIsB3`uC>*(aH*AJYbpzXZ??K-fP5?D|O3bzg`U~w_}mzZ9sSm zaw_Sy1e~F5-M~C`{{SD-lw0iM$vIwm6s`pohB!iezN4CuTE!%ap^BCq3cKb6LR~%Z zO+v=rX*S~oas>)P)NaVrC1rKU0G#58;|jS2oCD8Oj-BZm+9@{x$0Gv()4aFa<%!<_ z=V?5BanheN80n`zVUTf?k@HjnX$kqCE1&OUG%_|x&IerPo98kHF08=wk=~ABT#?*o zrZMTwGtNz;bVT#UDUERxa8n~a>83?tg^L{j0DBa#aom3NQSy>8>L?_)J9qKuF-nMY z{-u{5hdk8fh)_JH&v0=|L6u8L!*0T>j(}4`GJ>WxIp;Vz6=;_y8yP|7n9Nar>GjPk zi82ttoS4@m)}&bp2LVH4rfG%HZeqA!=4cxmw>^4eG+y8<0wnfPS=r4~?mCm!oA%@<8<(Dfq!KPcElF5b z8o|I*@{{TgX-RFT1oXkiDLlcuBm!~iN#?NS0RBdukj2X*h53d@1XCq)4=f2C=^Exu zyGjb-gj2zjyvS$nI+IH2{|AVNA;y~YE9*&YDfTjcJEJT zc+jxtBck`Ji7(niDNq5&%126ZSdjrE2fsb3Tx9JTc^e0(A3v=lMm}xMyl3u;UHCct z0nIfQ84xsp0K?Xr7K7nZ%b3?br=eqskdC?B08#;gW%02m{#DkYVIu?R9a$T-NPS$<)i!bLnP^`ia9ami}iv*s}4 zxT%GhT;cPNQ^htZ3ceY!j^~bP#y}VVS9cgAzo4tK0>p8~`2t)K$vuTgV!_7#hiJ*D z#^uJ<&U+{a@TgmoL%RY`Aocpw*hJo6m^fkwJP}CHM;emnAdC6(<0Yx%!M%gf`#~GI5Vz!n5A#l6{-9r?K^*y5tNT4#SRWcLQ7P zHtWDp&DfsxR^nBV5UNwBJ8F{L1#V-(#(5nNr8TE#I32hn(v^bbrWobt%nnKCj+DlU zk+z8ramMeZSC&SI5;$&i?NFP~pLSV*Jpt=V_5)5hWDU+4K<8?c{HkZ0%iK1CK*?MX zYP@$*%M$K5eWN(f6uXE;P|7d?zytbK+@Y^J=MNsxJvw5mv%t;rj!4ByW0{nwWjHw8 z7qv9nscb%X6O0aN*|JJ$Ac4MC`A=@+nzST&!1+$>0i4s?(lES&SbCguOlZVX1j$^E zxTjzhQb01Q#CmNtDf136och(cU{W`k2+6_7_w}bcz_F4gWh1M0p~V4>BQYN|9s8fw zqgMs~XxKJ!xC+);tyGd*EHHWyI_La~f>R5!uGAp%FbE^@?MlPkg-A#FS&yQgGf!1g zK`V~mL5_c|HQ|dRY<%?_PHH&f83YrNo?4xRNY9)w+Bo&((;c0g1QF}TKN@$Lg$nE# z4!~y=^+??a>?y7Zhl^!di1H1_^->B4e5>rMoL1;jz|l+!6T{3IH_P; zTc%axpG=d-`Nc7>8BXt&K<+zKQNR@b@MqooyAa)qpowsJ0K7310-N09FDy4 z)}Uu*Y_h56k}AvFM5Ab6SdO@==GixuEH>nTyyBgP0%#-~S89>e=QQ=5q9B9G;1N|t zxj+2C~;t0wg&?m#>MTcsFQ zQOs{@QKL>UxTym40QAZovoNoK*YT zCIm6ydeh(T{PC6@a5~jy5^mfOs3V~SQ%FURncp}L&BX>S%m+9lJU8>rQ#+M`+Hv2E z_o-G%LoQHl&j%dRX#pqM)D|1}4F3QNW}IW&9!~5Gj+DW3^Bur}?^F4`#^wW@FC$4ubl;)Y`w0b*t61PqZwDe~JA1|H&}W_1H=pdOV&9tt*a7jX1A zsTN&@eY+7=r&UH)CwlSFW13aCei)agxV@+r2>Y`HrVKBcn582ix62&$s)bW#xpJNwkg#^@ZV2Lm(!@}amWtU7?Fy+tf>g$3dx zZyaW(OGi_ML4Y_U)QfE@a#v{2OwtvKqBx`qLr72i-6_U0SmsQhPUfY1b^zpq+mA|* z%2e)p;Hl>nSOtc7V9GHixHtzKQnkvH#6ubCYO@?SDaJX^@}_RwI0vqHZYbPkzcx2t zx;An+7^QI%hT1`2b5z4^CqF6cjMKNQfQ`Us+aKq(CIrl}xC4HD@l4sZayMq70Th#w zxN;3R%E&%SW|pEAr4WqZdSnhi&lJU#0p$id zob#HMwzODmWIq{9+U z;z>Oz;jjvo>&eGIT4NMw@XF45ustf;1j(BCMk5#?f(Fs=QJ3?h8A78t$vtz_(_(eV z`8Q)H3;EQC$(c7SjH5IpA%A*Mub4uPgTG1;N@H){VOX9^axvb9LOxK(C!7w#jq0)C zm;m0LXefe9M)_E=Y_}xxao(qeHjZE4J+nmjaUx-qKO>BH%~p!>8b-&?0l@nD(V(nd zSW-yv`$5l4_M~wVwpR>yW2btL?LJ}cy~dMfB^tXB0D&hRUcNjY85}nAvt= zi5SN~I`yVGEVzxq$MEK~TM3eR;XwvYdvlKTn^}k6E6;!FQM4|&0o0zIGoPg+Cz7W)`Wo(rU z#Cr4IpfSeGNp>Gx=9S8Ev}|B7bI(6oX=yM zCmHAUsoG_iY8;FKjC0zC>``SN8F*JL3Ff0QD#?y>*P6~G+YE&zarcKfIjP`j040VC zAL2RAew6K?nk@wn$IG*(dS~9JkN03=Lx2Yv$T_OZBb8l>q<0v>^{Ge-r*vUougUsS zwZvLTVY$zgc?XeDc}$%ncT?X7>HR7c5fj9vTRlj}6=X`0!V1gg4^nBMD-{I$Vy?+l{l#4OIO3&MNZXjyXP%vE zNmPd;D&F-~qhyejW1e%#>qnp$Fjg?h7#^V0=C*NeCEkb#1cf||^at|&D>NA&jzJt_ zu4(Hrg!0%V@;@4ei&pA7M`T7j@NNcz56kloc_)fha0weh zEO^B;%q~a@8L`Opp%Oq;;EZ$xP>{!@VV`bF9AsqDeV=Xzm~a3j6Vj$u1+qf|M?MbA)5_x7j#l*YrB7#x9-O!HL<3Ni`F1ox!uE0O~6Ald1Tw3A)F2*C%ZIVPjt zz#M~se=2d5KGB3Ky;z)PmV>?b5n1E_F$d{RWgz^m+M4aS0Fi-?n={Qc8H(Vc8Sk9^ zGAOVD$l%}-PqC&5*C%pyJx>*26%r^RNFUwj6(b1QPSqJ5GPIguYmkIYe4~;&6W);{ zW-GZiWOOGq=dqcedYl8vrnS7NMj3;VjFCl{4qz3Czy$sj(d4i>7z%PmI@F4eFo<^*HpV z1UqrgcN5MHHLx28c73X6x8El#(*r7LL@a{=lxL_kz>M3jS0tV?0pgWy)DyLeoMYvr zmLiNd5BL^wOziN!| zV=;_y2N|Vn0K;)A0@9tJc0K70%H>Ggz{V-~jj*Z+By`P2)`cW3Ff+jGMS|nmBT>R&`<}GrATMGMBookPpB3%3$!wFmInR2sAVYu~1KZQy zrq%+OJCttQxcc)*NL;SNlizJxYb6IENjU4)qLR_U+PjAx0ixFezm`gz1B1xLCeFK; zcS@W@R0YAv$0v{T-lLKi0JaEFNZ|8DgvKKr5T}9I9AcPPX9WA;_oj#71}q1m8K;AT z&m+E8jYDyW@BAvHd$(F~k%M4!llNDpNwzc9KMFw%Do93PbJzSS1%wJDV0Mv?def0) z1nmI#>L{clX@2B?Q~c@(e(&c+6frX@U-qQiv<7C$V0P3RpQ*M9Mui-@%eFRyD{yn1q0CfKVBTzp_{{TIx zqMO)VORN6?9#H=Pp7jcU$IO53{{ZZ?QA`m~f5*h%LsK91L-GE!QAupZB)t#RR8oJd zpZ)qMq*0f5{{SB_f1lzNZ8|sk?L`zzpt_bJ`=5xbFZWOT$N8Elq=w6O-+8{j<4u#O z{{TJB6jhQ#ANuG20C-c|{ZafVqND7AC6D^J{-UIx@6GuC05e4uOJFH~82;p&oiiS_RKD1F&&_h%1f55-So&NwV{{Y}k{HUUVk-`0cprn(kejmQ$zlv{Q6Nv2&5OE>WBIDs>}C<6jpsiUW@LJsinB;ew0yEz^fnLZ=n7{mHzsS5p{Dn%t>YM%QD4}cxbN;b^-TtPdm#Y5&z^J07ZZut!? zsj+{pU#OyrXl#7{0C-hse(?H_pMHEPAZj=829%=r2n!9oSxPQFA%8Dr=vp)O&K3o0+PJV;% z{!~#?_aTxG^-1;qqMZlcfAic?MK)R`wfnF2s3+*(Q$-YsV#)rgKhJuu{{Ve{r}|Mv z4{#@oulxwqGk>l>PwPb#-oPOL0AC-}e=55Df8ay;(M4Q#1U`TD{{Wx#rp43#1^pE^E6RVNgUsK{{Y<|%+zhv m{{WuUQBwCbMTGDEd(-FFijf+QshmHN3^fdtyF$oC?4n7$w_0dPp>;&zbnmTtYChNXf`4D4AJU+1NP*1cih}M8!UQl$DcLP*l?VqNS~) ztEX>n@y*i8+Q!z^&E3P(%iAaP`;V~jh@X*(Nxza)Qh%qV=j9g^78RG2metnPH#9ai zx3u>5^$!dV4Ude@%+Ae2pbLwzjm@p?o!!0tgG2bm<<<4gE#mI}KU_!v{&5 zB4rZTV21w(?SCTse+Mk&{};0V1ML6ef&p-lkzOVbnE)USKzun050 zMggf0?~O5fu_3HQ%9Q6#vnLP=WfDR(32p5(Dz0@bbT=^ubku^)T9Jn1q5He-ta8$dW1G=RSBJeV zmIleyJ1~nIYqBL1rQo>64P^pd>uhs)csCgj;XvN0raebQ~P-5slW{`x|RHXZR5 zi$&wP(S2sN?JV=RH9PkwIDYYEY7I|#2}J6r-BQ?~*`AbO1wE*90y1X42qM6qO8ek-SpKk~#=j|GR2JbhphnhLkb6;-H;7TPA)T z%4w_jFeGqr(Sgg-Hs3+r9hDxZ5z2$>7h3nG0o{p?1oHl2fWRtdX>Z=;edD1RlRF(Os$2{4#7ZJ0*WKZaS`zq zH$Ny0nWfn`25rZXDdE?UL}aRI@|jt7*6vt%fcM#I<9kLhp0E~k1L!SD@O@-Vl|DP4 ztZU;EyB@>%V2nTlVW{U!_3ms@q#<9j=#kNFxS+E4n^&Jg&(x1f?U{l~NR(95`}eQq z$@2{Ss0n%RzLX08jwUxv&^c8?8AR34(!46OUzYVsa_=75Z0h`5>6jV0=U`Y6R5HkM zQn{Tt6zk(wn&>hIl4&BM^0-s|Jg+C{sV7vkHIpOQCT>-z+5BD3lXi|Nv3l{3rw?mf z_EUNCT(WYav%bCSj~>Cv!zNi(NJAm6qEGw?Tg3HdX;kB-Kl|vjnd{I@sX{M1Kden- z%6gITE*DKOqcm4jHEfRq&6WznliffNrO`aik~vy|w5}uXNU4xdeh=lc_0jHV82KTG zhgn%rFA#`XB|yk_hZoS0!!SC2tD+mH)!m2Z4|+edc&5qL0=92r7(_X`_*(a6l{Xgs z^L1f`9ArY1-m=6FRaCHEh?~vA)vthen1&;HJJ({{GR>2p@vE&slTH5Wb#bWy7uR0kR=w^&N%g|D% zw(cMaVr$D=-BVl-Bs4k-ppRrnV#D?4vfA*qv172o+Kyh2gjaxhBuGV>=3PcE8!0X(>j4NXDoE=zdsNDst+$ZKM)NdOj+!(X&aV zs5asgUnZwiI;?!JVcsHNC*YoA)HCTnz;~A?UEF*R5t17_C_UOfaHk9QF}mEjZ@#B3 zN$~X8r3-(`&`8eb0ors#8rex}9_s=9`N--hb}Df>(=pbs^GX`O>7lu@u;s1Q7&Pb~ zpw}wy-o`}spbY(c@Q}E4q2G=;8GI1^@w%#@LX(z&{CV%HEM<5CU)yhOQyzJM&mY%X z*Wj)qTy)&hD=l!d{CCnIFz`ck<6rG^GqHs~;16}yk~W-ZU#|vKHb1h-`P)(vMS%2u zlel_E0%w0cK;@m<&6j3cR-|`3X~&HOXU-dDqVIqGA#aP&DO~DG&L3PpN2(H0e@-Lu zc}oX>W0G4;TSzn6m(f-Kacix^N3wwat+}zudG%L|d!15)%gC*7L*|#HT)Nws=m*9} z>vMX3xSSb2JjD&()SY;pk#BeBZ~g(ixO`JX>IdVx`QK>ZO%FfU zmNJjy4U?u^M!3FO^~#>1K-)DoVosv$VmuUYk#7puUQcDLB)fSw5gBUaz)=0m@mn_CTE;}bij|cZ zF-b0fF~(aEb}DEpBC=U!)5s%mNKEO*8}bgEFH`@ndnQGx`NRA?yNlxq9+u(+lrsj4;^PsgZ zOOwS}ZN$YbW!CYK^7(iTo5QFB|FCqhoZP!p&{h{F%-E}H0Ok>Aljd*+HgEhXFKT6) ze+NAbk9+jAc>r2>;dIg-I67LPBuEbaDKUJdMSz87hQ4a)Y5os@R7pcVYB;0MxC@+f zz9ro&@uIp3tUY%O?;LRTME*Tyrc5ORSX!b_85#0}#`yUm)XV%7&O4ry)@h7qF1Fdp z#N0RS6SYnWKLAV3I)#>}mU09`JW`+u5elAteD`j?j=RC`S*14MENWgMzkRbCC0E)r6au{pO% z_87V@W1R$W1&lvab)7F}(@2T?mZWmP!Ti_PVDIuekf{9c$LDDS*J=sWqccqqlmP>= z;}ambPk9?Wn946p{qZehOml_3YmbR_&EN$%KUN&#^QInFvsX=T!Nj{_AdP$VXOoA* zz(66;Bb?K=-N(F#s+7)?Q82K(QCQzI;sjiAF`EhiNZqJthj9-Qjf4%dE?i(MW>R_N@I7lY5rqOQSz1h$=C2gyswe3?w-(9^E zl>|-Nzu{YrewuPMOwc!+G`H<#pO+a#uQbetcG6HyoOTkG! zuTMMTfU9Mn$7JMnEM)>WT&*x%ib*ov&Z9D&jmtO13Al0rIt1RJomUtY=0cF7i;&Ml z{0YRlZWLAT&PDP1VC~O$Sfjj0MrT2-(YGY72!*J*0|^BrEMO_Z-rB(lFpPq=+ph2@ zUjtuo^^x_b1~V(P!-^&hQmk2Sn~1PPV*0>@HQ4l&w_W|$C8SVQw9Yv1;!NLt*7{-egScM=IkG{8OHEX`Wj_$Z zh3AVuaK%s2VrE~Z-pvpi;Rj^5ccYn@Hr}Z`x=GltKnY50?x{Ew0#Ccm*t7UG!Uc@Q zVSN=XPoRS+_o44PPosCOXKG z>iUPCIa-|81m_Tj4j;!|IvGu$*?xy$>~*+jlhQF*6}oXnV|4;$+884zYw7l{3pW!k ziN0_WJyFMAH%Rsm+L#FC=lC3+h6z0Z(`J$!a-~(YS((+RBVk$-s z7kmEt^j*;n)IpJ}3Q4t(jV`E1q56qqI5qJVV%jWKd!aKg|EcQ8xC@S@=1{cPQbpY&4d*qvE&tp1h+dW1w#9n!7}wB=I#MU| z9WO)HpKgPq&WcsCd^a!z{~v&mylaKz%JKW}C^FF#BR(I-UV|Lg=Q)?H{-c9*IPMaB z*|H<)Cf)y;eGk%Z)cIwYV!&0|6`b1Z(MdXOKKCmT*<--Q_8CPtk2|U%Xp%&yn~*o| z%=Cr0O;tH48;E{eeYM4qpFPn%V|zj;ZR^ReJ7JC^bD&qnxO}lE*tVI$nxSr~fM9~~ z{DR}~t?Ss?n<%!%#qekfC(Qsfq&0R?QQ zJurHUwqOnQCwla6bm4l_OKk=ZS{R&`DIS2#P{pAPIY*`Pd}b!kql*3-hv5nu^Zv;F z;mSDM-D?VN&fh?y5OzP_+!aJuzrIJ3HBxJ5vjXF?7it8BAa8DdxmejQU#7SE&Cqe` z`K!sE`VO<88L^Y+J(9YzHxx=nnO=Gv>&o1cg{&xd!9%AHWGA;)|hk6vsM ziSdPKUgo;s#J`I7rXJu26$BSukHI)yZ)J((NF2uNCt@1;xdXdmL5Gusep(M6(KU*E zz}hYJSkTTSf!AeUCo>|W_=r+HjL5zCJvXUz>TT2zBb5BHybvx`A}CHCjVE0G>Q#1q z;h#z0mHY(>>2nnccYN6Pudt+D6xYa|XZGju)|AjfLnFOq&ZRFk+J@A=l($ujZ8`>3 zSp9WDb@WKVM>^8hT{H))gy-(#%QFmLsYIpb5;cII4eBn6F13NBs#c*bG|A!3f>&|J zb?3YIM2E_rPC>P`zfjyL%B1T8k1z52Gum(8q~xdJF` z6DkNe^4Fc-`phdbeKdpZT9q6G;@O?NiLz55_e3uAK})cO+#D7Mp>crYoKm%G-tI99 z)`nVxTh&Ym%`(BqOf^4WhnfgH&q?Ft7@R*1j90UK9U1rMH~a^P77%F|=LUI;iN)KP z**qxl3rr625Ln-mlEvW+e?X44{YA-?5nA@l(AkbCJ;$L@@>F|;9PbYrZaBk{-#F5i zSEG=VYS>sA&`V!x*#!}iS9=X;`%8@C&5j$iwyH|rIPq)O?`pJVs!y7V!V3jdQ<1Cm z?WWlF#k6DFd=j!szi<{^`dsQ?3Srmsxsxl{!DX!5{O`_&^Bx8iu=8ji>yiGA*$tj?{aqb2#YUuU&aqkQ((){@W}44trKC{ z%R$@N{8EWfQwiU0%IS+BT)zLf*ow9#JJKY*nP z1tO8ztOmBufzC~D}YKvv-?zox?g zUF)vuz=$ex$>j{!1*}tnwFO%HAZ32@+4LuBd!be7Hp;l(TrWnM-nh{}Xk@h*z05>GuDyP=@v_qqH&%-c_X;kar zdx-tyu~pt!7G!kohrh$2=w^9qFf4i0#$8Ih`6^T^0wQ$ekr0Zc`Ru? z-+}z}XCfmY&r*HQ(QxuRk%2ynFwkdU5*)gng_HfenFKG1kQylDYItt04LbgZ33oU$X$mb0~9)u32M{7%|yVj9| zxpdt3O*D=87YQu!@ReG0BkqX5rd0`&CQm4mta(|OGCS!T>b8o$kgxTJ9Hjz9gZ{nU zN899xe*k&bLId~*!0KHYX;K^`NhPo3h+Cq88u>N2>?FWZf4kn3^}h@7Nfg15?Lp|& zFgOQ(OX^kiex*tFLx>ZgU$|ukqD53*}!((#k#_h!hg)+5RCia@W-#~ zFs_e@a6x%ZAof714t6aMMIuF4#YS6O$|xfWF?f1-FKM~cOp2XzWD(0Uv!vMkB(}jAlLk2g zMccMmA`zIB!j(mwZXV)5 z|AMAnhR6K`rTN6FcuOokPs_w@J~tdm@rs{*yN9MLVi+k�u-=d_Y*AjyDW`39y3F zp_+_MZPNC3CY=XQWNs(SbXp-kuow~*s(-LE;!i&cQDk+E&8vsWi&j3(h-*_z2v<6K1xqvn^?ZzToJ);+Kp^Z%SN`v@ zY=e3W*?`KGG~h?0dZV$KY$75z@Y6Hl*hbTZ``K&jd%-Egj*2GKoj95g9HF5nUo55` z8EscBR=N0-0Vnm<9uqz;3MF!2Wrrea2W{Lo#rrSMv_WG&15iNvzF9_3f_==l`O}4( zPZ5c{@qE77>^H4l;l>uqlEPm+6O_3vqOYzimJ%225B?-<;i6hL;YGTj`RLGXtY~cY z!FCF->tj&o>&uy3c(hDiaDDtIS_)R*4euV#+OVnoMYond!->4CdVLV(=rSwAph6%W zz_Q#`=!oua={jPfc{Q}*ctWvm0t5A>z~rOO%+6Kg>`x%8Yr4a)SExN%W7|*6#2$+} zh(5wqqn}YurylJKStHx=o!WG?8q~Sk*iYrfT~`d^KCE^zUeP#L{In)W$+2Gq{kZxqQ2*HkWnlOk0` z5=d|=B}B1(*@yM<0Q~Qr%--`A_R$=WRP1`9ly!?lFY-Zjv<}yIh>b1$Fm;)tN3H3Y zZB~qIhMBDuN1itG;Cnh-?dV(9balVtZAkj8O=}X40RNjFjkhwV7**Z~t+E^fW9Ie? zzcEmQcb&vSk`58LSzZt42A)@59a``M_%v{bX%yNd-FQWvFF>0P#Kl!w`zaPHK6 z!M+UTRH{79?w;L_qkdZM@V;ysjtK_SF1{L=%#h$^Q*RvpvY#WO&9zkNgRZUuAqjPe z>qtpRtw2^qqSPt^DZeil!9B<}#R(!udUr73al1+>wgXdPl8vY3hc&b_Zm_wHv8jJS z1vmj~Lsb7|`kQ`1J!}5qu&n)$S)>1^UfL7-y=lDh*CD zwk=_*wrMGs=aa-B z7ZY?Rgdr`it5U_9x;PiXG~yp%Gdb%x359-7M6@bJiXBmYlGv59<>-?-xXP9?ani?& zoJ(|ufFlv@hYo*Wf1(SzL_uDO3>vU<6X^w zb-f4fs3rQ>5a%yEW*6awhAnfX()xcpIthc1v@UmQIH!(>Y3C~0dR~I) zVrQpZ(@O(j5-3I5x3qM^>l*CwyZ|$NEr$e7Qf&XInSC8oHO$nq+>T_>(+MJ}WoR<$ z*ZLV><_gIu2Ix2oT9mdn>uM&d82F@DMzo7=*gVRYlrhk;(f!`+EI}x)iyKL8!U$el zG3yk1LxbIzX6+Lq?a?!1udnI4O|Y{x^_!X#0%YlfK$v4`3pOt0*HVoLm5uMy^=yE5 z#g2s@O{wg!MNRQrz*hQjuBuQ!q1nH^0zdhDJ*r|l8J=O9l~?BnHFXS$$d*OWX~LBz zDM<6&x`r#qvwN0QgLQ|c$wZeJ_#Xg7N4(}>-J@=}oB!|`(Xt%OOBX{OP29$KRb=Ed z_Uv1{r}Yo8!BG9VuK4K+c*&v)6CSwOdtwgX(qj5=S?nEo^}+Mw{Z{1<=2kgoQ!D!e zZ>P$tHcy0eu3kLyS=r;XY78x~HBQ>WaiXMY(1e7H!_g%4NHrl`IN{#$9{@8TEeCNI z_k3R{#WSn=*e(~iu6372HZV!tX1XTwXfp|S9N;+2ySo@dP8TI_d5oJGgj=TLKl&9v zT`Nean)(>BK3$t29Rvu&nU|FUmKTSZ(k=7h!2Wb+Ei$`)I3!k?WPCdyIPSHckaR#! zmTss{lLyr|O$2n$ZH=tm=qn(((5%7t!&AXas*3pU-%)(q?035t((fBq#BhCOQ&^CC z-b;ek{{f4=w<{Tc*}yAjrln!je+wk)-Uo6W&P z4K|_9A{8eA3quxrG^Np(23iv(NPD~Bdu3CBDd!2`k6EY(SbDWesX2~P?H|CS%;H1o zz#_Xcq#N8On-yQhWa>sTVKW`p^~;y?$j$egM)N60#R~xt;*;`zw57tRb3D^P{KQX} zs&Ax^G9LgT@!oTqKYYaZId*szoG5Y)mj1wW$k;V*Bv(Z|iN{EtV1x-{u@O&@XR^F| zm0+~}>2n5U?p>^yPKu3~T#%##4iz{8E z1d^pjSZ1? zj|>F@P~3LYHxGSYf@jELrM=mw^?RM^P68yii~haX=$EFp_C8&3S$^M_s3Ii8UVv@J)5f@Q4f-{EJvk^;g%mK}t4 z#_N%-*=(_mw01S37anbQ8#w$kEM=tfs=4Sbjr!>EHh*nae4@_0BGU{~c&JHA&#N%o z1S>i*X69$!Qj|fHiUvh!*k`r1eaYt$6Y~9pcZt`#U_VK?%{hLwnp6v`m+29M)v%3sT&IW z!}&hYpM=g{RFS{O3EB!-d?an<`5{f>?ilW}+(WJ+|Grf%wN-Lz1$9`$?RfROjtSUL zaOTnI8%xtVChU|iaHd^ic}4e?i}Qq$B*q?zJr-9b#ekkcb6uiZL9J}?>1kkZeZAqq zZ7Rw?z*xJCJOqpg{G8}f9~>WIVY7{5jufDU4uAeKE;Xv0rT3a!`*4pbu)6pkpzcKzz`6W(D;w3;mV{^3yE!!@TOpzD zZ>E~!aUZ4wE4AR*7y<`54!eUS{)z`G)+08w6jU;jlZ&j;G8~PwZumI979RJI)8xXj zqC?$Z`9WTZLO7LIf;pS^H1*pWi-a|I9V17|8&CR2D()c*`T73)MMh%?A3C3vwo06t zeSh@zs7ojFXgv8se~OLC*k?wBUOuOp7DPasu6 ze`8X~C=@G^G1J~T0n+iH{#4wshP$NCom8dBos3DST)6yK&k%nD#~XP`&~Uk8=f+=C z`kjRG`egVTO+EIw*bQt_&d(8Pn+RDpg1J{h&P0 zj`3^DPuUqkNa5?!ly{#3W*S%7AXjAPT(t=4f%IQt>`bv5T-&UF{G5h70R(fG#xKri z64qvdP&92-9Vk~XJ0zXOI2Dv6ukI= zRV;ousnPjVc$Gsk%2=0Sn{@lzTu{J-^7vTVqrZ9RB?M=h;W?{kryaeA7>Jkru822$ zqxkxsL?ZIWF-!=wNkRN~phslLPzOIr>`PX?RsCTnOVS`OX$%G9wZrw`k+w=$v=zNa zl}hNxaK%}t(jQ+YaGi8KryH(<=a7=(CZY_(D%`BWD#Bu~$Vi zXrTQS6II2zFyu@qtMhtE+TVg#idUt-=12>GnN~ke^hr=&<f z0CYcGVEvyi8kaY#ceH56l!n)@t^D_5Rec69^jg5HXUc@JavXWq94FCElI4ZmUsV1o ztC`WSKWo|AL=Aeq5S5Dx)2#+`+mxb7`dD_^<#GX6sO{g~60sKohDXx;WSd!inK09+ z`iI4R%-fkrm(25Hq9m|z?#gdW0k0hc@{Z$oZZ?`)$|-r zruhL2k58nkG0>_D&p&0H#l5Hd$b{jMYw8m;zK&b*tBKbmZ-a{XJMC^1E?3=I4{yhl zpIiBP2$hPwB)cS<-)NoK3enw3Bdij)Rr!ISz}No(c8E@Kz(XR>?;ub&Y;E)a(ij^t z(AGH2*r*keeH~gNQ!59~QmbYx@|ib?vd$14w5bKVDB(T&&SneIbcs!S!y#9^BMAy5g_h$~#gxe$8M_M* z$}I&+>rYj;wk&)Y9_{=-&-MQFM%;wJHpQ`60JFXg2LMcNJfExmXrVb|sBJXf^I#Uz z9}RUaUSqIX5glOtqrB>qg@638$u7aX<<^*}QavGu-r*ky$AU={dVL%Q_>VjRFUJLc zx~+Jup5V>m{Hc}XH}QUygzEG^qsQ&y|E?9EQ*QnsHw|Bls7#+!%YNmg9s$-rQ6|K! z`hZxnbaQxSF;hI|e|KH8 zE2iN95h1_ps`PQ+MzMMKp!0j5^K&)6Nk)gy!1_cYx@IHzqfDky#>LvGb8zI5TN;!@ zJlHU+2Gf__4hvS>8B2)CnD8l4(!Xyq_(=2M9If%CI;nqc=|3uiyjm__DWF(S>t8n4 zB5URPBHkFHEfCK9THessS^qscUJf|SFwYcb&SDFSaoF4@=qRK49F`F7ItzYOx58nx zoDPQM*28NRO8#_Dy|(IWOY*cdd?wi! zI&=Y!OObqF5;F@w3eJQUExjI))r*PC@k8IFs30BfXK*5wjs>z zI4W@Ah#u9T%iPd2=Bp6o6muPJlogXaB8n0Zj6{5=6&ZHe*7*|=c)VvRL$$T(O+3Bx?M<@?JET--}-; zeRX_Y<#%ov`asdOQZApxxDywLM>Oby|G|(e_(5xQP?{48g6}9JTD1=@bqI3fc+{gL z$E}!4JH#(2SLvr!8iG>k*eJlBFX3EJSkJhR{7*Bzdgq)|XpWxs=fmR;el2yj!Ckkd z80Gv|&k*G(h=qL_M+L-_O4s!2P<(z|ZVV`xT(}J|06u+VQQ1&T5oI`4C77H3rQ5J3Y@6F!d?OOK}liYGzo}U zs6rL-v=n5lY3!Q(M19$oKq~!f%&7%WEmF%l?|d`KpN+97-#7wT?NWuaK}uE>@7EvX zbX#9Sd>COB65sN@s*MdmHM03$x%NRv@!%y<-RNMBxYnV$FEt3fvv+Wux69sgB3yGr ze2p4*FXcmF=K+@*Yu6kc8`4_E*KKgzBg1jJ)LX_L>3B7u=cl=AX*}5K(o#diP2_Y~ z>Zg{GFRT4*5!q@d6j$3?tvV9_02P&KEd{zybtj&=G_%86n9rN%(xqj>ty%-m;Oa6< zs&`C*p3-gsG`71*zUIEgm=ZwF3i|H~_mc4bL&=x0=k+aZgH+U}Zv8l#4fph)xgE1z zQFI;iAf%hk2z6DtzlY|ms>j)STQrpZ?+XAr7VC9t8+t!h*EbKAN(0z&;4><%LxgcGh zWVl4%NQJemsA^<;r@vu*{aFlJEF8LNaK|Mul07QIKxb_|y!7U{LX1vRS3e-FkUKZg zHa@Dl%AE zGg&k=9NZQgv5)D_%rw2n+RikT-0Bo6&6mpV@1m}#PjfSQ(H``6!R;e!bMQyV>l>3@ z<4X>`w`e=pb-)Pet*U<2FutM;IJ$yZW%^f*`&VT<{#rq>t%Y^y0A;`*!IMtn`gp}l z)=ViU{Hi}yItUQFZJ?8+@p&}sL%z5ff zW<9<;2;^QS*Y+WvzB7D@F@-K2%C)h!XsjA`UjU`FUz~S4ccm0NNkjibLv<+4agngG zhVCbq&fd;bp}cNFGVCc|bcp-#lkTBrWGy>$57zNvLpbJ{+e=>Gh-^sAF)hy^WClcp z&r1TBBn(aZ2Y7{6TJi;vQc$pY8Y4}~sh#qM+RnL0v!{v{nnKVqb$wNpdmO;w$AZ}5 znn-d4FOHS;?{XEXvY4(N6ZQFv-s|9RW}EdK`kK=(p_1UWT6(cjg0K)NT8%AlOX%jHlG9o^*k-Jw=l|s+xZUoeST%C9Ga*sz!A32Kl83*)m58-{X{BE~hX~>BPPyer^&F zP4FAn;zhRefS^aNhU|}pz82TdTIKopcV29x?$Tj#3eZlpN6GUqy-Ncoq;C7}!kUev zkTv?IqNlws>R@9=SKsscp}<|Qp4^tktlYP6-|L1(mrgRSmMWh2O41@b2U4eZASm=4 z3v5ImpFn$6qydw#`JJbgM;(tzrVD{0Ce-)ad&sadsWFi5qRapYSht_QfscFGEf^lf zHAfK7ZNDh3rcfl^OY>P)XSP9-M}upczPXRCJJ^I_+3zjfBRPri(;wdf_UB~50uuta zokd6Y(*E$SbmzPDrId^h>Q>XKYKHr;?%>9}{g57K8TD^lPm6aGNj3(K^$j8gES)3)i2 zWZ#W1B!HrC!`^3du#-AUqbnxG%ON_t7UMD`H?(I`@HjRAL+0C?K=iL9$d649hM}_^ ze(#eEWk9Z+bnfOrwjuKY4M&PKLyo#-QP>}`n?YXv{Y4?`2O~GS#U(vjbN$16bJ`H` zkZ!vE^qJX5^z$!b{{U8AIU0?u2um$fX(fciY(Mm-Gz~kb|LrP1Bdlu4YtSk_;MP$h zdVOZ+lV`%c;(8^152fJqfFVM92qM%N znWV@lDp7-8=!=)M$qEr@<`zh!*p89Man82!WveL_&yhh)-Z-m9&@yD`suD4zwaUND z^-=n0>do7%p|MTfp4%qrM=f_c+(+4p<^7b%pExchedve&M~yAeYc|WK)uTaAfEPrYcn-p>?R-Q2C%uzD*Rb@&uYi6tjgOsg;v1zy=z%M_@Vdni3X%+{#H@V(Fhd3* zDh2iQ@sOJrQ&$acVFBv)JU90@+8$IFSln?2b0q(VK*6>>teKKT9=n9$duXTE-qx?y zZ+S6dlP9q!HEPuSL{GrpXNG`fA!DKrF|^cgw8fR?_7H<7T1@;etcaR5Vi!GI%q#g{ zi11#q@KE=RETBfTtUJ0<@tbf<^k%`))jFo{w8^VpvxDhFv4S7xuG_rqt8lQ%7058_ zt;nyBAniv6?F4len=SY03+a>Uk)+#~I>nH@1fghC9W3tX#4XlzNMj>saPZ#L%noF$ zV?Lqd_g|`Nd{?z4!vQ9{d=y&V&p*hQUdvRJY`$Hw@SpL~{;3=8rp%qi31zgsU5`9@ zl-!6K+fUZAD1XMAuDE`c?cjY1+;>Dl_aenRWo(VT$niqo5)cai0R78>9maoE?>S!74~Dl4 z>7u$4(l=2TuY!||)DbR~mfx@^II>fQ9<<~;e0~8lQ0b1R8mN@^CdJOecWPQJ06{=L zTuMEKH!qs6y~z5j6~=^d*L#+%-QfVkb-0!VaAA+Do$X?a@v_oD6@z=DEvL6l!7UGc zrQFFTd^SA_Zg(lej3IRxSl^~Y9P2>9NCcBC`cli(?pT7~lIuk4 z9rA$@G3RtMYjKB_O~SD(30RXcc-(*}9D0Ab?GkfIvx4iU z?$y<;IXsZxIC9WR_uOE0M5e8i{$;$ltU$!hjvT$xS&290r!M@hmNU7JJI9xbGCk_O zh;|h#4veugOU6=g)5i|WnGN2@zR;K0R{AJzXZS}}lmpEXtR1x!xnL=rs+pwEIJH`z zzl(dEIZf_DTaxEPbGAb5m#ZofM3RfXOL1uW4c9A$&XMNa9e?0xC&T`&SW$KSV(&Lk zI{>im^-^BKt)DcxJaHip|A_sUI%!CQ9bwP)Z)h3DdMRh)IeER4)`j9_d)6x9 z$aKaN%z17*;bL-#-QbyU#CdYuP%dKUT13_}hsq4Oe?pPY3yPUL)TSLBr$7FvEfi9Z zFAGhjd>?lMBI{e1EJd+8uUvc0B9M&!&{59QhblE0dv|7_x30k=P2cWX%ad&45 zqQ<%`zfGLFa~O)e$2OP^nBHxi93t6x=5H|Ymkru%-9aU1@%rr(>uyE1+*?0yHN*X; zv(b@blZ;SqtK`2TFOE-9*58(b<>KRyUsI<4sgh`! zFA=Bz9e#}j)VtV7G0=v^ZcDydB)eC$5tu9eQ@Zu;s$ygP>?(;X!qvu}%236T8)Yhc zw680`#yeEV9PS3a-&$c?qDih=SieJP$QgB=B}CTvm#->=9IaDkDw-0#E6|zH$kh?_ z1=Ei>oUp-F--xKuJcHnN=Qi%Xb2aoWqqESx*(Kff&Q9JRjnNiOk3^1p21U-T zyuO)29p27mAZ^m>0m;0Bk=^7C|`J06EVH@^OSfS8+jgmjiF$wb2D(!$=Q**OHPRub}FrDA+&#r#&aW^1)P ztA9_|$DL$G04$+`{gg65bqfp+@Wl&-3m_gX;yfjfc)taIa*Dn^m4^l!dNkyl*r>3= za(#hm8Md#A&GFUz#qYJjUG(YDk`AdA} zyf%<5IEecDqpi{BHL5v6@*}VWk+{Q;o6gsb?{OA4r9%}kqe9S{!>Zn#^p_Ah9B*g` z@Sr21Q=c*W(F2-VU6ktD5jRlj@Ybckz5GT>T345FId&bIv9hLY8wDzHY|rWmY@)xL zvr0T#_6HKIcSJtDOJ;5IBS~RdZol99b0Y|4XU>;T!s0f*`sU`3L^gl{esf{9#1QCy zN3x8tx`~ByItV<7A2q<~!cL;lrmopD#uAhgLY9-F;hDh)#^?ozcdG`RVedsoC@Pid zFy8+5yW4}bQ154PBwKgx&A1>~H^>H*U4_42lOpUlSt#sT?s}I-S6^zA?%`WGUv#ea7^vbF(1+nK(ododsy>E!Xygu*5qb}mEHOIj%w$>1dVl`|43*FN;pfXx@0Vlc;VB?jA?9gV);>eIQjGZfTdDIY zF19;$3+T>C2Rx;SruJ$$W-@HZ!28_h@pQv;^|#C;WAD47{dgQDgWtR};~ns(E!?0l z87))19`Fr}wn}7^|Mh(j)&f$I9v}NGG9-xE7CN7eUgq`wkl~ooKL5{v_f6-wx`Zv$ z4|s!}UGoA*thE_f`Hn8YJ!u*n0!2RqWM?Kkj@t#oeXs2%LeB5BsMa=#`SAux`uDNl zBNUg$&1J2>#-D6u*UyVrD(%#cI4IB_bx0}IHbrbnfDw1Dk~zc&}X-Fy@R#)gj$u@>_fNafq}4v z64Z6)cRPDP#k)~-a*;9}I=lupWN)DncPgB7k4Aqq&_TwMB)8%(Cg)2iNz~?%xVXK* z?_{;$%*e^Cju5z%|9o+SJ)5qP!H9jS+=6B17BUU0xy>rM?}67?+!?D#mCku%s7;lj z5_GC?%9}TpLU0;nf^y;@aL4UNUeMU~J={7!K9~=`J%8$6>ymAM6ylSu>ALZ|zC*HD z#n@{{4Xam^9@>aNR))6YpTBHUe&*|Fsh$&vdA|>Pq1{UgD#uDP404|joqT9T%INT) ziS&@(Hg)YO5!ebuM^SrS7lvD5{I|<-uaZEW( ze8cty8lsHbTyevidpg>dpGOXTzJ(7Op?{kF4rwikx7%gXz9haUIRxf5B2p`CDF2eu zvQAyApedIG%-Y^()xBqqdKfjJppLu~4B+zv&icKdlu9aHdHE{!hWbXF@hvnM<{@(a zpe{tzgYM$%8u6DfYgL#}mo zg_9BXL(>~VY}byoH~>KMC~R7aZ*o&o`w+58!O6{NCqVQnHhVw;MLQ>~>)RSB!c~99 zPje%S>V0!_ZMOK4T$EO#mWKvKz@t~sfa#;nloZ>@*3Sle`M&=FxIjn0dR~#MYeORF zP4<^|&nXxHF7OUOJwT}TiuO!tGdJT$#~6Goqc)MF$>;c<`F2KAk!**8{{UY~`DaVe zwJG&8E#^=w;zqd2`Goi1(>3lIr@&ob!TNpoh%Yq@__c+TciMG*5sVC^fVph2L(Vgk zUWefS0EQkaz0vF=@tjlWRyVF9NzR)a+PNnKc^sfUi*e6UQ90bvXnFpb9GY&2XKxx` z%`j4AYncR!Gtedn01C&|Y;SJKBHmA?EA2J?nb*mQ7DYrvR0|AyR{&sORBih+sOvkQ z!m;Mm(#OK_=y&NLKWCTz`L2sh(=`bSptX|+jzxWj-?LH-{`rkQ5832ekVLP=4tFU; z@V^XvHM!L7fs_>|pv7Cb_$~Xwa%<=tUxoLYdhCjJQA7MTzmc+TUZa_g%dz45XTw{) zG-fFsYktrBV?|CKoDX{SANW@nh&i}=hHr-xzYi;s??*7XS7XVwKZsha1ffP8b*>u*LU#$0Kj^8fg@*u7BYVE>P-!x<$D$W zHECjP9h0tOug@+*hy5KHDY0#~o_sOgfS1;2l&R_3Chc$C`87rFiyW(yg!qrB5BY=QSW4dK!8P3VN1p zVIGyy=}-Vc#bMckSbb}y(^qH)nkAhLnc4;`QX#kxO2@Zs0q{wbrBI>29tF zGCGn#=qr))-E$u&g?KNkR{E{)ZEkqai7lo3Ol7l;)$V@|yqRn*5b}7h7S`@JI+joX zYV`jA0r-LocrDQiiSrO-93(Y;EhFD}mRP*lHxlAZGQgs$LY)`d(cRtW=aD?PSkt)P6KvL8N&Jr|$W1 zTt7d6uLki~iKW%GJ?#rbxhLlxYeFv)TuP{u1#}z|1`aErmr1bIw9Vux1Y|Zj2CnaO z#guO+sn2+BQ6#8wgPQkG3Q7L}5~j!Zf2DYik!Edge7-QJ86CxYUxk+%OCR=sr9?@Q z^AE=czPxptY8Ys?rjI|9+%=L&X%Ba^7O3l z1zA1p>Vv&>_Zm>T(iD--I??dz2&^^aSpWzR^{u6K5mQ&2I$cM=a>b-cDMO4`$o~Km z;A>wHJZFKL{VBSKPrUHtgK~0}`Qh>U&1^hZFhUp-a7A>=e9%bpsZsV-uDYD|wG?_z zlcnl9D>1a5*hf6wuR#9wREGiC-9*0tm%&u;2;SR&gQOfP+f(E{@RV(&l zaXqpAB8Ry8epR;U!O1fM?)qEpUMoX3=xv5}G4KBX>ZU_0PS-bX!Z&}BJ+r|4mzu@B zvbZqGb#6_<%yMur>7K-O{Av14)U#egYbqI|jPGsTLH_{tQ(KbSvDIpe_B|fb)dnWK za7g~_YtVlx$ePhEuMAvlLAUbdkMb3AOTV|>-)bW!3rPH!Rk%Lq^WwQ_btkvAmA4-@ zP**<3ul1^yrZluxXGx@Zs&t5H_iYZTrxMQ|pLplxQU3ru->Q?-(z}~G3n6sZJ_o(H z(6zQqOQmWAEQJ365Q&cr4rwGmq)N|!a3HjdADUU%@IT#E$u-9{oI3vi+Lun%EcI!qQvW+@Xd`r*1sXepWch;Eug2 zV>Hw55{ptuq2sMu9U9k6@Ws4jc(OjnvG4sWpi?-c9 zTenbdZb(>@(0>U2wVef=+7-+{+0q5MlO|yx4I7T-l=b$ibLyt%;iuh*U@w_&oJtJ5WU1^dYZUEgqsQ&Vw;vejqLU zL|s7V%ThNE*srL56!`AbUDBafky-YDK{bU%b$FLa{fTJL4)MRi_{{Au*rGl7j0(%} zhr^u$?r*bRY5Qi+-i_G^`VedCZxXhe(MTlnz}jodd_AOE&*Gi->={dtaa{Ix)Y;uM zXIuCuUhtHGd_SrG0BKou{>;|1A_3^H)K!bW6!>=H6J@SPbKtF9{GsLs;>p|EMdS~y zdk(9jM+TnKARB__zEkm*y?3hoL|qW3>vBGJJoo8}N!+hfuDygMGP%FXwevhZ!w6Bn$_phidz8Z}uNn5=SQoQi}{xj!TNwr)3=Ro98FXB#771dslU>HRp)*(Q6Bl7x7^A zu5R1G^CN^O81Ne&$TblqD{mj2Cva@=DT+1~h4RA8t9V_G_I!>vK0 z^Br=hpdy{_jC8rcd^GT+kX$#MI0qv&?s^`P6fox%i>GKUrYi!0TfY@{<5~XBvXFzy zgIP;m&e&>_N2K^0;V!>pJ;#auAKu$rYSRz&y&q8o*xzHjU}Ya1ZXbv>^FRC)3;TOm z@cw}=r|_b02Hx4+Kijo95;-mAEC5ilr%+B$&4XV-{1f>329GheyMHg{z-ByG)RsEt zo#TxsZ1BN(Zr~FfsvS?@YY5ekHk}U2*gDi;rBW>?_aD#NASyZA?OMJPUo*uP09}g| zGOqiAa1dvc`B&Tj0Jg7!{6FK5i19(AX|`Hg>FeYdv$s??QW7#4jBqiJ&bvR@>-I_T z=AVBzi*>67ymgJO0L?zt}uOYJjYh0rI5LDJ2`G8F>0v zscXNpkHYT|#8&UYIyhth04*W5SloRI;Mbq){{XTdhmEtrqu!WpNFZ41a4p;e>AMU+ zI_0`%iY_|-U+_=P%}4f~@yCih8>skSSn);Z(q_V~vnslk4UnaX-GPDLze2tRYMS?n z{0ZS%b^TWV08-O0?P6GNttCSXVSenYR50m`^Is|aX#IfvZw9pY{uQ#*F6^YnZ7k(6 z?QZM?o<5ncuzzPygOL0;_-WzWJzmiUyK4wCks~lhJmVS0MQJA+T@s@2YaZ^G?G#HI z0fZP7)2YaF$RCAeHOK=Qs`B229dTGQp&HWajt4>cRY^5TGJ1V6S&w_TaC1<-!9D3% zqe4wmL(>@gRatdOj&YC5sYyRQYP(OtrZilWQkdtF`Br_-r!ddWOp<;&^HeTmBz2@R zU+rnZ`}DtWl2yaGBC)5Kq3$WGeSy}HBS%sy_HqwnUl)97h$o3zf#Y!;eQV!7Cj3fI z4%#|z!6TqG@{fvqO(wmlw9Nj6YE~pWI(fzjB#F1p{k8Q5IL_^w14zi!1SUbdKER*&7*xQ3&QD{+v{7J z;mw=cE}=8pl><+o)d^OJ*8YSD(igb|)esyCPJnvJw6 zzyl}qt~^3~>c`k{vgX1{Pr2-xe~B()vr*?1QZb5`MzdXiR|_#O&NG_e^#+9tcr)dwqRr|c0*UeH~>oHyrv)aqF9OQxpXq``5y0m!7G2~!^X+}@6btytC zN2%+d4g3?Ld`I#0vTAP}$>sp4=OZ1>dN1t-@EgLu9`MU*Iz%woU)z=1>Bj7yL9d3} z#@-?DXNaVp#_gk&9@W)ed~)%pjG7B=M)XYrbV4vbzV)lqTO5#Ac4w#QHrF#+t-qKg zbHG&@re1h&Q_#oT9wMWzMOpYw9qii0n_Q}ho9`dOz1QL-`eZ&4L518w2iG{PWhwIe z$EvB(bG0{p51nE7wR>v>*3TwXa5?E;H2hiCWY)Yzy_-4X^Ej@T#Ja4u`km6L`8OUv z8t42^XK8H~nXKutjY<;%H%}-eLq8*79fv{h|NNk7;vEZ#?NG0n`1PY3*y zSe}lioyUjck~=xAQJ_`^dF`cPl?&ZZahl}(W2Z?BR<|yzmjt#L9=wmzwGrRWk+MnL zAW~0Z%YRy3LeX!mH5tP0i6h*(_5T3tQ(8Nc)|Mjhj<0H(73rQvT~5;tXip%aKQjKc z=oflKb{a;Hr@-MZZW&9+rvw4>G0k{gypIbPKu7}&dwwrUGx1V2?8ZuJKFzqrv4&pP9L&ZU1ZjkA>I)kc9EbdCY zL*`tZE?4rd8V`uc4V2cyOhpq&UD+fc;GB{9aa~58t*?wOh1H}lV`pO-X9d5Dw?Ca- z;$q=dEpVFO@{2JcX zp88zFG>2qTN8AnuSH5Z2ZM#fJ9lXCV?vhxBP?O0A`@j8aZqIW#`$#`^o?UY$$WkO6 z@>`CkpmJ5!SNxjvU01`Fmx~nAx>_`@6(s{GtLi}O#c{gMg*JtEvPVf{e3+(ByCUbP ztX_`CrA@k#y{u^XZ^k#C7m8zQ@yIT4l0z(EfdJ$mYWjCr(|i%){{V&hM4#G%>%}+F zHQuKZ+6GG$P*v^3ukd>_@dDIVhU#Ck=W&PS3Wj&>0Q80Wb!xSwNDL#+HVHh0%6 zuIlTk%XrrI@y%$ZVYChMzCrusb{u5Z$;E7OwK*$ET)PPHP2?8#(%Ge_cE%ty}Vf-a|w02?1lj&Bk88p?P6&G4=4jQCaiUZPm!W1K3Ff6lY#kKxqX zypf+4U;TzSppj-a9K6hbd-CUnLDSUM--ER8gfrJ4WpC zNWnhzwC-zqi)Hx@@jt|ipAzZXZk}wdtSof>-b7aF+-}BlNhI{IO87tVX5T@MC}%hk zjDEF~WuSZkmef7Z#hpqyPeff6&OWsn(f$R;xWVyW{5F1F+B@{9ZVhso{`U84@n7b9 z2Z_EfY1+M%%6Qy3;=K34zZ_)nhltiSxOKL7;Ae`>8gIcV6nUQ)Z;$=Kq(9cMuXG=S z_Y*NsiZWmym7hnOKhB~lY-rS;>t9v=XVdd|^IY-$lxwC>u)lN973-e~{4;l@Tco$= ze5!X7UoiY8__gq(;pB10aq$dl3Y-k-b1D9{?PK=S_&P0&G5D|}k&WMDoBsfbtfvo&NyEPmNl4ge)bFd65Yi86%qc7f<-RW2t;cH|3XOHUTHzvi>%Bui@v#y-pde zzA0Mi@gXhc$*0cQ{#EAicn837%@l*<4w^QwJZaN|)}>Kfwp?m%ev@b(A-2{$II&fZVE1;xC?gP%|;V@}TO9JXgKr`y|*XLFZsb6w|xd=xxYZ1V{M zK#jC|lU#7{mxcs;6XMs0hCN}Ne@gTphWf{a{1s&OvwTeO<=Pf3G3Ks6HKw5NbEVgd zNnb;S@h63DVDW&`Liw%3DFe1^y-y6UhxF*o=}>&ug00a@xqKa!Bb(CY|7@)k=Is@ZRiT=Hb8iny8k( z4YihQtIvp9B$jZl_FEG3$Oo?64k{%XTSKCp;H0k1og+)Wog{eUY-c=I)c*ho{AqEe zcqT-Z&)U4bk%D{Ut$3c9@vGqOk8R~QizJ=#)in74s(Qce8{mBhO`bbFOxz2#VP$lP zLi!Nns*#%UFjO%s5ZzeXC*EgS-V+;_!w*YYM87tgz9jMdvI5h^0}!m=#UVd2ucLe| z;~RepXm<87rV0$P1F5e%{g!+&W#ap(HAJ|I?#kXU-ehf%@;CTb(l6j`F}lR3@ULd1 zn`gk&i&~sLzr_@~nPUn@eXFF4U5k{e^D+buk0sQH5G4sVAXEow51Z0m; zDhV!>j{g9aQ}6`qt7tIID`kp<&2$fAZa;`r*sI@Do4&XKcm}9%$}u4H^sb^?D0vyp zP`b2*Vtin5X*4yt@~_5=YrR`lSfI%px>t{Dnq|h8%jYfyeJSGivWLV&%^>x!Blxc^ zq|vXDjMs}!yuP`~Z4bMxT`Z;0<@ug-ZF*4WIId^Kmxg%&CpD_g<&)U@S1ql|UVeGz zzNsxw&ZP7irOV}AsRta_qSwd`mqPg2PJAuV?7H5G}gL76hiqo?o)5PgO1e)=`1zz1j zB0!~r;{Y1&O~jBR66bjwZxgmZw2+;>|SP{hdzd>43Fn;spC7lZ3$A zkzQ$~F}U#tPa`$#S5P4E+p**1>-be9(Zy13ZtU?$JPmW>J!ox5&?JRXC;rMDNK2%*A4x}8D*z9~csDEc!!6@O^ z9XnOMUs1gAGbO2Z2pc_X0>Vw&SYUbzyW!jN+L(|4PeZ$YGg;qVcuQWqwG1bR&M@3S{G_VCdn4HQ6Ui8!7Cqpu9rYvQ8}%lgUArT zbGZ>VvL*<|2e9I{Z+u;Qs!M%)sLci1TSfL*Rc%4*f<{0c@I82)@&ET*dYdAy_tL8^LrD2>N7b{Zgnl_QEYMM`lqd^X#a9;ZO&YKKtjj4?G z131U`Xt_4&bL1<%F5piJ`3_=%K_4&PJqPB0l|c=i?3c2Dk^>q2?iAo2;Qs(B`i9R# z(=>SXpAkF*QSkPhAMCB1$+C&L>!)@ys+V^$4uaXe%kvz)N@ z<${W?ZiYU^H+!Epcy`)7R$Gg^s64wg{n?Rz=mYODQ~7aRuf#d5b-j9Tut*`(?ZAfK zRYnoW0Q=mNw3Cxwq48$s*TcRKz0>rV36aXhx-}#uN`7Y~bll#)wc~#e^h=)*>t0N5 z$!i$%E?9Ic>GeG;JKeb*33A#eW8j|&#bcyFdv$4Td%ttcT<$TiMaDWF^~BtGDrn}J zqLC$pvlViBfOxN{d^_;Idyfl8cK-lkkuG=0%Iag1I3xSa9AtC%N6S_2ya{2U>3W8# zsp&UJs>LCKH4;Yil1y=KQhEXbGcd1QVL*^K~Ej%|eBTE{xCUOpOp5IeSz8iSu zbn~MH6LDjl0)L0=UiTk>H0?V|mh$}&+{w&7Trw)nInGq>?43CDJXL4-W2J45-rnfl z%+0toQ9sT)w4E|k9E|z{T3vNBw2uP59#uB`3cM6(9i9FfZr+nzD$lU)aZybo`GuQJ%!#cC^909k78=1-voR|7$jPPJ(`Olb4#(~k$59in0$s7`bsNK)8QwI zZ%&Jya>f(|`vxYD4#(_$?evM3z^=~DD|M$ofYXU;l@!=Dgo z3|8{oTanZ}qN~~XH(As!Qr_7F^GI-rl7dZohs0Z{ZYBYVW+xoh2Zo@!(zWeA>tiM* zU*}DsN>N)I`bX?v@vFoZ0`pJsW#!GjG8|73&){5F7vmp-UOV`Gbg+1fM7Yvsf4a94 zDBX`j4lDM<;4Y&b-+(NNwo60_$@Z^=f8e6l7i;3(KKMvl&2pPFkQn!@)SJ+9(kflg zh1Z2e?l@E7EpK+;vq(>-@%}Ww@P>GR zbM`sMq?wwFKeitHCs^It6?ts5({(+@5FZ)zP`E`bcLjH2H={0|=L*I_(jL*1*nxD)wtD`|P6Rm9C1yONNmQh)4&V)q7bURPfZ+ibuWXCaWfO)^Usegu}pkbUq+74T=oO?$*Y3ce~_=vUU( z=q#V@HuB8nW0?mi-814;9b*Pu^<@souw>YE~+R6=M!$L)4C)4RW_ygka4Nj8jmv+nUdc01&i&BJEoXX{f|vnaSuCO9Fok)sx_F z2-ra$nLor`31@@lXxL%%?h9tU2*(VfpE1nqOPQLR`ZA+ZR=Yk;@c#h9yPNCi(pcLB^((=zXw&}yXRAchV!1Gl;|K27vq#`h2>5!;JT=+w0OG zlbj41-@4X37ilv^C5MWol}-xBV8@@R6#IQc!S@p?{{X@@;<=EXciFH6eib~7e%2R0 zTk)TVZgt-fF^%0o$@y!?{vqhs8Xdxkq-G7!H){LQbX^}$@aWVpd=abM#Md&PI?)KY zNb~ZBUI`fb*PLJ3`~mUx`oXWmt!c6b+GLhyj3=&n;urL{{VtY=^C27$ArEq zA#SSCdf5iU)*azZ&!gZSQp|SA| zj6l0hrAl3bj{67)^38cQww!#B2qA|A9>$w!+8VO&gjAdL$2IKsEEe88_7&y68Ma${ zl_U%b^k^mxY@tqgq*_dyS-nAM%2-to3d|l>!QfU_z2=yt#&J*ZOE5n%$)<$S=qV=v zRfIh{3eSnUcB?BVy#Zrd)v&`Sir=%7Ab(osY~&?{bQVqW3Zx@$7U`ZVMhE`@CbF&H zk4n+EeB;)bkDIiuK1-{li2)>@a4Wgj^=I(5i5Z3VTmS|K@~-yxz&c;UT_#DcW?AmB zlKAAGKr6$1O{-7hJGNm2scsH(RO1!RQfj2Uk?~k;9bC5r;MebGvRwz-Yf@`3J>|mf z#~JlC#A<;x4Pr(e8M@Yvwcj&uN>znQK}r*@CO!_ccjjA2!K z*1Il`3S5C_2yxkowV?Vrgz^@nZOBIy~Jp0$B=~Fb< z>R6qKC>+;9o{ivpYg>;t;D?o)cg>3G{8Qu&H)Lmq0AN>}d{6Of*=i6&3lvrA60GSL zw|dr@lPd*Lk-M78N=<04b>Y37r|jnJj>^MTiajZjihx(S=CE!2K$cf*M7SNWYs~yd z;$25iypqvjlY07R2i~!veQN779qKyPytj(E%;)UtH63hwR;}Vusadpp@<{cr4^F(e z&~9Z^#P%{v=FMj>F-mcONcA4I#ixnxtfVo_%vg2iufgI}y2tMsN$hE;D{f~hjlHxw z%Nsj=YCBsUK4x2snVxH_37Du<1h>ll`T>fgaM0=&*V3eMK{dpp2+@(6O{3+{^EFP= z!0MPHW>!#e%5X(op8V<4>9+dXJ>H*a8_Jpiwqzt=WcSB4SGJ5>>{zhXS4+FQ07&+B zQF$W}s)9=7o_qA0CnV#dQ%C5N3=0C8oWA`*?FT~ z^29ToI_Iv@id32-d3~xUnIur5g?zP0$Eyx=-vjWf+|K1usmZ6^YMPa*M4nkl z43iueetO^m`I@Dxw7RyHsa+LS*E~k+Z){wA*7o3VN2V7o^)1GT|M!jA3j+yEB*JTEz+K={zrKlg1PqzCduA-y>gbcA^+;m)zOjI;giq_Zbrr)NE zVK~0gJUtpua}?wa(&Oboz;ldl!S=3G#JbE4t-al>658ss44X_9QM-g)y)p(^k=%;t z{5{~gqVVk3{&;&O{{TnlrJ zhL29M-Q=@+{{YKL&%a!KMRCyT7n9jfaU*Ti^#EnKK-}BK*)!@u1L!K$v0iD)x-2mV zxw#1UY>WofKXwp(`jgmZgx;dp5XoU4fmM(Gk@c&Ub~=bwto!# zIUSTJhForzL)F_A#P}mbi$k@2MeXDllY-4G$1Hj4?U7u6#19k6b2~(?c{t$HTI?Gm zx7U6?I!enNO5aMwAG9UJXDw@y>}$>R-7oF=C9(D9x1;cbBC3=UvyLz-^d`@Ho{6G< z(NN1OthW14p*886N5wl0Lfl9pImc{Q$r|s6X49SIP)5_*x*q^|nd5beN9o5VlkN$- z*|X|K_?4%Fg9DGiQ)Kvyr95R!Y%)K)UQ%zPb=|v@Rpq=wI?{71mG?St5^Huh&SZ&t z9P+ix-1w##Ln4A$_9CNegpeHL6uMuAPl#{CP$4bc;X;?UK?g5f~GoWwS4>gX876sTP$b?$s6t`9c$?ih29B@4+_S@6$fWrSIeKZ zt&r2E2&uO%)DL=vG~JLoO-EDaJ6sE>%W}JhYw6#II2=~~Zi34&?dpW-#Q+dSDV%Hg_Vyo~8Ls7c7{UB`>OMKm!Gk>y8x z*ARSG+ej!g+}3I-oeH$+yG68Bmcs0wP6+EzwT02{F;lmQOA>Zd4!%hHzpZGZkuI#; zyOSaN70~F_tDz+DT&a_udRBM#-bndBTCroSGu$W(Ly8F1l6$k&^h<;$FjNu6b=tPM zVWN0)>h@d@Dl$}hw_#p;pz0f=Opb9}x5d8{k!cQ?jCUX2NI#ISXA84$YSOvpnw{h7 z+NAd~lFsf}pL1VX{>o5W_%;-pW(jwC%Ohu~TzsOwX!v#E(c&Kw+d(M*07gUR%uh|D zx8q+4td3=-r>y?cp9<_gGWc9wTdFR-Wf^;E$5$B1M)o!F zx4>VB*B=G`E?8<)7K+=h4~Xr}!tRxLHTnMl z@IT{zp(lkGNVuG|`gjPWwbl|<3^)Oxmyt?35R^2iUI*3JTHnC$5eRNI&l77Gw|fG)nPwm#U^oNVQxYH9xG)1%xg~i8pqLR~JNS3u z=ZL&bFo#IcH7l237!aobXRb&U=r%vH$B2Yt<6P7<-wsAbOKKPB7=1ve$oC`N{*mpz zBJt0_&k^bHMW|~ps3ao_(pkBXV~@Rz*FOfW`#ZrCBCOM~Jx!yZ>0Udn{5J3pgXgzR zN8*;9GD#-MhMq*GKYlZjQGaWE3ee>$-ZD1%7jSLTx>-T?=`kQ(CYYz|TlBK7I zv|SLoaV5;=QoMU(CcX#O^>MH3T9%>;x7w~`n{uwucQH8ww_cUw@#+^BS23CAl5T&E z$*)J!-!?1FXrSY(V4QY}vMxt(&@dydZ)sZmh1EItu3e=0v7YtZ_&Y<2c>L!hb~DXr zks7yTKUmZdSe9ejuyhX-3t3<0<23&OhC~8t&>+-e2hWS*CC?3MQ z--~_#(fm2#3z?v{RAoX&(OaJdyfgm*2>zUjB5jbbIrXc59&r8&xlztg*PC5O+et%X zs)KQ*8OeLeADkCDduvh8DH-{WGuxVi!bxhi8NvCR$6L~WnsXQ9vZZ782uAq=)FYymr=;s3N*i9E) zWPNFJr0ykJYA!K)J<+9oBD`ehka8=MxpkK2RXFMmY1rxp;R>+>91v?0Qz9F{;N%m{ zZ53y!$t%h_9Z!Yx705kTwR%p7epw%<6~%Z~OJ<38Fh14X+n6G>V-Y-#hP9PFIis=9 z3fl5bYI!fjIsVbF$WI^)S5@Hca9CNykAn*g5=Y&xW5sq)d#T7{>z-?4LecJJu~KKWkM6WE+)-e^FfN)2-su*eq+61an=N!#yhY>de^O zO1qSRz4)y!AN)8^4Czx`rQeuHE>jo-)C$(Mw0&8Y51w~RRJxxac#+w)+0+aGdQ^7N zoP(Zf--shwb(v&ddCqC}adbH5xf?wQqgP7QZgkgIMkP;GJc{dLnjaBbkl5*hE1H58 zk}wYfoek^gQZ)HKP*PZa6;!G#+FKdbr={Xz+umOId&ZOh0B6tT{gFo_kV|$W>0YDo z-&*k1?zy5v;w>mk584af2;>nYqn7GA9OH`f&kO0}QPJ*oIk~#Mou~H;*jBZiA{SC{ zGsnzvMD%v(C?zPLY4}RV;OC39-xBItUXO1K$jN1C4duX&Q2zjQ5tiU@Vsp<-*Nc2( z_#b288KE8<@g|w5+QDzwY5g?UPR$hMNO4hTR`7 zgpR7fammgzio@|Y!>wn*)*GU`-;NrE@Shl&3Ptsb^*_iLSlIBm{x`y1%j!KqejC4HL zCvT?PN2*xq(!ZA-ykhTl=}_0(~-<)jMHn65}AB6TOK;GB*+SD60Lp9uV0;qM9fJ6M+5DLf;4 zZvOz;_ZHKvizBK1>0mii>5VzZ0OTN}%%PUoA2kdxH=05QdMzZiUF2gHqP z%_7q5{4Xk7eVRzD?i=h<$f^gJBIF^!=N(A|)h%~2mDQ3w*TYYyc*4f>P10@P(=|)) zD%ueMz*JH5lb+|PJ?oxb3&h_Dd`%_wpg{Xe>PPLTHb$J=zx?VTC9(O9|^TlM_ zi#D>7J7|1gqIkE$R~|RiB8ByKoxIIHB@w^c2s;Wd{PIvTN#dz^H^(+U9n$0%vWYcY z)?pRmfI=%A$mDio(01uud54~Ew(_X?a zFlXL`ti;=TotK3mRXK6E9zd@^)2toW%{dt8E6V&EtTa=uSaik>dbXXa!eT`-4aDS% zmT4W1bH-L__GVQ(nYm+%^Gz4bhg2wvSZ?eq)BJ0H9I!cwKwvNctlt25SVB(ja@Abp68~yhH;!$T*%Vi zXC0?}Gt@6eS+?i3dbh&gheJcSGF+X_)D!fo7QQ^TwOlF;d)0|PD6CO7%r70PSoA^T z=d@oI*dpL+N4H{@F=uHJdzmXAPXxz6bcBHl3ubWy+jami%k+ zJpLy}L6*&D8g`w{Wl=hIJQ5jNIRR2Z9+m071=6l{=)+4f*g6X2^nVRWJeYK9^i2Xe zd^rj7)8)l=RF;|?@Q$~89Pf^FpX|v@B<=>h&h7&YtUFg}<85+#-AJ^ZGt<(!3#o(h zdeeo{K`S?*O9p_r>r+88Z}<17$Wm7rAbQq>TI2;XGv2iHIDxq4t4(OD9^l-Xx1fyO zFV~t)=rpxE>lv2H2`7Fci8rzK7313OrzW?n&v7Ru*q^7MuctmH_!2(`{>v7ABAhq& zp1r7|Mlr%$s3d-5SI-^*@Y8sk#5VTL^1`RhnU7@^%SN?2s!k5}cKMr5GEFA8J1+!S zHif4|&bueM{o@|tdkXGt^+E^U=CV9lZnpR6BEp~m06Pky3pXDqa> zwNTU^4O+%qS^j9`U8mdLv!x8f@AalEL;>^DYT>cw_E&cv2=O||GqyL|ARoquujyW^ zCZind6tO<&Wl-qDxHZIh_et}ij_3@P7-LmF9O}0kj;*J7c4?x98=2M^r<~wK%s?4E zhD}Ohc1NHatR4+^J`eb5;(r^dlS|U3dsP{G$wtUm{segZtJ@R7KMDT;XKgd>`t!fU zPZq?xmcrZqJmP-Ao9_N({VV5BjeoT#jQ%RW*qV5BEeZleI#!eWs8Dv?M=VFb9@IG~ ziYd!vdVGHm{14!UTaSo7H?*^jIW`($^X?q_06KkYuD|+Dp0CtT)Za*}xx6Rfk){ZpveFf4iKD;f}Yc;D4n{pRGN0{Y6ei z%$WBy`6Jk$X)fniG*U3cmYEpA;Ga?WZn?*#<$>mgK^>gV)IIr^4?RBt@Rxb<6lht zC-|x24~&{NuW#bbUfN&mh(xyX$qK8=PC-(`9ff?A7m2LrU*E!GC%EKSvHUl%wui!+ zh2#?Xam^V4^ZZ0$e>#aPCvnBzS{{j~_?zKJi>wZttY2Sf`h;%i*YS|ij&Kg%;o9*{N0!jTGa5L#qSt= zB-CU70EB16_crlH+txHcA@(YADaF}n5{=o-HHFTbsT*s%d2McEZLbW-QP7UW9z}W% zl_o6{Bk-?b(SK?$0ep5yw4aRr8G_FEVQu_Rq$ig&XVt*#`j2Yp{7L&VzL}$cZ+Jsc zi^d)lka>~cS&-7A{{Xgw)6{;HN_t#STG^jJN?f@h8r87zZ1ymDOO)Yo+30XOy_;P5L<4|3Rop3E-3%C+cb$d_A1WCQBYj#b;gkdNfVpNe92BOsCJRf}KBSr9Cck6GrUWW0Frb za?&Cf(AC-P)f9{lDX_=47_21pI?^s%WpF!H4;si~&?HFtp5cTj2U1C`n+rLwBncEx zGoRs&Nk5RShWL4Nd#G4yT8z=!Xx3=cNZAa^-3CW*T8Qs)&FN#B@aFqCA+&7%?E>TI zD#o?o==@D2OL2KEv=M?zMt1J{n%vcOj}Pb@Ww(dz#nhPZ8MeUWbq75DAXl0Ax8kAH zl1q5@!vhcXcH{1!XFQQzKJ@Hz`;k^XXW{<KlUQF7 ze`!mtQJMzx^$k|qZT9+%*Bg=&Mi?Rif(~ov3)z0zF?=>pQcV_np;Fwi9N_xa(Tk4k z!c8}#vF$1oiS<)~ekQ2zy@Zb(A_Mbx`YlTo$Pt~7m7jopRW%+Asl1RcsvmY@7>N|Q? zK=8h(wiYR4b!l@2(AfZjASRJdB%ec7yhCL)da7B&rfL!x0ERUrq5=UdI2?U?aa5he z)wec|t9h(T65mYEX=fY3ZnI3q74e*<8f1pj#T-N;xo``)`LT|<$0r_wyL}_UjVXy@wz(5+XA9RD_G@qn>{zfz8bx}(3P$s5)uQ&g1d)S8Q`9Aj(~gDmuVh7)ig=m#L=ak zgc)s7LL`{!kUH1X8fU}pN?$rSbnA&&hK@ln2rx%pPw{?$U{{fRR`?CC_&ZJh*A|hr zh(EnZ4&xqsk?BNQxJf%))V}yhcjAuO;@3gmYe$x>9-!Ec28v8fHF?eUkej2v%H-|P& zq}=t0{{XDEe(2-3G&%H%Di`>VlC+P8I-Qh?oflVELNkLn(`}H_{Kkm`o+B4qgP*`t#~{;uRyp6`-gsO>o@S_pZxJsYBuoQ zk^caeEBRCBV&qGy^2LXQwTqjjlIwGG(2DPDY;G)}R9Nx!73|I7%R$GKzm+3+deCF{ zOZil{aj4rwc`19d#?$#ysJcB+{#EL&pjsdKW(6^{3sL_7EX7GyJitS6cq(cC0A!i7 zl{M){L$(J>z?)MCq%bSgA~Tnq8$LD;^k+T zFNWFyfCwJdM(e^tjfTO7T1y zuN{U7&2M-}E8d}%cS47Jfba^Awd=Ou2J95|PsXx-ATsS+Gt zVTMW*-y<2QbraDK*~&VzE93X=2HzO8{{Ro@@M$ZfSZJ!ztWe5XB;WvX*nmG;t?t4&HSQ3T6!s7sX4%Nwr!qLG}R50q% z)%V@q@8)ePv#mdAQ&wkqs`%VZkiZdG15uVE_p57G(`1)(DCafG-djwLLCtn2V<|;F zO%bb3{{VNNda-FPp?f+(Zw!;n=L(22O>F!*@E)b(eNr7eOSOBOhd4V|Abo4?kB46f zye04(MweUHn%3u7fj7x>a6yLGkJY`Z({Eu)JgIj)li|PYcc}QA!;gRCYw0vg3yAkc zXo?NQoN<79SH@m0&}Hy{i}g(_eqO@q;th~V+T0#E>5BU6;@9m(;(v;ow3;V^R(ne) z1th*B$gGqLyk3La?-r;)>t#f|v z9kJZSkKs?1UgEn-#mCmAC-(><1pVR5Rd-pgn3<$!&vrSjEi**C)t}0+vzF=lViYZp zt~ytHqI?h5^%)dgm9__;Ee7}eg*RonHoH8>?4brTpRGw1r<&MAXFiI#uc4s$1Ey$@ zM?R-2@<7`(gL(e|fQr8*_rre)lMs?i>63z%vEXOZHJ`Ly3-)iR^VA+1)Mr1tv6zG0 zp%tZX@cUf299a?dHSBkP7(6+&TUdB>jpPqDcgY^~Yrh+=ygjMSW2b3$CMYnXR!o-Q zQs#7Dw-Li>(lP%4XPTpT;f+ekcid_B5ij?QXCIw?AMpPG z;!60}Q5ALE@lS^3xVaaQJ*}O)`7T%H1<#7OF~ zkTZqG4sZr5)Vz0bFNZ!IXj)tjW0K_gu2kURNBElb`~Ls|_%Fl?*R~fiJcxG)`Y|=n z{80E?s%z%T^4Ng{5Qw8s)Y5ZOu+$n($B0_^zgD$~MAr>0f8CRsNcC9m<$l3eX3-vTKBYP+S+fA zJOleoSc&v+Bf)+>0}rOiK(@SZRXvIIABe9$)y4k+g|%rk&1%X^8_3QINH`<=%6bv* zE9JL_^FY3iWRc*R8+3OO5(qtxdm8!=!Jo7ZuaESre+>LaU$jbqM`f-k0%#9UnD-qk zo>5z!5p8I7)>l@y0CYa}xo=}_DuOXwE~Vki{{RZ#EG;tJ+Q>ISbqUOJuVN3csIA>D zYmX>=$LUzLW1$^gwu5gzFa=ILId7a0KN_W_-b)&Cd8?mko>wGvtl4%X9v`(+(N8{< zr^A-*(SItpWo10h_{pHPa7ynNQtB_j=s1#&)OBnu|--?nUemE){&mCyv>uU>uI_VN5!nl~ z3Z!FmH~;}!yCN*wJ-@~t9!2m}mr6MitLM{^UMH^JM=S{+1%R)oJ|fyJhr-K}%YrSy z&%YJ&oHnnhToAc96x!C{o~?Qo{3{OIl^&+OTI%2aCCiSbI@g(LQJXzPBXzG#mSWm` zUYlvRshUTYc#7&p7o4A+@>x;B#J zn2%YBo5%;gdCQ!SrFf@}{xV(bcjGe6ypmz-iT!0TKVr>@>>FY`#=P$Y=* zb|!rXYTNKv!>jKVT&db^?FZhvl?hSw9qXnrQCghtPFoz+-l2P{KItTO1e3|&eiaSA zm~N#JAbW{VM2Sli`B%|?9Q~hk+nh9}S;Fv4&B6ByKgPWy!@mr)e+@dxZE5?}{w!cr zO1xvSQK2b$A1!I$2>es1!m?ZVdgseLhLi-)=4!o{!>xAu&idvHC6O&=Fv_Aqki)JA zdiyuT5=&v=&3jZx5JRWiG?F=R2<$PQyw{s}BjE0){{Rn$w{NV;rQP{!cP*{lWTn7< za;Ls2Myryz=T0$|kCwF^CicY(T-ePDgN(8e1!2e)n~p2%`_zM0@UFEcrC`$wD@JK% zwPF#R@T-H|*UI*`$u-}Tb#BoY;B6RSYk18_#!+#%nbrI^GwJ#!v8&ul=F-L@u*O*9 z%wd6w&Hn&}q2fERu#HM}jTdPiV;pC$GI*_D7TL++e+=2_i5vZ%Z{N!{(iPYa2jN_P zfnoN`m|`R$k+~<*g!hmn+fYZn{1()%w4G|-Qr4$8mp6Hm7;az8%z48$4?|y7#i_?6 zX(VK9a2ZJSHTmnP__kYL7u#Q2$W|+CLl6gztNu0iFT?K>r-(c?X>l856$A|Q^&f$( zCCr*{snhQ381y|H-hm*NGCa}2^kL{~Y2=4ew2~>KSzwSAc-Ukd_BGGJBLk9Z>RtqF zfuB+9O5(Bef5h*E=i%nR7O`s__ns4yUH3L<=Mi(zf&4X7;m3|O{{VzK#m9_vXrQ_9 zwfyZJ-juC~-W-lqNyuT(HT2e?uwU891ktSa(t@mxG6+40ubRAZ;G3U>`rMky06`$6_h1c71E) zzXW*xe}NkHj-#T_Cx|>TszM)A()#Bl?tBlrM;NcDQ&F(jbW2YV=od?Wq27Se$fqp} zca8lRRVJjagqu&6Tbe&?_vV}SvFpuZo93hKOnLrP6>FOfb;nv`T^^N{_L4!QH$L>P zRgE(@In6Z9n$8y|rfH_&j`Rq=W#_FQYQXiZ({g=j47-5@;(^$?CFi|XeMf1|Y6k`S z8mle2Z1ajisV&B?26EMxbFK*U%{py7zHn-V#)@UnCX+=b%9nm9Wd16|)Vy5n42zS} zw61(EWXK0Kn>T_>XD!7fk{#be=dT}KJ7@8glUUT@wm?fr+>zLK zrxh0Nmc~-%dNZr=oQa_6t#c>>YoUb)s6qZU>Ao4fOWiwvwrk4u=$bo|8ledo3ZM=E z9V@u-OvW7}1QGI{v`J}mNWE-bywz>uSrxI6Y99#rI{V@_zZI6B31nvhtM2^eD zH(nb3o_rfU%SwnI?bvd|R_|xK>T|q99;nT9BM;;_uRKT|W4(z!=A^f_{wCtS zjQ9=vB5J=AbiepYyjOky00>`zBjjqAN&DN={mXdIEJz3D#yvC5UfQ!J&z>M$10O!4 z+P#zZe(*nm{9iVuCyFJ%y3}p9n@uLrh|+QVO(dVfqmG>YYuUaz&*1s|BWvIf0QgeF zL9l~226^!Mo$Dz3@<@3Z>$m>^)n6uB&pwB#oIAo!V{)el2B({uXg){wkF2h|VW4Q( z(R9?YwvHrbZ8J<8LE1BhldBr?ZF}RUw|^X#+GH^3_NRa#T)FzSdF`a{rC-M~H_WZQ zTR(VLLvv$mCG2ymuaj~-#m7KGz<12e+)Z%C{_s35B)-Q?y{{V!N`;M|>>BVfF?9O>!EuzMS zr8cI`ft+@${v*>LPtq)uu0MGv^sa~D?w*1@%zBFHyi?(*?{r9;y1O2qO3BXBvATuh za)-f~2ASdQUsaQZCuk@8stX_1xt&htNpy>6b|voSlHrHrf&FW|)u2xcd@M^;8)lyn zk8;N?`d1a=G+TSCYfNM8_UM@RBPaROUi7X;v5H4Q@V8A9-_3I43life^{%Nk`@2mZ z^8Ms_RL%vgY<^sWllWq)csEXaPX%1d8lOJk`Dxga!~yzM&kRQ1ggZ?c_{ zGZWASr|Flvx5LXgVS?UEjYeh@Ixr<-PRsQ*QC(3r8cxzzJcGnO64X37a?xJE$%BpH zaBCDs(J*y5E7O|#>c%@?iMo47sOhHK=Woo6xd-W9H}MnT-Pea@mgi4axVFwpDCI}B zYZ{k!k&CCL;&R>`@l-RQvDLtM-+>|R^{aaChh)-1&l4G}AC#4O+4Zh2NO0$Z92{Ww zuHWIejbPKS;?p&>`%aZ`!Z+W~ln;7xk5nknqdgPhPsMiAk#rq(jn0t&0P5M!S`*oU zuX(l7klU-$3DhiduX;@`o$&jH&(t?7){z8Jp2it0W@B=iLOSEzo*-YNe8 zgp0)w;T={(vPd1_b~)O^)7rV^9&J(5r^xLi(k=A4Lg!%>vtg$>UQmPYUa&qLtPW#! z86JbLxUCB>16#y?==`gn)abs4m)cm(8y%`ht7lHUxWEY5_ODn^3)@Hk04&$?tCspr zv_tMF{3#6bJ6{Z6OimH2S`PzWOfXf2dS>f*8-PD5Rn_8+xLnkSL6F>9T01N+h3qTL zemUK}&V?j!u`E>MA4>B5FXI)%GKZ5S_cgt7NWTH^OTfL6bi(UbVDZT9GTg5#!z&@cQ0H z0bSS?-gq;?KiVEC(`^zWL6|df*m8fBcHSEBb&Z6(8wxvDwfr;i6{W0_>n?*J6o={D z)LqV2Ev`r&ar;9uY2FA+0mPRKk%9E&{{Slai%PWq&6-AW$r#D6Uj3y!dlddYgH2Km zy_msn-eEmI06$vty>n55RR$tCUU6H=zGI%9q@0ZLI^63(CI&0eN!z8Sdg8ppL;dEh zn)JB={?8oajMPb%qq)r7>0?%n6#EywYzeyC7s;4RNK|&#|JscQCQwN_>re*8dKWnbH%4?7DS2LmRLH16rLT= zuWI?9#y&Tn#Mf-jn_0f|srjU1)tL9qZ5X*bvzf^*r$O-p;=R|1^+|5go;&$hZ1*7Z zCu{-PeXE4B)2{W)w2nC5d7O+WjAd{Kr3)dG?XwuM+7#7oO7VSCUO4H}b6PUGwwy0lMuKKkYLl z-X-|4W1?C~<*l3%zT9F}AF3g|up_*i^H)g`!VUpGL~F>h+m(nH;ksVAEB6nSZ; z&ppeUqe#&BP2#9-tmM${t<`3jnIvZUmR8S9S2^O(hwy8jFrD;`EgDp1QY28JPJUzg zSJCkNFVVEkL+#eqDGk5`23~{T73O-xI;ViOJx^N9Q)#nEaTUqK$N=i6x6sr@rK&1d z(KE+CXnPwk2KXaP@cK*k%iF7iF_FN`YC3`G&lTa?R3;cExEvK-k^ca^Yp?jTt!h3$ z@e}L5TfK++s1*?6HS|99zv3^2lIR}_bggKseW{O|GI}F){Hv{dsUwy9(r>Ziw;-%% zu~zS2NPf&dCi6wcmn)NyB#a-(*UtK)fW(Xd!!_J|4cAVQ<4qy=VIENv{Tqs^it!vB zrhU8+1>jSnft>WMyG<9(ReiY(GgsF~+Bg--bEd~WkTL=>ka~mOtKMj^>Nc`LJVI+} zRI4M5jCvaCMwD~NryU{FEQ6lvdKwcYpCfqt!CHsGd)wVdOtcz@hp$H0jr04H*dJ=E z@JGhpCippM`t`&~sCX|@ll?zT4U!O_^@=}*hxD(nbq@|&YjzV`T*R~5%B3WZMab#U ziupU@Pr~c3gIf3YlG{tGcv5n9z|M9={{X@qcL18ZwRX25rE7HjkEjKFYdxw1OLJbI3zzhKkRLHU`!`)9BVnq5J? zH9lbY{o@@o#hyBrJqF_PUk} z&3r$i-FTl{v(v2*$#ZuojX>>_$^5JCO&8&UXnHlKp9FZ02*PkWoN|9U>6_(`DbG!F zOPKMSzXL&X#hH0N)LM8x;^yidHo`sAN^m_CcPH3awoBki2tO$Rb6y$oOX3T7V)E-t z(WcouDTaB|t`AiQwPhCDvC$-wxo&yvEM;pOYlVly`g`uZx;<{C9YFON;8qT^;)wiH zsIQ1TKp8weVtl(DH#jnZ(SIK2@UK48udX#?Yp1h2M9;WHyep^Ux7Zok%o{6hGRsCe^Ll4)E=Y@kOCykiLzW1=YR2kTta5CB0d z^B#awLk>Q2IrOitzh_^9)?NnF>^ynm+Yx!L+%SI+_vW}p4$K|VPD(vRF zZ%xae0>5SLE5W)ho8!NVw&HPiqFoolTeiz~dWkMSxgSmsT=VHo`1$)z-)h&tVz!<+ z?yX0a40hvfoq}}P3UtWeXRj69{6lXN_>Wc>Kk@l;@(C_ylxKJSGeP_)Ishxibwz{~E2Dph3nIIz~MgArHs@$Ts zW!g`4c;$?y`%f_+`1rTvf3%Nc)g)-aNZjuzKZiWxx{E*AzVG`R>@H+0ETTpBAR5ii0m~=Q z`R?9ly1szQ#gCZ3&ae2P7yc4mK;VC^HS|`M`zrY|M=PVY!W*xpbnW{u+h5#0+eAxB z+m2~_Sf_H8IcuTwABXjstw2cC*nhIQuTjzk*0H78p@s1Ckid*_fnLp~e$RG#d#ETB zS3%+*1K9XiLz$wC8-@sw@%&72SvjW7lv-M|!hCn(Ekj?r&~0tu&7wJ;Lyoy1571UE z{{X?=V_DX(Zd)r6a>M03DE|QKSKJmJ2h*)|OB8E$Hw>|%W7oEStyB1kq}cd#!P=F~ zF)GN(clX7+{2`x_;gW!FsyIjK?I{etj z1Jb5n+2YD;rk;JRBOI~Y&3kp7x01Oi42{bldb+70;2`{}ZqhEvyR*tP&w@HlfmT=n zao)Kvi5~t$1?U&rR^=ocgA_ zsj*1#K3GRXBYO$?&sW*}RwjAbihu(jF&s%VX2pzBl-#@b-Nw@_kcIfugvDh@RY! zy^g<)YNb6T7q8$ERNj>%r{ZXS%by#x>u9Fo%$Uxu|MFhcY4Aa>&iJ-M#iz@G_j zz6k!(`hK4?wAyvt6HBGv4w6Pk+$Xso;A`d0Z%d0()6T18OmjwmeHh@W9qa4Q*{|Yy z{B`g$cqdf4So~?CAGGMV(hr&TkGfBOG7qt>V>K^*e9_NVo2Jz^?w+aspVrL#A3)U? zSb<`ckF+4*p5xNB*H0w-*Nu2b#Zp|xle+mAw(sQK+vk|3UKxMG+P$~KI*8X|XIRSY zRm*3o@5N_QJyBGp_E`kh9)gIppu>^PZ<_*7LTW|R$Q&*!6m%TpXez+)DkkvsYsu!i zw6SkWd+EEEkTXo1pOv~+mExH$RxxhvlhxA|)9MMUXek-Ki3vCv85pmot~6T<3!$4Qtwk|4ppN(gG9Z%Wmb(2dj0C^VwYFHj71Dm0D7GN z0QKt@F9dj}K$Ij_N$l9FzFh zLE{gK0t62DN#QLOp`C(Ezk(#of2q%!4E{EDCZmh$t9Pxtp zMawC@bVtj+JFu6;@-@K>^T@^B$mEi10?)$NVjzY#Z*nW_IQ|VLa&4Cc6!deO~yWIH6Tc;=0vUA@t{js|#mtUz4Js84L%$+(rq{+(!Rj>bzynnv|9}($VC7+ zW(siLh_1{30DTJM{yf{stzY;z!O)Dhx2dCCDE|PTc)xh$ z`)~*2USe);?C*Z+X%F}#@bvh5;iiiP%zx>##rCU`MfcLu=rzPs8^20xTf=Ln-93`=~wZT5#r) zL{*UFh;l0A^SCHAubOdOwMq?+YeF?8*v}mkj7jQS5eAx=c zepp9;#=Z&tygndFtN6ml!x7vWVzXbg-5lq1tP$Ln6b;u|QuL*X4uO_l}S3}!L_>M_Bp->|>Ii<`d->OLWsEwgH0 zx`IYuxeEQ}`c~cLqG_Hn)-2+<`%SC)iyVYtF!la)TU`p~(Ien3cGTNE(6Gb8?1Ud< zif@B&4VI^AFgWt(NBo#qSMg&^8gGnsh;g+``G9?Eh0+_!EPV(+t#vJ8bG;>V^^?OE zrvCuL`hCD$`LH8r>ML1vmd1AH@Tq zUsF>WOv{sQ$D*ZtUV8@$7iah&kJg{x$dRtKq#X z#`+D;pMPqXR&%M2SSDUavG=coelGkY)W2rkcKUU*ZDYZ@oNp3288Ma|BOcg2`_)D3 zbhZ4<CW?w@voNN0@XFhACu z<0$GB+-1J!C^SdzqL=;=_ap9)%DYz6+jrd*;io?zE>F_3yEf&IV~2kaI2@?Q(xLwV zgnfx1kJ7u>)8xQAG5Azp*^(2=^G!G0O-HfB{{X@|dXZ7The_xwy&6n}ecE5_i4X9K zSMI}>{Tb)q@Q=2B>HMj~!_EEEUVt=q^c26@C(@rcnEjuT=j-9-Jy-J-GuO>CFJkzljlee@mE4rD!qzoU-t*KUojpeqX@X+8?zq#tA+Ncm&<)Q$U*K z(|J%Nm?SCTm=9rJm>MpXf8#$A+StLq=Tg+}Ry8g2Gds6a`R25vX*T;AR?^8IOn$__ z2<6tb3-1`+z<$ka4YiqN;hg>EA5N9_Lik*f_i^}E=fZ!6mR}71AL!m5wnfFH07%&T z@*Y_JCpFaF3u_x`XPB&T#HHPj001&;Db7-r(a{J+MO~jUe$gH@xA!&&7%UC;Ujhnkfru*3uYlm$txq{x#2hE%1$} zh_$A?PB3fQONsXfOLit!XWwzv*14nOXDvQ z+UcGk14^Qyp#?rhnmC`jb z*A}h@6(9KH{-Uma$KMg4@aK+K!@8p<_I9Rr#?l-J(YP^tuTDL5U8$Mvp9_F(vhXYs$`O|e+rds zs{WPc9yie+QuvFYX@};BDVj*f`#me@{{Y;#TvwHN4&EOad{pr^sBqBf|boswr z*7Wf+N&7{y%UcTu(x&%g=F=JP?IIcZL38+UD~kB9Z7!j!cyq(*cN@EpIzT_Txc>ke z_lR^@=hiNqC?Y7;2h`-6@@+p&Zxwub)udmZ*G9Ei7tlH6{{TPIx@qEJQ)8B{7IfFC z)>=pQ7}YQYXfe~EtCo|ATh5SR^gw$Gxh9y?$Ut1ghoAP3_)}WjY2;x2E2rIi9M73O zOAy>dPatNsZ5AoH1Oe8tRwX=R2imRqjc9@~%N|=jhovVQjZLFy*qLNkJSZP1{wT7UzBffOR*dp82?tNVxipmZ;jPXwxJ+oZ@0D!f0*K`PFCo(6<{{UL)b`MJCvC^b4 zZO1gi!w0=gA_sv^7Wk!M^JsjRtzHzn+7Clsqu^~gY8q0pP}vpZy5{qE#D9A=?H>#; zh|*(LAH!aJcRdHPG^X&vHb;^VxfMWoVd1$2AQ;bFS8$goPdF7ZoiV@!)`VQ4rs?km zu-yue!mg}v?fIeu@T50wuz?xJwN#q%BhEq17Gt@hk=mXL0a7)cl)P;ff||T4equrQ zH80ztla-JzJ?V52Cx|VyeFsXp)b$BtwzIcGB$B@YK(B-TJp60Bn^bK_PGy?TP|Ia% zP7*k={<(%TyU-f+KiY%i(_4AEWI<=p?c6MaHYKiS9E2UVjC1s_f_!b_i8UMRi4y7A zt`6x&aysNF^aDLB(4k50&Kk8Y?2O+P>&vTN$@X=0jhiPYmt)kL?S2D%5!F5~c&6UU z+BW+zY|SzIs-v$p&ipa>bE{swl!Tl$}9vipOwA)~*8i6FZ=0@&KZ0P<9w6fIh zt==pWt1@x;AJkWI5JIHdeH>*7c;*2dvLJg0n^_X-p9uUPT6t#6?Ce&bQOaU2%JO6=VL z2Q|Td&Kg5U;2AahfJNts?&Z~_W*;$G8z>LaHj3FMk&AbdN1#lIq$ew^eb>Z6+A! z(+U^!uDHjNH|lcAN~D)#>dypt7RSKe7}N9}G9Z@ru>=5hV~$C!OUZu7zD>U~$?};; z@Q(Pci?X)6poPPq1%5n-D=wP{uM{?sXw3!KT7)l0R9RK@kKO$3t0Fv&TL)Zx1V&X zc@Zx_ey90YDgOWj=J-=J?}{&cdp`K#)5BWYkTJSOA2Iv^$KzV3%NHXj?@yH7{NH4>5jOWc zu^8t9z9smI)>C+*Hq%6_XU~)-`{ZM(#bXxUtkzbJi1|DAfq+NjS=eXiflu?VCDW9I zbLwl>{{U!5-{STi#k!Bw*Olq9ZzzCdyQ=jLls;BRh}fNOaZptwXn_X#$}*rFJ_+=Wet|j#GDMkF)`YjQ!aM zq4)Qy+V6(6Pl%oxyVCV5WV*7rQxxz@IaB=&b)Hmedsa@Tr95-}zQZ4BU%wYcnSBqx zp{H>>Esx5d+Hdx0)V>Dzh2@1F@5484O|9QupX@)O_wQQ&0J4|uPkZoBS&v56qOl5L4UeLcNJewX-%!nzN|9|~RRTBV|0S=^zHSS3(eHvWWq*X6J6G5a*?{{R9! zM<5Z)dEvX3Cer@^Zv*{?^kMqfY$m3;WN}UopS=_HXGhmG{{RqacDjz6aUJEA$_XNx zBb8-6D%fTI?S5eX$zQc?=fT}xFAwYTz1M`Uq{dj1Z5aOm68n0J{X)?0EPP3&+iE&x z%y)KI2qcPVm@=}SL9S|)<4=8#s&!>kZOGZiI8*r36mycGR=4(3df-w0klv!OQl^cX ztjyf;_eB`bVOqc0Cm(nZ!hxh5{ozZRnMyUxh6HpUr79>rM^9?jI%dP`N^Fn}5l@;O z3FWEO$}y?!`ckY&PBB_7Y@?#ne2@>_{VJ}6)UHwxAU!igi5xMsk`Rc1voYuZ>skQ^ z_kT+8Pun}<^873KMRygff<0SS&7>GOWl}z7A3;>E-H{H-U9;pL+h5|fzl*#@Hk7|& z)NOzgSzX#b^BMz!8_?t$+5Z57cYGNhDDi#&0FAW^eb!ny@@&fv8g?9?z<)aVD^0Py z@fNRdXK54M-rh>FMH=#1nBZ6JpTK{Hw%-YU7wCQ+hBaBCfnMdn!FhSF{{V%D@rL%+-fi|8SoA|@o=P27lz@m4WMwpHA6NMew z&p}?5@pef58T>oY`~hz!-D>$)Ro9C348t(OPkaNCJ#kF<72!xVZx+d{YA6ne;frR~ zuZw-wC^CuouodQBG4W0BkGx^3>-vU1)qOimz;sZ0DE0#b@UF=F%;uX(Tl(|wzam6twd?GK{D-ek5OAG zXBhOTCbUbh^^3TUo}!wz_jcdvWb@Pel6CwIdCm>>JL6I*>@Dt*70^~w<;RISU~$%l*j)GxR;`OUo~{#sd+0EX|} zA5lVsK?iTUFN)s`4~2C}gu3RBAC%kMEi6C6--k-* zth7^!&qc0oM*RrdnhZ`=#gbsWfSv*z2~lp;>L* zgOT@vL4)|>x?MUY8g;10{IXu&)xzpE(Ct){R=LOcg75neK)=3`imVW`l6{47UjcOK zbj=;^qzBH_RNS_E<&AEB(AUP>!7=I3AM~A4_Ks_e`9DmuBPN}%#Nh{3=id8KkV&gHP45y^=Z!Q?PBGol0rENnz8YN zSc^~ib8&AU`ul`SIMj7#ko?&C3hzE1>304X@Q#^es1`(lck#($GHWS8C^Z=FZyJr% zk2JRZPK;^M2*N604%J5ILsOg%Ye{uP&M{L9$k-Bk4zxxc;=KshUug2>hks<{rnbtM)O^3h z-oHw(E!;XREj`5P1PlC5aZkS1q`q@&pxS-8#|?J>04x#v=fBWZID8o#Zr9q~z`vC~ zZPW1#U+%B^;U6+w&+;=fg{F364d6DLvSr#beokA6M)CuQ!XZ6&yl8G)8K^N+Dx-Xz~F{ckzb~I9<>LId_SW2%E+Ixc!JR-l|Q+2 z7~3b}NAj=DZx!gRqH9-{EHUM|+I>fA`os1*_>S6_!);^2S5j`5N4iZy)k){ebGAS~ zGHa@9nI?`!?#j&jSKywx?k+vEo{s!%X z!MBMPyh6&t=EfniR>0~H6}zP_uXI0koO}1Dc1ugC*z0~Q77}b$&u%K6uZhy*{cHXe zg>m8CMrYqT{rX^at#d`#xIyDDbQro!lX{yST#^W#%` zxd0B;M(Xt~wL5Eo*jEM@{$us8GvaNt(9u~VP)49tEoR^CI&|p2d=2P-+8mGNRt?Ix zdSM}f?kHLb>W?%1pf-4u;e?tjOhv}F@mj~U^Yai}@)@qy&e9(acy7;6wNQ(10$aqR z*Z?cW_02;608{w=;g1i;8y!DIhHJ^#jtP_Hi}c-I%J{t0lnzqZZa$ljPs+6NFi!2i zk#Y-VwzWus&e6&B^)=!@7ycw!_*cXF-1jpG^ynmJY&SE$1{8Z|t#scPJ}X#wA4i|g zxe$Oh$jkSMJrPHCHS_m|{t@e+wC9cOe%lB3XMyaobA2+u$j#J7Jx&KT(M~p8%)+eS z!xI8&5Mt;pVk0--homg8Xz0Zg>8=XQ~ z)9=d>av~Y*0s+#a_}8ji_%q<%xuW=cHnw->JtD~!LbDelA&hq9*LUE*4@KdB3u!t` z3$hz4Sm6VvHmDRzWWpT__i9)yC@}IIeFu7vS-iIJ#)EsRUbu$J$`+bNJp!oBYH8NS z;zMvoN%>G?wS2MreSBA!!@e3{2-`V}?I5s#@H`wEntpDH$f>|B}gSHyo4 zYJap3#%b;?(d3_0yIXrmh|b9OZC}6+Pvc*#9|%4f>Awhm9qAq$(${>J(T1AiZUe}9 zX7}d4IsX8Hc>E1%@2)-~>o()cP|)efror-%DhvuXUKmZZUnwJy;07^RIXFO+ElmRDbVcv zw%u`g3IIo3xE0WNlJeWc-xQneCEnIsu&w0`u~2(gr2IGV&7QOSQ+QWP)0#=FBE61Q z5)2gxH(>rkyD!TFmFpw*M?=$1o{?j2tAP|KnDoFEpe7*0IiGoW?w_^ z=xZ~@{u=Q2#eWFiXxhb!UD({IM~X%Hqi<2@N3C^VV1H`NzR<2AoZnce`#>0yGI*4K zkG%_%xi8vVZ4bz=+CTPm)&2x{q3z~Yy3npzwVm>*!5`gkMjx$u5A1vUUEcg4)gtiM zi0&l1@U_c@jo9ScF*wNo0B6)!=(ml29_XJKJRy0a>lT9I&Tvtch9rgkN1+D4DE|Ox z-`Ue#_y^*rw7rSv(QaIu8}avKUqk4_(!1k0)LgPSTqg(2kJueIT!&h)g7(JYE-hz3 z@yi=-45z+o(YTcV02O{={>R_7<;TM7=sYXpn}(ai6E@gqZd%oe{{YYcDnZr0f0cfj z>6f$K*VDn)xqcNix1T6fw9r5|TLl=QbG^u-*pA3Vkz zrHL7sVL-_zwSG>1+rJo(#V?Duw$_uGG<_tSOJxV;Bz^Y$Px7z6{{U;hit+p*_(7@M zB%w7;K@FXngU;q0x8O2s@(Ik^mG2pr;^IxJGs`hNex|x8(^uv>>eQ25topzH3FGi| z_}Ai8-Y&m@K9!)1L>zGU3^@M)zCkDP75a2iGCB;`lm5(q3aq{h{2E_% zSY@VhllgS~E7adnu#fNEz&^4M@vQ2#Ib7C~lq0dm{ABTszr)Xlej(RoV5Zj9%zo}h z6n=QG#f$6b)-{`}+lMaFNYc3{lg?}ObN&iH@fO!k_*bUrRyUDLHlugCMTx;X5NDiw zkzbs;4wQ9k%R3)2Vtea|?Zl376cRE?_N{3vM&@-T?_I5rQ}HlwhTpSJoo&3utN7;d z-Or8)vWxC7(U^l?6#{LGn)SchpTzKMe-qb6(`8AtZw^_*q*@q`RfKDoVfA8vD)W5< zOnqZlv$H{#mO#9EXPWAUisvJ}?0qZyD)i@i5Cl3mSxa2=wEWrSnCK9%}~p_>V; zZQ-_Nfl&f1Q!d6B?So$*e#n>p9ME;mUi(zltaRh1#FlpP+)SuRBP`th0=}ewi=GFS zNR!0+Ll1kD{{R}|s@r-URQY4M+MOo~2x?W+h~sr|tMO04ap%bKmYWazKWj!Gku>A+ zlfw~md{3lKo~d@A{#mYTPx+3HYCVqS^mLsuAXi^QRZfj1us>#$k^cZZ1l7TL-yOlL?)7WAm%m9}@gEY}+Q+>?32)JnAS)r&c#QU5!h);JHsK>N9TO z0@ z#yWP3)rhdiUc)lz3v({`TNpyWzPb_3p-;JmBHQw0B!p7Wx(>4B8`Pusv ze$ak?ioXx_xfPZXC)lTu=K+`Qes%g8;oJ3_NsTTD`%DNDZ>SxA5nS_+Bejl*H#K{n zVdFx4Tk)gAx;A%P>9IBJ!?x~=U6+d7TQ$)HZfkWx_`Pyp2t2P7{6X=jiAx8FTz!TX z?S$a|qPm-_i+ywAMV*vHy}hlw%Q45u%m~2GwM&vuVJNnnzUP|w=EhBX;zxsY9Og95 zlg1t(f12LRiyL#>gM<8ydox+7=+!Qw*tpV@ zGYlN}p;WUDK#e(KeQ4Eht+dC67n>IooJSh+$JFAnukSB?ye@9cIrv0`2OD3J z0Ir06^sN6Q`ZthzweV6;I>?^OfjiXEu0QRmKO;Oe^?DS&? zUrENx`rOX`$yg&1TR|PXE5axk3O#_T7dmTlT$eumE2Tdu>rp%_AUPaXamL9%cyqx) zuXN=0?KtW5uO|5W;q|)FA=FiVXj{s2kKu3ry?e~sLNF&gYmxYe;pjC_2kQ2OyUJi@ zjbmNeU@|Mxr-+43ndj8QPM)XaU+oX!-|XFT8`M&(CSz}}QB^-?FAyIO{A1E#W(x+R zy2?zP0w00;*RKB2ei>`O3_Zi9Bt#N%+ZFQhc(>jf)27G_(5`;CuRR_P)BJOwY5J1jqwubuccE!9*~=hv5g}Q7j@8F4S<@XgJDFLEfEa!i5CvN-^^Nwq zK;?}=EzGQb`KYrrx;#fj)>~VI6j>0~kM=yl%Mi_kNo8Y#khE**jw^D1h?XfI zKuvJCEza&@rTR|_kP~%jcvD=vSMr6i892*VIX8=v`hSu$vpwraOz|0pLFLDj^r`e2 z>F^}eC}&VUwP4WQG3#;%9UWHmZ5~k9>Lx z+A-!al=)8~{i}X6j}Tr@quN~v;nLg)mA>rvU5Mw9weY`+HH({@r;2HW?iiUR9bJj< zU5~_%7uxGnyA_a6ZJ5>A;JcIazv64pG>tNScT|Gb1!-rQ!l3GMYu2R&2%c=@)6gu1uI;*cfPV2lqrD~0 zFRa|dF7fx3m$4j*vkJD{Vye%kN+fA*TgjC2NAS1QR%oZ#(zCkrt)p@LN(mnIU|iuz zCjfQ!tUV^>qn=w!kRY5kqX(OriJ5E1ZcGO(jT=P$c zo+^{X9xU-ch%OOLt;VR!8-c`=mZ=X|ph_?*|DCxNwp+Iv=e+xh3wei+!#x@(5Y&8)}| zNQd_`#_pYQ&rX_j)cxnN$2zW>mA0y=>T+wx{t`ob)4f8ndX;%J z+*afo?~e3u1^95^Tg@HRubAxtBQmfYk?5nTHTj?7Sar|Z-{bq~T6~Qyj5D#G^97Mq zADTn$#sIHP{jYvGT?$s6b_8v%D#&Ilm7r`Sbhlj7B;$# zAH+I}EZ4JjE5`^uhHIiyRHJ3Nj3FwryV)H_ggzHtcw@s_eu|pJkXTtnu_QJzh=Bv& zt#uk-!<{y1H#MocW#y;5VXIcAz3ERm}C1@ z&bn@GkCa4({L{c*YwIuAC&HHc{{Y4Nt!fmK>GZpDX1R00klI0Na7UpAy5BLgjn$QZV=c7i`p^Au{I!d;qx@;8Yks12G`BgSBqw3L_Y}l8yy+C$Knc#!# z>MOb}RF5o<7kYA1iTh`#>b4rSj5n5&rNz9e9V9{sAoRsx_{ZXBg?tABzO|`AYa{;v ztG6FC(w{{^l26cA=6~$T`(JCG4$@@sR;{X=ds~I_tTic9%=;NU4i8aRJ}G|E7JdZR zr?>F+tlGDVFh0@ZJzR+%PCw^~QN)4HXlv(Kmaa@RrA zaCT~5DZ5*3!_{FKbNJ%EY4Pvv!QyysWRAzix_NAow@lMXLG*WHn)wIDpAdEb0Ek+n z>KgW^KA~{j10#H@Jpkw{p2!^qbjF1^t0niG@~cu`6Z^D2jnw}DZ7+zIC4%_VP*MK? zOyG$>_FCoj58AWh718;;b*^OgS(QJKuNRT<2{lsRGTr&kGa7UF*28M`;GLt>5b&PFa52IgqJ6ah{dJ3CdnXRFUNn*;9fD=}Pk%14`XbOw&;?g#Q2$ zg}mvWPI);wuTJ_FsK5jE}AtC;1sSe$THM8W)x zMQP$aOHH|HZ1oF^X53HiY=jw0{sJ3os0^a!y|(CRkh{#f21k$+xmpVd4os_5taBF;jC{#r-# zujVTt$f?6*dr@S^euttjjJ!RoaeJe-mofhUmewtmAMg`a&WyK_`I;WLXL`q^61j#? zrVV+An1{VNWl%v4gX@}HfulQ_wLcU1X5}WD&gKS;=bM~!0yte#T zp`_z}fUU9Fw~a5nV3^XioBbyG1jAd)dg`nPVb5ywZxYRXx{QLdSQ-{Ja=`nQl;GFVaQtrZrRJ4)bEo*PRE6Y0BZZqe!0GhGeB*Mu zWy}pFta91h1I@c~53UV#zYe@gnr5REcT!>%JqXBU?xvigo~$Czd-yZ)o5ns8vJMz^$g=X}Vin!FzpVK6`s--L;54Pfl}L$|~0? zE!`vU{{VqDwq76jX&$F{dlYYXBTqY)8Qf1p?_D!q>mDbLM$^@_jWB=9{nFw|KkJ%* zQ(roKHSw;G;g1;GXc5@Jmr_aQU0eOA-5XXsZ;<`S$vqd7Ur!j`?(R9~m83{V?!z7d z^~Xx$bmX;c=~ZI%irBBAX&Zd0S~q!PQljSQ=0^2C)ni}rE}7wLZ#MSm315VUKbl!j zqJhaZ&1)VuxxLgR(tJ4(ztj|d^5u{EJ-_?S-2VWwD$ar6xHQ|J?F~Y9)istMHQe99 z?2FMy(xy;y-I_+EV62tOkZIbNjb>Y|Xg-&ub&pGpu_U9`W*_NL8FQOwyBE$;YKbHDg2l6t1S{wZt?8_SxCk`tDYCaCqAD1Rp|BW zXksrkp;UFRKBgLQ)+*R1@e7`dd-D_f0&@>oZ_`$vsea582VQlyWHs8W6iYB zhGi9Z=DM9X!BAS`E1XwI?j!ktrp0zi`?aF7CEb)}(UKG%Qt&&0UkQHHpAuy84~TBx zOPyo>&UUf32O}pRy?=j9*VrE&{vjU#d^yx^;m7uc%A0FMIr*ev*YK~({{VN%IW(J7$W}w2;bJP= zCe!avSc>6@$gUM5smA-1*2l_UlHq~$a2Qd#;X20@qt#9i>sH?c^u;HWo@$eQL8Z{e z)n$NOTbr9_RDa#EBOqs|Bi_8bzOHhV48%sEiTYD+TViy@T>{sF# zAz9{uu40BnmEw=@GKK_!?~K=${8aJYo#THH%i?V_K>p9sw8mXGNrO<$xxc(~wWP=Q zSAaU_1lL74bDTA*dp~!%$b3b)vuo>L?Hi<$)?gvF(ib9Iw#Re)Dh58a=zbSE#=WO$ z&}lXzNUZ>lXPJDcU|={@P&op;)5Lxi)qE@C+X1cX5o-P+iKCIlv<2wuC!`HQKH)YC<;qNyl!rS4TnweR(oA$dkoj?zoiP&p&Ig+8)cmUL1RCd6rg1@?f@)fb!#o$M;84YpwXj@rO_NXW=QoeI1-OcEa8W zqiifFtByTb^cC_4z>kEw-|c(xUSAYzuuTVpwCOi0$(@p5@PPOAWB69kii+lDQg2A3 z(7$2t+5Z4g)wQ{PDfq_K?sW^cXVapT^Ye9(srKM|SKdLBOZxS~ zrh#W2o}C&ZM1d96a1;(OqN!i%R@!_`J>2OUbuA-fCa{y{cYB+~a;qnyp(d$uaK+?k z!R>%Z707%^_=jcS&ko()Ug`;NF2W;CqjNW42YO_h#;4*UW745ER%fmC!t%rYh5n|z zTlR#8(@eIp(V-^mQJwtRuBSVs$MpOvR1g(GboWT*aS#d%w=C*)$~lVd2`DnotE~I;1ldC;vb6sBR(eamZ5dHlrj}l z{>b9GVR=Z#e(Y?FPYmdnJ|)*Q{U*^1+}_TRpdPGoU%EdGd?#n%Z-@E@gKdP4#?m;5 z_X;y#mtXKt--ai~{{R}G)$SoLr)Y7R*p}8+C}dZL;E)!8#Jd+a5~i#xsiX2)a!F0 z9K9U17g6(4e`H6re#508wv~T|hP;spImq{^Q&D_$2A%36Ql$E{K+#AKC~4qrU%QH! z>Urvdp1P7A3RH8{Tg1^qliEc>PjUFtbSAG0+jjmDPBL1a!i80>99TLj?KEeOdWu#; zMPCtH9*ih!IJ-aVPus_+eO^}4ue|0jbm^^b8ur8wKOek^pO#VnKK1e!?f(GrZ^F`e z8rQ8y!06F?sM+UbC ziS(4x^;5F)++x!AA2vU@$GEI5Q%$(hF3dA>r8y+OovC;=eECVUp{GNkIajAVfg!3iFlXcCy0DG zsZR~$5${Tslb0#voouh%8qj{HQP)jlFD$Mf;ke;T!hxT*$ zEeFJF9~5|xR4pct;oEPtTtJ*O#DjsKY-WcpRdyS@Bg*tO5?!ycsye(fNjaE;BP5O8 zvszkHD887W!VT=B&~~q;z93lISpL8s5S1*T7k66gJKT9=%p>=lZtL2ya? zFM)h{s4m;z0qPf^^>bp%-Q^J~v?@@4-zc@2uIpbAF0E$PJVW72`K%-5@2@T70z9AZ zFyor8XVnvU_dJ_V({%=wB(#ENbvag5BE3JucN(h%kX#KfP{)?>$tVdr`t9f%x$8!_ z@onl`>GNs^?o))df-@%Xpi!FdbWhmt;vTtkZ*zC#Xg1O|QtMNf%5(Hjy`N)>ZF2`Q zMN1js)HUcVHA`zexrBZ zd3)?$pdSAKg?;Dn_;`cjr-HP5tN9;C(kHczoW$6;yEx?M_!Rzi`QPKKzlf6ft3$VG zVuI4zG>XzS!znn&Blw#pzecU&5Fej-1&uo1=Jq8| zGf`nTQu9TV_;wY@)VrOEP+D@GD?v8Q#TIk?Pr$0PTJB-yzVrRn=~Q94yOHwK$93-H z{{TwTi&VIWkFvnu?wo%*hUzYtHcE@#v_%vl0oxN5^hK!rsrQ|*kU!crpgcn`z-tCS z?zl9A#AsXRvXw`u8L0M^7V$6HQhQ9!Xl8O%wjlNys?u1qF#YTP6~FztV67wKCQ8v>bG(yJ}36G%45y!x;e zO0;ArMo~UY_}Hv`MdAQXKv3X+z(sy@_}vQlpH&Bw=O5``q~9DZ79SFLjkl{|0^=j5 z1%7UL?B~ULwlR-9;}z&pdYm;sQ}svw3CAEmvre2b()#3N{{X&g>I2Pu#s2^V^s<)I z_HNPHkIOa9#DEV%=DwQ)Bd;~`*uUdS&#Ck{U*N_011RZ?ny)RSQt(uMRW|ICnrY5@ z@m$HtYMI$&^k<#4f&qXkCb(u^DzjY@*y~eABC_Kmy`#h&j?Q_j(FEE612suvcIa_g zzAV<0!af(&^?Qj{IIS(-QpE9)IQ?ppCQjO)Ab#546E3d2Omw|DgU6zeD4=c{X$K^a z_C+P0?@ z5h;14aEd)iJXf*^VN9i#ZdJAG@yxA1D(b=(9rVolfsk&fW?7&YZK zT3Cxtl^qf*IUR?UB7(U80Cu@cg5L%vy76Cy-r;45b1kC2$R9m^UVoKo8L4}+F_k9= z%>%me#;0fSe@=f9_^$FTKg0Iq#F1S{7FLtELDjyM@(;yt+dIKNB>1`Ee-K+wcXgv^ zmX{I>Xy7ZGk~YRbK8jzTabG+B)<3lej6Z0f85=(k$qlZTrb@;OTXeuucqb>huDjq@ z?5THuuIakRk2Kqq@RW$NM{i)JNiP`VWLX_E-=E8^ZlM^XB{@rTcfsBY@o(*?IIKshRhqdDoGgFVfDg7|jR!ru(MC1>IN5g^gDOE~sN5j#uzmM4-; zU}>>yv)J0{`c{=`;Vll?1a|P14I&`u0xtmL*0prs1=(8Mi>tPiQ@sBGp4Vq~K8w&+ z6R4zgQ>5doAwD#?*InY#Es91C(|vfQ6aE4^wOF&zwDpxPwHubzFISNBeZ)WPr$Jk% z7{E{m(vA>xAC+Z(#@2sn+=5+R*4h`5rBy@(lh|UuN%(={88!a^8Q$u0y0l=FfkDp$ zrF)OZFBc65!#`nwfp>a#anvt!Up7Eh z-A1FSX5$~Ne4XQuh5jh;UZ)akdOg*dokWql#!9$6@ZB@*UtM@3!>8j%?FFT2stlSw zfWqeFz>UD0FKsQuD?X?Ky5=msRaTyx<^Xa!dQr+8$Ikx%@K8S-w}n0cUfJ7B(a)@0 z$77IrC~{nX-yo01zbBo5Vl&NthyMV%%K2u)E=jssS`Pb)EkuywRHQPcq ziOW(>QaUX&TD85iI$o-GK!4QSe-sV=9@UAeY18PpjTFQlGLj+6y844vzRq)=wX11+ z6!s>=QDSZRF-ku4NBk>&jJGpHsdAy0h8^naS-zoW(n>^TJSxcF#C->?RlL$=(Pl`c z!3;MmFGe1sx-SCwH&XbG;#&<8%t&5G18`D8FLR2oF>17DQQ@x~_^06okW|zpwz`rv zXl& zbR<{LzAn9)yhncR)=5m940Y%F*P-}3#PZn9WpArIPh$e@G0b?yRPmY6b?*{f-zKL8 zv=4x>#SvmK0HT~+UZFR4q2=SwxgkO^&3$M4AN)hnJO|^dA*`a2m(JeH-D=kt;QY`MmSojjAxfD);^__0mn_uY^`w zG;v$_9_If5QDnrI63^xQ-n<&}o9$Cgm8TZkOfe(<-Ndn9p{L4;PFAtaESOal^oPRl z9crE#@VuJVhjJ}7Z8FsaQxF?&P7ep#zI~7??b?fAFWKI z%!-TUcRstk_@k`+af?aQ^#MMp_C(1g{m}p^BL@mN74P0M_+GYu4RjkQ^ck+S%Z)fq zJ@glc?;P{DwSIAFpAfuP3~||7YZJtegjnIi=kgWxHk#MAs8{{V!3=Tsy|ZT51i zNp=|IZv1O0N0(Bs&9UR!_w3oNomzPk{wORN&zI*)8S(VZTGKyg8x-62{a(}_=Hl@p zU(l0YJE&f2YjW}2TwNAZ(nmS{YMU9~?=v5A)v6^W*!n8V;V*%7mp^OqmxpXNo~RAW z{uwx}>uA0TX-HR5@&2=@0oE-#+sRY(Dl6tq3&T3UiZ9m7NwJ?&yKa!$#?rUf02~VR z_(5$RSdf8za1N$^gd!y)xskR^X*iE>Hy;+HRWMb-32uGxH6{hxd;K(*35N2-eM_<43{UJS9eh;XYW4&TXZmhtE9%i|9o=+_!W=AEXEGH?Wz z#vH3)P**;g2BOwJKKScixOwjN`>AJfgDV`j=hRmH*Mcso|ijBSTD zhpU zo5MPMI+mwz;;mX}#k>h)m9>C@f_Hx&-a2*mtg1(uD;G{mEt-NC@aK;8U1H_Lkz8$9 zke-SMBlE70!>b$1aeTSNCb|aZBQJ|WXF<@L< zGXDU!pD_Ob>s0A|rp|s>P<;;uvbqtIQOy){aOkW($2Ehaeyr$?cWsflWV9TNW9diQ ztxrK*=h^(T`>7p0x{7kV>5;W$9;$F?_A9u)u>Sy+8_zYN`^c<5m0Iz%^1n1m)3VlA z+f^HEit!)zhZPp9c?>#T-N<0xR7~7^3SPl?G*#Q^kB~A zE8uSvPA+x*MaL@lFN5v4*V#Tl)tb-4TE3|>72YI}AAWzvzAdwgO-E3-iE`4Eb6&3Nd>lY5YaB=Wi7Qc!lO*K-w!fjoI%( zRRhw6hNQX_@ikvlo`X#%49`}gjyPD1W}|`!M+#h;xGj$C=CiO3YmpewKr6t1Y!4qD zFX7}`L<2KwGVXty7>YbIeRIJ4Yv>lU-^Od;kK32UhJPD2k#BSmS=*#2po|85m;7)A zZ$>=QFsmy@-OrLdPpc-SG_$lGOwzHCgYVPyuIKh~@KpXW_=l%kM1<{dat3h3;D1y2 zSD4?*_ZOR4gY%4z_4fY&{1f}ZGMm{o?=Qz8&!3 z(lm=p7$7MHyh|8n`9J_;Jk^=vvbWEha-@&pgl_$MR&0jq=56xMWXJd0IQmnX*p<#Q zeMNb?c00Z2yDde3Z-eAp62qfPc|VsF{^b zNQA`@5?_&rm-u2Hfcn)&E%_rUIIlKE4YGKB2n0*0u=$+)!Xf@OoGgvRrI<6j9Fx?V znVu+#GC8=DxI@N$+M>4&e{jX-`!p-d6*vk$z0GNByAYkV8*8WATi?Cy++JsryDiaI zaA)pw(~8R0e7KewBZOO$yt&DBH<_ z020o74?*f`p0{%X#RaS?Vz$mm?#ekN(n{X*9GduId?TWM#Ityh#q&qv8*uV? zhi5tC>0Zlw`!@K>J#)jh+Fy@ni$u`fC0jQP103>j4mxr8*U(2!7Z!%`;|AcJ`$+x| z?*3I?+{tUJvp`yFSA;&&er4yktv4pbp%uBi6mq0WJmSSb1h+Y-dGaUTK;Gziq=Mo( z;@ul$cOAt$Ao71IiyAl82-An_l=fjuHPXWaO6)weQpAJM3U1>34wdJB7(7-q4J|Bf zUuE3jmSOxU-l<0J#JMz&7WkXu^WuLJ-CSpMN_@B!o{Bo3#=O7AelL&1ng#9YBYGy) z8S2NNsr8Q(Nb_A>%)&`a7~u-I;;=jg;K}|o{9n>MIfEvRrV)9mKk%^}g#7DiS?SRk zMYONgpG$tmUkc>#SHtGlwJ3_~I`e(1V&e>|e(L^J^e#xGv$cl8)*E<~BvB|tQP>WZ zF(|yAwTY_HSpsJ?IrS8QhHx`ZKQ|Ou28IM2(s_9~~4{{E#|Sg$~hG*|60j&T)f43JDB&q+kvRq%Ih8c&8vzF`57l0P8?1 zJ*ag6a5GR|-a%n%_ZKpyyb*#~+~5H~7JQxRb{)s50CcY@_=EA+Pw;N59fhm|QIa5;Ip4&y!AxGxTTaPeQpj~Mvt!JY>35_o@3fqa zT*qjZciN`4V60-0_Sfj=g>_$ue+KOJ3pZGFg7R*A zH)t-TLKiv6&N4e!pX#5t#PKu5FO6>i83iq136J~bMQ<4@YRwSTrumae9~0mF81dee z>X+US)@<3j3yT>e&*lYMv;Cj^eXQCu!Qs1+7fte%kDvGv2EK~dzib^BSD4FreX8nm z^~}4Q`I_f0KWhyUK@sVfvU+YRKdnD#`b4!2J?SHo(f8wHV^m^p>|qV4Q`%5y0 zxDv6DdIit5eADsn+TP2;mPsU`Zy>lS5o7b1h{^l~c0MTmpu9b$+)EYRjqFo_xj;~S z&%{^Ho;mS8r}2Bm5Sv>kjwSun5R8+I^3dH8x^{e&P5F20yo|y`U=+Ym%{7)cU!Ww z)Ul!2O#5WMxfxi;sQ~7+{B7}~=f(OwRx4p0k&VNNV{e#a(zuHs5Z+m833DW`bc%j> zoz56~RktCMKPiyddSk`r|PRB+u>QPzBV-ut8Sr<4A0qko^Z`serP?3!}3G2+l zpwfOY9|_Ae+6A1plFma&lDKs^t9M_t*Nq(RN41P+r=m+mCrm>H&b$wr?#h~_-8?%!CE$#uIaXVbeByM!lrn`tG;3ufok1Tj+)28CiUtD(yf^N z%QXnt^xfC>sZny=ZQJ4ObXsS@EniTa-T22|(>xyzH#6#UhMkABYI9pwIV=ablA*InAusq;?Rk@@1FIU!p zcx@Uhe)kjnk*M{_twZ814?qk3kEIK%mHz;jy+H24^$F6lHGdQ9J|u9rS2E0>!xDU` zKj0&ZX^h<(KZZOy=r3=dUA!j((!^mmdKd5bS5>WevQGnO(Z_3NZKUeADU`FeOuPts zG>&=PeJa0#{3QMmw1-ibPi`bSZo};dy5!TP->>3?TsEI~*4$*YvMy(0^iy{7tJ{-1w_hj>5-DDq_>@ z)G!hC3Rv{ub6;5aX37bryS#x>ZH%R1NY`qN3=@ipSl#P)_9%bWNQP7BfOPz82RD5U zlohVe3GtV}j}GWQ3)E)Q^wfgsm|fb>kDWrESMeO;yf@=+rKw2+U0McNEn|y&WA3Kh ze_H#q$GTKk{u0z<-La#AnHYZw1B&>g_JD%k%`G&Tu0$6TTE;IQC+BiE1;^7AMcq4u z)tYAw`#0$~`p3o{0^-ce=9`fu%$ffHRyl0@SLlwdrZu&~-Pw~Ew8#o#L~!8sv8;4h%Bd1VJ9s6153>QyzR-&2>G%-YJg@j_Wh`TT-F zVe5{@wzTcnw(B*e>pGG5WEmsa(nO<7pUNlw8dLI~$ljkyFZ?%h?zff_7$;6jsrg)b z5mIPbYn!MK?;u%6tVjA&f3>0AhusSv(k?2swxKZ$(?`E)!)^P@eZ^TxqPrXsApYt9 z01A;4DZ(8UFxa;*i_iz~8!`diqK~tpnK6%I7%6G~gPl z*Ak!}Y$T8GMsxa5m(Z}lY#;WxsVj2U(Amh(*1Xf>)}ophfNv&+j5l)?hBl41o=-oe zdP+%=kU<{R^Uv)cuYYK3wi+~Vn5~!3ly=GKRRwk_TF1%%03Y?!rT7`F^Om_Q5Cr}v zytDRd@LBQS#?K7trZXf7a<>wUbVNI{e-J9q?F-^&)x1$2o~`?3Uf_5(Hf5&J`Up*&IX6Ii@tJ7u|+WN!E^ft-5^{YUt> z;u$8dTUx;fhMEEAT)#5QDvWJW*k?I4`P=Z;B>uHEpETO+ZA~aaKCJ3vwpWlg;t!>2 z#cedIfm0bc1ox<5Q0|f2!c6jD0{pgpgUu|l+AuY5=u^dzh(ERQC&%Ldm_T> zE1{WUiXSrYe5Pi`)fntOYc6}qE_3F#eYy&!^7Gdu)X0k|130GS_FD^;Enef%nBhRr zu&Q$0o2j9+h;4}vn<+h9_N`ZFw0mg-HtVMtApZc%HU9t;S-u~$n^7+DMqWufHaK?=et8ridzy3DDQ_|+@}?cfL-iEc?h2nO z;0UBW5Ae6rr-9aJ)(PmEs@&bg>WTf;gVe&8_qXPkHX3j5wDl@(P$3I+fjFyt}k$1n$arLQNYUeFLSea!c95*z~kbZ7OGByke27=f;^GJPlCut*5jB`;3Qfet1 zCnVrfug!ufgLfs!Cmp>hSLSXwrgb3nr8x(KK#2n%TKUuVgz+w=4ye|1d6QZK1Xl5k zCnN7SuX_3nmt)0v_w6O%t#e-RfxPh4QOT-A%CZCZypn!mzN`jFtA)9>CQiulU)i(6 z3*i3%j2E6NzQ2yoLAgMe2tM;lJqM;b*V7-guf`oe!*E#W-U!zc+AFAp_t0tERh%*E z(kRYx)Mx8oFKJhAte4RA=q_WH(E==+GF|(ecR8;}_D) zJrAI*+@h1Q#^C1lA^2JFT7MGilI!}Op?UTOnPo*SgCPev&-ZM)9s*)yNA-zxs@ zb?kH5y+wJSJ-9xV+_lufF>}o{?I$N09SsG~dO|@RX@%~3o}J>YI`6}=O>ppiwiF1- zA_go${{RZJdGV&hM-Lx|bUSDt7&i`pWcN4~R z{{Wc!3Ni2bP_!gfiC)|g#IWG!wRB$%b!E}KTWr$f$(u1aJuqt@PL1VBVgO?svTCaa zx4BnShDOLDrq|HB+RYv3#akIH?^RH+whT(f56Uy&6~KVZI#;&KrNQIx57=Cp3JDNK z`$kA%UC)JoV?Q4Ff=H**G#IRGt&N4}ARFk>$exKos`}q9Di_3$& z(;ccm6I!v^cy~uk-P}v7T#xxxg3n2{O+F^zMP{G8 zZ>j2QqVTuu_wgUcax3Zn8Pq2df6Hl;%U`Zr75c;Bzk_}X@N{Z4pA6}iMSqPg*LUZN z=>x|RRJTCDafrqMto^F!iuP}-KRi4Q{{RHx@t&^H%i;}6$3}>enByjCKcE%wehB@6 zyanM~+gqFK+s$_F2Uja2g^Ur;-2?g8(i6PbQwUMYvHRN(3Oxb)J_Nz)<-NRCaRiNc z9Zzz7O+%?7y_#lTi{L*GczBsCG@DCl4_8Qr;p60S`z0FcfL|lbZ+6dx}Fp4lMJ5re5xwDOm zpc|%P*?m76w|#f^OK?Ek?ee0Iq}3^+I)0UNBOTJR7IyU}k{^(0jGA_b8$J>+8bkCX znyKM1*D`8%vPL78;(pzBfHu>ljNJ=OJ$~Y%yx%}LABm;W+C`T90rHE2KS4}H9}%XS z)4=-FkUAuy*~n=R1B&=+ZCCyl4~sPQSY)uCYlzVK}bADBcO$t4xnw5AB}v)sC-D#ylwF-MANjbG8k6I?IDhMN6Rq>8;{fq z*(D9mcRy&fKC|%s_0E~5SzBC~p5DxTk&ID<+D9SC9=WcnHjI7lLGN6yfa3P&REj`0 zxmI}n2^b^vtM(GfWjEL+B!SE_AL3t6sH|JQrtwx*H7{-Bxrvd72C8Y=t<}4=l&%XX z`5Pa3f5xqD!NICJga&by`DIQ~hhs?=I`ZAT-?TtN&N}C#k3&_Xc(o@i6vH}jz;HqS zwLP8Tx`^&wvab%lhNHQ$F5Yx)_~B*JbzNsr8Jx3inYblKPtv}O z_`9pLw()3I?}=wQl#k(2>t6%@)jl|mIg>!JU=e^1u>SxE>-n1AoOHi)88v@H=k0G% zo5Wh>)!7B*x|ooosmb7fD*a#mlYSq!!(W1$CZ9B3PN8!j+OC1?z4^iV3jFx}lD-nZ z#gB{HY%-z#%+r}~E}L#f6y;a(BEMMUwDM6>G8I%{DC{b$J0%oNCCw)ybYs=2<)Z^8 znspWXIJXwm_*?00W8ygBi~i3&sQS(u{M1(vD!UGzsP}%-eHL zXcQBN2Ofs8;M7I(2282+r#X>NQZrLwrg>NGcd0I&@LN(<21mJ?D0=kyv;8abdsvz| zWs#IJvpX>d>tCe*0JkQjEq)qUL~fb}n&_Rs-oi2c4Ss2A5T)){}$vFQyx~P0ph~ z*`bxWWB26tQBvLNvBPZE)~ohQAURR@oBiYcD<(;2)wNk}E*!{W8x-~jzC9^{4xuMs z^mIS>(fl0S3T@t`a^30l(S?c44*%%9X$ z^2c+0@Y&eLX1cg>8YmvU{)5)5>a3+wK-iTIR~`MSpAtSQSbPEaa~`)6CI0}8HMq*w58-` z`JLo*{wBTq_FDKQFM+gqd`GQ~-Xqn3l1==WRPq#lb)?<5BAh$!dZ)u5huU|-{{RN* z`VN|bVz)pYySsNM@vgWzBONL=0Wdn#+0>syT<2OsuOx=2g#+nLPzO7yK+k{5fEvw! zLiMH%{YdLlV-9^Of=)>Mr~$Yn=NYLMJe-~JQG&8BDt%P;HSnj# zPxvQxuch5wUw9VnH7g!iSX?&Qka`B_-;I8Ql1EB6m0`v>tBl-sB2ZG){7msr!ygoQ zF6piHtrGgeMK~sCSTEq%!Tjr*Yk3rqQ2tf>qkpGaYH@jYRaxZdeznTpd=~H? zqUG*9C#6j1l1;MK>ayx(?B&s)irzz$fQL0A>9=y6M+3jIf@}7c+u*-|>=+n)C#4}B zF>I!`EqoQ>>mm)liD3>uxo(5{Q})ZSdpUJK6)e0B;tfTBwb8GxGukU>Z{#be(*FQw zzlfUF1-FE?Rt?a#voG_-e#%3pTHCkotznIS*o+FcBSZrW`c(UGA$up>{FKr^Vqc2d zwg~X++o|t7s+g|VNB;nVbbLj=EB3pcF2PUt=Vv4E0>4fRV~wJrk;XEAN}p`Y_Di|& zR)zln1j)K=9b3iFAnvxdUF`j(}rFzbgAgZT>jw8+HZXCAbG zvK~+2Q|)9mN+|FfpV)ulg|>rrZDpY!w#gGLcQa1R$k^eBHTjeA7s0ol2JvR8qhCbP zTv<-gwntnZ2=*hI{kmBL4tO>2m;4kf;ILM=@%En?3nneEl}8!=2mYUxRXM(GMK~m` zk@C;MZxF0kQfT)L@=ABbo`I|N3-);NxAy-4grL*z8~rlL6-G}iosE7ySzXJeU&C_= zDJ)I68vB#>bNIb9qQc_IR$H=F9SdN4gH={HcSEN1v*>;AB-^vN^s16-C>%!!GfCX9 zLG?9}q8qJJ$u2Lh5>GSc5y8T!^!KgE;#kvas6p&2p4K$njbeFJnV&2D#pz7AxbpVf z+n^E=z#SBNnl3I37>XgjXXrgW%|6!EU`*%Ck-2w0%>Xh47K3VnN#&UJCZxEyOL!h> z4%AVTp8nM-VV;AE$&daf_d-Vh0FOwUso}W zsV4vq^#YKffmCkiH&gwQGq>+b!?5)HY3?nF?U3GF{i;qQUzQMmR;vCfyGu%=4syt@D-w4j=a?k74roBGG}@K`OoA30E;|P;%^)$*)%UA<~ta4*qK3ms&2st z94=U#S0w)c3~jy_YX1NYkP|Ka{qLQ1A@1RqkHaRu#d~;|mMDe5;fUtEH{!>JZuMPf z!FsF+9@|Q~FK~0vJj$bwsjItYPAUsS)&Br-rTD)9FglDpp4*iF09@9#r(iFk*^-i> z+>Sb_tgUk0WxdnLJ5J_M-h{6ot!n9)Epcs`5AQ)L{=`;Vn-S^vhVCX3uG^T7*%#l} z=~EPJfA#7uLfPSwZs8e~Mi_s6eJXocSHI5@SC{Mr5k>r4c^vlly95|bz0aZI+D$svYevUTm)`-;{otB-nu z?#4T)@^Y>@S6-mhHdU>u49mOU6`!l$rk`ac%aatbsVKnU=e1q5xnkZ$zzHbm-Jj`P zKg3I+D@xZA7|5{-Uy5El zi(AyKOZaa}Z!y^PmQnXqdgH15E3GuW=5o8Qb#MBgT>i#?22a9|hLK)h!l|t50d6HY z`H=|uSMsl+<81ToQO5!zBB%(80;-NM1wE615xK4zK4){XG2}@h9fH0}^FpyCk~;LD zE0ND6P+u4g)}*(f!bk)R=ARfX?N3)mRKey$CcRvmEtDuh| zisjU@pSzSi*XI4%9$?z#haE+B(b(as@iXY}*pasUU9&317&>8;;E$E-^vZcKn^DH> z*X8%@Z*XGxwP>M$$&a7Re_H)JvbB4s5yp)qtZ)MIO<`4g?CGcQ7Gu41RZ_WcTGP_> zYrR#~-G#lmIP+X}`g+k7jkku*Z_3`@o>I{cLV9mM(yVH_tngbYmHzqkOT0*m~A=$!pC_z`X52y!r!Lfka3b zfB?V)(wLW#S5%XJqiVpAAv=g3{8p3k_iG@LJU8v%J5>ED^}Ma+MA*nGD6lO_3xn-c z6tinh$O<6Ne;U@)bgQjq>|5JMx!bAiqtw;yR_DVX3F5rhd|P_3TiV<2ipjC&U9dmE zr2O3AW8a=DNXMIfjA~6md70YIx1#(X@QufgyjoBQ!5UVW#}Zu2{5T$`^c?zE#C{|A zi*c%J+SkNC7NRBn;bye7=^eU%3aC8*82a_CFN&WO^-qjmC!5ATDU{l1R&3wddRsTi z6nW#0Hbj`K7toe$9Uh zFMcNYpZ*pfiePoIh0peNoXf+1(?SpFUt$LvLVy4R01B_BY4B-QFxlF|u-n9;5fESi z1y~7@gVwp5x!RX1uz|=pBQ)j(j_hF27jGH-Xk79$nnM}^$69)pUc(eJbmo!6D8S_X zXaYcTr=DsY^X*BN8*_o5N^>sp=xGfN`@o*G(-dyw@Ny3kF5d}fH?K1E(cy} zCVY-K#Ujbi%g}m?0Tv46am6=jBc8O~3t0V1&7j`ZT#KD8NT-JD?4k@Jv7 zPrU#;A1|pi(x++uR0eFHmn%qFm<$YNfDu`v;GVTKQVxcz$iA(UNHPfKfGkOZPkd7g zMx&flH|=6OQnCW1905S=MipCe2ZK%AQy(cK)|5_&LYyf2Q=VTeoN_*tg2Zq0^x)Pv zi@Y&q<6jP6Xqt=*HKp7}H&z)OjP}AzbJo90{{U)#fHvP5JRL0daK^qPu~PQ45sl-z z6XPYaZiHW(>o36{iI$!z(Cy`pK*&@H z6ddetU!kvAn(#&yP=}Si;se*;>0gw;v#-ZnzX59TT3kv^w4WhTcxBH|t$vmKH`XmY zN2SH8+T9D-rckLVIVeB6eJd$O-lnOyvDsSNc^jFVe6Pzludw#3E5h<{YBKp?z3P^w zcO+Y3gl?Kn7u(#{JD0K>dS3qE*+RurtkSRWkM8|VT!IC+iDOU#D8K{Kps==ED0Xfo ziOV_a$JVT)g#@S|p+)or0ZVcjd8&fcb~Mn2a!+D|dB z5h?vmT~{Ow`qS83k>6bXlG)_s?fav-J%wl3+O${j33;2#X=Fa6d|H3vnU@^|u#^7)foWa&a8#b&wdX!GnMa2wJ(ftnAM})~hUQg16S3DE z2rZ=tnB_+OY4S>R`{6f8rukfsfx8+_Rur2_pB+MEejL=Kt>&GEMKw%2d7#FRkd?#FWF>m*dMxLe(?0GsLokJsU(~L zJJl()_P=2jy0cspnDN&&>0b|gCo~o+_vnXLU}*elFJ*D3)DAQ@oH2C@joLu9&WiV;*m`_clHn{5;jZ zD)`37L(?IWd2S>gbir~XLVjmsU#?#Yz8dKt349f0;r$^t!vM><9LVRZAFX+Z?790r zS^O06sJWZ>`t|&3_cImcIL^WJuX1S%4ww~{Szp~ow4&F%%c0Z(NbV4WR8msB4o_NR zT>`~pP9R)y=ZkV<~2enk*OMT)q*wP0IP6^}IfZV=c2O!{jRDOOk3F}o$ z?;z)sNYWL^>OD;ZTMdcBVX?QiX&OgnDjVhYrH&un{o;FI&>Tic2yszio+0~W=nJTP z5xtZ%w3d^{E>qC_r$3#3U1|(kMa+T79fm9R6XN%WU&bE}JVB*Lkn`F;&7&u9@4DeR_c84gmH}eoKmTNeL9sJa_V(fP^F9mQMt<#{-H_x?dpAnPGHq3g?>nrvaM7(iE^htrm=mIao1KpNm3Zg@Bke>YCRfxou;^v_Pd-YAHt%l zO3!enO}J6QFRAsbcIseIjHCq}soKiIS8Tg2+p_Hijyq0jb%H4t5NdEwqhqphEPL-r#eO|^BsmY={{cpsc9rzpI z={22paRTEB9y2Y&w|ay3R=(}m)&N2w3YmLqn zV>JrLuo4*Lj`YPLfGluFtx070EJ!B}Kz}K@U@4&i1ct{QX!4dt0gmzBfUz*0Sb{J` z21R_4P>2u>slfH83x6sy17uJk?THT6BO;n|yb^QTquYibK0cK81&(u?07A#^k_LOy zf&jq$>7fn|K_ZQUBb*W`0V)&e$E`DHVsdGY;l?=`?N3Bh0OyKeIT&rh=qceyQG5MKxpa~1*O7-`srjL4KaUV){{yAI|)}wZ89k|UaTw@a0z{X6LBzHA5 zZ}JAsG%;iVu+MsH$JtJPlqFy$W@$0l)J8^AmZgxLhhf7Ctq75W7C$z9X`vT4{J9oIiPneFU)c4N~#o*oK#BK``l9mib>qxOi&_IfIjw3DUh!@ z_L~s~3eQ6s2aCtNp3Jx)W z=}sF!^!m~$jdS0T>S|Iq01rxT-n`JL-hnL#pa=qioch$FZG#?KpL&3>1Y}c#smUPyXaZSfE(Q=Nua3WLpV?pj6B|kV zU*U^I{?&;~-P^`7=cxW6{?Pqv>?J=hCyH#Od1V@%s;g)_ron-D=kHh#+fk-EG&3o>OBp6&+TdZ94Cwt=fz$Own(*J zUgp9z!p(vAm;I{z_P?;Y(l2GUyM^tpVRDZgfU7AUqPtU75yX+>C@w>vmA2gYM!8N>klE=DDCRsfiNQbY_a60?6mJwIC68?6=0d{ zMHcKKTYGgtL6Gw2)sI?_PEyicIsPe6ga9*)RC;8&uty}F?96`>Xw)_@ zO5ZQadBsrC!F@eJ+<(XGu&#}k!tr%ieiAf=b(OH^Lmn=6B9vUgw5bb$K?)_rAvo%YVR+Z}ZJ*_~Xa^ z2>1n|&8zsAQM1u)e7&OCNb@DfOep1zXsPv`A%3yHxZN6p2D_D>f0kF zqvnwV9aF`+(F(I_T9woAs0%X_>0hD0vUlve55l`Byh*LE+B`vd0+9=-BG?{C9`&XB zHh#)lN5UI;b)7{20BCrQXn=N+L0f!|Re#35i&z-o=ChpoBX~mZnnM_|Kmy==S~+zGNfnLq-m0vRze#)MNz)8mRXd?F2Fj2(AB9VE65oh zl*PMJ-!}u=fs^6G@$)C3q%tWd0FT0!HXsGT$E`V|5{v?B3iBgdWNel>Q;WlffMZf>oP+~rv30Q*_73!;_JgRcrNbnh#pTkKyU*(fDb=_ zIIqvUgll=Z$RPJT*JbeY##CP_uHTZS$r^Eg*@J5?;X7fIwHy12eY>1yhUY7;L2Gv?AL5MDM>+f8= zw$$I6?##@)*7WT%>0r3K@?A#g_UW9vSBECVPv! zFSg7qtsgS9vy3TK>yPu=x@i1I;a>(!A=7Q7*Yz@XhR4W<>UjSEJ!?1O4bo~FPxi-) z^@ucGHq}}Dh^F%6Ku1PnjQ;?hwXA8yZga}4rJ`CMbMaU9h;I&C&Efw5hc?)`KP=kJ ztC9~-kRRrM0bd^Y-^UhS0g~rb)$dNLuFOlBBz^2$^WUk>LGiQpp3}8WW={_MIkY;9 z$o^7I6ldo?-OX-%5B-sEb*tNd6?{jLt}aOu!={$Xr*XUdD@MtLKJ$tAXYi-RKeWcb z9<|~*rm^s{HdP-XrWxo5rG3}oe+}tg3(;-#ttQ$#TR0Rd#)BW7I?qqH($Y9>U`u!+ z8&WvN03S-Y*^$lx413o+pE1=K^6FZNyv&s2J&iGow1H2|dr$~f+FNT52&kGVC1Zn( z4hX5SNtXU)Op}5;Qv^YQW+eIwWMu3mzGoh^*{&HFE-0|>PJ}2MRN(fdE>ZD}4{C?) zAda{vy&;_ycb}Wn27wca4tApP+|!FO1CEtYU1KDTzJi!9S+X;XQ?QH3-@}R+GByF@ z-l`kb+0*9q6xF|MaslVLqQfpTl^=K$=qY^GRpgWFR88t`$3lCK)YZHB`8*HSorGUy z+;?uqDH|soj8z}DhGGr^pL!;_0Ri9geQ2;32~ogd{uIMM7!A!(`)p-Of)x7GlHH|8FmE+ctt^*#p&$Sk?BF@m+&0KDAM2z$YbzF>X|xZ^nITfv4tSe(=UB6q7B0a(zui_Ztupz|=`A z88-kAahd?W*9<;f3Rbu>I0`xp^TkkEWM)?+XE@u68b;l?v0qA79nBS$n4IuEMLnhp zqmi1+Tc$D;&&oM0XfSw#v56+T_{DRc#IAvMZ!(&?H~Ph0`Xv4zBwUstf$zHmHPAJ&xgJr_>H7Xrfa%nSMW$Nxs+~CaC=w7{{R}lV2xwL z#^Y4*Oy^Xv1hKM|fhrGU-nvyqEhKZga*o=b%lkWj!Ab0HE@QIzqhk7$plyd(wpCk* zvFbS7J;4Xq*W3OS*8Dy3Q%_x2!`kJSifm<2%#g^j-9!G%FR#}X_!p|_H{Kt=lFw1J zmd@%khK^M#z}G?G--;e2@Gb0DS}w11V>DzGcx7Z|$Ds!_fp9=o~;HEw<@a)j9 zi|p;ZQw(e6?z~J%jP(wquek=k!T59k00j{EN#g4v=illc6uxe--6z^D^Xx`&KLxB@ z64B^rrzHFT0AJTrqSWurous)8=V=@cm1fUtSwMgg5kTozX84)mPl=YV7L~5}ds9L~ zosxM{um?En#%p%c+fKSk8MLdIvB>)+!pgs?AIhhbv8S}Y=;vgTF%tp_Pz_enSzk}Li=*4AeKeXTqq~YNNboR|ZK+!Zg^s8?&O;E^FEE3JJpKdCY7@bI?ojFsE ztDd>9JMrTW>IVc|6WdgOX63WNUu9wNMJ;*?S8+B1dGWwa7% z3d;Al0Pju9@)ZZ=9A`PIPl*f_3=kNJ*(({r zK%nGjpQUv7-xRzv;M*0GS@A!H{63)pF~U{l9C~N^SBH4>_VoBa;LTyLqq@HFZP<@t z3;U>;j)3O_9eWyFvFayMO=yp#{5$Z5!_D2OUmZq3dAGCsqu96q0IIr;TKmDD2(4~4 zom%%z@Xe~k737vhSC9^Ro>=<~n*6}{pZ@>_W$~u51omDF(=2>9YQU3+P}JjZGwf0S0I6S0`q1pP zmgbRNAdi^73jWRhDfrWQ=2^q`9VRzXHSNNcTzA{*YxG~?7r@U8d>PXc-$$|r{#6S- z<8vg3>*-xRm7n$`Fj;A~5yNVykbo+v_5!wqo>oo>Y*rGRmWJ_#o!+dR&E;e$>Dr$a z(I7D!eX56;(h@Q=?^DM+gxFVi(z7-$O+T2U0O!`LNjn6<1&N|I+P!h@N{+GQu;<=^ z6}EszerD`Rrj`V@eootuO0e40c;EDS?VNO8vFF zw6?#rmfGeOnh8*&#Osw{dV(wBuaAGQqiR=k>fQno!cJaIHdesT`vxn#E2GTX&7s1Iv+cHsBg)%oe- zp9Of6z*q8GYnn~opDbj9=EOpuqjWWpnur7tb~x@hHKUJIB{bEZr|qx9-x%on9zTo9I{H!|SzX7zbiT)zeRYj(^cWxJ^$xjvC*?!mm z02TD;1UCLU)uN3?02d9w{OTNKd)OLHSpDSDG+4BGOT1QA(JJ670g?Ec#Rrp!rAKF9WCyFCz^*k&T+|RJl5163yRrz>Uhbb*B#BNyk!K6 z#a|fjj!hBxyL4fL?*wtq39Qxd`~d97BBhr^w?qTW7#%T1g64wj^IS*rsSQnCQuBCH zr3KKt0V=dDu(xvBfNs79fMS z)EZz}c_cx^n9nsVa=Rcbew9K=PXS8fxIJm>a20mvwFnEM^|Aw>paPIwZD%>p9Svue zB{y&69tLP>w?709DS@N*kmyJoea%;z^v19Q)QA zvfzyKN@Xw={ppMzfEq)&(3^P>a6t#HSGY-{=m-^?9rT#mW@iVzSzD2~DI}3ag>pr> z4!d8HIT@$PCR7llcEB}SdxUYy06k3+-X`oW2=o-JGJLo=ByIK2G!Truo;x1(6lOUy zoyLi9j5^?S;)?+-z7%|boSwBT@)(a1?mo3rE1H+y1vBgl6P*gdUe8v9& zW{10qP&}f_KD-J+)X?(;DhT<{r+QI$(|7L2zk0|rGm=3mjtK^#n)_gIa=>&X^`-|v zBn6iT1dQgQV<07)3=cv(R!P*A5q3G~1W{tsiHl|{S1DY zr!*;qQULYmH5k_RC=R7gJJHk)zPza!?r~8OvOqKP^(Qrsn#v!$CNbCEnl*CL z65yE5aoU)T{{TOD0}46sN=_IM)rI!tKl0Ei>P1esyO160$9&K;N_qBjr#`u;(%`cK z5rEG_SrTgz`I|`uuTpwcGWdoYe6va)*f699``rKuQI_VP8w#d}Yurlm@*ldxTt?$Dj6z(QWgwZe;-DgsY6)c4W#5 zN9RwBJ_-2iz*ETN3$MgIVn(ZpQ{^~kO)&erm6aPYY54nYsa z&loXo2kfV$FlrOVM^B!+ zEf4mA@!yAl7G5H~i9p8GxOE?)uSfAW!M_daQZs2j6V>$YI0jwA^)=_$pR>1#-b3~) z3H-8tQ6TMAcssU@4tnjmVf?G4 z_%HE$#Qr{S6sEhX+-eIQkaQDD&$YLXc{Sz{{>$Dt-aN}D3*P|Oru-uKQ>y$qhg_Rd zww57vZXyMS0r|nj9_K+mW5E1d;$24XU(}pZ2$_R z6~fAd45-gz)|KUs00v3NB${Dz;J9xvvt?9v^`(+SBxh?39&11C(F}%ssr%z)<^sod0r#qg{wWy7I&{r2$`s{WdJ*eLX6wXQf=&ev%7I3C z^&RS+&BLQ@`BY~FliH$t`H^xAZH_)s&OVim%`A;2kTJt#E_lhJM^fK>W3^`+;^RMi z{h`vLp7_S3Gl1Q*+L)V0n_TQ*L-0o=(_*@eF)YIz=RoNhg-E*&*xtjd}G&s1YG&+r1(LvbtXJL zgET3ht!wI^4mE!mT!+25nn2uuNgxX8{2%)^Y4-A?-9_g&UPnr}yLB?0RJTXQuzWJq zd{?S4(X_ekp?3KqE?*wmuUGJI>{sGVb)q+|_C#!FdFH<2@SnqtC&R&fizw8NciG8kXXY81_s0etfQ(R3ZP@AD?P~b zK@4(v8R#m+GlHcE1&32g_uR6?mtjr{WsgCMj7$oW!xapSBLwH>?N#0dWD*8#-TrUs zNJz6h$+IEVk8B<)w6i`rU&z!A<;0i-1vE3Lj0~0|)`5>^=oErFp6*E4TO9B)O^N3TvF4sq*JGq7blcMjr>hFsnURobKSsSUwq1x7u=tjD;_ zyGP2MPI^>A;ZcSgcRAvn#BO6X&pmNaORPwsw%(qNSd(fQaIznpvBwooYvn52+Z^#u;B-;zyU=7{{{VNb zH0!`|xQuhp70I-%lEWkqL!K%NxujUZBm#RAO5tOvlf>U>QWrR0;8l0=1{ZKAcfU&G zns;^Dq?JC0y;pfA+;ZfvZfLmdc0b!n7&MZVb{)EXDh2TqCrdgLULyw96C>za=a z%r@@H>w(sltQI>#;!>oI-zQx3sAcg~Qohm-(Z(y4C}mB&@I7iM;+6A`G5kKX1&*d) z62>M_xgCkk2gIYyU`NZ24r`DwOssL=x1}Fvnb6~QbIwIDve@g}#G9kcZIaFhLw{>r8e!HSt<)1#k%T?@1?&T^lNK_*XRsoa!03 zamNOxF{q4rn9CeuhBS>w@pa?2;>x3+t||uc1>MS%<~ozWIq6lF$)#cd`F%}Dwr;7N zpn6aQGhN=x9t`Yp)aI(JGPotDe@qivzuE1ByYa`ZDAE&VR1Z)(P%GHQT?Ega@)zz7 zX;Z;AvlZNjJm+z(=xrL_AkBsJsgeg_o!K3!XoO|kcn?Yh69O}I&1zeCJ4=A6Hql`E zfl{PwFr%Y(&M8^sVS|-X-1nv}%a-~^pKG{_SdKr0DB`se8))NFkVl_QoC@dTnq^Kf z3FDfuCzozFHU%&@yx0_iAdKX6+A3t8)j`d=ulxqN!z82(V4q&%ogL(e7cRv9bOnx{ z+G}tLD5IW$bQF`^tK|_8FC9AKx%a!=#B-7Q(i^8m!!ZXK;)|1GY#0k{Rt&?a0=VCc zum3k<%nDXftuKc^@(Ds%8YI_==FaT+iBXBjg97<)>qL8-Yfe*JIguF^f?%A z+~j*v7p|h3e6Gf%cXl$v5-^ONgFJgs=FNuSBjbUASx{MB+!ndi?qj&RbGh0$!YMw= zI#t;0(ZD+tW1cCDak964-@9JF+{H$8$RuC&NPplxD$)@A6=BbM8e*%Ff$i%`;8Hp@ zn-B~OxN(rRDA29A{nF#Kt}Z12jx=@9u?cSf%U8Y zN14m*C#Kv}ywz0uo3`=7&=)EH0A?!9k(D5N@lQ*1hZtj%>T_4qWZ63#2h?(EIqc!N zRm43qcsMkGuGS5bRf$qPXjGCJ2*y})c&iXdAz0BPVd$)Rt43Fmh!NmpKQ#lnT?60( z&4Ji)PmXC7$}?|XqX#CX1SlPe;~i=-3yFpl1jceY(1a3Op^z3<$@C_i_NcKHPzO27 zbrmy4Kr9aMJ!z(FV{#}S{i)n6M6EhPa;nUD3^}0;semGL&-%=8wPB?NcPROnk%NjB zX5^8cKJI84u+xIX1>?SIxwvRqv4ft2JPJi{01g0V836iv(ijtxNcQHl=wGQ?Ti4p* zBPonyBz62ND)M%0GfYd7$j9BSf}{d>U}qWYQA=eW(Tp675=9mlTbx#*Ze}hYc|*{x zQisE84qOQ`G05p%r7w0Frt76>8@(-1d@b#fsT9C&Kh$LZ+qtU5a=qoC*Ah!daGgej;8v)5D zwm7Ad+hZvTG1n%W(49d^3IR~wfYPJjV1w77rPxUa1Ewjd1~R!g&p4oCg~(>WCmjg% zsSJl8j=1NZDhUFQxISUmC$&ls0gpMr$Qh<07P*n~PZ;A3N+&3wmcpLc%{<#LQpY)_ zXUhyig2Rq!SS|%uZsW?Hf}>UvF$_wB-kx$-FV1~CQyMRr9iu1shcv)RVnD_;%cwaA zrfKS%P>^zcG1i?8y8(^mn4E%oikWO@XxDb#!=JnbFH!ms1BJ?-2V7H3Imyle?M^7c z_LWio=?5m2Low;NdegY9Ko@2J+(9@v=M>p3-)=VrImaV4FfKqUyc3RkRB{P|?+QpK zgN`UcYmxl+=yy3Ix4j@HL!Lm#T+pVrlz9`B!SU10Zuwh6gCh$7vqCnqXCFn36jmN{QegACrU5L9HgY zW^Q)l9AI-uZL=XPK3s4K%>ZXaz;*g`rh}Od&`HMx^sOea1-Suu>D{!D0QIUVH1}h>2Wakb%>p_1vvN+=3EbT!now0QT49AJ4o4!6y1*4>r9p?NhQwSpTa<+$TvAN zrH3jNO6RzzwwwqHf?u8hu9@tFAO&6t$o8abJh1@_ZXDx)DS^!mFLA&?%uaGeOD>nW zau8#nJ;ih)#pC;*Gj>oorlci@BMLcT{uF@c;nIWk19FV>OgdzD3It#SpP7wxa$AEa z^7(9FaoVN(9@G&83a`~b@9Jr=xywPM0PN-S9xxMcdTg3<1NSzO$lYBWN|UY;m9TJ1 zbQJSr6K6Oit}=PeATT4+!N^=3_fj!WO)-}vU~G5IZX0A=0Gk5ipr@olH)hyC+k!L6 zrUyS2gUGu=ssYrVGHTN4ps65ktw`dCu0}w~;Ps@6OGal@lzECd?}|WXErPO81UvM$}u}p0^f_G=j$5Ftpl-nslLhf7|WRMRi z7XatB2*9QnIq$KRzymq&PirE$3ZIkg0DI@HMArE% z0RXp5P$HTmG@Gz&m0bb!;BN_+M$}!a7yr7vhmG5K#pjo#^m`=sX6^BJ6o9sO~SIbryrjg zsT_qm@e6xp2YJU_Gi23}rdGjEldoK6mRJ~t^2(|Y;m0%#|JhNHLn{CP literal 0 HcmV?d00001 diff --git a/test/e2e/setup/demo-only/podinfos-production/assets/podinfos-xxx.jpg b/test/e2e/setup/demo-only/podinfos-production/assets/podinfos-xxx.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ef493b5a98bc1b0902b27edca9c7c48093d1f782 GIT binary patch literal 97275 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/nvxZuA_QV?!pXqepA1SV5}YPVS8h?o$}f z)hNJ^o4-7BRj?(lrLP6}ZtUycRhgLCD2j zyVW$-PGt;X%ABX;lZfIFVF)zi3{&1g3_8XmoAeWR!LW`l1Lx06JV=9nCx zosWM^X1XhZta!2S?xTvv>r0Kin^`Uo?^OuMK3VA9diTw0_`Bj~!;!2~*sEO;&kcxcYwx-*lL{c5U3OWk8zT+y_< zmswISj?)ZbWQ>v&l)xAr2?P1no1GfZS9^FC!A-0rB!gor$_{W#kK)ccdRGTFvGGz( zR}<;FCb3{9^A_n>_ff^WAA2E_0Q4iAA6oQSbqOwP<5(e^?aCX>@=&*%w;NQiMZw9) z=D8AaN+nBHL2cn(Ge@>`bduuoKoQI%2jyZyoSxpanwN<*?+!w3P{nb6NZD-=FCip* z4@1+|xotw~`u_k_lGeiR8yk6)LT+b($nP_f2n&(NZO7iWCbQOrwvfy%tRlCKaWruQ zFPObsJ#kYSR@IZDdW?@y)vsP=xYJV7BAdc9gCj0TAc51=ipjT%^HS69;rk?TUaWq0 z*$Bvtk`rrpJaQ_|p{(mVj;nZljWP@Cr(d%n4hgqaDpv!5#~+EL)<5AYmS`c9PJJ05 zPz+bLrZIE~$XM8CuOpLM^0lxnOP&T~(`BB+U0aK*ds)F*%${eNfhF zvj|6$WZDABot-&cd-tvbSM&6*2H4ug5B4qA(>X|_-0IHUp?>R+>sj6wx`Rv9kT$0s zy@6sRhW7VyR!~`_Dl$MLpT@1FJqb<3>9ttyBfOee?O<5cU?LG1IpgK`sNjt1Dv{gi z(ZeAaidKBce!Yb=#6Ba^?qjgSi-*0F5v`jIf<3u15;Kev20iMey4vXv2#O!HS==$V zH!)g;suU$#Xh-j#n0?#j<`ET}EVIZ_m4`WnpfK8}`m zvRp-OTIJtnC361&G0t2Az~jGqxpiqYaQRWAOK!&!w9**L=dxpxYP=d{jg5p>rb(~- zkk00546F|9sLAIT&2_6i8A<9?(r!0u659E%ZF=k_$crSX{_*>u*0t`dXJYeS>mlxz z>@L{iQ{^L*fw(B+0CYaJQr}gxwn(ienJx7TnH|ZNOqovXsK;!M-qk1CH5l~?Be0Gs zqj(uqKi%_lk+>2E9P?c7`9|4t=I%RYK1zull~qXR%Jn{+`}$UG!|Rq8Yrf!LLyz8h z@F$oQ^1F8MIO2^T#Fw&Z{{UsaO+Myk0a@(+?2JC}W9^=U`qlf5b5M%n6|&R(*xabP zj!)ezkuLzN^Nw-(R*~#2^+i~1mqD>NaZJ{*NO!zU=2DqBDgfw7?MngHH7#~)OC2iA zY3puY<51KX9D&rvotO^ioYMxCr&`A(dV1Vi7E4H#N7-$nLAZ>65g&J+ zL9Oi?Bc;lUvF-B_QGD2ofs=yy=s3q7^`E2a`qkRrsnaG|6`5nWYz4RgbB@BMw_Ck3 z)9m*;vtLVi$YhG<^&yVlT<6OK`fbNJdYZ$F_Yt*J&lG)wF4CW)6Y3!21Z~<0Ppchc(Eh;L!C) zwU*ZHH1vmHxiB$^x#O3{17y}z+l-tT*lBkV+cabtWn*(K%u&Gm42(t_f-|2%OQLd;pLKU8sXX3l@4O*KPIjDh#~+nhw$Pi!QQE<#N`mU*LX2h%rZ52@hR0GpD{kAt zV@ZJ~@fFNEd@?`otf69NbzhX9$E|68sGA(Et^LK3j?(`1Z*}S1&vLeryO0>2ymce| zYo)dDuC!zu=C`oAyMdbB%wZli0D-&`MoAz8Pt&!J4{1|ePvPATTdT;R+ZEm1gA@jd3+M;%2;lEqviQ>w$Q8x5@Wf*IIKtw*|CgGZ7fJ6Zl4#^dL9`LH4B-AX^WEqHh$EWko3yk( zhXnn^W6p36I#qr!3F%hu*W|`A_|+6{Ip_M;PRFkTSg;7^nz@n)s&S3Z3F98MZ09SQ zNoZ)dG%fiY44RajuLmR0R5sstt}1iKPL;#d*P%-5{Z~CbD=AQK>B&51w{IaIWD@r* zRF6(85Jm$7o`$+1YX-#fkK8w>)~VX_llgPrt7&ovl3hC1igx~*rx zcUnR6r;vT8kKBSd`kLl0ZKbo5Y^YR^TGmjKG3HWNLjn~$k3U0GfMSo>^r#{kU~$KK zzin>!a>op5xRXoBi`zqc!I~;+SJAb{%VdN+&7a{Mk@$+=)_h2H&35MHA`nDQ*x^sR z9Akx1^c7a}b8gzSLEJhi*HLT_Ayw&`+`-u%*YHLmdE!e*H%O@?ZGR}?h{kXY zdo8r7;cYti`y%$^#Ooc*a$C=IO0p(G$c>YdtH}2muM7A`IE!1hX(sX`+&4-aZo%B= zw-xk&@b8E2d_83^hh~cNR~Ivhuj63F*LKtlGqfq=lh+xoDd?Ht;v&?}me<8vOIgVa z>P#-AUo~ySC1cBcyEpd$5toGlzrQv!Tcwj~HxVB`TT4yRtWgH}b}{V09~7q*#sZrHtNF5pQtDId>jZ z1}la=c+DD(maE{6S5VgDxx3eG)dQu0pUXu9ossZxLH$6iN%f13e?nbLSZ1};L{X#` zGem-0R%ZE(lH&v%o_gepQEFW^3wAZ^EsP!A%tG9 z`bglE5+CmKpT?ycJ>Hz~Zfkk1-XPPxqcI5m8F?7TemhaVps&=HEkFBn!nT_IzOiYf zTLuqtVwXGaT}ym`f-!@DdUP4Cvqsk;@b0^5s)()UiIPcL-YK_(85qbt+l-&Ba8Kc# zD*3Ivu>SyT^8U{r(SqtZ80<(n{41o=**rbxL28>VU-xfvstGHBovLy`JqR7?PCUt2 z75bNaX{g)lULBSlKHA^x(?~AkfP$=7Jg#^oWal2Who*R1`p--IWO{bX2;~u5nI!?4 zw&bbF$okctW5X7jy``2Yr@y}*#$WE_K!Ug~-p(stiG zGkL%k!d8NMlGfZcWAjMSw0Lw9+ za%&UCHtlt33E+m)ax{Xv-bcQOMU4!(=3RB<+fNZMLen zliB|Oq|T6*22vE^OLtN;oOI&1Z=~?`^peeasGSznsd!_x7>)~uz*vdr^e#`cj%nQce+i5)U{G`9{r%6ia_g!@n$)Rbt1Q=ZqPa{1m zFIv~;(v}Ex4FH>YPoH!4`;DbswgSSr`Mdjdt*c!_?at_Aab>@GwsVF?9d|E5&1X^` z>XO`RR8&?s7baD(kX$Ji@iV^ERSYu9qmDr9S_!M&9XeYpiv@<+kv*Gk4M?$PpHzla6)^{C^$f#SJ}JApe!%^6d`QR+R;X+};m{c&F^?8jw!KAIHb? zrxx1Kmb;d9SnX_Nj>_fKAp{02;Im_eP&%K|vUL9d3_(4dmYRl)w~?jF%%d3ZfZbTnI<*8%TCdCO+Nn9 z#9DA!wlv}^xQe`uA>m@j-DNo>`&G7*<{G;;r`$6`0Jc=DYnpUCyGn;vKH}qhVlW0L_7nI?OduUT2bmf$}wqo-XphN z!bcioATti97~?s~sNvO{ShbqwI3C6rop(a&QKLY9=qHo)tlL%)-djy?shg-SVEbE5 zEz22YmAW#oL4nkEsVr=-bxWq64NBhmC5l)U-rpscC+6LrGsj=8bgNie>}-Xac5xNU zC923Kl|fQx0Pv*y_r+D$Q0lg`8#xzGo?yk|3a*N{`Biswl6fYkv$)l5G`m}UI>uQ_ z#`(L6RUKQV=?Ungfyccz8-lXM7Nrfn{oIjeYsj1=2>tQTCpZ`$=BuWHX!-NwR1#ZF zJX74I+=&!nrbWV&jGjHZeMNXh@_fgd`Um5>+}x@}0`0>|8EKTnhRA@OC9i=}y+r*>q-=qN!0*wl@H$1a=h_tF{yYjyqJYxH%jVTrWp*(;}TqA;~{a zddS*Uy7%c@Wp^Ha@3mw|LRk*hN zr>E4`kX^@PS4q+J4-)G3dRCWe_m|g_h@gQ7-eP;6z;!0QN5oowm+-?zZw%>(ny-mo zOzPUu8HU)8`C$R>*~a7B>s&8{ynlb-cJ5vuhqNT}J%woK z+F{Xb-sWhzeq9>%_yu(o|} z5pgKCywofqEh1Y)y9@i!CEmPc5%)<6B2mj#Dv>vcq;j>%lqn>0I-s z{x!KtqcS+|^-UJu*3S8DWs2OqHnPdO*4@_w%gN=AF`DBNdz+|!!E+=Ujn$!Vv|dBH zBC?WM6+!#U{uRAzJ=L6Y$M$&qlyfUwH{Y3v+riFA^}wtv=ru{T2b$i-0jK??78`_@ zgPiVn&Pwe7U=F$Wr3rItCu7n#G{iq=ll>@1eG~jcw(*l0B&3F*}@&70;mb zt2cUzPob0-i>7GMvlDd7xy!iZZsU#t0Qam3?KJIb^7~x2)Gq`PGMMzXn5y}tV?J7v z4mxMvmgh;dy}X(YM$+I|T!c%FHsl!Mi9qG!9gj?B+NH~OJ8NTK!=59zxYYFxI>IZ9 zcvX?D?j<0<%0^_}(C+K_)czyzwY;XvS@iz^C@k+Ym}ARb=R21_-rdG)m$ScL3rTyV zY4_Ii*lZIBr?+7|K>XW8hk`i#%`?K@D%9^R^r%0#Vboqy^G#?U2*4a|ECD;n#sN6% z>sd}d(^b%&otb@YS6Q(J*5$MTDLa;F{Fx(Q-*iZMC#F4lrrLNbP|>wLXw9fMiDM9~ zR?+!_5b6$C$Vn$~JP+@ED*mgZfhoAv7woYTq*_j!l~_OC&U;oqhpoIfhDeg*5l9*< z$s`44JF|iA205Y=v~&|gC64lMKGO44kHfYx`EHjoSR}HA-HoPI`Ir)MjCb~{PoWEi zn8l=6HkfW>StUta-CSLTIBkc87{zS=0K!Xmr)pPTCeUEAODSjBd3UN1&H*_Am+tQB zay{yYi1l3)RI&Xm9FbRv4b=>7TVN7;(ezJp?!B2g>xjzmKw2@j8M+X3x)pxmSfb`a&0xr z2`im7-k#cZznQGc_E0io$&Gib1D&dS^X<(?r)joYtTI`pzndk*o_vdu1RGcGsU(A* zoiSD-(vfVM;^zK6>boPhxRCRXiles`PWs073+p&Sz`AUM&5)Pe$FN-1$grDOnj5IC zu2Vr-*@{}-s>wXSran!l91>h$WPy{%xvbk_$tAV4vc)CF z<~H^g*2}OAj!d0NJaJa_Jy!HxI?Ja+WWa&tv*aQU2_EOKG?N9P(Q2B7%$^)8X(!q) z))D3Ii-lv+0O)bo-niWsEn7^pv|UajZ`w`kE5&A*wQ%^CC_yz%YUv2T)IOs(C?Z`KxDJDYo(n|sA` z_Lh$U9SHll>yue}l;!MhUglQObm%0+bIeiqk~u)VK|OPhII9*9rnafF?iqZD-!iNu zhHf*tPk(x?XW|RVEc`<&X_M)#4&SiKN=UZ=zX5^B1o6dORhEP|&`m;^?*Gs84UK-C9~s@e6BXXc6)=^0978^yGT`Rq5{p z4{HdKZDYPbM3*E+jkIOop2LypQ(ih=ByVLZHN0p%xfw=W9x|#rDD^$@LV9WzE<Z*|*yc9Gp%_fH&!9k%ac!*|n+sEvLI* z$#!gTtt=9O_KB1noE1I)071<*^HbCHvez-&P4+3AeC%@^C|dv)JzxAPmeyl8d{b|% zTEQRoP1v@3yO|zWS9+u1_A- zW-kt-Uh3AD7O+F8ExeI5tTN3TVT0i0ZVEUf)~#vY0MayzOOtIq>~_W$m3Q1LZgtMp zU`GLn91gXcviLy6(w5g)I-t^`vTF@JnPpp51Vxp}QhJJ{*II6s7}cY^Fvo7CT2+nC z+?*8(I+AgNRs1b?t02@;_f530ws}@NwbOGF`K=ixiOm!d0p>} zcM(QRsT^FQ4xLY@>sO~bZS3FL+Jv!3aVr^q*5weA$B+vF>@iDWHI;&D5ecKzH3*$# z628?~fd?^=I3lfXGsJ#9+5LxAxPv1C);R&{4&Y9I&!v3jd`9g1`q$Av8-#M-O7X<7 zDYa3xUwrr?pdDhR1w&tFWBHGJ9w1OJddf7cH3N zcPG-UzFAb3#zj+uDsm4>xULlBobg<(UEJFG2#vAI=LCAzT*-;S3y^=EYN*SuM_SC6 zi86l)2W89y@1ai z^=*D-?)+Q^Owk~7F7wKdp@l#V#XPS<2b<&%I&0;^66 z&bd5dw9%`al5YB*h2W3$Yx^l2;4`ZXb?aYCX_hfqXqPuovm{r_(%o9!{I0uLfwuz; zPh6V$Tf?_ye3t}FeN z*`SuOY5q>bEYBsX#;lX=V-Y_n1pfeZ_8n>V&<_lxw$}35&3AY=V_X5ujLf@25I#&} zIUTDhmrd6!bf%W#LbfpsYBAj%l1`sHA&hj!NEqsIP>b}su(#Esn^Z@YNfz>2tdh=m z4Xg%pur5As#{kxkLy2p-e)q-l>d@;D_=e|Bylp-yBDuEG6tr%s=D3(gB z0)TV6Oq`NC40WzUR@SxsE5+J6Tljj~+rw)wlW}((V|?L=%d-RW?c?*UJwI)PmMDmIL>rIKra!1EyW$y0C?wuM{3LQ4c)JW@7_sS?Dd<(RFX!w-xP|!ab)CR{cEJr_3OE% zjy+dR(=`EeYKA!44UVYXhaXISH2H3|8qwU$zSM90K_gk-L`CAGW@bcHWgOsi?Vo;Y z2T{=Pbc=Wey|ul*jM14RpI}**Lyi{*?vc`+u6R#O)Vyh`-P>wwc@%LAhKf0Z%L@hN zN@tIo990kPD0F=zOx5Sxe=O1qX>BE(qUUBegU&mCRimfMm5kfFcQtfsR3LteSW?Hz_2-O}pWbEgVPt%Z{M>)@9P(BzE@_=vKS$GIwq#R2qh);3&Q3BgK_lGMTGfuTuSaQW-Xyr2OjIz)@~C-3E^@yw zAPxXI&T8pRNhQ=I)$~RCNG$aBw|m_pT}M-ai?**Y#EF5Bjs5Pw%BosjX}%)SA=I^t z7rwQI@<^jN@|q`5NN4Ylf2BW7(d=Q;C6a5DSd0rI>JlR?#l`?qyNF|q)^*2*B=JSe zW+<)}!d5KS^6;+8K32fs?mdsSR-D1Utm^MHnKb=w($?Zsycg-AoT+C!vWeiW;E$h<&cdRWB%4@6JyG!Y=B54~g%vlhQ$ZJaNSfz#L-zw7D zMUcXeks}P8ls!&)tGc@@p+ire(%RBkgUzPH8Ogf6m>C(ja#a5S2+6Hxe<7fmV;Z%x z9wwCTf!a#-)Hb_Qxu;T!C_cbgY8r5&@?JiNH zwX$|m46tOPlBDNg;{Xbl@1QNo=GCn9OOml@*3n0K!$EB`DdVdHf<<)BntZxsa5S^b zWi7S235;+Rx{?Q6^{#EMG;0kSdrR4k=C0QMZL38um$imT-iMM7D}EgcJu6Sq?;lZz z&bzZ0a$9-ZSq?H2pYLZFrz&csY{s6pW>j%cZ{(YCD3OUrE}wK~8T9Gv?^0ZAA#NbD zxLCtUIl}owgj64W@xabbK9w@vYrZgoR-)hl@&rM#tz=#d)0f%<!sExuuo&60TUj!%%6aru}~Pp?Csiap7!NOcRVtx`){ zo29sEVRlVEIMOjP?!mzyGUF#1^v!7t)t=s1#_iFu0J4#m>_I(w#b*@$!Qmh%v~m5oG!i|VNK~&t%%uI}oMci~dlYH-YI!t&8$+YPdwt?9 zZRC;s^nn%>ap{wdpyZNzo|UoTtz9kVz7gq?Oz_LPM%567GHxK4i06;3O)j#!U8SrV zT9lP09%O3}Wtfy8F7e3s>^s#+8&I8LI;Nv7z1+~Y%Xtt4i=J3ue-FP}#!_kWjL+8e z-4|b$(p&kXmP=;yY^>S#Mqj>9Q?&M`UFi0a#}=_Tvip3B7FaFiEWvY}h3+~Gp5~q5 z#?so@>GyNTuD~C3dW34FBw!BZW7TnleC=mZxNszS0Z*YaXqfyeOr^VjjM z7LRcrQ}MdqJAFmbGBIkPulHLABl%a(T7vCxM{&}=i1@)Ln>c!udnW0M+`~bkFT|Q$B1Xsz76R&9u@H2)OWrrnKo?4RWIkklL$2mXN zx4n-(t=zd|xrgEln_mR!lK6W^oo+6ODX8i(Hxf5=L;cay-n{baFSbA(7!NOkgRVVl zOWTJC5qMt2_o!pG5N&60WNwJQp7i(Cl%lD}m#G96=2;0*oDN7ms%w2q`#xe6e7M0l zq?$#yjHF+D^&+dq3E0H<^`~p2J@z^XCbYhTYT`F;aylB*f;F{-1|#ZgjD`)&rZzFZ zQO_ThZ&<^1Ha^fs(dc@L%2%>Xe+n-LGTg?yxCc3_cOZGlaodWdiW21pJ*xH4!zMan zwqFg-c`Z(hL162GGB9`@YwS%v&R-Qlqp}>c^BotRgA`dT&njK@z;lO z=0Je*Zie)XB-;?}6&k~J#@jGtsP*+?ad zF#tvZ2g|q*y`1&$*10%meJfDEyS;?N_FH2Y+a7N*%wK$<9Rh+Yps?4jHCt6%NbSV8 zuthAAB9@tC0kas#U^pYzuiSWRP@dl1Qaw7_-LoWfTy+>5TX=3;obl4MZp!DLinhyh z;&|`18%W`f%GxBD6DWy!QbyeJT=Pkw_+a?jIb^g*^!A5Gbq!<0lR=&8+E$T1-Q$8)$lwAil}=K2 z=rpcLrg&~Y58De=)E4f}64QBz=b>(e`ru>Nis)bLU)ox8Xj;Y1#B(>EZL|BErd9x{ zXF1uQUPceCdEsopg7G5G2k!o6n)wR8)%9YdXrT{B#ZbG&} z9lsjqwXG&Qn@_iBHrF?{#^FR)7f+3!!)Kki0DwBu`8Rr9>>94Md8fVO8RClC))yF!lo;a;oG8MMy(<^Q zcG_&-H@j;&E^PH}K_R$;(Wig2TZJGn58og+ZUtEI_N5lHVLRPSRIHAMNR+pj0U$E& z=N|r*+NY(2UApkLzv4?f4;E><7Kd;4g>SW7UPAHBY{&RmgU=oD&S^C*J`F`Ql@?Z) zW;aOh(g^VsN`vpO)Yit4s%uc{7xCX+NqeN+?elHeU?P7B81~0np>?flHqdI3+Fk6? zuH;zCq*;(AwMm zu2q^}vfHRG<#W&8>DRqmvhh9MpLF8FIc;uPTYF2lWK0RjD}#~8T9)rz)9kfKZS@nY z=~jbIDIMmRfy2sp8{4or$v*XmtLm0|E}<*mc{+rbP_48#))H=38+Z;506D9Tq`8nS z1Z_i9ut@;XqrbNjM;cmMMo|?QZu1%Xlhg65HzIhXQZ`*2v0t^bB8>FlbjNC#Y8tfK zoIhl|Xzg*i-~)OwINHSYvjUP0g~A<*#wc z>UbHf=J!_|blmTumey%iZ%edq5mq6-_&C8e&tBUU*JgIIAo*Y-09k`C@YKE+&||Q< zySu!Z;__qj5tA%V4o}P6<3E*Zc;?~>bW6MaB`#;WS%WGl1?B`~kUJiE&2vsNO;nFU zw<>AZy0YcQi5U!m&)X)!Qb8bvPl3*>mPG9Zzgja#-D4YC_LY(qy@@ zj@>R*+!BPIH;jGN-Q1qlFI!xjYFL9-7dq{b;4eTGeXG6k9lUz} zt|y;Lx4n&UOmZ&NcKfQw>B*>!ZDOq2u}xXLQ=vAStz4#-!qly$!r~Va0g^yoSoQ1H znXG70c!NlXPrB80JBww66}SXPAZ6qz80g-o(wV4uX>^GopZ-1>qw_rRA!2}Z1F^{G zBDOp`t--5WIaOVOi~@OL^E{_LF}JDu@liNA`_bJ7olMO$OM}8*6}P#QN>!H!_a0}N zBb@YNFbS!w{Oj>O)uxdgOK|2X?X&ak05{`<{Ht$N@acz7TgH}MTgeH57w*+$Y@L7} z81dG*sHM2oL=!69X!dB$xnGwJjD`#jF_BRg<$iUjc^+Z$&e`p6tdz&)qgpqWz+aFt z91h&q&6sKK;7zecm zo<)mL6daCo`Bdm}B8=lTS}}#jdYZ7w_eX4265hs_wuGdHA-a+2RBriye${mVOoQL0 zQM&|O0yrL&ptZP`n+;{kaxw4kP)DT8F-aM`i*mslW*h=eD&7IcIrgaT=bq-yK_t7P zjYibzj%!+f7Nkn;9p#6CwVw#Y+J>Ft7_A+nU@uXOBqAF;>dTpj#rYQhLFE$$*C zcKdcM&Q5m|$v*z|ly0s(pCC^&LU{7bgs0`wqeQ_2IO933Wv;0laJ+S7j;zFFlg??z z;9G;xxgBYqVNbl^k6}==4CG+usa>r{ElDCiYL;MkXNr}{R09~MMp?+u0;H6HfH2AJ zR>YN&C5&ON61v3*v9DkSa{5A(GGqhqT|)lw`Dix~GDTxFF^c-*!5U4zv*DdJZKs*9 zqlhtzHDWR|-=H|p;a?(nX@GP&$s`K;+u_yG)O2qS>H3G1x@6m;#M2d40OxQ3j{g8D zJ-DpeQ)z5?*uIA1+kavkJDW1G%PX*ZxfuMQ50`ESQ~f>b3s=;26My}cb8#-Scv+>4 zTsGOZ1M?6+g*p8zf5sMiu8`KVX_CRH+Yp{|K&&ORLfH8j@ws~U>0D2p4y&!-wTFpr zwM!P^?k_DdcakvCnMeczdBXA1u}!w(`W|}IW2U^*{7`3Rrm=?Z>f2ej)Z{ky9!hyn9E0X)k z7n)`LvUqbz)8oEnhAmG@!kJ;g`=pOjNpHfr*6{q=O}3~l^#pZ}-6Oi1)%V;&LyfJu zPt1A)o^XAua{Ox#ZrXmmf2a6i+i`~C-8`p~JdwyCfVk=hsi<`QG5jle8tK|}w+(Q7 z=&s;K@@60au3T`y5KcPwtJQAES{j#HZHA9`rC(btiFq(fN0Lc0XAHR{fyP$^gX!;G z1-7tk9Z|IFo0yst5{=8{xhL;}TZZ)Y6_I`71lGo@r>>zSej#FZ6thDf02v{>Hz(!v z0M<^Gd#w0#Qh2qEHpuC+#vVJUiD_`WVb`E1^u=bq<*3=yYI)P5k*+PIirG}W4Jrd7 z51Jn;jnJ?@#+O-OOWxX;T|O7zpHW3G4V8^lLL`uIcf}*H3nhCg<|w z4G539Eu4eE73bRRuA`tNI(?0ppGJG9yts_X^OAnxT=oW`KW5T<0({El*=*Wdi31@X zPsrF}H*Gomsrl2cbi3;)x_^|=t!p9TuI_vNhPz?uAslN^42(nA#;JXhU{|1vTJ=(y~TSoR?|uF-k5DP%lIyD zY~YVEGRgw3c~)Y3dsa_~UT43y-*F|5b z0n~M?`ZtFok>ZjhOY3GPUou~kFn*_z&2!4SZ@4|QDo5efHlk^mUEQ+%)H&mk&usBk zbX{k|It7NIcqf}mhRwHJg^zfSKzRjvbK4wOMm$wvr(9oJNpWi><+_G=EiK>6h>mf% z^(Qqv9z4^vJIU?sw2eIKGQ^1^&1NE2&j2wa2OUmoBh68^!pZJs$*5@FJksrTjV5c0 ztv6DJXkBK9$zE7;268zZW3^{#UJ|y^d?|BimT(IrBQSzVJjqKA{d1o~U0t@RG#058 z*BW!Fa9l-f2HwF`ju#%nvAjp&dG2Jn)pWavthFElLv-opB9Hl>)TEKZJqPpzm0=Uk@SD$@^vOvD?b~jLi^7n3L$d(5on}N$!Vz zP|NjwE#5mPdzhFjd5LZv!OlXkJYeyf>L)%S@r}RshNE`QV||d%J(8m@2MP+{W0U&& zRm)}5d?#mfac^lI%+jDxZi(5)`#pNrW&Z$==fAVJo;yo zZcE+V7N}P6qFO_5aW<`VE+-5g2;p2m`-F0TmTIgzzMZ8@_EQD4aVng&n~S*z?v1=x zJ*ZylI)ld7)4X6u<*$?o36q=-_{~?dlEBX$tlFAg-v(bY*e)EVdE3S-K1VBGa5^n- z#VvPdEsdUw6@rp;bO3h4C;%zzjAyCuRcvFw&~HA@_pw76k1kwi{sX|^_pWPJQJ_8L z^b=giHQdFchT!fHMf^Nx8TxwE*0$Hs+Plmrxq?XpNbLFAdkjae&B)Y!3jTvL};RDNJKZUwiN8#;mO;+A`@9ag*%cA)V z(Saqn4flcXo`m{UEBy!TZXV-&Q;9$YhzhKE1bUp}vG-}dUWD1`R2G_rwU?bN_crCw zD}h~8?#i(jPYZ23F`gYQ|_(OO~g={AeE`#KpsqVlN(s*Gj7`t`F)!_@Dl z@ur>O!Kgjo*KEGA_I=V@TU$*TXk|;FcE(pb$pZt| z6x+`Z_;19PUuwM5?k*=XnQh=9qjp?l4EZ2qp1jm|Iz_CPI%E-O+I^0b3~I9xBQrdK zw|vZToM2Fr5Y3_7=9(lDmb|tf=%diTEjIZ}b0m&Vz#+{~Xz8u%Z%oh;p zejc_`l6`@LTkU2TQhCb~21)0d(7x03``tqRNN!r%SaGo{fNhLyIoufMeoit4U(t0f6HU~~vDBi5`fn~X3qB+fo*VES z_2l)ZYySY*R<_o5cG^w9_L~;+rgt7}i{@ZBBcS#*6Q`}wYg5L4H>?PndfS_0n|3Ug zn8as4b+O-!*UegvJjMrslgO{9J~3E6j=F8U(7_bx8yQR}pfh(T9Zh`Cs-g4Wf%l2) zSiO&H0{z<aDeLG~4yK1n12T61Tf2fb#_peQ-xJm#hEbKj;SpO-ae*@;8bimAx@ z8nJEutn-8U)Lojkuc>}D$WENqo?M)Q3Yvn}UoUG8c8>gv)aZ8{pT?o?3$Si%St8D)qZb;lGQNF?;>igwZz5->aBl>j5AeFb!*l2&_>5L+0}y*@Y@ zjxso_u<3WRL+3{W82WF<2hyT=mK0Itqyqf2{BTWAqvfk~cT?!@;#!-~n$Ef-7EP28%Y zQa(Z7n$nWpt?bduhRDki{o|bGxBeV%w`LAbQdijvEa2O>m@)_5BxGi*-h%e1LFTjN zx_J>}Cj+Pzp>hjZsi{(OMon3oei~wqoiY!4`b)zWmMh>m^{rClRfgU+5W{h1H$`nG z{D8x@{9?Xp@X;H=IX>pTviKEw;kf)bqh5znokaVji8A&?1M;(=JhxiT+T8N-TOCe= zsHclGxV1ZJ?&LS{NgT@4x0ahu1i?tcg#->Njl>#$l>=Mc-P>NoRiqPK7am`i)>UQd z+;`&wo340_>H362Oz`aU={I_hn`LKj8zsfM1UuZXPblOar>$=#kk{-bxi=O|s9U7Q z>rb5(cd99i9iLI>HQ$|U)gE$)w3ml9C+niRBnsB)-E8N%AyglKmUsU^koBhA4s_(VD zmiV=;qL2^Hgz=tut=N1euEV9u-X6JV^ts?Owe*t|jtLhV(%d%yXCoc!155DUqoU}4 zY`M0?*AdKRiq3gcKo#n7?CQDTW%YlUTKu%6+ z-95x|f7sUPuDa^CFKK%lEXQ2(f+B9<%`QIn3H=Q%r;JC1d}uBkQnl7J97}H=v5_KO zz860s$=j2Hed|Y7itAj_VuI4@D+`q^=1nYHjFD%E*?W=CHI+Vx;r%N2OOoHicUq)? zu(-OlZUYbi%smeyIqga^jqJrGZ4utcxu3iRUt(MNx|JJ=Owxi zVO<5brD(HSOEcOnmP11&Q0}6WN8qTU!6_}E-r>5x;X}XL?>fSjbf0(?tS=@8L z^1Z6wyLUCNqHisePtqe$KXi$+b~{Ep6Y77hRKL6M{jRGOx_LTgri!vGm#lZZnaPoJ z&~)OtPly^mku}R|nyltnp^aq|OEYD=lDy+N^sYZ=$u3s(E0?Ps<7>?~P_*+k8;wU( zj!62hdpq7H&7WEBhPSkJS_G?>f z645MdwxpD10FC>PC6B#E)<*gPYb;M4;-M`xTrpqaBi911uZ?sg4UOlBv}-HaRrW(} z>-JOtD1G%hyO3zR6D=*o#BX1-_Xj=)l1W@IKWapsHGf$N{a|XQzOtD#MGRbXg z_S5DgT(e3Ska-{;aB&qMnV}@1AD=v8@f6gm(l3I$#bEn_!n`|%k8Rm~^ zicB*%LyY}-HD6XTX*RR9o~q|kKfHUXzHo<}U>*SW6_4QUVs+Lehg2}#7R3HsXv-@n zToye6 z`J{}gU=KWW`qWx~hS7Rrf;}|__sxI-mL2YbTx6=-vZv-~;NYKX8 zZZkGZGjX|zJ-d2Tx^1b^H3dOu=U7^zfhx_tqzC4e^Nv}M0CRy>Z!I*Trg^tIfQGY*3pHA_p>=g{MLFH~Q_e{??21VEOE;D8wPRqyrdR<^q*a^h(=nt}(_ zV)M7kO}faz1Y!u!End8N#vFiw7U9dXvNPwc(N71Xt_-0FHxw{55Cx3(8P9Wwc{IowOW zJM=i`r>#!$$W}ik0d;1MMV&Lb3CSzyOJlD%6$Xur&WWTj{SmXqr|oPs^V4!^F3`Ro$hMqugEYYspY0vv5j8y-54Hu6I*=qI+QqJ~qG%J^ zUp4$vTgW`eK%__*ZW$eN2tL`bne~CTckI0Mtmdq&de}X*F=jX&2+e0*1u8+$QXI;ti;~$-3-lxACqQCI}07|{4s{QFzys`JT)hvcgXD1xvwOqAuW5VYLs5IV& zo1(--Z|jV7s|ax1_vmVt@sK(Swx>LvqOz8nnng9MC6&8_(zC7`67=^6-mgEVc*oYW z?+C^R1ac^eTdR|o4uQL$Us{GnEL9F!JK)h9ZcHi8M@;vmK*B}G7&)i2x+2>Zt{3ND zl&C$ATJ#Tw-wa{aG)cT&;z?!Fydi2xg@D^Gt2k_VF#iCu2<3;+S0gr~ZFR0gHPkz; zRnW;Ae7n>Uk?ouj_}8L*Nb#xht(}$JH}FBHS*Mey=$3yhR#E#8Q;tn<7I9Tlc1x+v zYP$N*6UPx2*5Qec*nbHB0QF+B#nd+leANuKkvJ~nB=gXUizXmKoL0(hkJ%$qUk@9M znJt1S2rAI?E$*899G@q zunBL15)sBa5_;A$n{MN2B#g#8U!K*hs~g%|IP-2Z)+X=IsKsFT zE@{oStzoF<$4rjlC9|3bX=ROpIc&EhjAyarRi7qL1!&r3)D~dE5_&;t4Fpzn-kRl!)xoukk4WZBfr&ovPfP8;e)huQl6kWL^!XXzkUuCzeJx zkbd#wik|1jJ}8z;y&G4$dAu!nzhkzuc;h6EzjYiAF@S0MmxcA&z7kU)!IjxHmSxnjVMa7G1gX}T|o)<`X{V9}E5+Tj#j%CWq+8;Skq)&3lG zHLt1o+F0I6Fm5g#m^wH^mKYf%3<5u;YLZV;EmnsiqD?4c% z4=c)2_#BcOu+MJdx{Xfq-aC6owt{=8SvQ-M07nP|EYIk?VzYAP=sR4C!`g+qUD?Nd zzE-qwVUd*?+AI~?#j}%)4t*-ttsUmMX(YE+w#Mq*H=7IFt4As?{IlA65(gORUTJ;d zc-8K&V`R3sGX1JKY~>}@bCHg5j+N+I2Z+Vf#P>E9Ge>$E5^3f0T1}k0V}lzHU#(|2 z-ZxA(ILm8>Ygn!=^y&P$oXGZ@2KbPJkXNSBSG3JJ{5@&r+FU!sD8x`rFUlP9dSr_0 zHGMk9-tF}ulU&?PqCK|r97UYqp3E`%)-cqx$d#fArpth!-CTx_XkWJ02Ogto>yuRp z-`#tO>#0*kwMPvV{-(;Wt(6p;)E-kO8v(lqwc%J#P+r2Z62V7^h zThJ4^Cx*Ogd=PEs(+fwY+sOI2hxdjOzy=#nQGwF8JX3k3>Z0vzwG*im8Mtefj6;G( z?Tr}d!1{FrRzhlGdrd`jn|n*^tLO|+!6N*HxI4oRGup0c8jZ!bhh&E44I*7a><=~C z6Xw9noCQ5Txu*T)W=gtdUHtllwyUW_sm*JB9(Jlk7+fy^hhlOoqOmbv&1ru7z>Qin z1WXlO--6BS!K%8BsdcRC&|PW}N3kLh2rZ*n;!ry0A9;_hCV;@|FK--#JT3&P*yFgz z;Z)4u&P1mb%T;PcpTv%PUS28Aj=_PQ@d#o|MU!EevW`bKS`tGqOq^1LhcxIY2YO zIOe5?!`73#$|p%JZNS9V**H=BCB~xczM^V!)=d!sL)>tw!y6)UR*%=+X)UxRMS$Cz`{{TAAI_=hvVQ+hDZ*8f-XgKc&HP(VI2i9;R;A-F5o-3^^*g;ZPiK}A zTR^ex2Rwa8^roGbqp_`RcOC7yF9;Azgn61kjGW^Ddz03aTJUw8_NKxp7ScF=X%coV zyyW2WI)W>e@V={Lw&Ewdy3`f!0F!;QqJ=p*S7GyG0B6>^h+&>t7WJ-TzIbBX*0&Rp z&it<6I^!6@_o~&quZU6dGu`lH+7z3zs0ohtM7uBxIjTp5Iwd48=S) zS8-e!t^B4)6lU3j60Q zyJQZP1Ug%3mfCF5IyI_EwcXHQfWLJ?^vN~4w&=~GD0O?A-3G@+)n~LidJz}e92D}| zh8e)lPD#(UDk$wWD_taLYp98>l}_mZ#Xvg0EymWIFvc;|g1n!`wERb7qgvWq2bn(A z<*bvS2Qg{uZAskFA3|ncP*DPE86Xy6gI_FCpqiSJ*t{|3A2p&z<$xB z>5!}`4ZV^)aU?)0;f_xqg?!ts5VE-SHS~wXYg?%NH2}JjX{0h1W}gAa%g}-8n)%09 z8M|-?az3?V4=%}^-GH6maz^{nN4=u)|6P5}26nRoM$ z2*x0u;hs+fu3jGs!<-A-BR zJud?gBpX}TJQ{f$utBQvTS+~{a7L{1J8VT9l21WhE~()q@a2?0YQGPuFwB-<0wNuO zJ5Z%Mi`-NtddX$(iOHQioX z!jYSdB#_K7htTz}KGAIMW)jH<9$Mw|)Piwc1(P`*Sz)!N#j9N$a#nhi=)+~g;P>XfhxkRLoqobw zNVQ0GshQL)^8iBR9@wv)bhpLO;{!S3zK-}E;#qt#r|Z{oh$KH`$B}V>vJiLyk3*AD zYqQSAS;IrK(e5OMTU$h(?rwDFVBTw}mU$2Hs{a58^e4a4i|g%2T8Xr^PwiEPQo4?x zJit3B=c(vNP&?wWyeD?At>0Y>NUSwG)Dq57_o+;M)z3IM;;*gEg`LZ48YE^ZukH8U zNqRoy%fQ?_40NT;?Z(@6nVwsLs|3Cl3!k;W^e@chy1 znq~dH#L~yB!s{x^s?ou58a7I{dmfk_4>gJ6Jwp3d)^tx0>k=d~Mk2MjZ8!+Pl;;Vx zcqm2=?|f4)HF@+X9q)$wb0Ln_I2E@5*(HeS#yR{dT&(rfNhC|+%{x$z`%2WjD`~0T zMFfo{wXN%BIM@QL%bWwxAC+_8Z(kQ{7aC`cY+CZ*#%gd4FyaOZ>#~JI#J&kQ@x(nFDCEGIEUAgmR)0QUS zw{>py7QL%@UeIZiwZhrWGc55!vans5`O6X0@%5^A`s5d|KBM;I3#bbb7hZD+$@xQf z1fGPB_^Q7)!d~u!uHd%1vazypx{DBM+;Fbq3@Mn4+g@b0HJpKrWJWo2t5 zVkvFI_MmX>kc7Zb>15#irh+CUn}Cfu!o1+T2AWT3Xm-^4lY5@sqhn2O0e;4JW{N9x9f5 z%`n^RHgPt?_81(-!G;WO;Nz!y>NFeg1!?n7ZEtHPrz8l;Yi+8@mYj~OkT3`P-K(n6 zekbU678e?Rha32M>7z@FiyK(>#IKHd#t#5=uDWoFTC+EIq0DGnPy8bqOxk>U9O)b= z3)r(2cz3?k!DEsM&1Y*K5Yx3`sSEE9%Nn!>7rB#iGB_k*SD_=1o4r)kZ!QyDxYpqD z4bAGv0g}c@@?$*wjCz62dUvja!=5p*@krDoDLCH9uPYWJG6pbDetjznkWJ~idmJX6 zW2))8JaB3gTV7tPG;qrz{qz7~n;i)U^Q)H{c>c^X+}I6Ku2;%fVR5!5-GjIh$lK0E zZg|qd$b*J6jNfwuTd3zKuwfxwGQYj-F zg7cBaGwoT&-rsZ@M194bo!!<%(`dpC%6`P8ZRvs!w|{EYp4!IB*-{J0@1`n5@)*dG zNa?uy^{Re1(QUu8WQtu{&enM@B50<#%7!kvAfHah>s4Tq^X<~i_mKHz51VToK%<;v z3VnNz!nSENOJ%Kl$68I5vfD#5SxIn;5+5U#9B>C!&wA-}uMcQ?4ymKTtvq_1SI90B z-5(N+gV)gK^R7Q$d)rwo=dy;@>imtfNg^xATe|bm1D zlgpA$as4Z6EeksxHS9GDySp1$=2#Kf!@0LhoDY?s&>Zk9mhoSQjE@z=*qLnQe<*_{ zS=$7V2lb`W<ol2u^ z%BVaBC#Ffs+wLn)&I@k^>$-KsbBQNYGXDUjNB7o2f&z}gO=s$HX!2^JLuck(Z{C+V z4HGY6wDLWF8oG5OQa2{sSX~C@)GOKD!t&fe5XC9ISCdm<7s?u0c2hj@4INhhD$G zvxh~p4dJN*8RwOAHqvtIo`B=6Ty-7IJLHz`($Yv>U`W`p1Q&T6@wvMXU!^z2ma6yi zL1*?oPS(x_Z?s#35xSRd;#qqDPCfBj7Z);lM&{DW8KDyd@+6ZisuzarG1{A>X)|h9 zcWN#z?Czvd8^V~#CzJD@0)c_eC3};hQ^S(@pH$JVE-&t4+=(30TE{Rc5ylW89OoTJ zYTfWoy*G%o`zsmmav*0{GNOQ?azGu~(%^`HN zwF;q`l&;cyjyhACXl(pFaU0&5bt^{l-o)WHNypxh{o#^F%hMIqcwXO9@fE$a_7`@u zK@GM9a*Rkngtl|e0qgHk>%IlBu&{+C((LZSI7ka3wGIV{JYVb;0clI0p& zT<7hhTWvGXlH|hsl>{-NV=-gS4n9?0zWplP8m60fs7lutvVDh5K_=P1bs207=RLFO z$*i%dYb|edUdlZYU}Q*IS$xQ3AmNBUgq|y*{>r}BH2a%-m@Q_8>lV`wEq6Y8gdKRv z=C_Krf==q(uAgyK7)1xBkYk)NQTow9y=Ut_RnCr#Qza zt$i8e+5Xa(g`?Eta{H%>Wdh0>NaZ0p=ZK4a4-rB%*Qb5<>J zxDqpt)Eh0dE$5zlREzUsqCIKp!3VE%Q!8{dj>W8HMn(l^-yz5qqKpJ!nx%Mh0pk^v zcPS=OkL4V4I#r}>t~2;mOLgBS+~Ykn+NVr~^uYZqe=|EWNv^XN#TXsxv|-)jSfhES zk;HLB5|()!oCQ!ilTKtZNUFWgFe}>pEf$IJJHwXR>q`c=@hbWuHH1rtw$Y(s_usQh zMn~z=wQlOiEal0v)XaYh$M9QCR@Rm&{BwT}<+2|#>5#DCxg2?hyuVYwmipzMMlB}n zlh&$m2O_tJk7QEme};?aM{t7$ysgMGMo1$A@##hE?s>SSc%IxWG@TCjSkVO&$V(I%tKX)qP1IV3q44mwBPxdmjf7JIt9m5TD}904Jz(Np3>$> zT~FGymPIWBXNeOhfH|etrPJ&@QTC}UWWTe8cO~3_uE@d8N&$ib=OFslT-?t?%e9bs zHrf`4HLFU#KyYpw8`b3dp~2@pjb>@SCQTDly|dIcIc}i?e3I%>4=F=**ax;h8t6Po z>8Qs3A=2#QJ`~E^!4;I_&UtaWb4Gas9Qt5aEvV|&0`@IVO&;!eE<&Q|@_@jqa7jIh zJke)+_ZG#^58i5eS#^f$2hyaQ&WbtL_s+!pq!GqCSD;;ZLeE#wzSpQ*&uw9MjgZ{j znLf`nd1qwqs@!07uQo*0HCU$9E!N&GF4$qPsaq#0x z(pygZD?=pC;Te+P7uXN+b|9Q_>07$KixjM`2v>_jD?q9LsAy z^_4+8LEDV;+upfLJ#u?}S_@C?F{Y63ZueI=AUI*33JigsgFNJl?QXSr?(8C1V0j)X ze|dRs-@K5Mo$3w%UYvHtbGKsh#_nsWn$l*UEFa1F+>mfw_dfpsN~`V#%S~Iuy8W%q zr`rsgoM7*_w@r+pLC#;f&Ic7YgzRXvd|$7iS6s4xpKSX}Xft?ArTj*n5<$#Zrg z*Tuy8FZP7j$>I6k;thQ`2kkMFv|#sM-lDnXw7B?jaBt<3;{Miow+6F^E~6P)NCyQv zVDzLTo>s_x%c&_czi*({K$ zX+j3J84QgX>U!fnYWpTLIBPjF94qpKCLxwN&k zh6{+~KWKU3f;h~DM{)q;pT`wP!*6q@TU}~*r+iB&N3u98w;Y{-k`6kL%DQbQPM=nN zRqiY-bX@}3yx6q|mu20dPhTgracTK&~ z64zB(ZQjn|)Tp<&%Y|Ugfp z!WY(d5i{M(eGFxOTTq)`MkM@)y7nacb*wKB*lG9oW9>I=tCYp9yJA^axDp-{7#yEp zYQFbZR=3)gn`Lbtoo6DUDG@hQONS?El>Y!B)|QQ3l^q$OtIHg^a@;+-{{U|0PGyq=$~Qvk2vm`r?k>lv;BoX7s&u2E+Rd%%nxr=-2_#u9 zWM)a@z|PiQ{{Syj+lr-c74_Did97#~lUUeWO%BQSdBSFt{mdQ)0sU)^5pbz)j3l+X zG@DPdJ`}kSYo#xzh2f6d1I$oP3mkMLRx}z-{+p)T$Tg{1Bi(Kwkb}8c43MB@4{vf-y(e<09wz1gHEaYusSD4Ydk~))J z-4%!~rmLmtcQ#FXA)Cp4uNt-+cp#9&>BqKc@m{9M@+X%1Mr(r%Slq3QqNyAX z*`nI-N4?Y0o-MbW=1F#yd=d%v{-DSi{5`htK9gY#+I-g1EwUCfF=Hc= zcRxM5VyP)w=W!xXnI z_Gl#pi8F(d_3K!A=ZNiL(l4%VE$!r*@nViWkpSzKJFo|^;0m$e?Q%8JuOQX6D;+k$ z7_$EWXj<>ORXs|v<#vwUYZ{5#=++wO%*(rLYs&??yMeWJiQGoJ+#&MMAgJmy-`bpR zE_6*^dx@_W-^-6?+bIOQWIm2K9f!4LUT9k5URz6}U9xz7*@^^-!#wH^@0EJ(Ip^Hd z?DV;GI3|T}VbQ0#w=y-f@qp})l2jq*cRz(|8Mchu*FygQhTQ3pBUm(ZMKeosml8O4 zM%-FOZ1p@7inVXze-3K*jV!ibW|rRAg;6jD<%QF!fJP}_=_|ANIN_$8rlJ3Si%z@rdF;-0NV5ifL zmGg&)3HxlHl|vQu`dVjIII?$pFvMo zp;z3IjPx8;t1=6YI@M>v_RURXO1~boBcsT@w=J}eG1irG2SY)9&FV)PrBYczJbL=n zG|Nvy#2_-@k7}=ZkOJ)h9+hq(z&@2y{0*7pWMego+@*G|WDk~8o+{<5WN#vgO9C4iU&%H?^7K|`G z@k~R4pl9ppRnS&XV^YOLZZVu=xanHKit-K#tm^~{7bdi}bU5o=^0Jb-tbQ91lq?2U zl4{g3a1TM=plNoTWCD1_U9ybIykz9!x?s}O;;W$RQa;hh<3F8y&%o{QA~%o!*@fBE0$b zJq}Gm$t@avD(c@z*QSf)I)H7GsO6#rblH?{u`*ueB!7E-sMUPh&Yo^3>q( zRqUi^p4FqO#=1tfx?9@A_N0V)vs}f#J;y&XHMo%YEfr`MYIx#EA13yA56Gri3!#tB} zma=PlOwv8Fu9XO340r(~LUDplcs%1ZxHQOgzZ4~r>I?ktDY(tHfnV6@gXQeE{Q}S3BYx zeHPC48*OSihNW`Gc{Lb7NYISu&0)|J$i-){k@TT+C9~gLT~FmahNU@Y_kJ;7GUhmL-g{AXZ#0s5 zcmQRef%muogV!9_FQa%u+|8&*1R*533N8b_Lo)R^?f6$)q-olevd3+x-hH-1m?MZ? zc}!5}?*9OM4@$*UjahOOna#u+&rOEhPaUjpm5JXYbCKKJimh|1OW|vnw0oT{+f>vE zTXd7{8-yJ`V~)Qm{cC$$(cGoYiDZot9UIL50G4@7WDIrYxVud+Pw?M|EG+NuqmM~x z#InhDg~?!qC=w2Ga(MQllfIJNZ5OP+;Uk+_Z6@RUEi_b-j5m|)m7&~w0yBiXz09xIsM>5YH-JEL@tVnT&jBr68sis`sX|V@ONMp9Q5TuYStactpD&6?W z#bVm((B1fwDpr@7bfO|`z8Hk9&tme+Aa z%W)Kg4Y@)4s!v?{nzgBTdTW-vxw9YHw?)zJF71ZTJmZybPCJUzw$XJ>6HwJG=b9rW zlHuES=*VI|>Ek>R`O~gESr($RXfoa#yWC}i#6)PMU;x8{KplU=ikD4YlC?Nb4tO%+ z*8c#+cag!bN2ppxXNpCWCCEK;I`DJNb^a8)ZBo+NHJK82kb+I(oIU{ny7b6iD=R?0 zUkmDT*-Ne5*y*?1Neu73Mu@DH_X z8C|9u9+jx}mAQHHAIxn#9ON<6KjcKgHV z+Df3(;j)fq+AW?%!NJRJIs7UeD^=4jW`+w*4$e3<)M%no3kBXz>~r7L(p>m^!*?2- zJ|om48jLouEzw4imL-gQpyS;0jAx3)({&3=t4}XOy0EobZw!*%y0bfjfw6m{`fyK5 z>c3l=?V-^{uWAr!6GNxpjTvoiz>oN!0OeD5G8FN}XKK2>r>l7v7gyJpmohYKnOwh^ zK*t`6GEP0|y0x|K)y2fOT4ls@%R2^+-rdYNQ?@o-jyU~IX=;+&S<9!zZ+AVEs|kTX zaL%ll1P(AcJon8*ot1$!+9-I3MqMuZO?f}#_5+wpe9C})*dx>tn!XDasM>Z?oL#$aE`P-7iG5MYC(G;=k!KV|Ei9k^cbJs-7a(CGhWs;k>)l zmfjgp*rT>}-gh3ZqoC`I_N%utx##``{$gR+__JjjDB^AcBNz^rbk-Oq3=QJQ&78ksVxQ=W4%L-xF?fR+Z;bmYL}VN z-S$Q1bCHgHszk{Hj!5rOG293L0IH-;!#%2AyOjdeJbZSKh|qL)3!hAy=&_R=+la_N zg(${Uh2+$i5eOzR?#l)vG>d@18no_=`rJp1u+BQvPv}QJwQF(P_3cnO3UQ2{^`g+t z>OjDe^5^JjRe&SVcBerEx05V!uJ$L5wd&d*?A79Hp9Wp{w^h2c(DZn7m#@fj%!iK5drEt)j1)K8-|V+G9Wls#xqwTw4P?cjkxx%XBTs! zN-v)GRBS(fhLE;c7XqP>IQOR9L^LCcgzxC-tM%%Fk^ABA{xW9?w`*NdQ{nEv({Vmrz`4P?r%# zGo8e9jxqSxCGh-1ucFN^rZr0#E$;FTXx3p$vk@zTGqPen>49!t!Oc7?+9eIl6$u@ zrdupoPhdD6l?V1U)}sWTn;nWHCIOP<7j$I zTbP<#1(z1`8Gw**00+-4&HlqQO!fiO3}IKDCdnYB#oa z5p65|gK&1oZyY$1J-7k4^N)Ic zpz&swt#714^V*per8jdGmP~>$v6J_4=~)t5+R1-xwIUaa9ilN3wv>;T@d!pcC4`sjhyu-<>^y|)NG)R$*m@uREgWlTxA;sh3BDP>0GP{ zbK-(zw^y;!ttDyfCATIy5@yK($3__){VS`o@bdVk(hWBDTdRxNa*SXsrbh#k*f|5O zOLrlyPFKVJ64kDC=(P)}#lSE;mnvk=LB<1Pj{U28{{X`})II}y>F=$yrqZrT+TOtG z&pN*VIX!R=DYqXEZ#3&>&@^pDEhO{HBU^_o%Z#!e$DhKkH-|K>J6*Q6lEYfmElq}I znnUKnk@t_5PX~|ZT^Htektqzl(I&RgTH3$2HdeUW^uMB7qC3!U4$9+<7Y8v8-gU^o8&*%IDqq1gb`SiuIuxZJ&VjAUot zxt)GXJqpRSoiMo4F1hmKU$ullcx2C93O~Eu^(;Ni_nIMEaD3B!VR$cO41EYN|#lCM|Dr zuCYD7lqE|!B%1CuCB9sc*vjX&Ks_oA74Li84O%8%6^kn+KL- z&u)}P>vGtZx(&vws+@p}j!Pc@0AEV2qi9KasLiY!Nt;xKGd0PZ z5lJg&I6c5T`cho4Rx4XV{{Y0@Yg~%T+e^`|657#aK_!}|WpT6sSe}FVRjUXz*&f$V z8a;#;a43@Y;L2n-FPw$vfW!0@^;_K&%b&y(LYJ3hh}}>>$bRqUGtFS?n%{;zE1_z( z)|WTOT5F*3&n=@gExWEUoO*NZP0yL7ZHE{$&^7HQ<5JS%mI!7E0`Hj_MqDu<73>#` zXV}*xJ)ukWllwN>@<_orx{u97BoUCk$m%oPRsAQ&EvWcH-uqF#TY(L#31PPwWJCvQ zws;uMa6Qj@x#0LF@y*q@_I0)GtW&>~_eLVVTzyLd!|=^;SG_r@#HXQa!M+cV;uWOl z?C+-Ou}K=sa>@R*C;YRB9yvKA*Q)5A1JSfL64`0cBcpkOM~oIzj05%eq7Mz(X(qxE zc_21`tPwlyj1HYUpL1KgVvloAdm6iOv0FTL>0GX?r1d#xE3$;Gx?RhICUKL=uQ>6Z zouJ;{SjB&%#dmik{{UpO`3jgu;9bC8tWT|X*Y_5-cGD3dLaY9>cwA?JlkZtNef5`y zEnHj(poP%?m|l3Sw?b^kGdL6 z@JFR_UM%oEkHWiWZChD~7hpqZlO6MhCoJ1nuKKi|$>Hx0POvm~GD(EDedFel z{{W4bt_NSjzHzzGt*!N2-B$O+mfEJEuEt?16L8Df)39G*{x#7<+SGSXM071#Ko|bo zxm#;xdm*)>zKuxSJMQ-#G1|LbH^L*t(dinZYIDIorO(;gIb5_8p&Y0f+J^+TO>$bK z{{U~ghfkj3@M+fZX5FCzF%Tay2iH93)~@J&CyM6v;kUZDoU4O6$1c<=xagydf!@8P zrms^)zK2Dw#XJy4r_U3ZElHjdRGqDk2>Rx#*=cP)o*~s*DeZ4<%+kcM`HYGf;aHEm zk}>$#U3s^}7V^)hMDnBonm4?gnS!nWC)+(M%e5^##Tp-nCGiaN8*MUMp&XG}+!DnU z;H$6k4E_VHC?=(0-=Ll$xHozw>~^+7!p*Flly}K02*RlRE9OmmKW@qCoY&NP&Fz+< z;XPL2?%}q-me{?XlMGB*V>#Z^IpAPm@yX46#jHdwFB=ROWM%$ijt6Qbrst=Dc9S`q z)dE)ZCpC$Dz{`x_?apgz>50a9=e=WI0AwwUaoVw3A3;w;IkTMgr&}N1Dd*gpea1Mz z+DEN6(Q|@u4INIck)quc^Fs6U9u6rtVMcmVspt9B$%IMaVo1T{R62;vc_TTi5I)g^ z(9}2h^2a&jKT5$XNfwNej+_e6(Jvv>wOdPx;`>FsQ!J7~KaD}s{Fe+slNxzqpkbWO7Q*tNqYV1M;V(%Pf9b0f)ahsyO-Bbiq7S$io@W6<&r? zv)q`B{NI&8(FY?gRA-vev(o3kw4QX@vB0~Zb7PF-@#|TVkgNolVg1l*(q>m%9WIrq z*=cHp3X|ORHP-5WF}~OI+gnL>TdA!eU9j89DKW-9g?XfQw%0L+j^xKCKh?@H`5MsC zwCP3C$1T)xD*)pX0x_D>?_-)#TeGs$G&mpk<Uxw$*Hb`I=DsPkyx02oAup9<0 z7J9H4Irl!5t8*>Rj3u$Sd7L~THq5WuV#q2_t^geSR5~hj{SPH?WT-C;dgryxb69~PxSg%E{UKPr!HqXFfxz8?mG;eb?|E@NB53WSmd@}6 ziQrPwGwv!$C%TcGk4k0!lMSzkZyM#K)OXuO3);fKB+9CJCpgF?=kcgi(n%G|*z?G3 zEIdbg@m^_WEe`ao^4{Ps^atb-kM4};>sl9fI+UR-wB2eAq2!Ova84k{5rh0E9X_>f z?8THU29DOyE`QM8a7IZ#C}GE1i^CT4qF<%mou!(U*%T|wCV4DAx)1QE&f#aYsH{Vzt-4gQ|(G*<|&WpS2rdSo8`tEq!TxVN+n_Hxf? z(VQ7`z!|~+01BaLX%w1tGMK;7OX195V4gQFdTt(+)$gcvBfHmcHA_^Lt<+e+N%<4^ z&nDA=ImSJ!9{W$yym$614Q+2NFBl7pS*@@njIZ~PPUnN#yCbJ)X7WR)$EZi4vYm=s zGe-F5IpdNJJu8ycFCo!&K^BXlYEdQwEVuHB8csPNfzN;CS17K5c6uI-@X`qUFEq9{ zjPO{*?y_GUj^rQikG-BNi}4M;!$&pM{{V`=vY|t{ysJh{*cjQoa((Ni&~BiSrB4Qn-<9A5yrwmNDi!w+h5A04>SdIu3hQgw_vZ zrCZsAh6{)JuI}VVmBRC#iJtu_y3MYl`kjr;GD{7d^KFytq;EN1hdlPJNObw&vBJ4v z@ya~=l0Xm1QM~IGE41d#M#9!ktStR>+<~|5@(zBy*F6`E zd{Oqxh!b47w|jYlyUP-l=m`pO?_MLJRnE-9R9*Bt&x#&T{33Tz#uYBEN=6%@+#3te z<6e2I>KD4GyR%4cwR@c_Y!d4tb%xZ840F|+uTN^Du4)?Ji5lk4=JFjvIpo?J<@R}T z_vzR2tD4MrJ|EMx`&exJT^Q! z2#i+h95Za_PCEW4y>nhA(r&cb!dcqfJ(iH@+mI$Cb;&s=->>6Zxy5M8 zT05G)4e>(T1y{SbHqioIFkOm}bF>e@R{pQ%Y1&?_(Ae8*zHAUiw&aj9{{TAxKMqOs ztUm^LF5gJh68hhEiv(!WMKX-DGLMr!nLO2-y;DcJ)f&ocnXNT>>>_g(-O%1LvGX4z z?&O?fsX3)m(vLJ4B;<}e#u~KZ39P)Qmg4H&?rx?+h8PrJ7*vkDmFZtM_=5{@2;*?C zrM1l(K@PHB;dJ<|ZJt={BS5J8--X9q@JT+^^IwUgG2JG4TpSvOD9#C^(ZKG`Uhh3K z-m>P58Po%g4Qt(e%Z;NT^%avg65&|#4QBQ}iWW}gD~NDT;rP^XpbwBitC!d9EwHuP zp;YeN2P5&SF=P?Y`>OrAX{_TaRO#zFpd26|_uTeANE zTA+R0(Af_6I*mAOiG6$4yku<8YRj-t*u`tI7}bYQW7@p=``VJ-v^neSnVGv)%_+{n z2SHRWoPDDt?p~Z#?JVI>dhesL#U-I7<6`SLuGhfQV&VqQesR<5TvgD-qdeDV;8=@> zbUSt)J&kE6W0tD-Po^}t)3ohJL>jI5GRqq^&BH>{C9Gr;xM9@bE_uyl_=8uu)-_!c zIi-8C=E($C7GcRK!~lQ};~afyET-1=_@HT4$`1_gRr2?5GPxUYhp0I`ip3$*x)*b5^j`Vw~Eg)t%AV_L!Rv7y?&n&ae=SLwuAJf~XHMg!^ zT=>?)7KZ8ziw3!Sg9OKS?!Y^@cFDlN_N;47PfN7B)Gu@^a<&scl_j~9Z8E6B#-Q}% zlU+XT*cv*1uHRt$e77((Q;_l)m<2<~=zIHB%UEtNCW7(xEw-!jh=iXh-M}N$89A&w zO%e$Z>GsgsL~h(OTf1dfIrZpjcr@F)Nm@qKWRlV_#t1FY4aaZH931oLM_wv-qLrgMSM^R98VT`x+#jS>sH zOX+q?1TSUgM{o~ZxZn}!deo=I`ZbctW^_Nb>sL;x1=307tPFngG7cNQJM&c-r|z|( zEZ4ZX)09YN)2=SAOKnhgm>2_sNj&})n?H*e!*?wm+_v^wV2Gu=qln7qw%Q(o3e71*WGf`L7CUR>;JL9EN7-IOEd1`Eo{fwu80U zHof9I%d2}Y4(O6=A8JK!w7_$^epCaXBdPbUy)A7lH3m!jnO@h;PdRMdG>U^cDi2=O70#ij=?!%x*HcGrA`=^4 zg$|>xK3`G&YSZe9@;Uzi7W_lM)ivw5CM^u(5yJwYJ5Y2vA9vQH(r>M8Q6|)&H+M`y z0z8*~*dCSAYqs__v$e&Pacb~P6{bZS8UFy;-PZ%uRtBf5K@`^_&ejbMD<)GkmS|n1 zMN&v+IpiD;Drw0{S?+^XxiyB93q%qMuB9d~vK3z=BkwV9y@A+wt6CI(9o8Yf zw$bgp`;}l~Tapy)Q}an79XfINRV@lzKM(+x)k*1&gr91cOrGQWF(X(V4QPSK7%PHQse(k*lr)$Lrg zQWh5M{HqR(Na{0D&2&@HwR0w&sHdA|_fbhJ0&T5aEF(QZ9eq2|cdW`NH|BN^^aYM=ZhE3E4B z!sKc2$#8BiEgedi9MZJ! z4r=#zw^k3UUOcEglztsMkDw%e0=MiuBXOnanqB6gkz2(KZ1%^2BL+Am4!Jq&=qe3E z#TrJG*YU>7G`ErYVjzQjcsa^HVtf8I5^hajVJ*qLE8)+y-QUBdK@1lffs*R!CqRmd zGo7cN$ABu`xYIPKuFUcXrYFf*MB{R@fz~|d2aj&Ghp%|TS1&D$pV{dutR~t%QCDsT zPki%}SvL^rcX}imgpwwuuCW|bjdDmSSb5r z%)4`xHocHeWr5bDpP zd#ZnA3wtGmqfPdOW+a|+qosW7;^f(KIr&CF=Dwe^5!q;3?3R-tw!N~ICbnq7ATjZPp&H$}OKQScWip#kml1~*4 zX{q#4*F!qtx5<_2YB$*r%YiD~p7aVBRcoy2sg8b>GP&%ZS_!x<#WL?4MrR?J$sWS2*r_uwJ^EEq!N(+atGaq(MJ0y^ttnpG z7~fkXsd=ox!fhJ9aTK&*@imvGU;Y zo|R7hHb@RnHET>_TO5<`UDUN&9FDn6sW#WRxU^uboNq1lDliYI zto?Ju-)q(_^*uE%HLW)A8+(g=OoJp*5(5puZd2danz5{D7B@Z^(KV>9uA1)FHCZjK z(DKA#K_m0UGr|#ENvU0bXzKRX=`2#R4=3w^1q}yMuobg(twYhz=aKLU+>N@@=p)l)LUJ&w< zSjMYuY)_mDfjo@aB$3lOu5~oM3iC{|)~+q?bX{NVEd|W+NH=+9ISdKGQo|XnVEyZs z&g$a+^HIN@FD7d_Bmtzoa2`TbW6K{<2j^VZh_pyGtJJr;w2lbOc`G9BMmPi?P*3x% zzX#t~_?ui1Lt!-9ln^r`5+qkez}$p=a59-U#JrLDUxOJ~cZhv#Fs{qEWK?~0`-hqPU0^HJ3B&1Ep&QriYNrN%dc zIv?<N|A|_{&Teh0nb*OKnOmI^FdBT_Bfnk%Z_U%sKrk zcae2FEm(Xwn!$wlmK8Glta!3IOijpu4XlBB4~}> z)VD8tsT;X&6syF^wPAC~btChv9}#I*$*El=I!jz$p+cIZG8KwIPB#PHhdHIwG&%Ix z?w022LvcEo$J!>sN0LQ1OM)E=*ZUUlUR4l-h*??I+A`&kD009^{=f|@U09+iFC!pgv6|sBa>y3Xyd$Hz2Y$q|a z(H)skLB}U|BagI@<9Hz=)Vqhsc-DUHk~%J zW6DU1pD_+_T|w$bPvcSiK+$yT&+Q#2O|iH|wK2yPo!f07BK-Mr*nMl2ryt%IJsyVx zZ5_U^s(Eo;`4MehsQ|_oxCiM*r48PNCA`{xr*gVYyspwkc5>4vJx6RFfK&WFG|dg< zLX-We^B?Y4%nEUwvFHc2Yis(f_fy$Lq*^7qOrA@&q@mcMKQSG*9^RGN9_W^u)fO!L zM%vw#rNl0R+9-)ai?nUWBOl&B*0TQqv~6S8g~j7sERw>J2+=UAIpNPmBp$T7TmmC< zPKh<8lu~ZCl(8Y>P<+O3OtJLoP}+E|D~p+)L2KeDr;NvJ_g3v3XvZHgUaUqi38;pT zK)uTl++M$yv&1xA5+-*?FP3IJ?kW!?;0}GOLMD??)Gg$(x{lq$jCzqVN?2kUAbw6LN9}5L*6L^k0 zi%m`EySO)k9iU2?VInBU%jyS8?R+`ni=PW{b@~iqq-Go!zn@d8vT&&7W?~gE%@{ql{ zgNhrwxF*(&>-0y^fn=E$vo0Z(*Ipm&6n@ z6VQzI9X}e_w9(_!^gCsbQ<8Zu<1q_)@;7Y2ET{Xq$OG7uUTLUWUs+zX6KSnI)(flc z%yHwO&*fb9T=soRcD|;Uh@#hSuPv@7zX@iMZX!u|3+ejws1Af~nk$Ep6qLMyBHASK z!2l9JhbI-8b$fez8l%qylc-r`SY!kOKZogoSM=>d3%iuonIUVbbG}^Q6vi=uz{PYZ z#8$FK{{V+{4LexBTPxj3EJ|gOq&P!?(40|qX>V;dqQ+}T;IohK$iW%VAAU&h&1Y%1 zcKS{I@1HD_yodB^L{eX4@P6uO+Yt9SNS`B_AcazQ+wz!Gs*d_k*U>DqP2kW>3X;agj4 z3!$GX+_prXnLRK%53N4U^v2bov%9#0d!@H#mMdZAeE2*cPNV25<*vbdlIjU(p-re+ z+iGI+z{BQ4E5sDaNFx~jRr6-L#Hun*e?O&o z2a6E3)R;L^azCYKC2QF1!Td)ZDw5{|B#xDldZZRTM@rdeF-QvMBcZH|4(eTz%Af!U;w2kiqOC}rmuBxH|Dr=Z?=(QmG-bc@STq|WjQTw*C5 z4tBB@InFU!_SzHawpUi7XS{2;iaCB5GUV(YnCFi9p~@=PC&-^l|HINV~STSY!S4tO?#uGC@4&{41fiEm-y! zSQ1~r(cS6NNv6u9{VAiwO#P4_Uiqv$`xn!!t?umXA5i-UJNL0CU>z8aqb9l!wL58X z+08Wf5q+VidvhW3*!0djantKmE-tV1v1s}xgwh6VeUQ#wQ4d|j^%(W(R|zdFCa<|` zO}7``9k-rUiBdn^t)6*BB(Ws=0!?Z_Y_#7Fz1!VIsZ3Q~Yk0VovyccRW9lm)L8jYY zUPiXDxwvh`Z6zgR$>bg1%BUSydrL_g2n?4kD2K@_<`hzJNdwylJ*oS{R_bf&mh#xf z)5QxylYkxvVg}*L`^{w6I zus3c#YkPLfDHsQX>73{5Qd`)i(n+XY&1!Beq9Q2RozARqLU#JqYo7}L0Af!){l=&L zq4tRt24mQ$7~_-OSJS!0IxRh}Zngqsjs+yNW97g+oK;e&nq9`39fyf;^j&8Qt!f`- zxrj$SwX=voQTJDHJDIDmwa-Ph6gV3L3k!*_59)jMAII8lBYl zX=gloRkSQl?VHT&+$bGMJbTs#q2O&#Sk~-hx|Z_WOO{gzxH(jasLO31f#$tW;;yf( z>-t@v_D!JCV1gkGk+Ukw7ywz=_T#74xEn23SMfcJv+45R-Do;n7>v@O9$7zmz&Pr2 zj}q!Z*-emO&IDX?S`cCEK}P~{{Xy@pS#nr>yFirHi2svr)Ia4+*-?YNHXu* zst-~>0Zv!t&}&mWP1bGT)uy(E4b|nV32v58^=lY8+FQ9i)rn?~D~(q2J1kgCjM0=i zAY(fRrzWfG`hA=_PuXm56#24=Wpy7j01nVkan60sGfUGA<>B!C-TTR>wXfPIYk7RR zByfII_7o+3E)CggN21uDTrW$z^9{F9Ct5%S|0 z13#}7>3T)=&a0wBZT+zsirlfah`IBCBb}Kasp(Li(XNeMo=*BKc8PakE}I>a7k9Yw z#u*rO%k?}CwEHa*D{Bop9Tx0e+uTN3pjfvFBxk~)J^uju^|j;g4Z)(c2?nP#TZk1d zZZ0t1bj7m!0PF|L&#A2F&GwHK#5zs9+CtnI^%9vaqEmBZ$#CJRlRiUM%R##U{^Uq?C2WwT6YPu2t#~B$U4k~+V7XZWY5eMNAw-)YrDM~!&9}^;IaE0EvKqU<{jR2nIYS+0Gx5{R9e`st8aQ0 zoW`W9vmK$a_jZn>J!>ZF>8)e?DRh@oR897i!I05{NE?5I9%-wT_0Z}sd}Dv&s3E?Z z?%FGBSSGiQ-o_=0C00@f?0w^b)Yr{ED+ld!af}>SxoH}QhxNPL2`%*rG+S$Ej2CvY z!IWoJCu*qU8;=#@J}i@S&e#F>oQlp@mZyFZ+D8jN40FdOv#tw^S);()Ai?*odDAKh z^{k83#BGkIp<2h#(`xGGAvyU@ays))iTTetsKXqd{i#uQkWXJxLhjDZjVKSvfs^&7 zyAW}=ziMr<`F^yrlhgB|-osnmdcICEPGC=7IO$L>0NOtalo5}-7acQMnlW}Nj+sA7 zSd=8I#&hY~m?YW=>P<^*H_E4=>5S1QWJ=#sDA+96U^cNM{{XL4E+QgU8Oa^1Ms>$H z0|$yri(j=O$r|o_X9KClX$N(#=2E@xXBEKyMAgd+1igwvTqBN|?@%hEGYNMt0RRtP z)f|5-@z>WJ)~THBN=nE<84>~9aqU{xN&^ltn$L(Gz@DeAYFZ7z6WX(=?z9Ari(i-k zc>|?sULcoAMgs>YCbFza!(BY_ROat<3M@O4&^hmrZ^{nYoGJ*$cyP!hS>JVq5 zoRSA>)wOe)PRRPJ!Z#`Z01DzoDdt=YQXq?Fe8~YMDI=#^=k%RlU07E7@I9QCU>BLA z-)`xj^=!V~k6+H6;O`L4rfC{vP#FyKJEUll2L$j&@5Z#eH>pYDJ%8-_-M{u`mmTYg z@_%+*<04;9Fh(l~tG0NP+%65~sbgt9t*F!t6Eu<-fKp@Z+4ePWRMoX>KMEUb>7kNq zX@vg(XpJ)R=cZfOU>~J!Xp_sK!*g$I6f#=HB!U^hE50@);~v$6uK0#cMdoWLk#^gz z`#?YpS+K-|+XFR|YW%2ZtD4U=4s`r<1> zpi2X7k=3}#9S9wdwrfYq?AJmYJtxAG>bLq#`mU{|NiW&qBJS+A#b-@{opC;+q~6;`sV3qZ0Dll11{favQUkrz zl1Oz+ZlK8@gWEvE}6w|CapfHadO(-&E3YSD@LDYk|kL1qXgl4dQ}UQhfiC( z$o#_uFyzEo>{acwcCJ{;OZRNiSe1&z#`@0e4LsmtOg3^c^3`7u6MbT>VRT{^$~PiP z5V%lD-R+7^D%MGSH)*KLbe8aH49jokr^>7bdJc2-tDYT>#!LAwwVNAz>s{q;j-E=h z0x%96@Xd6k%XBM{XqR?YI+vQiW7Y+-d5B>B?XabCjQYZJqxER2FVpd)hb-?(Qv&1zdM=Cx^Kai_wmmMHUl#y0K?c3(=*S#0z* zc^H6CwMQ=F_c4s{MdRJ56$G+EXriSYpUA*!WGF$+I0B7X`jAoQ%sx+3wwr**f zeU*lbXucrVH2Y=R?<7+Oe4?C?3h|JC4A%*#kL`QQ_VBDTUCDbRg`VnCu*V+qR~@sA zAIF;N?rpSfF2?>_iQ&{>jQI^`X&??pG87DbI#x`rd?F90waie50w`@FUFw7nkdB9( zoz&bR4PrHW7p$+M@dk|6S}gwnX|?kYl<~k9%u|n;oNnvuzysd6L8!suJyO|Wp6*#% zW0>r|;{bAU-MQoOuCv3siP0=XnnRl?bF|=cdv!eE)^@D+R+7r^cLlo=vBVgKX*0`U zQ0(+A+e;pW9h5r8m8k0S-Yt|c1X%6nY_yDVm&ft-#(k@p@hyYe+*;X*KFJYr=Muy! zzH&DK*Bx=~T>bw5i0)p-8<@fcwRWD+EScT27+;_{Bi|LH=C_J8`&-NQFf`W+&1#^m zfCb5u+~>c&VO?z{x-XYYkJNQ5>1`6uC}5H`kh!=iiC7cw$j@3&6Y7@}Y1dX(D{Hu- zM;MsyakaDZ6VQ{K`&K-<{+p#tMaA)!c^_*7mU6^!0Pow1^a*}1X_`ldZEUp<3PE^8 zWqj3AUuga|AaHAH5>kHiGLmalagbcgpk7UDcVR8YuMyeyp@)TEZvg)Qdj}mj;}urZ zO|jA~^-X)kT9hJq5J<9FsbcpINJZP+WOLLCnjakLW5Rb=YPU0p<=GL6EJ^1u&mfL~ zXPRxbrjd1`{i^EKqOmE-cFAB{rbzvL@l>=a>|$ySsOq|Ox633}^TQ4jbsS1@yJ^n@ zuWHo2)voRQH4^Fo?74wm#4YCeD0-3?p~v&3dyO(JJyPNcZmbpdHLN5on=naWPXio% zYQKU7zKf<3}-p(s~I8)}6 z`$YkHaCiynk~(m4UR`SX?uQZ5}Po;Y0>=)iL(rzs6ra^lo zVPa)at{k_{z+=<7_pUR>HWJ%gE2OeCaK==mcyBY@5!afU*MyXcdYt?P^snt1?&6D4 zlI057+Xliwr|&ub^B$hnodv}D&WCvpsbw9D+%gD_i|5Kta<8A_Cl!!x?XiOJ~;{3VU5#_-FUl{{U;2oz>>ni-)ytw;B z_X)Y&<8k!OW(5(P{{Sl4yE6o>5mr5o^I$ijtI#Ipz~FsqvJA2jocs5ykSh(m;Gasy zR~3^qVN6>)aB+pD7at=gdB*~)yY06C9J3z3N|MUh&fj=*-H%EfZjvy5Msxk*PhVP} zOj2j$U~`Jo`xc#Oz};L4oFDaZ{{W3k7OiC-78@bChuhA^iUUfx32QIpUf#-dRnmf-37k2i{1Y5>R$`58Ef<3A&DfP#* zwVDX#p6W?}nc+}EMUjEewR9dGxBE24E3F)Tn*H#?cFG3ENpJA1eNw{vLpq|zJKQ2n zt!E@nwkec=Svc;wtzw*&%+j&bYnCXw-jl0Crp;$@7-KX=uvRt%5Jx#v!O7{0hg2c7 ztK($$w~?$v%!#rI@34Qg2(D_w#9k<~*R=MQ2<&{jXNpp-<{O14a1Wk?9R+n>BJ=di zbkr{)v$TTXdFas1oG?+8VcEOm(zUBa$zD65t?Y3^*5_LA#+z{LwzA#=!WfqVB@9Ln zq2jf!?QAq{Hqc()LB3RV0j3Jhz;^@un$^-gTW8{X3rqW3ba>#-*p&RI0OupNd8|Ja z>lz-Fp|$j2#%j;keTCVC2&a2cJOiOS*cwl^fXs8CGiEpkU?o_9PKI=HwTlP z<7n;2HNB*0T9&D@;`Z_@C?Uy6WPV=*BoBW|qv6jOSY2t?Hwk4By|u)V!DyLwP(UrP z_4o9y>dm3mEZTg55^`2Z$~u$5%ATC@nwe6INs{j^Nuv0F4x}x0fdW90kcRSU!onZ2 z6WH_jeZ@qJtoXj+wCx{EjwC6y=8%CMx{eFG_UWH`&GANqE{mbw+}qww1&J}p)(<98 zTYwxX>Q4fwY8Urn5BNt#-R-n8pE`RmJhudn3lYmH^rMy2Ng<^j$Lkha9+`fYIz8HI zURslNcCqb>Nwf0>J-`DQ^{62K07kmEkRLV{LS8%-Ku1o0AyqYtEkf4T+}gnjW(NqQ zZDr0g>_?}1tzs|kEoR-RPPTUuhWjU2<7Fgd4}SG=PjDY`X=boJ)z+bN_SxhNBb7Fx zjE*Ee?05qnrm;1B9z9a|EiWu(dvy7X>mLMk!m;b0T9Z)|SY9mpj-e9X&y){vc9x6| zI`#zOw>51h{%;FOdwX|wkgQlsn}E@;0}2;CxZ@txalYvk?Q7iUZ=tcwaV#>piZZZU zM+t16!H-}u$@Z>#Gvj?zUAVW=E#=h(l5d{ygB7GvcP#C|9dJ6F6Vke?T`@0htnMu} zGS{tdwA)1ab{+CPI#*BND?6Yb(^I=cW#;*l*@?C+jH4k~=bpp8OjEwAa80M7$Xx0z zVQFsgXc1~RlCnh{vkV128bEsbDQ9WKB;AQav+ya zauJ~iqY=>u@~hf;lS9)bhVuSL7b6X77lNpAfR6n1^s9PipQbLB_NhE}^G`4iv1V}raFF{FXqgnW0N4tGEK_t70jy7yFk`5b=+4ZEc@SVl*gH*pTtjQZ9v;zu~ z9uFp)PX6f|lm)Wrk~O`x?cKnCm(J8%%x8RYw02&4dsfbss@qx%*>4Ip*=>rfFpq#T zP#=HmS&tRIo2BWOli7W$*4Ed1>~i-8Ly^k%#cNpDuZcA4sV5->S=f?BLo}Ra0rdca zM{X)57No8hd!0XoJX3$IJ@1`kd#Bt1pFP1(H2@%Cm$w}&tkoA$(=-cfOWR9ZZ?!^_ zBuL^ypkRhPWM|XsUN(^Hz8KQywYR=RxS!!Z9DHsyE~` zLjxEUcImdbAZI4Cu3sv|{#BlqvGj3AJ#&U&0OQiDg&~ONO8TF{s7(C=+rGW;}Pe5rj5kjJ5g*nf)PYU6N;yUyd7~(;LfmS2o&wgtrk!!e& zxNenQm-mh7R@f4KDkef$5zc)o6WGpLk*s_kNv3QZ;BY&7)ae=7oOh-=;GXrQZp_%& zfai>RnzXxBNFONenywcEgPNWQ3%ca-Nw(yS9)+3NWAc?oNKv1yH+dKU>EzpG}hZS!}gv)V`z~t7pZJhIxwU45{6nNf03~7;AU9_obsZX@Y8-3Ob zwk>!e&9j0-Pt_hRWMbiI~H2zG)gk%CSJe1J{6SD>Wsl;m&DEqt)~q#@4hqxso+Y z^K50f`LGmc2Wa|Z-mvuvE_9tLT|nJDw}6y}H9*qIgV%4O`__h`8ei&Kgi+a;VU}Yg z0^P_06~;(Cc*j3l$?;wN>WV)jQ;=# z+-iO(@-A-g74*q%!s<3T3p5}CrI2?$e@eM$qutHnn2{~-EOZo|+R91B8))E`C#fn2 z%kAx2cXlI3iS4a)Q>yCbGbDF09jR`Z?!y`8s>x-m{6x2A@t&b2oElyvT{yMK~@Ix+g3dg_;dX^uS|9_MVBkje}0A-++^;hO0+trK0mgtnm!21yCI8g9}c zj&cCyx`4oT>?+h(_L>yZEv#nJH|*RH$EXz?7jxKZ*B2L8w>K8Zq13)ePvqZ2lIfR^q{AB9qUlzVz!q&H(ls&fBj-E*^Kwoqgp*n6_R&RSWUxm&kS4P}TNXU! zhf1@2miH1R;u#`IP98#d+s_~#hc#AdH7hG)Z((ec+B1h~B|km}N6Vi4^r&*bsL1WC zA@gLofLbo1T!=Mj&8N=?5J6aeizM?ep6 zT$5PW-X2$m7%lClgLyKv%zjp3ljUdo-RrCHe~7NEQ_Z-vEXf-~42>rxvBQ(VLe zD{2b;4BrzAi=9R&E&%&CmV;r-5ZM5arx~Z~mrbU4+W!D?E(D-KG>loDN`tifVB;Ru z)LB^C>TYEP#r>>h-Dg3EY~XA;?hbviUVn8h{l2px)FwacGaEKGj-YZqs~JJw?00st zt}f=azloVomJqu6gZExwIT`iisHQ=yta_vqx;^czjLmf(ogV)HkmZ>`9WrY(?UR3K zwUynNwMi8g)=8TwJAV=O9X}e;xYQ)mtld%*X#y)2`y^sE$j)$Alg$^DTCpz0U3bB{ zSB>sbFPdb$2unx-!X|UH4^zneD?;+p=foE`mXXZ&Y>0^x-%3fz7z&{900Xsi+9!y$ z4-ebw7oIECC4|W$H<*x_OEEYnYK-6lNCUnrH$>MZ@lLO(>n^kD`frfX+Q1y7s&j!W z{{Xal;8#uDM`Xy%L>ASM=+Mc>Dq2cJDxk=)hStJ0!9}5z7 zC)ApoNYi{v40o6F!EY{|2n%a*6NxszV#B!@>C&s;+iKI;`EaeXNMvaqL;ktR0G-GC zqp+@bEmv~Zi1ce)+grPpxj}I~w6O#Bcr(kW`HKS*4@gqo>R75qVPe-O!ciIwC3p0n|hp0hP3fnG?#X*GBZemU~r{(V;_b( z*5%!%m8)qsI)&$mE)A$~7fwt=7#+_1<+;cC6s@Jnty~D~bhevLwL)i%O0kw0EbKGe zsjFTWuo};bjs42o+1N=ULu!-8thVqd05Jo(JfB*qxV3RFBDR~VB-70Ho==gup(K6M z1_39z#aM#(R9Iq;{&{ABPUs_yZTU&yf$n;f$*kWI>l$v4eRHN;DAXdllutC4-yv27 zfaH#G>GZ359-;(SKV^u)aSU6LrF`(&#_xR9Ni?(sFO94&wA*W2eKS&y`C@(2>JUU@ zR|n>0!Rvx?pJ7|NU9PGuU`s~+FM!F96XQ${*dvm5d8q5p!!x1uF zDPXG4!k|<7*B#<73+fTgdhy$N@mbmZrNnQvg$Dp1bJnyxFXE3kLD_J`{`+f432YvR z`BuIB^6PNklO$x^H-X{Kw@|}xqFdNBcJ~&sg_2nTz~AYf zPp&J(ej?ioYpGRB1x6{8$>3tWcf#vzbbE`Gl0za$Wd`6K>`QV0>wtP!mV8mZit|>x zlHFKAER7)blrW*z3btO&(QtxW*48WY&DgAfBVzw6Cx-dRAP&FvfrS)jbcR zgSm}5=2O&U=RTEOe7VL?<5w=?+aX@NcokfN5E1|+WMEKsr>I-~@x@L@U*%QdQdkf@ z>c-r5>rD>B5d5d8&w7*+@u)m|R7=43H6(HZGm6f}i9N}C`c%-KPDTYpiMacAr>D!u zsI1!^?o19?XY{2Do`(mqp>hwsC>d;?zSS026KNvNourd8rsM^(L34q}y;hwzxmF`1 zzA9Ka03L91GHH_nPh8b=BcE36CAGWUPLW2XowxvZtxKI2>qpe4v%HVXxP^B-WRBz8 zy=6xuE(-ju(>be9$s}m=Zpl%DyOYgaVw*-a6}Bl{N)+uMjX((6N#y2{-_4gj%^+4{ zFuBL2TvEM}l(a&WGCncMtFS_TRUWkaYi*leLBZ)(PTYoGJ%x3~Ivma2mSY)+t~v_U z(QV8&Q%F~IjyA?U>m>;zc5po^?S%4OUqJ+C?~6c9zjW z3dsB04E*3Kj^vZly&p`|Zf|8v?DhPyRtmVb2-|BJcEJPo-k=Ta*amY&}(Bx zIdxr2?3S?KGFmbM1k#eN&)z1w{W{v%+ydh?S{fRHN`S!dhfku8- z1fSi2M$CF=1br%4F77p147Za>DtSnPq^Wx8gl!Z4S~IVA9O) z@wVvEhCBSsN#LJvTFssa5?7HB!L(;|faDKz-xZm4cX4~9&2#q4DQy;3*($LnrBZx9H~NH_(% zV4kM3lx2O*AvBfRI)4f3(wVJYA-0laSrTC6m2=AT&p>(ns*i{@-8aN{*E(&jz?W0C zkp=7~KbHrU+THmbYNfWH4wWaFyJWy@^JH_%58*$TUbU~`$n?4AeL-}{BfFV4dGTZ& zpyv(iUCAk{GZySsn@&BDnah%9ZqCbB9$)My=oo-^u6Zl0Bc;olVL`p&DW>Dqm(1+jAqyr71e zf#rzljB`j%ow%Irx4JvQ_larCXYFC@FVo$ajRjhS|0IKrzQa@qII zMQX8zjHH(2Nez+mBDS9Cj?(h)a*+lHu^o8DRo8VJtw!B!Euf0j#hEh*$mz=xdY)X7X_OIy-M}1^_*T^U<99Bg zwR)KTG}7&&Np54dmixMYjU`? zP1hUGE%R}YZi1@AqfcvLcdQv^x0>oiiKB^%NDu-}2;}s^`iiY-e`l|1SGog5_8%$& z4a=Ogj0RW|dTsZuHQ6C2XQ90VN2^J6e$8vdqxg?WztrVZcPoDX z0Qb?vxW*4fAJ(c`Xmi>vrJd|>x7k&MI}k6J6p&vX@^jC6t94-__?JqTK!P~$W-Raf zvQ}UQPf^>@R$SV+%W&#;lX!;S3wZwkvoy80(xaHHes~|f0GyouJeugVYgRrVx`qX} zSyh@?;I>TdhpBcx?UD2Xxhbz!{?Z#AM&dmwZHp{sc+NJrBoXRPNUQoTxYlrelE!&7 z=oUwh?F|rSn%s;9!Qf;A`PW{PvVBX@XlCoyk=xlmsWrk)EwcczCrKH)4EEXuVEBJh zm+emT$r?v6W?%+b5%);Xy><5>_@c*T&lS4b$1%5&pz@MWFDGFB^B$Y4hiPKAh9hu- z&iJ>Qkh5|kAaReadGwdEe5`v9yVMVgyv6%?)2>8bTTbH`KDZ;Tc_)scSZ68c8<3*{ zX6pFkHR;!Sg}k03`zMmk7R7lkRAI0|`EW-E9Ok^^;!2|3u!t2!7+v0-tFl+Ujj+$( zN1W@PNVBqJ!H_9Q20af4@fFTa;1wYLHLs|-b6FCpEHTryLwD5rT6X2o&%62NJn`vJ z$;ce%-mFdsnnCYX+c*aRibq-)h*Sp81mFO9sa?*|$81zeIv(TGy-4JDJ-gLxF^X~D zlg27V%K@Iop#eb{#Y&|>8OdQuyPDskEkvXN{b^$PTal7FR8SnA{{W3h#D?Vd&0^Jt z?vZCX!6OIKlx0X62il{J`hQw-oX7`ID>tZ~sFp5Uo(&JTBSZY;!YIs)0LK-jWdg=> z;Bim_ARvVwK}aQH{0_CeQi;gRlUxP>jy`tgx>07=MwLvtu11v)0KAEgrf1dz++3Y&kOqT88{VhrPTJYr+x`FFTdaEOz zeY>8M@WrmRJx<~q5gDE0Fwa3ikjz~V0l_s#db*GzicOA8YFc&Py zS(NU;AD%xt)0b3{5gH(}+d07@_WcjP(w8b~%nZCu;ftG{KG{y9C$%%J<<4Fx$tNJM zY;l@(uAL;RV7tAwn&}Fq!dplkNp9{H?RFQkv2#_@wG9%+<|C;~Bv&6Skv1{3oPd8J#bd>(>Pu~> zX-jreOKF6zQ2zj~=yUlDS3-P4YvN05neFW3pHmDPX9`sq0gZ>S_4TSYx>{XX+s8G8 z6I?{$nV6DFb=&Rrtz?pEAWXZgLp-{Sn+Z#Mgf2H6ux#!seY<9|-7RGBz1_C9vFW9m zerRw;)10d^H zd8V57IIzTvCl`FRg7p#O^L3kU3m{zDQ{p=o@>)$|8&^w{gc_D{}G}*bkAI3NMY$6Wm@M@+F>yQhLHSm23)V1cj$bw5sN!lF-2OytWD>3(I}PS+wxe7uPB z^G4ahDhT;cPL+2_@UEEmaa{eRr18jxM-91D4CD|y)oHITG+}K5O%MZI_- hi { - return hi + trust := []string{ + "Git", "Git", "Git", + "API", "API", + "Both", "Both", + "Neither", } - return v -} - -func randomSubmission(rng *rand.Rand, userID int, runID, session, namespace string) quizSubmission { - // q1 singleChoice β€” weighted 70% Yes / 20% Somewhat / 10% No - q1Pool := []string{ - "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", - "Somewhat", "Somewhat", + adoption := []string{ + "Yes", "Yes", "Yes", "Yes", + "Maybe", "Maybe", + "Only in dev", "No", } - q1 := q1Pool[rng.Intn(len(q1Pool))] - - // q2 multiChoice β€” random non-empty subset of tools - tools := []string{"Kubernetes", "Traefik", "Prometheus", "Terraform"} - rng.Shuffle(len(tools), func(i, j int) { tools[i], tools[j] = tools[j], tools[i] }) - q2 := tools[:1+rng.Intn(len(tools))] - - // q3 scale0to10 β€” Gaussian centered on 7, clamped [0,10] - q3 := clamp(rng.NormFloat64()*1.5+7, 0, 10) - - // q4 number β€” realistic cluster count [1, 50] - q4 := float64(1 + rng.Intn(50)) - - // q5 freeText β€” include userID to ensure uniqueness across submissions - q5 := fmt.Sprintf("%s [loadtest-user-%d]", freeTextPool[rng.Intn(len(freeTextPool))], userID) + mood := []string{ + "Curious", "Curious", "Curious", + "Excited", "Excited", + "Skeptical", + "Confused", + } return quizSubmission{ APIVersion: apiGroup + "/" + apiVersion, @@ -137,11 +107,10 @@ func randomSubmission(rng *rand.Rand, userID int, runID, session, namespace stri SessionRef: sessionRef{Name: session}, SubmittedAt: time.Now().UTC().Format(time.RFC3339), Answers: []answer{ - {QuestionID: "q1", SingleChoice: q1}, - {QuestionID: "q2", MultiChoice: q2}, - {QuestionID: "q3", Number: floatPtr(q3)}, - {QuestionID: "q4", Number: floatPtr(q4)}, - {QuestionID: "q5", FreeText: q5}, + {QuestionID: "q1", SingleChoice: roles[rng.Intn(len(roles))]}, + {QuestionID: "q2", SingleChoice: trust[rng.Intn(len(trust))]}, + {QuestionID: "q3", SingleChoice: adoption[rng.Intn(len(adoption))]}, + {QuestionID: "q4", SingleChoice: mood[rng.Intn(len(mood))]}, }, }, } @@ -346,7 +315,7 @@ func parseConfig() config { "base URL of the voter app") code := flag.String("code", "", "join code shown on the presenter screen (required)") - session := flag.String("session", "kubecon-2026", + session := flag.String("session", "demo", "quiz session name (QuizSession CR name)") namespace := flag.String("namespace", "vote", "Kubernetes namespace where the session lives") From 65614ca60a143d52e29525bbd4ea50a295fb7f1b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 23 Mar 2026 09:13:53 +0000 Subject: [PATCH 29/30] chore: the last fix of last night --- test/e2e/demo_e2e_test.go | 57 --------------------------------------- 1 file changed, 57 deletions(-) diff --git a/test/e2e/demo_e2e_test.go b/test/e2e/demo_e2e_test.go index a2369c164..84212a5b3 100644 --- a/test/e2e/demo_e2e_test.go +++ b/test/e2e/demo_e2e_test.go @@ -231,54 +231,11 @@ func (r *demoRun) verifyResourcesReady() { func (r *demoRun) seedRepository() { value := strconv.FormatInt(time.Now().UnixNano(), 10) - r.patchClusterResource( "namespace", demoNamespace, fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), ) - - r.patchNamespacedResource( - "gitprovider", - r.providerName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - r.patchNamespacedResource( - "gittarget", - r.targetName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - r.patchNamespacedResource( - "watchrule", - r.watchRuleName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - r.patchClusterResource( - "clusterwatchrule", - r.clusterWatchRuleName, - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - - r.patchNamespacedResource( - "deployment", - "vote-frontend", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - r.patchNamespacedResource( - "service", - "vote-frontend", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - r.patchNamespacedResource( - "ingressroute", - "frontend-static", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) - r.patchNamespacedResource( - "quizsession", - "kubecon-2026", - fmt.Sprintf(`{"metadata":{"annotations":{"configbutler.ai/demo-prepared-at":"%s"}}}`, value), - ) } func (r *demoRun) patchClusterResource(resource, name, patch string) { @@ -294,20 +251,6 @@ func (r *demoRun) patchClusterResource(resource, name, patch string) { Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("failed to patch cluster resource %s/%s", resource, name)) } -func (r *demoRun) patchNamespacedResource(resource, name, patch string) { - _, err := kubectlRunInNamespace( - demoNamespace, - "patch", - resource, - name, - "--type", - "merge", - "--patch", - patch, - ) - Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("failed to patch namespaced resource %s/%s", resource, name)) -} - func (r *demoRun) verifyRepositorySeeded() { expectedFiles := []string{ filepath.Join(r.checkoutDir, demoPath, ".sops.yaml"), From 88c39033abfade75201e19294c51080845483563 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 23 Mar 2026 14:12:18 +0000 Subject: [PATCH 30/30] chore: Let's invite people to react --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9e7dfe0d..805ad6b52 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ in [ConfigButler/example-audit](https://github.com/ConfigButler/example-audit). The broader pattern behind this project is described at [reversegitops.dev](https://reversegitops.dev). This repo contains a concrete operator that is implementing this. +## Kubecon Adam -> I would love your feedback! + +I am at Kubecon Amsterdam 2026: + +* Feel free to [book a meeting](https://calendar.app.google/FgYaoZog1dFpRG9Z7) to share your thoughts. +* Take a look at the [manifesto](https://reversegitops.dev/) ⭐ or the [concrete open source implemention](https://github.com/ConfigButler/gitops-reverser) ⭐. +* Let me know what you think, I'm on [LinkedIn](https://www.linkedin.com/in/simonkoudijs/). + ## Why Today teams often choose between workflows: @@ -46,7 +54,7 @@ GitOps Reverser bridges that gap: write to the Kubernetes API, and let the opera 🚨 Early stage software. CRDs and behavior may change, and it is not recommended for production yet. -- Single pod only today (`replicas=1`); multi-pod/HA is not supported yet. +- Single pod only today (`replicas=1`); multi-pod/HA is not (yet!) supported yet. - Runtime behavior is deterministic. The operator does not use AI or heuristics at runtime. - Current roadmap includes signed commits, HA support, and more edge-case hardening.