diff --git a/packages/extra/seaweedfs/Makefile b/packages/extra/seaweedfs/Makefile index a2504ed5..a252aa58 100644 --- a/packages/extra/seaweedfs/Makefile +++ b/packages/extra/seaweedfs/Makefile @@ -4,4 +4,4 @@ include ../../../scripts/package.mk generate: readme-generator-for-helm -v values.yaml -s values.schema.json -r README.md - yq -o json -i '.properties.topology.enum = ["Simple","MultiZone"]' values.schema.json + yq -o json -i '.properties.topology.enum = ["Simple","MultiZone","External"]' values.schema.json diff --git a/packages/extra/seaweedfs/README.md b/packages/extra/seaweedfs/README.md index f52bc9d3..026076d3 100644 --- a/packages/extra/seaweedfs/README.md +++ b/packages/extra/seaweedfs/README.md @@ -7,9 +7,10 @@ | Name | Description | Value | | ------------------- | ------------------------------------------------------------------------------------------------------ | -------- | | `host` | The hostname used to access the SeaweedFS externally (defaults to 's3' subdomain for the tenant host). | `""` | -| `topology` | The topology of the SeaweedFS cluster. (allowed values: Simple, MultiZone) | `Simple` | +| `topology` | The topology of the SeaweedFS cluster. (allowed values: Simple, MultiZone, External) | `Simple` | | `replicationFactor` | The number of replicas for each volume in the SeaweedFS cluster. | `2` | | `replicas` | Persistent Volume size for SeaweedFS | `2` | | `size` | Persistent Volume size | `10Gi` | | `storageClass` | StorageClass used to store the data | `""` | | `zones` | A map of zones for MultiZone topology. Each zone can have its own number of replicas and size. | `{}` | +| `externalClients` | A map of external clusters for managing bucketclaims. | `{}` | diff --git a/packages/extra/seaweedfs/templates/cm.yaml b/packages/extra/seaweedfs/templates/cm.yaml new file mode 100644 index 00000000..fc29b210 --- /dev/null +++ b/packages/extra/seaweedfs/templates/cm.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-deployed-topology +data: + topology: "{{ .Values.topology }}" diff --git a/packages/extra/seaweedfs/templates/dashboard-resourcemap.yaml b/packages/extra/seaweedfs/templates/dashboard-resourcemap.yaml index 02c9b2aa..75e5b940 100644 --- a/packages/extra/seaweedfs/templates/dashboard-resourcemap.yaml +++ b/packages/extra/seaweedfs/templates/dashboard-resourcemap.yaml @@ -3,6 +3,7 @@ kind: Role metadata: name: {{ .Release.Name }}-dashboard-resources rules: +{{- if not (eq .Values.topology "External") }} - apiGroups: - "" resources: @@ -27,6 +28,16 @@ rules: - {{ $.Release.Name }}-volume - {{ $.Release.Name }}-db verbs: ["get", "list", "watch"] +{{- else }} +- apiGroups: + - "" + resources: + - secrets + resourceNames: + - {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner + verbs: ["get", "list", "watch"] +{{- end }} + --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 @@ -38,3 +49,15 @@ roleRef: kind: Role name: {{ .Release.Name }}-dashboard-resources apiGroup: rbac.authorization.k8s.io + +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Name }}-dashboard-resources +subjects: +{{ include "cozy-lib.rbac.subjectsForTenantAndAccessLevel" (list "super-admin" .Release.Namespace) }} +roleRef: + kind: Role + name: {{ .Release.Name }}-dashboard-resources + apiGroup: rbac.authorization.k8s.io diff --git a/packages/extra/seaweedfs/templates/external-clients/deploy.yaml b/packages/extra/seaweedfs/templates/external-clients/deploy.yaml new file mode 100644 index 00000000..21b6e633 --- /dev/null +++ b/packages/extra/seaweedfs/templates/external-clients/deploy.yaml @@ -0,0 +1,159 @@ +{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }} +{{- $ingress := index $myNS.metadata.annotations "namespace.cozystack.io/ingress" }} +{{- $host := index $myNS.metadata.annotations "namespace.cozystack.io/host" }} +{{- range $clientName, $client := .Values.externalClients }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $.Release.Name }}-objectstorage-provisioner-{{ $clientName }} + namespace: {{ $client.namespace }} + labels: + app.kubernetes.io/component: objectstorage-provisioner-{{ $clientName }} + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/component: objectstorage-provisioner-{{ $clientName }} + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app.kubernetes.io/component: objectstorage-provisioner-{{ $clientName }} + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} + spec: + containers: + - name: seaweedfs-cosi-driver + image: ghcr.io/seaweedfs/seaweedfs-cosi-driver:v0.1.2 + imagePullPolicy: IfNotPresent + env: + - name: DRIVERNAME + value: {{ printf "%s.seaweedfs.objectstorage.k8s.io" $client.namespace }} + - name: ENDPOINT + value: https://{{ $.Values.host | default (printf "s3.%s" $host) }} + - name: SEAWEEDFS_FILER + value: seaweedfs-filer:18888 + - name: WEED_GRPC_CLIENT_KEY + value: /usr/local/share/ca-certificates/client/tls.key + - name: WEED_GRPC_CLIENT_CERT + value: /usr/local/share/ca-certificates/client/tls.crt + - name: WEED_GRPC_CA + value: /usr/local/share/ca-certificates/client/ca.crt + - name: WEED_CLUSTER_DEFAULT + value: sw + - name: WEED_CLUSTER_SW_FILER + value: seaweedfs-filer-client:8888 + - name: WEED_CLUSTER_SW_MASTER + value: seaweedfs-master:9333 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - mountPath: /var/lib/cosi + name: socket + - mountPath: /etc/seaweedfs/security.toml + name: security-config + readOnly: true + subPath: security.toml + - mountPath: /usr/local/share/ca-certificates/ca/ + name: ca-cert + readOnly: true + - mountPath: /usr/local/share/ca-certificates/master/ + name: master-cert + readOnly: true + - mountPath: /usr/local/share/ca-certificates/volume/ + name: volume-cert + readOnly: true + - mountPath: /usr/local/share/ca-certificates/filer/ + name: filer-cert + readOnly: true + - mountPath: /usr/local/share/ca-certificates/client/ + name: client-cert + readOnly: true + - name: seaweedfs-cosi-sidecar + image: ghcr.io/cozystack/cozystack/objectstorage-sidecar + imagePullPolicy: IfNotPresent + args: + - --v=5 + env: + - name: KUBECONFIG + value: /kubeconfig + - name: POD_NAMESPACE + value: {{ $client.namespace }} + volumeMounts: + - mountPath: /var/lib/cosi + name: socket + - mountPath: /kubeconfig + name: kubeconfig + subPath: kubeconfig + readOnly: true + enableServiceLinks: false + restartPolicy: Always + terminationGracePeriodSeconds: 10 + volumes: + - name: socket + - configMap: + name: {{ $.Release.Name }}-security-config + name: security-config + - name: ca-cert + secret: + secretName: {{ $.Release.Name }}-ca-cert + - name: master-cert + secret: + secretName: {{ $.Release.Name }}-master-cert + - name: volume-cert + secret: + secretName: {{ $.Release.Name }}-volume-cert + - name: filer-cert + secret: + secretName: {{ $.Release.Name }}-filer-cert + - name: client-cert + secret: + secretName: {{ $.Release.Name }}-client-cert + - name: kubeconfig + secret: + secretName: {{ $.Release.Name }}-objectstorage-provisioner-{{ $clientName }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $.Release.Name }}-objectstorage-provisioner-{{ $clientName }} + labels: + app.kubernetes.io/component: objectstorage-provisioner-{{ $clientName }} + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} +stringData: + kubeconfig: | + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: {{ $client.ca | b64enc | quote }} + server: {{ $client.cluster | quote }} + name: {{ $clientName }} + contexts: + - context: + cluster: {{ $clientName }} + namespace: {{ $client.namespace }} + user: {{ $clientName }} + name: {{ $clientName }} + current-context: {{ $clientName }} + kind: Config + preferences: {} + users: + - name: {{ $clientName }} + user: + token: {{ $client.token | quote }} +{{- end }} diff --git a/packages/extra/seaweedfs/templates/external/cosi-bucket-class.yaml b/packages/extra/seaweedfs/templates/external/cosi-bucket-class.yaml new file mode 100644 index 00000000..05c212f0 --- /dev/null +++ b/packages/extra/seaweedfs/templates/external/cosi-bucket-class.yaml @@ -0,0 +1,16 @@ +{{- if eq .Values.topology "External" }} +--- +kind: BucketClass +apiVersion: objectstorage.k8s.io/v1alpha1 +metadata: + name: {{ .Release.Namespace }} +driverName: {{ .Release.Namespace }}.seaweedfs.objectstorage.k8s.io +deletionPolicy: Delete +--- +kind: BucketAccessClass +apiVersion: objectstorage.k8s.io/v1alpha1 +metadata: + name: {{ .Release.Namespace }} +driverName: {{ .Release.Namespace }} +authenticationType: KEY +{{- end }} diff --git a/packages/extra/seaweedfs/templates/external/cosi-cluster-role.yaml b/packages/extra/seaweedfs/templates/external/cosi-cluster-role.yaml new file mode 100644 index 00000000..92f114c4 --- /dev/null +++ b/packages/extra/seaweedfs/templates/external/cosi-cluster-role.yaml @@ -0,0 +1,59 @@ +{{- if eq .Values.topology "External" }} +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner +rules: +- apiGroups: ["objectstorage.k8s.io"] + resources: + - "buckets" + - "bucketaccesses" + - "bucketclaims" + - "bucketaccessclasses" + - "buckets/status" + - "bucketaccesses/status" + - "bucketclaims/status" + - "bucketaccessclasses/status" + verbs: + - "get" + - "list" + - "watch" + - "update" + - "create" + - "delete" +- apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: + - "get" + - "watch" + - "list" + - "delete" + - "update" + - "create" +- apiGroups: [""] + resources: + - "secrets" + - "events" + verbs: + - "get" + - "list" + - "watch" + - "update" + - "create" + - "delete" + - "patch" +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner +subjects: + - kind: ServiceAccount + name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner + apiGroup: rbac.authorization.k8s.io +{{- end }} diff --git a/packages/extra/seaweedfs/templates/external/cosi-deployment.yaml b/packages/extra/seaweedfs/templates/external/cosi-deployment.yaml new file mode 100644 index 00000000..92367877 --- /dev/null +++ b/packages/extra/seaweedfs/templates/external/cosi-deployment.yaml @@ -0,0 +1,88 @@ +{{- if eq .Values.topology "Client" }} +{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }} +{{- $ingress := index $myNS.metadata.annotations "namespace.cozystack.io/ingress" }} +{{- $host := index $myNS.metadata.annotations "namespace.cozystack.io/host" }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $.Release.Name }}-objectstorage-provisioner + namespace: {{ $.Release.Namespace }} + labels: + app.kubernetes.io/component: objectstorage-provisioner + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/component: objectstorage-provisioner + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + policy.cozystack.io/allow-to-apiserver: "true" + app.kubernetes.io/component: objectstorage-provisioner + app.kubernetes.io/instance: seaweedfs + app.kubernetes.io/name: {{ $.Release.Name }} + spec: + containers: + - name: seaweedfs-cosi-driver + image: ghcr.io/seaweedfs/seaweedfs-cosi-driver:v0.1.2 + imagePullPolicy: IfNotPresent + env: + - name: DRIVERNAME + value: {{ .Release.Namespace }}.seaweedfs.objectstorage.k8s.io + - name: ENDPOINT + value: https://{{ .Values.host | default (printf "s3.%s" $host) }} + - name: SEAWEEDFS_FILER + value: {{ .Values.remoteEndpoint }} + - name: WEED_GRPC_CLIENT_KEY + value: /usr/local/share/ca-certificates/client/tls.key + - name: WEED_GRPC_CLIENT_CERT + value: /usr/local/share/ca-certificates/client/tls.crt + - name: WEED_GRPC_CA + value: /usr/local/share/ca-certificates/client/ca.crt + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - mountPath: /var/lib/cosi + name: socket + - mountPath: /usr/local/share/ca-certificates/client/ + name: client-cert + readOnly: true + - name: seaweedfs-cosi-sidecar + image: ghcr.io/cozystack/cozystack/objectstorage-sidecar + imagePullPolicy: IfNotPresent + args: + - --v=5 + env: + - name: POD_NAMESPACE + value: {{ .Release.Namespace }} + volumeMounts: + - mountPath: /var/lib/cosi + name: socket + enableServiceLinks: false + restartPolicy: Always + terminationGracePeriodSeconds: 10 + serviceAccountName: {{ .Release.Name }}-objectstorage-provisioner + volumes: + - name: socket + emptyDir: {} + - name: client-cert + secret: + defaultMode: 420 + secretName: seaweedfs-client-cert +{{- end }} diff --git a/packages/extra/seaweedfs/templates/external/cosi-service-account.yaml b/packages/extra/seaweedfs/templates/external/cosi-service-account.yaml new file mode 100644 index 00000000..4fc736a8 --- /dev/null +++ b/packages/extra/seaweedfs/templates/external/cosi-service-account.yaml @@ -0,0 +1,17 @@ +{{- if eq .Values.topology "External" }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner + namespace: {{ .Release.Namespace }} +automountServiceAccountToken: true +--- +apiVersion: v1 +kind: Secret +type: kubernetes.io/service-account-token +metadata: + name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner + namespace: {{ .Release.Namespace }} + annotations: + kubernetes.io/service-account.name: {{ .Release.Namespace }}-seaweedfs-objectstorage-provisioner +{{- end }} diff --git a/packages/extra/seaweedfs/templates/seaweedfs.yaml b/packages/extra/seaweedfs/templates/seaweedfs.yaml index eafdb4e3..69477256 100644 --- a/packages/extra/seaweedfs/templates/seaweedfs.yaml +++ b/packages/extra/seaweedfs/templates/seaweedfs.yaml @@ -1,6 +1,6 @@ {{- /* Preflight checks for Helm template */ -}} -{{- if not (has .Values.topology (list "Simple" "MultiZone")) }} -{{- fail "Invalid value for .Values.topology. Must be one of 'Simple' or 'MultiZone'." }} +{{- if not (has .Values.topology (list "Simple" "MultiZone" "External")) }} +{{- fail "Invalid value for .Values.topology. Must be one of 'Simple', 'MultiZone' or 'External'." }} {{- end }} {{- if lt (int .Values.replicationFactor) 1 }} {{- fail "Invalid value for .Values.replicationFactor. Must be at least 1." }} @@ -13,16 +13,24 @@ {{- fail "replicationFactor must be less than or equal to the number of zones defined in .Values.zones." }} {{- end }} {{- end }} -{{- if lookup "v1" "PersistentVolumeClaim" "" (printf "%s-data1-seaweedfs-volume-0" .Release.Name) }} -{{- if eq .Values.topology "MultiZone" }} -{{- fail "Not allowed to switch between Simple and MultiZone topologies after the first deployment." }} -{{- end }} + +{{- $detectedTopology := "Unknown" }} +{{- $configMap := lookup "v1" "ConfigMap" .Release.Namespace (printf "%s-deployed-topology" .Release.Name) }} +{{- if $configMap }} +{{- $detectedTopology = dig "data" "topology" "Unknown" $configMap }} {{- else }} -{{- if and (eq .Values.topology "Simple") (.Release.IsUpgrade) }} -{{- fail "Not allowed to switch between Simple and MultiZone topologies after the first deployment." }} +{{- if lookup "v1" "PersistentVolumeClaim" .Release.Namespace (printf "data1-%s-volume-0" .Release.Name) }} +{{- $detectedTopology = "Simple" }} +{{- else if lookup "apps/v1" "StatefulSet" .Release.Namespace (printf "%s-master" .Release.Name) }} +{{- $detectedTopology = "MultiZone" }} {{- end }} {{- end }} +{{- if not (has $detectedTopology (list .Values.topology "Unknown")) }} +{{- fail "Not allowed to switch between topologies after the first deployment." }} +{{- end }} + +{{- if not (eq .Values.topology "External") }} {{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }} {{- $ingress := index $myNS.metadata.annotations "namespace.cozystack.io/ingress" }} {{- $host := index $myNS.metadata.annotations "namespace.cozystack.io/host" }} @@ -198,3 +206,4 @@ spec: cnpg.io/cluster: seaweedfs-db cnpg.io/podRole: instance version: {{ $.Chart.Version }} +{{- end }} diff --git a/packages/extra/seaweedfs/templates/vpa.yaml b/packages/extra/seaweedfs/templates/vpa.yaml index a0c6d6a7..6e91764c 100644 --- a/packages/extra/seaweedfs/templates/vpa.yaml +++ b/packages/extra/seaweedfs/templates/vpa.yaml @@ -1,3 +1,4 @@ +{{- if not (eq .Values.topology "External") }} apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler metadata: @@ -64,3 +65,4 @@ spec: maxAllowed: cpu: "1" memory: 2048Mi +{{- end }} diff --git a/packages/extra/seaweedfs/values.schema.json b/packages/extra/seaweedfs/values.schema.json index 2f6e48bd..1bd91f0d 100644 --- a/packages/extra/seaweedfs/values.schema.json +++ b/packages/extra/seaweedfs/values.schema.json @@ -1,5 +1,10 @@ { "properties": { + "externalClients": { + "default": {}, + "description": "A map of external clusters for managing bucketclaims.", + "type": "object" + }, "host": { "default": "", "description": "The hostname used to access the SeaweedFS externally (defaults to 's3' subdomain for the tenant host).", @@ -27,11 +32,12 @@ }, "topology": { "default": "Simple", - "description": "The topology of the SeaweedFS cluster. (allowed values: Simple, MultiZone)", + "description": "The topology of the SeaweedFS cluster. (allowed values: Simple, MultiZone, External)", "type": "string", "enum": [ "Simple", - "MultiZone" + "MultiZone", + "External" ] }, "zones": { diff --git a/packages/extra/seaweedfs/values.yaml b/packages/extra/seaweedfs/values.yaml index 50846fcc..cd9a99c2 100644 --- a/packages/extra/seaweedfs/values.yaml +++ b/packages/extra/seaweedfs/values.yaml @@ -3,7 +3,7 @@ ## @param host The hostname used to access the SeaweedFS externally (defaults to 's3' subdomain for the tenant host). host: "" -## @param topology The topology of the SeaweedFS cluster. (allowed values: Simple, MultiZone) +## @param topology The topology of the SeaweedFS cluster. (allowed values: Simple, MultiZone, External) ## topology: Simple @@ -31,3 +31,24 @@ storageClass: "" ## replicas: 2 ## size: 10Gi zones: {} + +## @param externalClients A map of external clusters for managing bucketclaims. +## Example: +## externalClients: +## cluster1: +## cluster: "https://api.example.org" +## namespace: "tenant-root" +## ca: | +## -----BEGIN CERTIFICATE----- +## MIIBijCCATCgAwIBAgIRAMg6nrACqxg97aDuhzrpqGMwCgYIKoZIzj0EAwIwFTET +## MBEGA1UEChMKa3ViZXJuZXRlczAeFw0yNTA1MTUwNzE5MTBaFw0zNTA1MTMwNzE5 +## MTBaMBUxEzARBgNVBAoTCmt1YmVybmV0ZXMwWTATBgcqhkjOPQIBBggqhkjOPQMB +## BwNCAATh2nTL8tglw2cHO3/QSMqsiP4Ws8UIxNxjG5MtmIx4E7EH9fJjcpx6Ncfg +## Ekq6lbeTi6KpQOksAvhiq/EcwLLUo2EwXzAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0l +## BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +## BBYEFEwtnxdCdotUI1cbgsszGHM/nbUuMAoGCCqGSM49BAMCA0gAMEUCIQCA785y +## 6H6l8T1r0HjhAkHqWWYpuPj8lhiSqG0aXinWPgIgPksSKIRTnVyiQ/w5pWMnKuBD +## QFq6nUXbcO+ABY439FU= +## -----END CERTIFICATE----- +## token: "eyJhbGciOiJSUzI1NiIsImtpZCI6IlJMaEMyMTVvdDVVSENqZ1EySEFtVUhobnp2Mi02RXJ4bWM0bDJVenJmYzgifQ.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJ0ZW5hbnQtcm9vdCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJ0ZW5hbnQtcm9vdCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUiOiJ0ZW5hbnQtcm9vdCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50LnVpZCI6ImQ4OWRmN2M0LTI1OTctNGQ0NS1hM2U4LTIzMWVhYTJiN2U5NCIsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDp0ZW5hbnQtcm9vdDp0ZW5hbnQtcm9vdCJ9.eIFJwuT5q4nxgsTraxTw7cnaMiKaOOTpFazSf_LKS_gE9KO6AC5WvitFFIMudnm_0XrYMrMbjoBvtNf3khzmy-n5WSmmlxyZj0RKUUgoWnVjwy2pq0xLiZWSzbrHKnDpfyYUgz6Qz-LcqNK5O6Mnkd5xpz2FrY3vdjLJJ90f3cKFewI3GksvI6F7NG7ANSdhI0xBVKCCsIGoQL1m7ieTQtJoS9p4HtlfTBFOHj6NCu4Sai-2z-CC_cVxYQLCPD3fsYTpF4MZGMunDHaHadMVmvBZrHyBdH4OcrCGJ2ZtXBOsxxYRq8SHsGP7M5T_jwZ8j66BtJ_d3-3Aij-lorX2dpmwBLcc1yR5Pxgh1bwjRn_04oM30s6xlVcpupAfTvs4WpApADKsV3RixLTYNf18mTCpZL6phbw3TWSRSD0Gz-08QkROWvB2IJqsya65BPnSfab5eFumYvYiZVn84mHhh7BxSuRXBTTGAYzQ-D6t1V7W2sSPF191lrSkYkxwQ_rtyi4EfDqayD2D4pFGWJJhqSM7Ra4ZsAy9TpSTfKtWsIwngwnGn7zXsWKPIYGP8Xyt-97nWum4qvgHQAPBa0SofxI7dKHiZ1KKo-vOuKdL0Qka0_E-h_3w1w3Gn6-q1X8qp5YNB-fHooYYGeL8kFiBjRacB1QSAGAuBwaKl37xB4U" +externalClients: {}