From 21d67a8622cf3d516f3c57a261922f5d21614efb Mon Sep 17 00:00:00 2001 From: chlins Date: Thu, 18 Jun 2026 11:08:01 +0800 Subject: [PATCH 1/4] feat(backup): add global backup volume policies Signed-off-by: chlins --- changelogs/unreleased/9928-chlins | 1 + .../resourcepolicies/resource_policies.go | 74 +++++++ .../resource_policies_test.go | 193 ++++++++++++++++++ pkg/apis/velero/v1/labels_annotations.go | 5 + pkg/cmd/server/config/config.go | 79 +++---- pkg/cmd/server/config/config_test.go | 12 ++ pkg/cmd/server/server.go | 10 + pkg/cmd/util/output/backup_describer.go | 15 ++ pkg/cmd/util/output/backup_describer_test.go | 28 +++ .../output/backup_structured_describer.go | 15 ++ .../backup_structured_describer_test.go | 21 ++ pkg/controller/backup_controller.go | 113 +++++----- pkg/controller/backup_controller_test.go | 95 +++++++++ site/content/docs/main/resource-filtering.md | 82 ++++++++ 14 files changed, 654 insertions(+), 89 deletions(-) create mode 100644 changelogs/unreleased/9928-chlins diff --git a/changelogs/unreleased/9928-chlins b/changelogs/unreleased/9928-chlins new file mode 100644 index 000000000..29ee82e6c --- /dev/null +++ b/changelogs/unreleased/9928-chlins @@ -0,0 +1 @@ +Add `--global-backup-volume-policies-configmap` server flag to configure cluster-wide global backup volume policies that are merged into every backup diff --git a/internal/resourcepolicies/resource_policies.go b/internal/resourcepolicies/resource_policies.go index 14ded0968..43895b695 100644 --- a/internal/resourcepolicies/resource_policies.go +++ b/internal/resourcepolicies/resource_policies.go @@ -351,6 +351,80 @@ func GetResourcePoliciesFromBackup( return resourcePolicies, nil } +// GetGlobalResourcePolicies loads and validates the cluster-wide global backup volume +// policies from a ConfigMap in the Velero install namespace. Only the volumePolicies +// section is honored globally; any include/exclude or fine-grained filter policies are +// ignored (a warning is logged), as those are tied to a specific backup use case. +func GetGlobalResourcePolicies( + client crclient.Client, + namespace string, + configMapName string, + logger logrus.FieldLogger, +) (*Policies, error) { + cm := &corev1api.ConfigMap{} + if err := client.Get(context.Background(), crclient.ObjectKey{Namespace: namespace, Name: configMapName}, cm); err != nil { + return nil, fmt.Errorf("fail to get global backup volume policies ConfigMap %s/%s: %w", namespace, configMapName, err) + } + + policies, err := getResourcePoliciesFromConfig(cm) + if err != nil { + return nil, fmt.Errorf("fail to read global backup volume policies from ConfigMap %s/%s: %w", namespace, configMapName, err) + } + if err := policies.Validate(); err != nil { + return nil, fmt.Errorf("fail to validate global backup volume policies in ConfigMap %s/%s: %w", namespace, configMapName, err) + } + + // Only volumePolicies apply globally; warn about any other filter policies that will be ignored. + if policies.includeExcludePolicy != nil || + policies.clusterScopedFilterPolicy != nil || + len(policies.namespacedFilterPolicies) > 0 { + logger.Warnf("Global backup volume policies ConfigMap %s/%s contains include/exclude or fine-grained "+ + "filter policies; these are ignored, only volumePolicies apply globally.", namespace, configMapName) + } + + // Return a fresh Policies carrying only the globally-applicable fields. Using an allowlist here + // (rather than nil-ing out the ignored fields) means any filter field added to Policies in the + // future is excluded from the global policies by default, without needing to update this code. + return &Policies{ + version: policies.version, + volumePolicies: policies.volumePolicies, + }, nil +} + +// GetResourcePoliciesFromBackupWithGlobal builds the effective resource policies for a backup +// by merging the backup-referenced resource policies with the global backup volume policies +// (when globalConfigMapName is set). The merged volumePolicies list is the backup-level +// policies followed by the global ones, so the first match wins and a backup can override the +// global baseline for a specific volume while still inheriting the rest of the global rules. +func GetResourcePoliciesFromBackupWithGlobal( + backup velerov1api.Backup, + client crclient.Client, + globalConfigMapName string, + installNamespace string, + logger logrus.FieldLogger, +) (*Policies, error) { + backupPolicies, err := GetResourcePoliciesFromBackup(backup, client, logger) + if err != nil { + return nil, err + } + + if globalConfigMapName == "" { + return backupPolicies, nil + } + + globalPolicies, err := GetGlobalResourcePolicies(client, installNamespace, globalConfigMapName, logger) + if err != nil { + return nil, err + } + + if backupPolicies == nil { + return globalPolicies, nil + } + // Backup-level policies first, then global, so backups can override the global baseline. + backupPolicies.volumePolicies = append(backupPolicies.volumePolicies, globalPolicies.volumePolicies...) + return backupPolicies, nil +} + func getResourcePoliciesFromConfig(cm *corev1api.ConfigMap) (*Policies, error) { if cm == nil { return nil, fmt.Errorf("could not parse config from nil configmap") diff --git a/internal/resourcepolicies/resource_policies_test.go b/internal/resourcepolicies/resource_policies_test.go index 5988de56b..cea7ed2cf 100644 --- a/internal/resourcepolicies/resource_policies_test.go +++ b/internal/resourcepolicies/resource_policies_test.go @@ -18,11 +18,15 @@ package resourcepolicies import ( "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1api "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + velerotest "github.com/vmware-tanzu/velero/pkg/test" ) func pvcVolumeMode(mode corev1api.PersistentVolumeMode) *corev1api.PersistentVolumeMode { @@ -2172,3 +2176,192 @@ func TestPVCAccessModesMatch(t *testing.T) { }) } } + +// ---- Global backup volume policies ---- + +func globalPolicyConfigMap(name, data string) *corev1api.ConfigMap { + return &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "velero", Name: name}, + Data: map[string]string{"policies.yaml": data}, + } +} + +func backupWithPolicy(ref string) velerov1api.Backup { + b := velerov1api.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: "velero", Name: "backup"}} + if ref != "" { + b.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{Kind: ConfigmapRefType, Name: ref} + } + return b +} + +// firstActionFor returns the action type the policies select for a PV with the given storage +// class, or "" when nothing matches. It exercises the compiled match logic so the tests verify +// merge ordering rather than internal field layout. +func firstActionFor(p *Policies, storageClass string) VolumeActionType { + pv := &corev1api.PersistentVolume{Spec: corev1api.PersistentVolumeSpec{StorageClassName: storageClass}} + vol := &structuredVolume{} + vol.parsePV(pv) + if a := p.match(vol); a != nil { + return a.Type + } + return "" +} + +func TestGetResourcePoliciesFromBackupWithGlobal(t *testing.T) { + gp2Skip := `version: v1 +volumePolicies: + - conditions: + storageClass: + - gp2 + action: + type: skip +` + gp2Snapshot := `version: v1 +volumePolicies: + - conditions: + storageClass: + - gp2 + action: + type: snapshot +` + otherFsBackup := `version: v1 +volumePolicies: + - conditions: + storageClass: + - other + action: + type: fs-backup +` + + tests := []struct { + name string + backupCM *corev1api.ConfigMap + globalCMName string + globalCM *corev1api.ConfigMap + backupRef string + expectErr bool + expectedGp2Action VolumeActionType + expectedNumPolicies int + }{ + { + name: "no global, backup only - unchanged behavior", + backupRef: "backup01", + backupCM: globalPolicyConfigMap("backup01", gp2Snapshot), + expectedGp2Action: Snapshot, + expectedNumPolicies: 1, + }, + { + name: "global only, backup has no policy", + globalCMName: "global", + globalCM: globalPolicyConfigMap("global", gp2Skip), + expectedGp2Action: Skip, + expectedNumPolicies: 1, + }, + { + name: "no global configured and no backup policy", + expectedGp2Action: "", + expectedNumPolicies: 0, + }, + { + name: "merge - backup policy overrides global for gp2", + backupRef: "backup01", + backupCM: globalPolicyConfigMap("backup01", gp2Snapshot), + globalCMName: "global", + globalCM: globalPolicyConfigMap("global", gp2Skip), + expectedGp2Action: Snapshot, // backup-level wins (evaluated first) + expectedNumPolicies: 2, + }, + { + name: "merge - backup inherits non-overlapping global rule", + backupRef: "backup01", + backupCM: globalPolicyConfigMap("backup01", otherFsBackup), + globalCMName: "global", + globalCM: globalPolicyConfigMap("global", gp2Skip), + expectedGp2Action: Skip, // only global matches gp2 + expectedNumPolicies: 2, + }, + { + name: "global configmap missing - error", + globalCMName: "global", + expectErr: true, + }, + { + name: "global configmap invalid - error", + globalCMName: "global", + globalCM: globalPolicyConfigMap("global", "not: [valid"), + expectErr: true, + }, + { + // Parses cleanly but fails Policies.Validate() due to the unsupported version. + name: "global configmap fails validation - error", + globalCMName: "global", + globalCM: globalPolicyConfigMap("global", "version: v2\nvolumePolicies: []\n"), + expectErr: true, + }, + { + // Backup references a ResourcePolicy ConfigMap that does not exist, so resolving the + // backup-level policies fails before the global ones are consulted. + name: "backup configmap missing - error", + backupRef: "missing-backup-cm", + globalCMName: "global", + globalCM: globalPolicyConfigMap("global", gp2Skip), + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := velerotest.NewFakeControllerRuntimeClient(t) + if tc.backupCM != nil { + require.NoError(t, client.Create(t.Context(), tc.backupCM)) + } + if tc.globalCM != nil { + require.NoError(t, client.Create(t.Context(), tc.globalCM)) + } + + b := backupWithPolicy(tc.backupRef) + + p, err := GetResourcePoliciesFromBackupWithGlobal(b, client, tc.globalCMName, "velero", logrus.New()) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + if tc.expectedNumPolicies == 0 { + assert.Nil(t, p) + return + } + require.NotNil(t, p) + assert.Len(t, p.volumePolicies, tc.expectedNumPolicies) + assert.Equal(t, tc.expectedGp2Action, firstActionFor(p, "gp2")) + }) + } +} + +func TestGetGlobalResourcePoliciesIgnoresNonVolumePolicies(t *testing.T) { + data := `version: v1 +volumePolicies: + - conditions: + storageClass: + - gp2 + action: + type: skip +namespacedFilterPolicies: +- namespaces: ["frontend"] + resourceFilters: + - kinds: ["Pod"] +` + client := velerotest.NewFakeControllerRuntimeClient(t) + require.NoError(t, client.Create(t.Context(), globalPolicyConfigMap("global", data))) + + p, err := GetGlobalResourcePolicies(client, "velero", "global", logrus.New()) + require.NoError(t, err) + require.NotNil(t, p) + + // Only volumePolicies are kept; the namespaced filter policy is dropped. + assert.Len(t, p.volumePolicies, 1) + assert.Empty(t, p.GetNamespacedFilterPolicies()) + assert.Nil(t, p.GetIncludeExcludePolicy()) + assert.Nil(t, p.GetClusterScopedFilterPolicy()) +} diff --git a/pkg/apis/velero/v1/labels_annotations.go b/pkg/apis/velero/v1/labels_annotations.go index 921af498e..13da279d8 100644 --- a/pkg/apis/velero/v1/labels_annotations.go +++ b/pkg/apis/velero/v1/labels_annotations.go @@ -80,6 +80,11 @@ const ( // timeout value for backup to plugins. ResourceTimeoutAnnotation = "velero.io/resource-timeout" + // GlobalBackupVolumePolicyConfigMapAnnotation is the annotation key used to record the + // name of the cluster-wide global backup volume policies ConfigMap that contributed to a + // backup, so that `velero backup describe` can surface it. + GlobalBackupVolumePolicyConfigMapAnnotation = "velero.io/global-backup-volume-policy-configmap" + // AsyncOperationIDLabel is the label key used to identify the async operation ID AsyncOperationIDLabel = "velero.io/async-operation-id" diff --git a/pkg/cmd/server/config/config.go b/pkg/cmd/server/config/config.go index e19086217..c8080da21 100644 --- a/pkg/cmd/server/config/config.go +++ b/pkg/cmd/server/config/config.go @@ -145,42 +145,43 @@ var ( ) type Config struct { - PluginDir string - MetricsAddress string - DefaultBackupLocation string // TODO(2.0) Deprecate defaultBackupLocation - BackupSyncPeriod time.Duration - PodVolumeOperationTimeout time.Duration - ResourceTerminatingTimeout time.Duration - DefaultBackupTTL time.Duration - DefaultVGSLabelKey string - StoreValidationFrequency time.Duration - DefaultCSISnapshotTimeout time.Duration - DefaultItemOperationTimeout time.Duration - ResourceTimeout time.Duration - RestoreResourcePriorities types.Priorities - DefaultVolumeSnapshotLocations flag.Map - RestoreOnly bool - DisabledControllers []string - ClientQPS float32 - ClientBurst int - ClientPageSize int - ProfilerAddress string - LogLevel *logging.LevelFlag - LogFormat *logging.FormatFlag - RepoMaintenanceFrequency time.Duration - GarbageCollectionFrequency time.Duration - ItemOperationSyncFrequency time.Duration - DefaultVolumesToFsBackup bool - UploaderType string - MaxConcurrentK8SConnections int - DefaultSnapshotMoveData bool - DisableInformerCache bool - ScheduleSkipImmediately bool - CredentialsDirectory string - BackupRepoConfig string - RepoMaintenanceJobConfig string - ItemBlockWorkerCount int - ConcurrentBackups int + PluginDir string + MetricsAddress string + DefaultBackupLocation string // TODO(2.0) Deprecate defaultBackupLocation + BackupSyncPeriod time.Duration + PodVolumeOperationTimeout time.Duration + ResourceTerminatingTimeout time.Duration + DefaultBackupTTL time.Duration + DefaultVGSLabelKey string + StoreValidationFrequency time.Duration + DefaultCSISnapshotTimeout time.Duration + DefaultItemOperationTimeout time.Duration + ResourceTimeout time.Duration + RestoreResourcePriorities types.Priorities + DefaultVolumeSnapshotLocations flag.Map + RestoreOnly bool + DisabledControllers []string + ClientQPS float32 + ClientBurst int + ClientPageSize int + ProfilerAddress string + LogLevel *logging.LevelFlag + LogFormat *logging.FormatFlag + RepoMaintenanceFrequency time.Duration + GarbageCollectionFrequency time.Duration + ItemOperationSyncFrequency time.Duration + DefaultVolumesToFsBackup bool + UploaderType string + MaxConcurrentK8SConnections int + DefaultSnapshotMoveData bool + DisableInformerCache bool + ScheduleSkipImmediately bool + CredentialsDirectory string + BackupRepoConfig string + RepoMaintenanceJobConfig string + ItemBlockWorkerCount int + ConcurrentBackups int + GlobalBackupVolumePoliciesConfigMap string } func GetDefaultConfig() *Config { @@ -275,4 +276,10 @@ func (c *Config) BindFlags(flags *pflag.FlagSet) { c.ConcurrentBackups, "Number of backups to process concurrently. Default is one. Optional.", ) + flags.StringVar( + &c.GlobalBackupVolumePoliciesConfigMap, + "global-backup-volume-policies-configmap", + c.GlobalBackupVolumePoliciesConfigMap, + "The name of a ConfigMap in the Velero install namespace holding global backup volume policies that are merged into every backup. Optional.", + ) } diff --git a/pkg/cmd/server/config/config_test.go b/pkg/cmd/server/config/config_test.go index ba17437f1..a0e33c413 100644 --- a/pkg/cmd/server/config/config_test.go +++ b/pkg/cmd/server/config/config_test.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetDefaultConfig(t *testing.T) { @@ -17,3 +18,14 @@ func TestBindFlags(t *testing.T) { config.BindFlags(pflag.CommandLine) assert.Equal(t, 1, config.ItemBlockWorkerCount) } + +func TestGlobalBackupVolumePoliciesConfigMapFlag(t *testing.T) { + config := GetDefaultConfig() + // Opt-in: defaults to empty. + assert.Empty(t, config.GlobalBackupVolumePoliciesConfigMap) + + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + config.BindFlags(flags) + require.NoError(t, flags.Parse([]string{"--global-backup-volume-policies-configmap", "global-volume-policy"})) + assert.Equal(t, "global-volume-policy", config.GlobalBackupVolumePoliciesConfigMap) +} diff --git a/pkg/cmd/server/server.go b/pkg/cmd/server/server.go index 33f8148ff..83627f9d1 100644 --- a/pkg/cmd/server/server.go +++ b/pkg/cmd/server/server.go @@ -57,6 +57,7 @@ import ( "github.com/vmware-tanzu/velero/internal/credentials" "github.com/vmware-tanzu/velero/internal/hook" + "github.com/vmware-tanzu/velero/internal/resourcepolicies" "github.com/vmware-tanzu/velero/internal/storage" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" velerov2alpha1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v2alpha1" @@ -390,6 +391,14 @@ func (s *server) setupBeforeControllerRun() error { if err := setDefaultBackupLocation(s.ctx, client, s.namespace, s.config.DefaultBackupLocation, s.logger); err != nil { return err } + + // Validate the global backup volume policies ConfigMap early, so misconfigurations fail fast. + if s.config.GlobalBackupVolumePoliciesConfigMap != "" { + if _, err := resourcepolicies.GetGlobalResourcePolicies(client, s.namespace, s.config.GlobalBackupVolumePoliciesConfigMap, s.logger); err != nil { + return err + } + s.logger.WithField("configmap", s.config.GlobalBackupVolumePoliciesConfigMap).Info("Loaded global backup volume policies") + } return nil } @@ -671,6 +680,7 @@ func (s *server) runControllers(defaultVolumeSnapshotLocations map[string]string s.config.ItemBlockWorkerCount, s.config.ConcurrentBackups, s.crClient, + s.config.GlobalBackupVolumePoliciesConfigMap, ).SetupWithManager(s.mgr); err != nil { s.logger.Fatal(err, "unable to create controller", "controller", constant.ControllerBackup) } diff --git a/pkg/cmd/util/output/backup_describer.go b/pkg/cmd/util/output/backup_describer.go index 89fc74a70..4c8222f81 100644 --- a/pkg/cmd/util/output/backup_describer.go +++ b/pkg/cmd/util/output/backup_describer.go @@ -99,6 +99,8 @@ func DescribeBackup( DescribeFineGrainedFilterPolicies(ctx, kbClient, d, backup) } + DescribeGlobalVolumePolicy(d, backup) + if backup.Spec.UploaderConfig != nil && backup.Spec.UploaderConfig.ParallelFilesUpload > 0 { d.Println() DescribeUploaderConfigForBackup(d, backup.Spec) @@ -136,6 +138,19 @@ func DescribeResourcePolicies(d *Describer, resPolicies *corev1api.TypedLocalObj d.Printf("\tName:\t%s\n", resPolicies.Name) } +// DescribeGlobalVolumePolicy describes the cluster-wide global backup volume policies +// ConfigMap that contributed to the backup, if any. +func DescribeGlobalVolumePolicy(d *Describer, backup *velerov1api.Backup) { + name := backup.Annotations[velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation] + if name == "" { + return + } + d.Println() + d.Printf("Global volume policies:\n") + d.Printf("\tType:\t%s\n", resourcepolicies.ConfigmapRefType) + d.Printf("\tName:\t%s\n", name) +} + // DescribeFineGrainedFilterPolicies describes cluster-scoped and namespace-scoped filter policies if present func DescribeFineGrainedFilterPolicies(ctx context.Context, kbClient kbclient.Client, d *Describer, backup *velerov1api.Backup) { if backup.Spec.ResourcePolicy == nil { diff --git a/pkg/cmd/util/output/backup_describer_test.go b/pkg/cmd/util/output/backup_describer_test.go index 936b19422..248b0a45b 100644 --- a/pkg/cmd/util/output/backup_describer_test.go +++ b/pkg/cmd/util/output/backup_describer_test.go @@ -72,6 +72,34 @@ func TestDescribeResourcePolicies(t *testing.T) { assert.Equal(t, expect, d.buf.String()) } +func TestDescribeGlobalVolumePolicy(t *testing.T) { + newDescriber := func() *Describer { + d := &Describer{out: &tabwriter.Writer{}, buf: &bytes.Buffer{}} + d.out.Init(d.buf, 0, 8, 2, ' ', 0) + return d + } + + // No annotation: nothing is printed. + d := newDescriber() + DescribeGlobalVolumePolicy(d, builder.ForBackup("velero", "b").Result()) + d.out.Flush() + assert.Empty(t, d.buf.String()) + + // Annotation present: ConfigMap name is surfaced. + d = newDescriber() + backup := builder.ForBackup("velero", "b"). + ObjectMeta(builder.WithAnnotations(velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation, "global-volume-policy")). + Result() + DescribeGlobalVolumePolicy(d, backup) + d.out.Flush() + expect := ` +Global volume policies: + Type: configmap + Name: global-volume-policy +` + assert.Equal(t, expect, d.buf.String()) +} + func TestDescribeBackupSpec(t *testing.T) { input1 := builder.ForBackup("test-ns", "test-backup-1"). IncludedNamespaces("inc-ns-1", "inc-ns-2"). diff --git a/pkg/cmd/util/output/backup_structured_describer.go b/pkg/cmd/util/output/backup_structured_describer.go index 8ec31b72c..dfffcda06 100644 --- a/pkg/cmd/util/output/backup_structured_describer.go +++ b/pkg/cmd/util/output/backup_structured_describer.go @@ -60,6 +60,8 @@ func DescribeBackupInSF( DescribeFineGrainedFilterPoliciesInSF(ctx, kbClient, d, backup) } + DescribeGlobalVolumePolicyInSF(d, backup) + status := backup.Status if len(status.ValidationErrors) > 0 { d.Describe("validationErrors", status.ValidationErrors) @@ -699,6 +701,19 @@ func DescribeResourcePoliciesInSF(d *StructuredDescriber, resPolicies *corev1api d.Describe("resourcePolicies", policiesInfo) } +// DescribeGlobalVolumePolicyInSF describes the global backup volume policies ConfigMap that +// contributed to the backup, if any, in structured format. +func DescribeGlobalVolumePolicyInSF(d *StructuredDescriber, backup *velerov1api.Backup) { + name := backup.Annotations[velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation] + if name == "" { + return + } + d.Describe("globalVolumePolicies", map[string]any{ + "type": resourcepolicies.ConfigmapRefType, + "name": name, + }) +} + func describeResultInSF(m map[string]any, result results.Result) { m["velero"], m["cluster"], m["namespace"] = []string{}, []string{}, []string{} diff --git a/pkg/cmd/util/output/backup_structured_describer_test.go b/pkg/cmd/util/output/backup_structured_describer_test.go index 77d219f49..cb46a4676 100644 --- a/pkg/cmd/util/output/backup_structured_describer_test.go +++ b/pkg/cmd/util/output/backup_structured_describer_test.go @@ -627,6 +627,27 @@ func TestDescribeResourcePoliciesInSF(t *testing.T) { assert.True(t, reflect.DeepEqual(sd.output, expect)) } +func TestDescribeGlobalVolumePolicyInSF(t *testing.T) { + // No annotation: nothing is added to the output. + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + DescribeGlobalVolumePolicyInSF(sd, builder.ForBackup("velero", "b").Result()) + assert.Empty(t, sd.output) + + // Annotation present: the ConfigMap name is surfaced. + sd = &StructuredDescriber{output: make(map[string]any), format: ""} + backup := builder.ForBackup("velero", "b"). + ObjectMeta(builder.WithAnnotations(velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation, "global-volume-policy")). + Result() + DescribeGlobalVolumePolicyInSF(sd, backup) + expectGlobal := map[string]any{ + "globalVolumePolicies": map[string]any{ + "type": "configmap", + "name": "global-volume-policy", + }, + } + assert.True(t, reflect.DeepEqual(sd.output, expectGlobal)) +} + func TestDescribeBackupResultInSF(t *testing.T) { input := results.Result{ Velero: []string{"msg-1", "msg-2"}, diff --git a/pkg/controller/backup_controller.go b/pkg/controller/backup_controller.go index ea1c53c5d..b7222d489 100644 --- a/pkg/controller/backup_controller.go +++ b/pkg/controller/backup_controller.go @@ -84,32 +84,33 @@ var autoExcludeClusterScopedResources = []string{ } type backupReconciler struct { - ctx context.Context - logger logrus.FieldLogger - discoveryHelper discovery.Helper - backupper pkgbackup.Backupper - kbClient kbclient.Client - clock clock.WithTickerAndDelayedExecution - backupLogLevel logrus.Level - newPluginManager func(logrus.FieldLogger) clientmgmt.Manager - backupTracker BackupTracker - defaultBackupLocation string - defaultVolumesToFsBackup bool - defaultBackupTTL time.Duration - defaultVGSLabelKey string - defaultCSISnapshotTimeout time.Duration - resourceTimeout time.Duration - defaultItemOperationTimeout time.Duration - defaultSnapshotLocations map[string]string - metrics *metrics.ServerMetrics - backupStoreGetter persistence.ObjectBackupStoreGetter - formatFlag logging.Format - credentialFileStore credentials.FileStore - maxConcurrentK8SConnections int - defaultSnapshotMoveData bool - globalCRClient kbclient.Client - itemBlockWorkerCount int - concurrentBackups int + ctx context.Context + logger logrus.FieldLogger + discoveryHelper discovery.Helper + backupper pkgbackup.Backupper + kbClient kbclient.Client + clock clock.WithTickerAndDelayedExecution + backupLogLevel logrus.Level + newPluginManager func(logrus.FieldLogger) clientmgmt.Manager + backupTracker BackupTracker + defaultBackupLocation string + defaultVolumesToFsBackup bool + defaultBackupTTL time.Duration + defaultVGSLabelKey string + defaultCSISnapshotTimeout time.Duration + resourceTimeout time.Duration + defaultItemOperationTimeout time.Duration + defaultSnapshotLocations map[string]string + metrics *metrics.ServerMetrics + backupStoreGetter persistence.ObjectBackupStoreGetter + formatFlag logging.Format + credentialFileStore credentials.FileStore + maxConcurrentK8SConnections int + defaultSnapshotMoveData bool + globalCRClient kbclient.Client + itemBlockWorkerCount int + concurrentBackups int + globalVolumePoliciesConfigMap string } func NewBackupReconciler( @@ -138,34 +139,36 @@ func NewBackupReconciler( itemBlockWorkerCount int, concurrentBackups int, globalCRClient kbclient.Client, + globalVolumePoliciesConfigMap string, ) *backupReconciler { b := &backupReconciler{ - ctx: ctx, - discoveryHelper: discoveryHelper, - backupper: backupper, - clock: &clock.RealClock{}, - logger: logger, - backupLogLevel: backupLogLevel, - newPluginManager: newPluginManager, - backupTracker: backupTracker, - kbClient: kbClient, - defaultBackupLocation: defaultBackupLocation, - defaultVolumesToFsBackup: defaultVolumesToFsBackup, - defaultBackupTTL: defaultBackupTTL, - defaultVGSLabelKey: defaultVGSLabelKey, - defaultCSISnapshotTimeout: defaultCSISnapshotTimeout, - resourceTimeout: resourceTimeout, - defaultItemOperationTimeout: defaultItemOperationTimeout, - defaultSnapshotLocations: defaultSnapshotLocations, - metrics: metrics, - backupStoreGetter: backupStoreGetter, - formatFlag: formatFlag, - credentialFileStore: credentialStore, - maxConcurrentK8SConnections: maxConcurrentK8SConnections, - defaultSnapshotMoveData: defaultSnapshotMoveData, - itemBlockWorkerCount: itemBlockWorkerCount, - concurrentBackups: max(concurrentBackups, 1), - globalCRClient: globalCRClient, + ctx: ctx, + discoveryHelper: discoveryHelper, + backupper: backupper, + clock: &clock.RealClock{}, + logger: logger, + backupLogLevel: backupLogLevel, + newPluginManager: newPluginManager, + backupTracker: backupTracker, + kbClient: kbClient, + defaultBackupLocation: defaultBackupLocation, + defaultVolumesToFsBackup: defaultVolumesToFsBackup, + defaultBackupTTL: defaultBackupTTL, + defaultVGSLabelKey: defaultVGSLabelKey, + defaultCSISnapshotTimeout: defaultCSISnapshotTimeout, + resourceTimeout: resourceTimeout, + defaultItemOperationTimeout: defaultItemOperationTimeout, + defaultSnapshotLocations: defaultSnapshotLocations, + metrics: metrics, + backupStoreGetter: backupStoreGetter, + formatFlag: formatFlag, + credentialFileStore: credentialStore, + maxConcurrentK8SConnections: maxConcurrentK8SConnections, + defaultSnapshotMoveData: defaultSnapshotMoveData, + itemBlockWorkerCount: itemBlockWorkerCount, + concurrentBackups: max(concurrentBackups, 1), + globalCRClient: globalCRClient, + globalVolumePoliciesConfigMap: globalVolumePoliciesConfigMap, } b.updateTotalBackupMetric() return b @@ -587,9 +590,13 @@ func (b *backupReconciler) prepareBackupRequest(ctx context.Context, backup *vel request.Status.ValidationErrors = append(request.Status.ValidationErrors, "encountered labelSelector as well as orLabelSelectors in backup spec, only one can be specified") } - resourcePolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*request.Backup, b.kbClient, logger) + resourcePolicies, err := resourcepolicies.GetResourcePoliciesFromBackupWithGlobal( + *request.Backup, b.kbClient, b.globalVolumePoliciesConfigMap, request.Namespace, logger) if err != nil { request.Status.ValidationErrors = append(request.Status.ValidationErrors, err.Error()) + } else if b.globalVolumePoliciesConfigMap != "" { + // Record the contributing global volume policies ConfigMap so `velero backup describe` can surface it. + request.Annotations[velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation] = b.globalVolumePoliciesConfigMap } if resourcePolicies != nil && resourcePolicies.GetIncludeExcludePolicy() != nil && collections.UseOldResourceFilters(request.Spec) { request.Status.ValidationErrors = append(request.Status.ValidationErrors, "include-resources, exclude-resources and include-cluster-resources are old filter parameters.\n"+ diff --git a/pkg/controller/backup_controller_test.go b/pkg/controller/backup_controller_test.go index 5f04be98f..a96a5d27c 100644 --- a/pkg/controller/backup_controller_test.go +++ b/pkg/controller/backup_controller_test.go @@ -46,6 +46,7 @@ import ( kbclient "sigs.k8s.io/controller-runtime/pkg/client" fakeClient "sigs.k8s.io/controller-runtime/pkg/client/fake" + "github.com/vmware-tanzu/velero/internal/resourcepolicies" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" pkgbackup "github.com/vmware-tanzu/velero/pkg/backup" "github.com/vmware-tanzu/velero/pkg/builder" @@ -2076,6 +2077,100 @@ namespacedFilterPolicies: assert.True(t, hasTargetError, "expected validation error about namespacedFilterPolicies incompatibility with old-style filters, got: %v", res.Status.ValidationErrors) } +// TestPrepareBackupRequest_GlobalVolumePolicies verifies that the cluster-wide global backup +// volume policies are merged into the request and that the contributing ConfigMap is recorded +// on the backup so `velero backup describe` can surface it. +func TestPrepareBackupRequest_GlobalVolumePolicies(t *testing.T) { + formatFlag := logging.FormatText + logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag) + + globalCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "global-volume-policy", Namespace: velerov1api.DefaultNamespace}, + Data: map[string]string{"policies.yaml": `version: v1 +volumePolicies: + - conditions: + storageClass: + - gp2 + action: + type: skip +`}, + } + + fakeClient := velerotest.NewFakeControllerRuntimeClient(t, globalCM, + builder.ForBackupStorageLocation(velerov1api.DefaultNamespace, "loc-1").Result()) + apiServer := velerotest.NewAPIServer(t) + discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger) + require.NoError(t, err) + + c := &backupReconciler{ + logger: logger, + discoveryHelper: discoveryHelper, + kbClient: fakeClient, + clock: &clock.RealClock{}, + formatFlag: formatFlag, + defaultBackupLocation: "loc-1", + globalVolumePoliciesConfigMap: "global-volume-policy", + } + + backup := defaultBackup().StorageLocation("loc-1").Result() + res := c.prepareBackupRequest(ctx, backup, logger) + defer res.WorkerPool.Stop() + + // The global volume policies must load cleanly (no policy-related validation error). + for _, e := range res.Status.ValidationErrors { + assert.NotContains(t, e, "global backup volume policies") + } + require.NotNil(t, res.ResPolicies) + assert.Equal(t, "global-volume-policy", res.Annotations[velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation]) + + action, err := res.ResPolicies.GetMatchAction(resourcepolicies.VolumeFilterData{ + PersistentVolume: &corev1api.PersistentVolume{Spec: corev1api.PersistentVolumeSpec{StorageClassName: "gp2"}}, + }) + require.NoError(t, err) + require.NotNil(t, action) + assert.Equal(t, resourcepolicies.Skip, action.Type) +} + +// TestPrepareBackupRequest_GlobalVolumePolicies_LoadError verifies that when the configured +// global backup volume policies ConfigMap cannot be loaded, a validation error is recorded and +// the contributing-ConfigMap annotation is not set on the backup. +func TestPrepareBackupRequest_GlobalVolumePolicies_LoadError(t *testing.T) { + formatFlag := logging.FormatText + logger := logging.DefaultLogger(logrus.DebugLevel, formatFlag) + + // No ConfigMap with this name exists, so loading the global policies fails. + fakeClient := velerotest.NewFakeControllerRuntimeClient(t, + builder.ForBackupStorageLocation(velerov1api.DefaultNamespace, "loc-1").Result()) + apiServer := velerotest.NewAPIServer(t) + discoveryHelper, err := discovery.NewHelper(apiServer.DiscoveryClient, logger) + require.NoError(t, err) + + c := &backupReconciler{ + logger: logger, + discoveryHelper: discoveryHelper, + kbClient: fakeClient, + clock: &clock.RealClock{}, + formatFlag: formatFlag, + defaultBackupLocation: "loc-1", + globalVolumePoliciesConfigMap: "missing-global-volume-policy", + } + + backup := defaultBackup().StorageLocation("loc-1").Result() + res := c.prepareBackupRequest(ctx, backup, logger) + defer res.WorkerPool.Stop() + + // The failure to load the global policies must surface as a validation error. + var hasGlobalPolicyError bool + for _, e := range res.Status.ValidationErrors { + if strings.Contains(e, "global backup volume policies") { + hasGlobalPolicyError = true + } + } + assert.True(t, hasGlobalPolicyError, "expected a validation error about global backup volume policies, got: %v", res.Status.ValidationErrors) + // The annotation is only set when the policies load successfully. + assert.Empty(t, res.Annotations[velerov1api.GlobalBackupVolumePolicyConfigMapAnnotation]) +} + // TestPrepareBackupRequest_ClusterScopedFilterPolicyIncompatibleWithOldFilters verifies // that a backup referencing a ResourcePolicy ConfigMap with clusterScopedFilterPolicy // produces a validation error when old-style resource filters are also set on the spec. diff --git a/site/content/docs/main/resource-filtering.md b/site/content/docs/main/resource-filtering.md index 6a01e9419..88584b362 100644 --- a/site/content/docs/main/resource-filtering.md +++ b/site/content/docs/main/resource-filtering.md @@ -704,3 +704,85 @@ volumePolicies: 3. The outcome would be that velero would perform `fs-backup` operation on both the volumes - `fs-backup` on `Volume 1` because `Volume 1` satisfies the criteria for `fs-backup` action. - Also, for Volume 2 as no matching action was found so legacy approach will be used as a fallback option for this volume (`fs-backup` operation will be done as `defaultVolumesToFSBackup: true` is specified by the user). + +### Global backup volume policies + +Resource policies (volume policies) are normally opt-in per backup via `--resource-policies-configmap`. An administrator can instead configure a cluster-wide baseline that applies to **every** backup by starting the Velero server with the `--global-backup-volume-policies-configmap` flag, pointing at a ConfigMap in the Velero install namespace: + +```bash +velero server --global-backup-volume-policies-configmap global-volume-policy +``` + +The ConfigMap uses the exact same format as a per-backup resource policies ConfigMap (a single data key holding a `ResourcePolicies` YAML document): + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: global-volume-policy + namespace: velero +data: + policies.yaml: | + version: v1 + volumePolicies: + - conditions: + storageClass: + - gp2 + action: + type: skip +``` + +#### Behavior + +- **Only `volumePolicies` apply globally.** If the global ConfigMap contains `includeExcludePolicy`, `clusterScopedFilterPolicy`, or `namespacedFilterPolicies`, those sections are ignored and a warning is logged. Those filters are tied to a specific backup use case, so they remain per-backup only. +- **Merge semantics.** When a backup runs, the effective `volumePolicies` list is the backup-level policies followed by the global policies: + + ``` + merged.volumePolicies = backup.volumePolicies ++ global.volumePolicies + ``` + + Because the first matching policy wins, a backup can override the global baseline for a specific volume while still inheriting every global rule it does not override. If a backup references no resource policy, the global policy applies on its own. +- **Validation.** The global ConfigMap is validated at server startup (the server fails to start if it is missing or invalid) and again on each backup (a backup whose global policy has become missing or invalid is moved to the `FailedValidation` phase). + +#### Example + +Global policy (`--global-backup-volume-policies-configmap=global-volume-policy`): skip `gp2` volumes. + +```yaml +version: v1 +volumePolicies: + - conditions: + storageClass: + - gp2 + action: + type: skip +``` + +Backup-level policy (`--resource-policies-configmap backup01`): `fs-backup` NFS volumes. + +```yaml +version: v1 +volumePolicies: + - conditions: + nfs: {} + action: + type: fs-backup +``` + +Effective (merged) policy used for the backup — backup rules first, then global: + +```yaml +version: v1 +volumePolicies: + - conditions: + nfs: {} + action: + type: fs-backup + - conditions: + storageClass: + - gp2 + action: + type: skip +``` + +When a global policy contributes to a backup, `velero backup describe` surfaces the contributing ConfigMap under a `Global volume policies` section. From ffbaceeb6d98e895834ac46c52666a75980f64ae Mon Sep 17 00:00:00 2001 From: Adam Zhang Date: Wed, 24 Jun 2026 10:30:49 +0800 Subject: [PATCH 2/4] add resourcePolicy on restore CRD Add resourcePolicy field for restore CRD which is backed by a configmap that holds ClusterScopedFilterPolicy and NamespacedFilterPolicies for restore side filtering. Signed-off-by: Adam Zhang --- changelogs/unreleased/9939-adam-jian-zhang | 1 + config/crd/v1/bases/velero.io_restores.yaml | 27 +++++++++ config/crd/v1/crds/crds.go | 2 +- .../resourcepolicies/resource_policies.go | 55 +++++++++++++++++-- .../resource_policies_test.go | 46 ++++++++++++++++ pkg/apis/velero/v1/restore_types.go | 10 ++++ pkg/apis/velero/v1/zz_generated.deepcopy.go | 5 ++ pkg/builder/restore_builder.go | 10 ++++ pkg/builder/restore_builder_test.go | 36 ++++++++++++ 9 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/9939-adam-jian-zhang create mode 100644 pkg/builder/restore_builder_test.go diff --git a/changelogs/unreleased/9939-adam-jian-zhang b/changelogs/unreleased/9939-adam-jian-zhang new file mode 100644 index 000000000..187bf7397 --- /dev/null +++ b/changelogs/unreleased/9939-adam-jian-zhang @@ -0,0 +1 @@ +Fix issue #9935, add resource policy on restore CRD diff --git a/config/crd/v1/bases/velero.io_restores.yaml b/config/crd/v1/bases/velero.io_restores.yaml index 1b92ea4fc..c41fe88de 100644 --- a/config/crd/v1/bases/velero.io_restores.yaml +++ b/config/crd/v1/bases/velero.io_restores.yaml @@ -404,6 +404,33 @@ spec: - name type: object x-kubernetes-map-type: atomic + resourcePolicy: + description: |- + ResourcePolicy specifies the reference to a ConfigMap containing resource + filter policies for this restore. The ConfigMap can contain a + namespacedFilterPolicies section that specifies per-namespace resource type + filters, label selectors, and resource name patterns, and a + clusterScopedFilterPolicy section for per-kind filtering of cluster-scoped + resources. The ConfigMap format is the same as for BackupSpec.ResourcePolicy. + nullable: true + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic restorePVs: description: |- RestorePVs specifies whether to restore all included diff --git a/config/crd/v1/crds/crds.go b/config/crd/v1/crds/crds.go index 032a1ac84..a2947da00 100644 --- a/config/crd/v1/crds/crds.go +++ b/config/crd/v1/crds/crds.go @@ -36,7 +36,7 @@ var rawCRDs = [][]byte{ []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xbcWMo\xe36\x10\xbd\xfbW\f\xd0K\v\xac\xe4\x06E\x8b·\xd6\xd9C\xb0\xe96\x88\xb7\xb9S\xd4HbC\x91,9t6E\x7f|1\xa4\xe4\x0fYv\x9c\xcb\xea\xe6\xe1p\xf8\xe6\xcd\xcc#]\x14\xc5B8\xf5\x84>(kV \x9c¯\x84\x86\x7f\x85\xf2\xf9\xd7P*\xbb\xdc\xde,\x9e\x95\xa9W\xb0\x8e\x81l\xff\x88\xc1F/\xf1\x16\x1be\x14)k\x16=\x92\xa8\x05\x89\xd5\x02@\x18cI\xb09\xf0O\x00i\ry\xab5\xfa\xa2ES>\xc7\n\xab\xa8t\x8d>\x05\x1f\x8f\xde\xfeX\xde\xfcR\xfe\xbc\x000\xa2\xc7\x15\xd4\xf6\xc5h+j\x8f\xffD\f\x14\xca-j\xf4\xb6Tv\x11\x1cJ\x8e\xddz\x1b\xdd\n\xf6\vy\xefpn\xc6|;\x84y\xccaҊV\x81>ͭޫ\xc1\xc3\xe9\xe8\x85>\x05\x91\x16\x832m\xd4\u009f,/\x00\x82\xb4\x0eW\xf0\x99a8!\xb1^\x00\f)&XŐ\xdd\xf6&\x87\x92\x1d\xf6\"\xe3\x05\xb0\x0e\xcdo\x0fwO?m\x8e\xcc\x005\x06镣D\xd4\x7f\xc5\xce\x0e\xd3\x04@\x05\x100\xc0\x01\xb2;\x84 \f\bO\xaa\x11\x92\xa0\xf1\xb6\x87J\xc8\xe7\xe8\xc0V\x7f\xa3$\bd\xbdh\xf1\x03\x84(;\x10\x1c%;\x1c\x9c\xa5m\v\x8d\xd2X\xeel\xce[\x87\x9e\xd4Hy\xfe\x0e\x1a\xea\xc0z)\v\xfe8\xf1\xbc\vj\xee,\f@\x1d\x8e\xe4a=p\x05\xb6\x01\xeaT\x00\x8f\xcec@\x93{\x8d\xcd\xc2\fٔ\x93\xd0\x1b\xf4\x1c\x06Bg\xa3\xae\xb9!\xb7\xe8\t\x1aE\xaf\xcb41\xaa\x8ad}XָE\xbd\f\xaa-\x84\x97\x9d\"\x94\x14=.\x85SEJĤQ+\xfb\xfa;?\ff8:\x96^\xb9!\x03yeڃ\x854\x1d\xef(\x0f\xcfK\xee\xae\x1c*\xa7\xb8\xaf\x02\x9b\x98\xbaǏ\x9b/0\"ɕ\x1aZl\xe7z\xc2\xcbX\x1ffS\x99\x06}ޗڔc\xa2\xa9\x9dU\x86\xd2\x0f\xa9\x15\x1a\x82\x10\xab^Q\x18{\x9dK7\r\xbbNR\x04\x15Bt\xb5 \xac\xa7\x0ew\x06֢G\xbd\x16\x01\xbfq\xad\xb8*\xa1\xe0\"\\U\xadC\x81\x9d:gz\x0f\x16Fy&j^\x01\x128\xe1[\xa4\xa9u\x82\xe5Kr\xe2\xe3_:q,X\xdfcٖ\xac9a\x00\x92\xf5\xe8\x87i\xa1.a\x80\xd9F\x9fE2\xf67\xd3\xc0\xbc\xb2\xa0\xb0\xd8\x1db:=\x9a?4\xb1\x9f?\xa0\x80\xdf\x13\xe6{\xdb^\\_[C<\x17\x17\x9d\x9e\xac\x8e=n\x8cp\xa1\xb3o\xf8\xde\x11\xf6\x7f:\xf4\xf9\x1a\xbe\xe8:\xde滫\xef\x82c\xd4g\xcf}D\xbeA\xf0|\xa6\x83\xc3UQ\xae\xc04x^\x95\xe8zs\xf7\x1e\nϸ\xbf\xa3Hw\xa6\xb1o\xa4\xb8w\x9c\xf5;#\x03\xe3\x97\xde\x10o\xf74\xbfBƞ\xe6-\xf9\xeeD\xf8\x14+\xf4\x06\t\xc3^\xa9_\x14u\xb3\x11\x01^:%\xbb\xb41\r\x04_\x02!X\xa9\xe6$\xf5\n\xf8\xac#\xca\xe3\xccP\x16iXg\xcc\f\xfe\xc4|F\xfd\xce\x1dP\f\x8at\x95\x82\x92\xa0\x18ޡ\xa1\xc9\x7f\xa4ZF\xef\xd3\x15\x95\xad\xfc2\x99n\xb8VDG\xe5\xf9\xeb\xf1\xfe\r%\xbd\xdd{\xa6\x17\xb7P&\xa3q\x1e\x8b\xa0Z~A\xf1\x1akiҸS2\xf2w\xfc\xc2;&j\xb6\xa2\xf8թ<\x80o@\xfc\xb8ŝ\x8f&\xdf\xf3\xd37l\n\x88\x81\x9f[ \x85\x99\xc1X!Ԩ\x91\xb0\x86\xea5\xdf\\\xaf\x81\xb0?\xc5\xddX\xdf\vZ\x01\xdf\xff\x05\xa9\x9962QkQi\\\x01\xf9x\xae\xcbf\x13w\x9d\b3cx\x94\xf3\x03\xfb\xcc5\xc6n\x18/v\x06\x9c\xbd_\n\xf8\x8c/3\xd6\ao%\x86\x80\xa7ct6\x93\xd9!81\x06~\xa4\xd5\a,\r\x7f\x19\x06\xcb\xff\x01\x00\x00\xff\xffx\xae@\xbaJ\x0e\x00\x00"), []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xc4:Ks\x1b7\xd2w\xfd\x8a.吤\xca$\xe3|ߦ\xb6x\xb3\xe5͖v\x13\xafʔ}I\xe5\xd0\x1c49\x88f\x00,\x80\x11\xcd\xcd\xe6\xbfo5\x80\xe1\xbc@R\xa2\x93\x18\x17\x89x4\xfa\xfd\xc2\xccf\xb3+4\xf2\x03Y'\xb5Z\x02\x1aI\x1f=)\xfe\xe5\xe6\x0f\x7fus\xa9\x17\x8f/\xaf\x1e\xa4\x12K\xb8i\x9c\xd7\xf5;r\xba\xb1\x05\xbd\xa1\x8dT\xd2K\xad\xaej\xf2(\xd0\xe3\xf2\n\x00\x95\xd2\x1ey\xda\xf1O\x80B+ouU\x91\x9dmI\xcd\x1f\x9a5\xad\x1bY\t\xb2\x01x{\xf5\xe37\xf3\x97\xdf\xcd\xffr\x05\xa0\xb0\xa6%\x18-\x1eu\xd5Դ\xc6\xe2\xa11n\xfeH\x15Y=\x97\xfa\xca\x19*\x18\xf6\xd6\xea\xc6,\xa1[\x88gӽ\x11\xe7;->\x040\xaf\x03\x98\xb0RI\xe7\xff\x99[\xfdA:\x1fv\x98\xaa\xb1XM\x91\b\x8bN\xaamS\xa1\x9d,_\x01\xb8B\x1bZ\xc2[F\xc3`A\xe2\n \x91\x18К\x01\n\x11\x98\x86՝\x95ʓ\xbda\b-\xb3f \xc8\x15V\x1a\x1f\x982\xc2\x0f\x9cG\xdf8pMQ\x02:xK\xbbŭ\xba\xb3zk\xc9E\xe4\x00~qZݡ/\x970\x8f\xdb\xe7\xa6DGi52w\x15\x16Ҕ\xdf3\xca\xce[\xa9\xb69$\xeeeM \x1a\x1b\x84\xca\xd4\x17\x04\xbe\x94n\x82\xdd\x0e\x1dch} ;\x8fKXg\x88\xcecm\xc6H\xf5\x8eF\xac\x04z\xca\xe1t\xa3kS\x91'\x01뽧\x96\x92\x8d\xb65\xfa%H\xe5\xbf\xfb\xff\xe3\xecH\xfc\x9a\x87\xa3o\xb4\x1a\xf2\xe65\xcfBo:b²ڒ\xcd2H{\xac>\x05\x11\xcf\x00^\xf7\xceGL\"\xdc\xfe\xfcYTnUa\xa9&u\x19B\xb2;=Ŧ\x0f\xba\xbfj\xac\xd4V\xfa\xfd\x12^~\xf3T4\xd9>@o\xc0\x97\x04IyV^[\xdc\x12\xfc\xa0\x8b\xa8h\xbb\x92lR\xb4u\xd2\xfeR7\x95\x80u+\x18\x00\xe7\xb5\xcd*\x9b\xa1b\x1eO%\xb8-ؑ\xc6\r\xef\xfc#\f\xa2\xb0\x84Y\x83h\x9d\xe6<\xec\x90Z\xe5\xad\xe2Ֆ\x9ed\x11}\x96*-\xe8\xc0?\x9a\xa0%\x1d\x18\xab\vr\ue1212\x8c\x01\"o\xbb\x89\xb3\f*)\xeci\xf1iL\xa5Q\x90\x05\xaf\xa1D%*b2\x10\xbcE\xe56IE\xa6\x02l\x8f\xdd\xef\xcd\x10\x95\xf7i\xe1\x18:q\xd7\xe3\xcb讋\x92j\\\xa6\xbdڐzuw\xfb\xe1\xffV\x83iVcm\xc8zن\x8f8z\xc1\xb17\vCr\xff;\x1b\xac\x01\xf0\x05\xf1\x14\b\x8e\x92\xe4\x02\x1bR \x91p\x8a\xec\x91\x0e,\x19K\x8eM+h\x94\xde\x00*\xd0\xeb_\xa8\xf0\xf3\x11\xe8\x15Y\x06\xd3\xdaB\xa1\xd5#Y\x0f\x96\n\xbdU\xf2?\a؎y͗V\xe8\xc9\xf9`\x8cVa\x05\x8fX5\xf4\x02P\x89\x11\xe4\x1a\xf7`\x89\xef\x84F\xf5\xe0\x85\x03n\x8cǏ\xda\x12H\xb5\xd1K(\xbd7n\xb9Xl\xa5oS\x86B\xd7u\xa3\xa4\xdf/B\xf4\x97\xeb\xc6k\xeb\x16\x82\x1e\xa9Z8\xb9\x9d\xa1-J\xe9\xa9\xf0\x8d\xa5\x05\x1a9\v\x84\xa8\x906\xcck\xf1\x85MI\x86\x1b\\;\x11t\x1c!\xd2?C<\x1c\xfb\xd9\b0\x81\x8a$vR\xe0)fݻ\xbf\xad\xee\xa1\xc5$J*\n\xa5\xdb:\xe1K+\x1f\xe6\xa6T\x1b\xd6y>\xb7\xb1\xba\x0e0I\t\xa3\xa5\xf2\xe1GQIR\x1e\\\xb3\xae\xa5g5\xf8wCγ\xe8\xc6`oBZ\x05k\xb6%\xf6\x00b\xbc\xe1V\xc1\r\xd6Tݠ\xa3?YV,\x157c!\x89wg\xf9\xc3c#\xa9\x12!s8\x7fwVsy\xdcn\"\x12!\"x\r\bFRA\x83h\fR9O(\xd2$;AKi\xedE\xf4\xf4G\x91\xe4\xd1Em\x96\t G\x1e)\xe0\x1f\xab\x7f\xbd]\xfc]G:\x00\vN\xcdB\xad\x17\xf2\xed\x17\x87zO\x90\x93\x96\x04Wo4\xafQ\xc9\r9?O\xd0Ⱥ\x9f\xbe\xfd9\xcf?\x80\xef\xb5\x05\xfa\x88\\5\xbd\x00\x19y~\bf\xad\xdaH\x17\t?@\x84\x9d\xf4e@\xd4h\x91\b\xdc\x05\x12<>\xb0%G\x12\x1a\x82J>d\xec'\x8e\xeb\x90\xcduh\xfe\xca\xd6\xf3\xdb5|\x15\x9d\xd75\xff\xbc\x8eh\x1cҖ\xbe\x81u\xe8D+\xb3r\xbb\xa5.\xef\x9f(\v\x87Y\x0eP_\x83\xb6L\xab\xd2=\x10\x010\xcb)\xc6\a\x12\x13\xf4~\xfa\xf6\xe7k\xf8jȃ#WI%\xe8#|\xcb\xde'\xf0\xc6h\xf1\xf5\x1c\xee\x83\x1e\xec\x95Ǐ|SQjG\n\xb4\xaa\xf61\x01~$p\xba&\xd8QU\xcdb\x82(`\x87{Л#\xf7\xb4\"b\xd5D0h\xfd\xc9$1\xf1\xe1\xb4\xd1L\xb3\xa6v<\xcd^B\x16\xf5$\xeb\xfdl\x19\xc8\x139\x11ʅO\xe0D\xbf\xf4\xba\x80\x13\x0f͚\xac\"O\x81\x19B\x17\x8e\xf9P\x90\xf1n\xa1\x1f\xc9>J\xda-v\xda>H\xb5\x9d\xb12\u03a2\xd4\xdd\"t\xbb\x16_\x84?\x97\x12\x1e\xdaT\x9fJ}\x00\xf2\xf9X\xc0\xb7\xbb\xc5%\x1ch\xb3\xfb\xa7Ǯ\xa3|X\xa5\x84s\f\x93m~Wʢlk\xbd\x9e\xb7\xadQDw\x8cj\xff\x99l\x87\xf9\xdcX\xc6h?K\xad\xda\x19*\xc1\xff;\xe9<\xcf_\xc2\xd8F~\x92sy\x7f\xfb\xe6sZT#/\xf1$Gj\x988>\xce:\xacf5\x9aY܍^ײ\x18\xed\xe6\x1c\xfeV\xb0\x906\x92\xec\x99\xf4\xef\xdd`s\x9b\xa0f\xaa\x81Þg\xe5\x9f\x1e\xb7\x99\x84\xaf\xdf\xc5>\x95\x16\x9e\xe4\xd7yU\xb8ǭ\x03\xb4\x04\b5\x1aֈ\a\xda\xcfb\xc6aPr\xba\xc0\x19\xc1\xa11\bhL\xc51=f\x11\x19\x88)\xffM\xecA\x17\xe8;Ɛ\xac(ۮԊ\xbc\x97\xea32\xe7\xfd\b\x91ߗQ\x87\x9e]\xa1\xd5FnS\xb7s\xca)\xd5T\x15\xae+Z\x82\xb7ͱ\x9a\xeb$#\xefy\xcbi\xfa\xdf\xf7\xb6\xb6\x1a~\xa6\xc1\x98\xa7j\xd0v\x9c\x12C\xaa\xa9\xa7\xa8\xcc\xe0A\x1b\x89\x99yK\xceO\xac\x97\x17\xae\xaf\x9fccQ)/)\xb9c\x19\x9c\xabJ\x93\xa2\xa7\x04\xbe\xadL\xbd\ueabc\xacП\xe1\x1b\xb8\xba\xe7rd\x88\xf7,\xdf.\x19\xed\xe9u\x97\xdb)\xa3\xc5hf\xe8\x06G\x8b\x91\xbe'\xf5\x90BC\xfb\x19]\xa4\xf8Ȗx\x1a\x83\xa3o\x9f\xde8\xed\xbe\xb4\x8fą\x9d\xf1$\x0e\x8d\xfeK$\xfej\f$\xf4~\xadHF!k:\x94\xfeC_\x17\x8b\xbb5\x81\xb1d0\xdb\x15\x82йw\xa1\x85\xf9\xa5\x8b\xc0\xa4\x83Ƒ\b\x1d\xb4\xc9\xdd\x13\b\xed;\x93@O3>\x7f\x99\xbf\xc87\xa6\xe2\x9b_\xff\xa5\xe4\xa2.\xd5\x14̔\x85\xd8r-<ᴏ\x8d9\x8eu\xe0\x0e\xfc\x8a\xd0H\x84*\x94\x8b\xe4\rʊ\x04\xb4/\xd9τ\xb2\xa6\r\xa78\xd1ǵ}\x9c\x84\xde\xf1\xfa\xef\xb4$3L\x98&<\x7f\xa40\xc7O\x8dg$y;\xda\x0e\xa5\xae\x92\xbcTS\xafɲa\x86\aOP\xb4㺿(Qm\xb3N\xae}\xb0#\xa8\xd0yXw\x1f\x06\xe4\x88\uffd8\x8e)\xeb\xbfpv\xa3&\xe7p{Ν\xff\x18w\xc5\xce]:\x02\xb8֍\xcf\xdb\xef\x97.\xb9\xa0\xe7u\x0f\xb3M\xb1\xa1\xf7C_\xb6\xcen\xd3TU8ӏ\x1b\xdd\a\x1c\x01\xab5\xe53\xfe\x13\xad\xc3S\b\x96\xe8α\xea\x8e\xf7\xe4\xfc\xf1!؝t\xc8p\"\xb0\xbf\xa5]f\xb6\xf5s\x99\xa5\xbb\xe4<3K\x93/1\xfa\x8b\xb17\x9e\xe3\\\xbb\x96\x85y\xf8\xce!\xb3\xf6}\xf0*\xcfbv\xc2\xef\x12\xb7y\xe8\xadw\x96\x17>[\x98\xd8\xdf0\xff@%\xfab\xcb5!\xba\xf3\xad\x06EH\xa9\x91\x96\x9e\x04\x82\xeb\xf2\x1a\x84t\xa6\xc2\xfd\x81\x96P\xfa\xb1\xa9\xe6\xdfG:\x8bj=\xa6\xa1c\xa9\xec\xe9\x0e\xf7\xe1k\x91|]{\xda_\xc0\x19\x9f\x11\xd6\xf5qg\xf8{\xdcp\"\x15w\n\x8d+\xb5\xbf}sF5V\x87\x8d\xad=vee\b,\xe1\xe9-mJ\xaa\x90A\xb5\xf3n\xcfr\x16Ï\x87.\xd1\xe2\xd5\x00\u0099\xb8\x9f\xbee\xcaE\xd7\x15{\x01v@\xe1a\xf7f\xfc\x05NjC\x90A\x9f\x1a\xe41\x1e\xe5\xba\nZ\x85:B\xdb\xe9+;\x9c\r\xe4C\x82\xfe\xcc\x18\x9eU\xa7\xc9d\xc0\\\xf4`\xa77\xcd\xfeL\xb3><\xf7/\xe1\xd7߮\xfe\x17\x00\x00\xff\xfff=C\x19\x96(\x00\x00"), []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xc4Z͒\x1b\xb7\x11\xbe\xefSt\xad\x0f\xb6\xab4d\xa4$\xae\x14o\xd2*Nmbo\xb6DI\x17\x97\x0f\xe0\xa09\x03s\x06\x80\x01\f\xb9\xb4\xe3wO5\x80\x19\xce\x0fH.\xa9\x925\x17i\xf1\xd3\xf8\xf0u\xa3\xbb\xd1`\x96e7L\x8b\x8fh\xacPr\x01L\v|r(\xe9/;\xdb\xfc\xc3΄\x9ao_\xdel\x84\xe4\v\xb8k\xacS\xf5;\xb4\xaa19\xbeŵ\x90\xc2\t%ojt\x8c3\xc7\x167\x00LJ\xe5\x185[\xfa\x13 W\xd2\x19UUh\xb2\x02\xe5lӬpՈ\x8a\xa3\xf1\xc2ۥ\xb7\x7f\x99\xbd\xfcn\xf6\xf7\x1b\x00\xc9j\\\x80V|\xab\xaa\xa6F\x83\xd6)\x83v\xb6\xc5\n\x8d\x9a\tuc5\xe6$\xbc0\xaa\xd1\v8t\x84\xc9q\xe1\x00\xfaQ\xf1\x8f^λ \xc7wUº\xff$\xbb\x7f\x10\xd6\xf9!\xbaj\f\xab\x128|\xaf\x15\xb2h*f\xa6\xfd7\x006W\x1a\x17\xf0@P4ˑ\xdf\x00\xc4}zh\x190\xce=s\xacz4B:4w$\xa2e,\x03\x8e67B;\xcf\xcc\x18\"X\xc7\\c\xc16y\t\xcc\xc2\x03\xee\xe6\xf7\xf2Ѩ\u00a0\r\xf0\x00~\xb1J>2W.`\x16\x86\xcft\xc9,\xc6\xde@\xf1\xd2w\xc4&\xb7'\xcc\xd6\x19!\x8b\x14\x8a\xf7\xa2F\xe0\x8d\xf1\xaa\xa5\xfd\xe7\b\xae\x14v\no\xc7,A4\xceo<\r\xc6\xf7\x93H\xebX\xadǨzS\x03,\xce\x1c\xa6@ݩZW\xe8\x90\xc3j\xef\xb0\xdd\xcaZ\x99\x9a\xb9\x05\b\xe9\xbe\xfb\xdbq>\"a3?\xf5\xad\x92Cr\xdeP+\xf4\x9a\x03\x12\xd2V\x81&ɐr\xac\xfa\x14 \x8e\x04\xbc\xe9\xcd\x0fH\x82\xdc~\xfbY(dz\xa0\xd6\xe0J\x847,\xdf4\x1a\x96N\x19V \xfc\xa0\xf2\xa0\xc2]\x89\x06\xfd\x88U\x18A'\x18\x04\xe9N\x99\xa4\xea4\xe6\xb306\nke\x8d\xf47\\\xe8\xb3\xd8Wn\x90%\xed\xabuE3?B(\x996\xb2\xd7\x05>\xcb\xc0\xfaDJű\xc7\xda\x04\x97\xb0\xa0\x8d\xca\xd1\xda\x13\x86OB\x06H\x1e\x0e\rg)*яi\x015\xbaR\x8c\xa3\x01\xa7\xa0d\x92W\x18t\xe8\f\x93v\x1d-c\xaa\xc2v\xda\xfb\xbd\x1eB\xf9\xd0\xca\xeb\xf5L0\x85\xa1ۗ\xc1\r\xe6%\xd6l\x11\xc7*\x8d\xf2\xf5\xe3\xfdǿ.\a\xcd@\xb4h4N\xb4\x9e9|\xbd\xc0\xd3k\x85\xe1\x9e\xff\x97\r\xfa\x00h\x810\v8E \xb4\x9e\x8b\xe8_\x91GL\x81#a\xc1\xa06hQ\x86\x98D\xcdL\x82Z\xfd\x82\xb9\x9b\x8dD/ѐ\x18\xb0\xa5j*N\x81k\x8bƁ\xc1\\\x15R\xfc\xd6ɶD8-Z1\x87\xd6\xf9\x83h$\xab`˪\x06_\x00\x93|$\xb9f{0HkB#{\xf2\xfc\x04;\xc6\xf1\xa3\xb7&\xb9V\v(\x9d\xd3v1\x9f\x17µ\xe18Wu\xddH\xe1\xf6s\x1fYŪq\xca\xd89\xc7-Vs+\x8a\x8c\x99\xbc\x14\x0es\xd7\x18\x9c3-2\xbf\x11\xe9C\xf2\xac\xe6_\x99\x18\xc0\xed`ى\xa2\xc3\xe7\x83\xe8\x05ꡨJ'\x81EQa\x8b\a-P\x13Q\xf7\xee\x9f\xcb\xf7\xd0\"\t\x9a\nJ9\f\x9d\xf0\xd2\xea\x87\xd8\x14rM\x86O\xf3\xd6F\xd5^&J\xae\x95\x90\xce\xff\x91W\x02\xa5\x03۬j\xe1\xc8\f~m\xd0:R\xddX\xec\x9dOY`E\a\x8a\xfc\x00\x1f\x0f\xb8\x97p\xc7j\xac\xee\x98\xc5?YW\xa4\x15\x9b\x91\x12\x9e\xa5\xad~\"6\x1e\x1c\xe8\xedu\xb4Y\xd4\x11Վ\xfd\xdbRcN\x9a%ri\xaaX\x8b\x18I\xd6\xca\x00\x9b\x8c\x1f2\x95v\x01\xf4%#\xcax\xd09\xb3\xa3\xefMJP\x8bX\xf6\x1cy\x8cw6\x06\xaaj\x18\xa8\xfa\xdf$F\x1a\xd4\xca\n\xa7\xcc\xfe\x10)\xc7&qT;\xf4\xe5L\xe6X]\xb3\xbd;?\x13\x84\xe4\xc4;v&M\xce(H\xf5@\x95,\x14\x1d\xb2\x89:\xe0\xde\xd18\xb2s\x8b.\xbdYy4\xb2\t\t\x87\x1c\x13\xfa\xb9\xe4x\xdb+\xa5*dc6\xb5\xe2g6\xfd\xa8\xa2\xe30\xb8F\x83>\xfe\a7\xab\x95wƎ\tٺ\x8f\x90r\x83S\x89}\xac\xc8\xdd\x1cS\xcdq;\x84\x13!)\t\xf8\xf5\xe3}\x1bvZˊ\xd0'\x91\xa5\xcfO\xd2,\xe8[\v\xac\xb8\x0f\xd4\xe7\xd7NZ\b}\xf7\xeb\x00\xc2\xfb^\xa7\x80\x81\x16\x98\xe3 \ue050\xd6!㱑܍\xc1\xd8\xf7\"\xf8ԣ \xe9;\xc4GR\t0\xf2\xf1\x82ÿ\x97\xff}\x98\xffK\x85}\x00\xcb)\x13\xf2w\x15\xacQ\xba\x17\xdd}\x85\xa3\x15\x069\xdd>pV3)\xd6h\xdd,JCc\x7fz\xf5s\x9a?\x80\xef\x95\x01|b\x94\xf4\xbf\x00\x118\xef\xc2Fk5\u0086\x8dw\x12a'\\\xe9\x81j\xc5\xe3\x06w~\v\x8em\xe8Ą-4\b\x95\xd8`\x9a}\x80[\x9f<\x1d`\xfeN.\xe5\x8f[\xf8&8\x89[\xfa\xf36\xc0\xe8\x12\x84\xbe\xd79\xc0q%s\xe0\x8c(\n<$\xda\x13c\xa1\x80F\xa1\xe0[P\x86\xf6*UO\x84\x17Lz\n\x8e\x18\xf9\x04\xdeO\xaf~\xbe\x85o\x86\x1c\x1cYJH\x8eO\xf0\x8aθ\xe7F+\xfe\xed\f\xde{;\xd8KǞh\xa5\xbcT\x16%(Y\xedC\xbe\xb9E\xb0\xaaF\xd8aUe!\x15\xe3\xb0c{P\xeb#\xeb\xb4*\"\xd3d\xa0\x99q'ӱ\xc8\xc3\xe9C3\xcdO\xda\xefy\xe7\xc5\xe7+\xcf:\xbd_,\xd6?\x93\t\x9f\x98\x7f\x02\x13\xfd\xab\xce\x15Ll\x9a\x15\x1a\x89\x0e=\x19\\\xe5\x96x\xc8Q;;W[4[\x81\xbb\xf9N\x99\x8d\x90EFƘ\x05\xad۹/\xd9̿\xf2\xff\\\xbbq_g\xf9\xd4\xdd{!_\x8e\x02Z\xddίa\xa0ͣ\x9f\x1f\xbb\x8e\U000b0319\xddX&\x9d\xf9])\xf2\xb2\xbdU\xf5\xbcm\xcdxp\xc7L\xee\xbf\xd0\xd9!\x9e\x1bC\x88\xf6Y,8fLr\xfa\xbf\x15\xd6Q\xfb5\xc46ⓜˇ\xfb\xb7_\xf2D5\xe2\x1aOr\xe4\xb6\x10\xbe\xa7\xec\x80*\xab\x99\xce\xc2h\xe6T-\xf2\xd1hʕ\xef9)i-М\xc9\xfe\xde\r\x06\xb7Y{\"\xeb\xee\xc6\\\x94v[ɴ-\x95\xbb\x7f{\x06Dz\x1b\xd8b8\xe80&\x9d\xad,:\x12's\xcdg\xe0Y\x8a\xdf\x12n+\x89\x88\x86\xb6\x98*U\x88\x9cU`}\x9b\x8c\xc5\xca\b\xb3\x95=\x05\x94\xaaG\x8e\xe1\xf6\xab\x8a=\xbc\xde\x17<\x1c\xf7\xb4C\xc8\xc3\xd1-jeD!$\xab\x0e\x1e\xdb_\x1d%\xab\x99\xff+a\xab5\xd3Z\xc8\xe2\"n\xdb\xfa\xd6\x12\x9d\x13\xb2H$\xfa\xfd\xf2\xfb\xa9\xeb\xc0\xc9sr\xde\x05|\x18\x01\x01f\x10\x18\xed\x89T\xb5\xc1}\x16\xb2N\xcd\x04\xa5\x8c\x94\x15\xc6\xd4z\x85\xc0\xb4\xae(\xaf\v\x99d\xca7\xb5պ\\ɵ(b\xe5tʔl\xaa\x8a\xad*\\\x803ͱK[\xf2\xb8\xf7\v\x85g4\xfe\xa17\xb4U\xf7\x99RezW\x83\x02\xe6t3(\x9bz\n%\x83\x8d҂%\xda\xe9pN\x1c\x13u\xdc\xde^bR\xe1\xe4\x9f\xe1 ܙS\x05\x87\xe88\xe25$^\xb1\x83\xfbHG\xf3K\x1d\x8a\xc1_\x1b\xbaS\r\x11f\xe9\xda\xcah\x8cV\xfcfLZ\xdf\x17\x8f:\x0f\x9et\xdc1<\xf4\xa3\xde@\xc1\xb3\xcaR\xbeP~Ia*<\x87E\xdeC\x1a\xe0\xdaG2\xba`\\]\x9a\xa2;\xacvȻ7\x84k\xea6\xaf\xc7B|A\xd9\xf0xHD\x8d]\x91#ډ9\x94]B\x88\xd1\x065KZ\x04\xf8G\x01\xeb\v\xa3_\xdb MXh,r\xef['\x8b\x1f\x8d\t\x9c9\xcch\xfeu\x0e$]\xec\n\xcfs\xfdW\x98\xab*_S1S\x0eYG\x9b\x7f\x1fj\x1f\x06S\x94\x1d\xe4u\x84\x05q\xc8\xfd\x95\x1b\x94\x845\x13\x15r\xe8\x1e\x9f/f>\x01z\x9a\x8c}N\xf2k\xb4\x96\x15\xe7\x9c֏aT\xa8\xbc\xc5)\xc0V\xaaqG\xac\xf2k\x1b\x8f\xd6E1Y*~\x0eɃ\xe2\x1e\x86<\xfe\xe46E\x93PK\xff\x19\xee\"\x8c\xbe\xa8y\xaeHIcR\xae\xa6\x83|\xda\xd7\xc0\x89\x18\xf6\x80\xbbDk{\x82\x13]\x8f\xd1-$\xba&\xbf\a\xe8w\x86Jr*\xa7i\xfb\x922\xbb\xc7\xf6D\xdf\xf7\xfe\xb8\\\xc4v\xc4w\x8dC\xe8\xeaХ\xaaZ\x1f\xe0\x1f\xc9eS\xafА*V\xa9\x8c\x18\x98\xe4}ͥ\x8a\t\x9d\x846\f\aQ\xb1\x1e\x16\v\xe8\xfe\x94;\x05\\X]\xb1}\xb7\x19\x7f\x83\xa3#\x9d~N8\x9c\xab\xd6WQ\xe49\x92\xb7\x9d\xaeTw?ZH\xdfOOg\xfap&\xdb\xf7\xfdݏ\x11>\xcf\n'\xf2\xce\xe1\x8fC\xae1\x90\xe5@¹`\x11\x7f\xacr\xb9\x8f\x1f.\xf3g\xba\xf7${\x93F\x8f\x9c\xf7d\xc7'\xaf~K\xb3\xeaރ\x17\xf0\xfb\x1f7\xff\x0f\x00\x00\xff\xff;\xa8N\xc3\x13&\x00\x00"), - []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xdc=[s\xdb8w\xef\xf9\x15\x98\xf4a\xdb\x19\xcbi\xa6\x97\xe9\xf8\xcd\xf5:\x8d\xfb}\xebx\xec4\xfb\f\x91G\">\x83\x00\x17\x00\xa5h\xdb\xfe\xf7\x0e\x0e.$%\x90\x84d˛-^2\xa6\x80\x03\xe0\xdc\xcf\xc1\x01\xb2X,\xdeц}\x03\xa5\x99\x14W\x846\f\xbe\x1b\x10\xf6/}\xf9\xfco\xfa\x92\xc9\x0f\x9b\x8f\uf799(\xaf\xc8M\xab\x8d\xac\x1fA\xcbV\x15\xf03\xac\x98`\x86I\xf1\xae\x06CKj\xe8\xd5;B\xa8\x10\xd2P\xfbY\xdb?\t)\xa40Jr\x0ej\xb1\x06q\xf9\xdc.a\xd92^\x82B\xe0a\xea\xcd?^~\xfc\xd7\xcb\x7fyG\x88\xa05\\\x11\x05\xdaH\x05\xfar\x03\x1c\x94\xbcd\xf2\x9dn\xa0\xb00\xd7J\xb6\xcd\x15\xe9~pc\xfc|n\xad\x8fn8~\xe1L\x9b\xbf\xf4\xbf\xfe\x95i\x83\xbf4\xbcU\x94w\x93\xe1G\xcdĺ\xe5T\xc5\xcf\xef\bхl\xe0\x8a\xdc\xdbi\x1aZ@\xf9\x8e\x10\xbft\x9cv\xe1W\xbd\xf9\xe8@\x14\x15\xd4ԭ\x87\x10ـ\xb8~\xb8\xfb\xf6OO\x83τ\x94\xa0\v\xc5\x1a\x83\b\xf8\x9fE\xfcN\xc2B\tӄ\x92o\xb8Q\xbb\x1aD<1\x155DA\xa3@\x830\x9a\x98\n\bm\x1a\xce\n\xc4;\x91\xab\x1e\xa40J\x93\x95\x92u\amI\x8b\xe7\xb6!F\x12J\fUk0\xe4/\xed\x12\x94\x00\x03\x9a\x14\xbc\xd5\x06\xd4e\x04\xd4(ـ2,`ٵ\x1e\xef\xf4\xbeNm\xcc6\x8b\v7\x8a\x94\x96\x89\xc0m\xc1\xe3\x13J\x8f>\"W\xc4TLw[\r\xdb#T\x10\xb9\xfc\x1b\x14\xe6r\x0f\xf4\x13(\v\x86\xe8J\xb6\xbc\xb4\xbc\xb7\x01e\x91Uȵ`\xbfG\xd8\xdan\xdcNʩ\x01m\b\x13\x06\x94\xa0\x9cl(o\xe1\x82PQ\xeeA\xae\xe9\x8e(\xb0s\x92V\xf4\xe0\xe1\x00\xbd\xbf\x8e_\x90xb%\xafHeL\xa3\xaf>|X3\x13$\xaa\x90u\xdd\nfv\x1fP8ز5R\xe9\x0f%l\x80\x7f\xd0l\xbd\xa0\xaa\xa8\x98\x81´\n>І-p#\x02\xa5\xea\xb2.\xff.\x12u0\xad\xd9Y\x1e\xd5F1\xb1\xee\xfd\x80\x02q\x04y\xac\xa88\xc6s\xa0\xdc\x16;*\xd8O\x16u\x8f\xb7O_\xfbLɴ'J\x8f7\xc7\xe8c\xb1\xc9\xc4\n\x94\x1b\x87\xacia\x82(\x1bɄ\xc1?\n\xce@\x18\xa2\xdbe͌e\x83\xdfZЖ\xdf\xe5>\xd8\x1b\xd4:d\t\xa4mJj\xa0\xdc\xefp'\xc8\r\xad\x81\xdfP\roL+K\x15\xbd\xb0DȢV_\x97\xeewv\xe8\xed\xfd\x104\xe2\bi\xbd\x16yj\xa0\x18H\x9a\x1d\xc6VA]\xac\xa4\x1a(\x19;d\x88\xa3\xb4\xf0\xdb洈U\x8b\xfb\xbf\xccq\x99m\xff\x1eG[~\xb3+k\x05\xfb\xad\x05T\xa6N\xfc\xe1P_\xa9\x9ej\x1f6\xcbF\xfb\xd4\x1dE\xb4m\xf0\xbd\xe0m\te\xd4\xeb\a\x1b\xcc\xd9\xc6\xed\x01\x144z\x94\t+D\xd6\xfaؽ\x88\xeeWT\xe0T\x01\x11\xd2$\xe01\xe1\xe0\x11&\x10\x03I\x9a`G\x03ubœ[&D\xb4\x9c\xd3%\x87+bT{\x88F7\x96*Ew#\xd8\n\x1e\xc0\x8b\x90\x15\x81xU\xc3Y\x81$\x8f\n\x05\xf1\xf5\xe7E\x15\xd3VQ\x86]>HΊ\xdd\f\xben\x93\x83\x82\xb4z\xd9\xf5;$K\xa8\xe8\x86I\x95\x12\x03\xa9\xb0kϞwjZZ-\xe9\x81\xec۸\xcc\r'\x91UI\xf9<\xc7\x10\x9fm\x9f\xce:\x90\x02\x1dʸ\x15Omo\xbb\x97@\xe0;\x14\xadI,\x93\x90\xb2E\xd3$\x15i\xa46\xe3t\x1fW]\xa4\xef\x1c\xa5~\x9c`\x9a\x83\x9d%Y\xdd5\xaf\x84\x03Q-\x0e\x06\nY\n\xb0ۨ-Q\xbb\xbeJ\xb6\xae\xef(RȒj(\x89\x14\xa33#\xbb\xb4\x1c\xb4\x9f\xabD\xce\xe8\xf4\xd0E\xb7\x7f\xf4x\b\xa7K\xe0D\x03\x87\xc2Hu\x88\xcc\x1c\x94\xba\x96\xa3XGP\x99ЦC\t\xe860\x01\x92XN\xdfV\xac\xa8\x9c\x87a\xd9\x13\xe1\x90R\x82\xb6\xda\x04]\xe6\xdd\xd8&\xc9\x1c\xf9\xfd$Sڣk3b\xb5\x0f/\xa5Q\xba\x96\xa1\x86\xbb\x96Dm\xa7{\x0ft\x8b\xffn\xe4\xe4\xb6\xff\x7f\"6\x18\x93\x13\x98vB\xfe\t\xba\x9f\xd9<=ʷ\x18ၾ$w+\x02ucv\x17\x84\x99\xf0uN\x12(\xe7\xbd9\xfeĴ9\x9e\xe93I\x93#\x13g\"L\x9c\xe2OH\x174\x19O\xdebd\xd3\xe4\xaf\xfdQ\x17\x84\xad\"\xd2\xcb\v\xb2b܀\xda\xc3\xfeI\xaa>P\xe65\x90\x91c\xf5\b\xe6\tLQ\xdd~\xb7.\x8e\xee\x92`\x99x\xd9\x1f\xec|\xe3\x10A\f\xcd\xf3\f\\\x82\xf12SPc\x1cN\xbe\"6\xbb/\xe8T_\xdf\xff|\x18+\xef\xb7\f\xce;\xd8Ȍйv\xbd\xb7\xa3\xfe\xfa|T\x10~A\x1f(\x06U.\xe7rA(y\x86\x9ds]\xa8 \x96>4tΘ^\x01&\x7f\x90Ϟa\x87`\xd2ٜÖ\xcb\r\xae=C\xc2\xf5O\xb5\x01\x0e\xed\x9a|X\xec\xf0d? \"0\x86\xcfe\x03\u05fc($r'閩KB\v\xb8?a\x9bY\xacҟ\xa3\x9f\xfaD\x0e\xf8I;ZZ\x89\xa9\x98\xcfij@\x99\xc9%\xa8k\xdf(ge\x9c\xc8\xc9ȝ\xb8 \xf7\xd2\xd8\x7f0@\xd3\xc8(?K\xd0\xf7\xd2\xe0\x97\xb3`\xd4-\xfc\x9c\xf8t3\xa0\xa0\t\xa7\xe5-\xc2\xfa9?g\xd3,\xb7E\xdc3M\ue10dW\x1cJ2\xa7\xc2\xf4\xae\x9b\xceMT\xb7\x1a\xd3uB\x8a\x05\xda\xcc\xe4L\x1e\xdfR\r\xd0\xfd\xe2I\xfd\x84_\xad\xb1p\xbf\xb8$3\xa7\x05\x94!\xb2\xc4\xec'5\xb0fE\xe6|5\xa85\x90ƪ\xf0<\x8e\xc8T\xac~7DZO\x9e\xf5\xee\xb7\xef\x8b\xe7\x98/XX\x93\xb3\xf0\x10\x8c\xac3p\xe0uw9\xbf\x9f\x85\x95ٌ^\x81\x13f\xbb\x8e$Gǻ\xe6 \xe5\x05\xe8@+\x8e.\xce,uiY\xe2\x11\x1a\xe5\x0fGX\x94#x\xe1X\xd5\xd0[\xbb3\xc15m\xacZ\xf8okiQ\x9a\xfe\x974\x94)}I\xae\xf1\xa4\x8c\xc3\xe07\x9f\x87\xeb\x81ɘ\xb2\xb1SY\xfe\xd9Pnm\xbfU\xe0\x82\x00w\x9e\x80\\\x1d\xf8E\x17d[I\xed\xcc\xf6\x8a\x01\xc7\xf3\x8a\xf7ϰ{\x7fa\xa7\x9f\x9d\xb2\xafd\xde߉\xf7·8P\x18\xd1ᐂ\xef\xc8{\xfc\xed\xfdK\\\xa9LN\xcd\xec6`њ6y\x1c*\x92\xc9\xfa\xae\r8\xa6\x9f\x9b\xef\x92\xf2\xdeɞ\xdam\x16\x8b6R\x9b\xcf\xe9\xbc\xe1\xc8z\x1e\u0088\xa1g\x9cȱ\xcdF\f>\x8f\x16\xf5\xbdu\"W\x06\x94\xcf%:\x1b\x10\xe2\x8f\x17Ff\xa9S\x99\xfebc2\x90\xc6\xfc\xaeE\xf0\f7\xb9\x83\x9b\x9c%\x1e\xe3\xb0Z\xbc\x1c\xe9\xed\xdf~\xef\xe53\xad\xe4ڿ\xfb\x1bym\x87\xba\x90uM\xf7O5\xb3\x96z\xe3F\x06\x9e\xf6\x80\x1c\xf5պEyε\xc8\x1d\x0f\xe1\xf9喙\x8a\tB\x83\xda\x00\xe5\x19\x8a\x92F\xa6rةVQM\x96\x00\"\xa6\xe8\x7f\x04W\xa2f\xe2\x0e' \x1f\xcf\xe0zDt\x9d\xd3ٽ\x894\x89\x94\x8f\x1f\x9c\xc9jdI\xb6\x15(\x180\xc6a\xde\x1d=U!M/eq\x84C\xda\xc8\xf2'MVLi\xd3_\x82&\xadΥ\xf5\x91\xe4\xb3\xeb\xfe\xcaj\x90\xad9'\x82o\xbbi\x06g\xcd5\xfd\xce\xea\xb6&\xb4\x96\xad3\xe6\x86\xd5\xf1TףwK\x99\x89\xc7V\x98\xbf1Ғ\xa0\xe1`\x80,a\x95>\xefM\xb5B\n\xcdJP\xa1J\xc1\x91\x8dI+\x98+\xcax\x9b:%J\xb5c#`q\xab\xd4I\x01\xf0\x177\xb2\x97w\xac\xe4v\x88\xa0̽\xe3A\x1a\x10\xb6\"\xcc\x10\x10\x85\xc58(\xa7\x92q\n\x8f\fD\r\xcb\xd5sy\n\xdc6\x10m\x9d\x87\x80\x05\n$\x13\x93)\xb7~\xf7O\x94\xf1s\x90\xcdr\xde'\xa9\x1e\x81\x96\xa7\xe4h~\xed\r' t\xab\xf0\xf0\xdf\xe9\x8e-\xe3yk\xb6\x94#\x9c\xb6\xa2\xa8\x00\x95\x90\x18\xea\x06\a\x9e\tm\x80\xe6\xf2\x82\xf5\x8aZ!\x98X\xe7\xd1.;\x11\xda5\x87\ua954\x1c\xe8\xf8)d\xd7,\xae\xdf@\x13\xfd\xdaM\xf3BM\xd4\x11\xc1\x1d\x9b#\x1d\xb2)j\x95\x16\xa1\xc6@\xdd8\x91\x93D\xb5\xa2o]Π\x88\x8e\t\xc3\xfd*^3\xbef\x82e\xd0v@\xd7;\xc1L\xdfy\xb4 \xce\xea<\xda\t\xa2;pJ\x86\xedn\x00\xc0\nh\x88Cp\xed\x91k\x8ep$\x97@hYB\xe9r\x97\xd6\x15\xf1a\x89+|\x1b)nH\xee\xeexO0\x8b\xb2\xa1\r\x82N\xccê\r,Z\xf1,\xe4V,0\x18\xd7G\xeb\x90\x13\xb3T/\x9dޜ\xac\x8c\xe6\xf5K\xbe\x9a\x9e\xd3BC~\xcd\xe7\xa9\xe0?\x9dA\xcbd\xf3\xcdQ\t\x8f).\x98\xd3k\xae\x00{\xe4\xc7\xd9UL\xcd?1\xd8\x1fJ߸b\xe9\x17\x95\xc5ݥA\xf5\x9c\xc2m\x05\xa6\x02\x15J\xb3\x17X\x92^N\x9e\x90v\xc1K\xac\x93\xb3L\x15\\dW\xfe\xb9W9\x87\xd1M\xcb\xf9\x85\xe5m\xda\xf2d8l$\x8a\xd8!geՏ\xa5=\x86\x9c\xea\x8bl<\xf6+-\x86\xf5\x85\xb1\n\"\x14\x18\xca0\xb3\xa7qj\xbfXX\xda;\xdf\x1f\x96S`\xfe/,\xff\x0f/=̨\x94\xc8Gcn\x95fDb\x02V\x82\xc1zh\xec\xea+|?_\xe8\xfbc\xe1\xd4@\xfd\xa5\xf1\x123\xea\xc2f\xa05\x01g\xaf\xde\x04\xadA\xab\x9d+\x10\xed\x80\xcf\x19\xda\xf1ׅ\xbb\x05\x11\xc0\xa4\xf8\xf5k\x05A|}\xf5>\xd3\xe4\x9fI%\xdbDU\xdf\x04\xcaf\xaa;\xe67<(\xf4\xf0\a\n`\xe8\xe6\xe3\xe5\xf0\x17#}\xd9\af\xd1\x12\x800(\xea2\xb3L\x94l\xc3ʖ\xf2 \xb5\xdd\x1d\x02\xc7@\x1d\x9f%\xa0IE\x04\xe3\x8e\x01\xc3\xf8\x01Ñ/\x8d;\x969Z\xc5M\xfb\xa2y\xd5!'ׄ\fk>F\xac\xe1\xb1\xc7\x17\xafR\x05\xfb\x87\xd4z\x1c_\xe1\x91\x13I\xccTs\x9cPÑY,\xf6\xe2\xf3\x96\x9c*\x8dcb\xee\xb3Ud\xbc~\x1dF\x16~\xe6k.\x8e\xc1\xce\xd9\xeb+ް\xaa\xe2mj)2+(^\xaf\x142/\xfa<\xa9\x14`>`\x19\xaf\x82\x98\xad}xQ@sҖfk\x1a\x8e\xa9d\x98\xa5N\x9e\x98\xbdY\xad\u009bU(\xbcm]\xc2$\x17M\xfexL\xe5A\x8c\x93~\xa1M\xc3\xc4\xfa\x90)rYg\x92m\xe6Y\xe6~o!\x03\x9e\xe9\x873]t8\x12\xfa\xba\xeb҉H2\xa4-\x990\xf2\x92\\\x8b\x9d\x87\x9b\x80\xd3\v\x1f\x854\a\x17\xd9첶\x8c\xf3\xfem-\x04;\r\xcaߙԴv\xab\x1a\xf3\xf6\x93t\x95j\xe0\x94\x9f\x148~ك\xd1ώ\xbe\xa5\xe7_\xb7ܰ\x86\x83\xf5\xe86\xacL\xde!3\x15\xec\"\x92\xff&\xf1\x86\xd4r\x87\x90\xbe]\xf4\x9eQ\xec\x9eq\x926\xbf\xc9\x13\xb6\x97Q\xcc~\\\x11{\x06\xcdrE\xf1\r\x8b\xd5߰H\xfd\xad\x8b\xd3g8k\xe6\xe7\xe3\x8a\xd0O>\x81\tG\xfd\xf7\xb2\x84\a\xa9\xcc\\p\xf2\xb0\xdf?q\x92\xda\v\xd8$/\x89\b]\x13\xbb\xc4\x10Ç\x17\xa7m*}\xe8\x19\xdc\xe9_di\xd76w\xc6\xf2\xb8\xd7\xfd\xe0\xae\xf2\n\x14\b\xf7\xcc\xc7\x7f>}\xb9\x8f\xf0S>\xaf\xf7\x8c\xf7\x9e\x97p\x1eL\xe9\x91\xe3\x8f\xe6|1\x93\xc3\x16\xfa\x00\xaf|.B\x1b\xf6\x1f\xf8\xaa\xdb\v\xd2A\xd7\x0fw\b#\xf8i\xf8L\\\xac\xa2\x88'\x96K\xb0\x16+\xa2jT,\xeeV\x03\x88Ê\xdf\xfe3JP\xba'\xb3\x82\xc5d\xa1\xc6\xcb\n\xdeÝ[\xc7\xd8,\x9f\xac\xd3(vD:\x8e\xac\x98*\x17\rUf\x87l\xa3/\x06k\bff*\x9d3\xaaX\x0f\x9f\x01K\xa27\xbc\xfe\x85g\x91\xbbfxڻ\x8f\xbbS\xd61~\xffd\xf6\xe6\xc9+\xaec\xdcb/\x10S\x89\xcf\xc9\x02\x93WK\x93yM\xf4\xf0\xed\xa4\xb4\xcbc\x1c=\xad\xe7l\x14\x1dRM\t0v<\xaa:-h\xa3\xabēK/\xd3u\xf8\x1a\x99\xa1\xa6}\xc9&\x1d\x80\xc1>YQ\xf5\xb4\xd5\x16\x82>\v\xdbFi\xc5a)\xddnm\xb3\xab{a\xfc\xa2\x97)x\x9b#\xe1\xcc\xe7\\N~\xc8šgD\xfd`\xf6˪\xb6CL\x9dp\x18<\xeb\xdae\x14\x19O;\xb1\x99π\xe4\x19\x8c\x13\x9e\xfe@|\xe5\xe2\x8a$_\x04\xc9|\xf5\xe3\x0fE\xf4\x84V\xd3E\x05e\xcb\xe1\xd47\xff\x9ez\xe3\xe7_\xfd\v\xb3e\xbc\xfbg\x91\xdd3\xd0\xd6g\x1e\xbe/\xe8)\xe1!\xf7)9\xe6\xf0ap\xe0\x9e\x17+\xdcK\x94E\x01Z\xafZ\x1e\xaa\x94\n\x05\xd4@\x19\xba3\x1dW|T\x9dM\xdbpIKP7R\xacX\xe2\x84d\x80\xd6\xff\x1at\xde\xe3\xd9\x02?\xb6\xaa{\xdaq\xf2Y\xbc\x17i\xae\x86*\xca9\xf0O\x8c\x83\xfeYn\x85]W\x86@>\xa4\xc6\xf5\xeee\x15\xad\xb2f}GD[/\xad\x93\vƌ\a\x8b+\xa9\xa6+\xa4\x1dޙ0\xb0\x86T|\xbdU\xcc\xc0SC\x95\x06\\Q\xc6\x0e~\xdd\x1b\xe2\xa2\xcf\x15\xa7kW\nW\xb2\x82\x1a\x88\x06\x18g\x18[>\x8e\xd7\b\x8b\xef\xb02I\x8e$\xbd\xb2\x85z\xecJƨX\x8f=/\x9a0\xd5\xc9\aF\x9dE.hc\xf0\x02\f\xd2\x11\x89h<\f|\xb4w\xef\x8d\xd1\x01\xd8qN\xf3e̾`N\x1bZ'\xa2\x84y\xbdss\b\x06\x9f\x05Ve\xaf\xee\xae\xff\xc0b,\xb0#[\xaac1u\xd2\xf7\xee`;0\xe8\xaa[\xd0P\x12\u0600 V\x14)\xe3PNq\xeaWL$\xab\r\xa8\x9ft\x84\x83\x95\x80\x96ş\fU&.\xfdЏYIUSsEJj`aG\x9f溥\x9fIU\xea\xc4\xe3@\xbc\xd9\xe6ţ\b\xd7n\xac\xf5s\xf7\xd1jК\xaeC\x10\xba\x05\x05d\r\xc2\xe2=\xe6\x16\x93\x1eS\xb8\xd2\xe7\x8dE,-\xb5(\xa4\x85i\xa9\x9f\xc0\xb9p\xf1\xf44\xbcO\x8cQ\xeczTE\xa7U\x85\xbf<\xf8\bT\xef?w}\x80\x8bO\xfd\xbe>I\xecv\xec\xceF\xa8+\xf0\xc4\a\x8f\r\x8b\x91uJ\xa6\x8dę\x8f2'\x95\x94\xcfYn\xf6\xe7رK'1\xe1X\t\xafL.ekz~\x8eGxb\x99\xf8\xfc\xe7+\xdb\x17\x84y\xed.P\x8d\xe5V\xf3<\xbd\xcf\x03H1\xbc\x95\x86\xf2`d,_\xc6\x0e\xd5\xc4\x03\x02O\xe1\xf1d\xcew\x17\xfb\x90\xf7^e\xef`W\xddS\x9e^\x13t\xd7\xc7\xc7Ҫ>\xeb\x97\x04\x12_\x01\xed|\x92\xb17\x17\xe7\xec\x1fB\xfd\x84\x8b\xca\xc0\xf1\xe7\xae\xf7\x18\x1e\xdd2\x9d\xc3\f\"\x1di\x12\f>L\x15%ㄥOx\xa9ME\xf5\x9c{\xfa`\xfbD\xb7\xa3g\xae\xa2\x13\xfa8\"\x95\xe9{\xae\vr\x0f\xdb\xc4W\x87,<\xfdB\xa9Jt\xb9\x13\x0fJ\xae\x15\xe8C\xa6[\xe0}F&֟\xa4z\xe0횉/\xe3\x95\xdfS\x9d\x1f\xa82\xcc2\xad[Ob\xecM\xb0q\x89\xdf\xe6G\x8f\xff\xc0\x04\xe5\xec\xf7\x94.\xef\xff87Ä\xbek<\xf2N\xb1P\x01\xf1s\n\xd0k\xe8\x9ft\xcf\xfc\x84y/ɽL\x8a\xb1? fC\xa0L\x93%h\xb3\x80\xd5J*\xe3\xf2\xf7\x8b\x05a\xab\xe0 Y\r\x81q\xa2{͞\xb0T\xe2=\x1e\xbd\x05\x87e\xe5S\x89\n\xad\x0e\x86\x9c5ݹ\x8c$-\n\x1b\x13\xc0\amh*6y\x91\x9e\xc6P\xd5\xcbJ\x8e\n\xb9\xeb\xf7\x8f9\xbe\xa8>\x10\x9cC\x1d^gw\x06\x9d\x8f\x9di\r^\xcb \xdab\xef\x14eB\x9c\x1a\xbb\x1b\x0f\xbb\xf3L\xcd\xd7\beL=\xfa\xfd\r\x1e\xe2\xf6\a\xac\xbe\x93%[QQ\xb1\x1e\xbd\xd0V)ٮ\xab\xc0\x9bc\x0e\x11)[\x8c\x9c\x1bT\x05:\xfc\xc7!\xa6U\xa2wh\xe7k,ƴt\\\uee0f\xf2\x02E\xad\xba\x8b-\x9d\xaa\x9a\xb0\xf9\xd9Y\xc2\x11\x88\xb3\xb6?\x01\x91\xea\x9d(&\xaf\xe0\xf8@\x9bM\xdc՝\xc2P\x12\tQ\x1b\xbf\x1a\x12\"\xc41$\xf4}\x89.\xe2\xf9a02棜\x88\x8ei'\x06\xb78\rj~\xd3}'h\xe8\xee\x1c\x87\x0e=\b\xfeNJ\xbb\r \x1c\x13\xf9\xe2\xdc\xe9\xb8\xf7ǍX7\xd1ۺ=9v\xfd\xb6\ac\xef\n\xa4\x8db\xbbiB\xbc\xf9\xf7l\x95\x92\x17\xf7\xbf3-9\xfc\xc3\xc1\xafo|\x95qK\x95`b}\x12F~\xf5c\x13\xf1\xbc\a{Έ>\xac\xfc\xd5b\xfa\xa4Y:\xf8\x88\f^\xf6\xf0\xecg\xf2_\xfe/\x00\x00\xff\xffP\a\xb5\x16Cm\x00\x00"), + []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xec=Ks\xdc8sw\xff\n\x94sؤJ#ǕG\xa5ts\xb4v\xac|\xdf\xca*\xc9\xf1\x9e1d\xcf\x10\x9f@\x80\v\x80\x1a\xcf&\xf9\xef)4\x1e|\fHbF\x1a\xednjqQ\x89$\x1a@\xbf\xbb\xd1\xc0\xacV\xab7\xb4a\xdf@i&\xc5\x15\xa1\r\x83\xef\x06\x84\xfdO_>\xfe\x9b\xbed\xf2\xdd\xd3\xfb7\x8fL\x94W\xe4\xba\xd5F\xd6\xf7\xa0e\xab\n\xf8\x116L0äxS\x83\xa1%5\xf4\xea\r!T\bi\xa8}\xac\xed\xbf\x84\x14R\x18%9\a\xb5ڂ\xb8|lװn\x19/A!\xf00\xf4\xd3?^\xbe\xff\xd7\xcb\x7fyC\x88\xa05\\\x11\x05\xdaH\x05\xfa\xf2\t8(y\xc9\xe4\x1b\xdd@aan\x95l\x9b+ҽp}\xfcxn\xae\xf7\xae;>\xe1L\x9b\xbf\xf4\x9f\xfe\x95i\x83o\x1a\xde*ʻ\xc1\xf0\xa1fb\xdbr\xaa\xe2\xe37\x84\xe8B6pEn\xed0\r-\xa0|C\x88\x9f:\x0e\xbb\xf2\xb3~z\xef@\x14\x15\xd4\xd4͇\x10ـ\xf8pw\xf3\xed\x9f\x1e\x06\x8f\t)A\x17\x8a5\x06\x11\xf0?\xab\xf8\x9c\x84\x89\x12\xa6\t%\xdfp\xa1v6\x88xb*j\x88\x82F\x81\x06a41\x15\x10\xda4\x9c\x15\x88w\"7=H\xa1\x97&\x1b%\xeb\x0eښ\x16\x8fmC\x8c$\x94\x18\xaa\xb6`\xc8_\xda5(\x01\x064)x\xab\r\xa8\xcb\b\xa8Q\xb2\x01eX\xc0\xb2k=\xde\xe9=\x9d[\x98m\x16\x17\xae\x17)-\x13\x81[\x82\xc7'\x94\x1e}Dn\x88\xa9\x98\xee\x96\x1a\x96G\xa8 r\xfd7(\xcc\xe5\b\xf4\x03(\v\x86\xe8J\xb6\xbc\xb4\xbc\xf7\x04\xca\"\xab\x90[\xc1~\x8d\xb0\xb5]\xb8\x1d\x94S\x03\xda\x10&\f(A9y\xa2\xbc\x85\vBE9\x82\\\xd3=Q`\xc7$\xad\xe8\xc1\xc3\x0ez<\x8f\x9f\x90xb#\xafHeL\xa3\xaf\u07bd\xdb2\x13$\xaa\x90u\xdd\nf\xf6\xefP8غ5R\xe9w%<\x01\x7f\xa7\xd9vEUQ1\x03\x85i\x15\xbc\xa3\r[\xe1B\x04J\xd5e]\xfe]$\xea`X\xb3\xb7<\xaa\x8dbb\xdb{\x81\x02q\x04y\xac\xa88\xc6s\xa0\xdc\x12;*\xd8G\x16u\xf7\x1f\x1f\xbe\xf6\x99\x92iO\x94\x1eoN\xd1\xc7b\x93\x89\r(\xd7\x0fY\xd3\xc2\x04Q6\x92\t\x83\xff\x14\x9c\x810D\xb7\xeb\x9a\x19\xcb\x06\xbf\xb4\xa0-\xbf\xcb1\xd8k\xd4:d\r\xa4mJj\xa0\x1c\x7fp#\xc85\xad\x81_S\r\xafL+K\x15\xbd\xb2DȢV_\x97\x8e?v\xe8\xed\xbd\b\x1aq\x82\xb4^\x8b<4P\f$\xcdvc\x9b\xa0.6R\r\x94\x8c\xed2\xc4QZ\xf8msZĪ\xc5\xf1\x9b%.\xb3\xed\xdfco\xcbovf\xad`\xbf\xb4\x80\xcaԉ?\x1c\xea+\xd5S\xed\xc3f\xd9hL\xddID\xdb\x06\xdf\vޖPF\xbd~\xb0\xc0\x9ce|<\x80\x82F\x8f2a\x85\xc8Z\x1f\xbb\x16ѽE\x05N\x15\x10!M\x02\x1e\x13\x0e\x1ea\x021\x90\xa4\t~h\xa0N\xccxvɄ\x88\x96s\xba\xe6pE\x8cj\x0f\xd1\xe8\xfaR\xa5\xe8~\x02[\xc1\x03x\x16\xb2\"\x10\xafj8+\x90\xe4Q\xa1 \xbe\xfe\xb8\xa8b\xda*ʰ\xca;\xc9Y\xb1_\xc0\xd7\xc7d\xa7 \xad^v\xfd\n\xc9\x1a*\xfaĤJ\x89\x81T\xf8iϞwjZZ-遌m\\悓Ȫ\xa4|\\b\x88\xcf\xf6\x9b\xce:\x90\x02\x1dʸ\x14Omo\xbb\xd7@\xe0;\x14\xadIL\x93\x90\xb2E\xd3$\x15i\xa46\xd3t\x9fV]\xa4\xef\x1c\xa5^\xce0\xcd\xc1ʒ\xac\xee\x9aW\u0081\xa8\x16\a\x03\x85,\x05\xd8eԖ\xa8ݷJ\xb6\xee\xdbI\xa4\x905\xd5P\x12)&GFvi9h?V\x89\x9c\xd1顋n\xfd\xe8\xf1\x10N\xd7\xc0\x89\x06\x0e\x85\x91\xea\x10\x999(u-G\xb1N\xa02\xa1M\x87\x12\xd0-`\x06$\xb1\x9c\xbe\xabXQ9\x0fò'\xc2!\xa5\x04m\xb5\t\xba\xcc\xfb\xa9E\x92%\xf2\xfbA\xe6\xb4G\xd7\x16\xc4j\f/\xa5Q\xba\x96\xa1\x86\xbb\x96Dm\xa7{\x0ft\x8b\x7fn\xe4\xec\xb2\xff\x7f\"6\x18\x93\x13\x98vF\xfe\t\xba\x9f\xd9<=ɷ\x18ၾ$7\x1b\x02uc\xf6\x17\x84\x99\xf0tI\x12(\xe7\xbd1\xfe\xc0\xb49\x9e\xe93I\x93#\x13g\"L\x1c\xe2\x0fH\x174\x19\x0f\xdebd\xd3\xe4\xaf\xfd^\x17\x84m\"\xd2\xcb\v\xb2a܀\x1aa\xff$U\x1f(\xf3\x12\xc8ȱz\x04\xf3\x04\xa6\xa8>~\xb7.\x8e\xee\x92`\x99x\x19wv\xbeq\x88 \x86\xe6y\x01.\xc1x\x99)\xa81\x0e'_\x11\x9b\xdd\x13t\xaa?\xdc\xfex\x18+\x8f[\x06\xe7\x1d,dA\xe8\\\xfb0ZQ\x7f~>*\bo\xd0\a\x8aA\x95˹\\\x10J\x1ea\xef\\\x17*\x88\xa5\x0f\r\x1fg\f\xaf\x00\x93?\xc8g\x8f\xb0G0\xe9l\xcea\xcb\xe5\x06\xd7\x1e!\xe1\xfa\xa7\xda\x00\x87vN>,vx\xb2\x0f\x10\x11\x18\xc3粁k^\x14\x12\xb9\x93t\xcb\xd4%\xa1\x05ܟ\xb0\xcc,V\xe9\x8f\xd1O}\"\a\xfc\xa0\x1d-\xad\xc4T\xcc\xe745\xa0\xcc\xe4\x12Եo\x94\xb32\x0e\xe4d\xe4F\\\x90[i\xec\x1f\f\xd042ʏ\x12\xf4\xad4\xf8\xe4,\x18u\x13?'>\xdd\b(h\xc2iy\x8b\xb0~\xce\xcf\xd94\xcbm\x11\xf7L\x93\x1ba\xe3\x15\x87\x92̡0\xbd\xeb\x86s\x03խ\xc6t\x9d\x90b\x85639\x92ǷT\x03t?{P?\xe0Wk,\xdc\x1b\x97d洀2D\x96\x98\xfd\xa4\x06\xb6\xac\xc8\x1c\xaf\x06\xb5\x05\xd2X\x15\x9e\xc7\x11\x99\x8aկ\xe68\xf6ɳ\xde\xfd\xf6}\xf5\x18\xf3\x05+krV\x1e\x82\x91u\x06\x0e\xbc\xee.\x97׳\xb22\x9b\xf1U\xe0\x84\xc5O'\x92\xa3ӟ\xe6 \xe5\x19\xe8@+\x8e.\xce\"uiY\xe2\x16\x1a\xe5wGX\x94#x\xe1X\xd5Л\xbb3\xc15m\xacZ\xf8okiQ\x9a\xfe\x974\x94)}I>\xe0N\x19\x87\xc1;\x9f\x87\xeb\x81\xc9\x18\xb2\xb1CY\xfey\xa2\xdc\xda~\xab\xc0\x05\x01\xee<\x01\xb99\xf0\x8b.Ȯ\x92ڙ\xed\r\x03\x8e\xfb\x15o\x1fa\xff\xf6\xc2\x0e\xbf8d_ɼ\xbd\x11o\x9d\x0fq\xa00\xa2\xc3!\x05ߓ\xb7\xf8\xee\xeds\\\xa9LN\xcd\xfcl\xc0\xa25m\xf28T$\x93\xf5]\x1bpL?7\xdf%当=\xb7\xda,\x16m\xa46\x9f\xd3yÉ\xf9܅\x1eC\xcf8\x91c[\x8c\x18|\x1e-\xea{\xebDn\f(\x9fKt6 \xc4\x1fό\xccR\xbb2\xfd\xc9\xc6d \x8d\xf9]\x8b\xe0\x05nr\x1b79S<\xc6a\xb5x9\xd2\xdb\xff\xf8\xbd\x97ϴ\x92k\xff\xef/\xe4\xa5\x1d\xeaB\xd65\x1d\xefjfM\xf5\xda\xf5\f<\xed\x019\xea\xabm\x8b\xf2\x9ck\x91;\x1e\xc2\xfd\xcb\x1d3\x15\x13\x84\x06\xb5\x01\xca3\x14%\x8dL\xe5\xb0S\xad\xa2\x9a\xac\x01DL\xd1\xff\x1e\\\x89\x9a\x89\x1b\x1c\x80\xbc?\x83\xeb\x11\xd1uNg\xf7:\xd2$R>>p&\xab\x91%\xd9U\xa0`\xc0\x18\x87yw\xf4T\x854\xbd\x94\xc5\x11\x0ei#\xcb\x1f4\xd90\xa5M\x7f\n\x9a\xb4:\x97\xd6G\x92\xcf\xce\xfb+\xabA\xb6\xe6\x9c\b\xfe\xd8\r3\xd8k\xae\xe9wV\xb75\xa1\xb5l\x9d17\xac\x8e\xbb\xba\x1e\xbd;\xcaLܶ\xc2\xfc\x8d\x91\x96\x04\r\a\x03d\r\x9b\xf4~o\xaa\x15RhV\x82\nU\n\x8elLZ\xc1\xdcP\xc6\xdb\xd4.Q\xaa\x1d\x1b\x01\x8b\x8fJ\x9d\x14\x00\x7fq={y\xc7J\xee\x86\b\xca\\;n\xa4\x01a\x1b\xc2\f\x01QX\x8c\x83r*\x19\x87\xf0\xc8@\u0530\\=\x97\xa7\xc0m\x03\xd1\xd6y\bX\xa1@21\x9br\xeb\x7f\xfe\x892~\x0e\xb2Y\xce\xfb$\xd5=\xd0\xf2\x94\x1c\xcdϽ\xee\x04\x84n\x15n\xfe;ݱc,q\x85o\x13\xc5\r\xc9\xd5\x1d\xef\tfQ6\xb4AЉyX\xf5\x04\xabV<\n\xb9\x13+\f\xc6\xf5\xd1:\xe4\xc4,\xd5s\x877'+\xa3e\xfd\x92\xaf\xa6\x97\xb4А_\xf3y*\xf8Og\xd02\xd9|sT\xc2c\x8e\v\x96\xf4\x9a+\xc0\x9ex\xb98\x8b\xb9\xf1g:\xfbM\xe9kW,\xfd\xac\xb2\xb8\x9b4\xa8\x9eS\xb8\xab\xc0T\xa0Bi\xf6\nK\xd2\xcb\xd9\x1d\xd2.x\x89ur\x96\xa9\x82\x8b\xec\xca?G\x95s\x18ݴ\x9c_Xަ-O\x86\xc3F\xa2\x88\x1drVV\xfdX\xdacȩ\xbe\xc8\xc6c\xbf\xd2bX_\x18\xab B\x81\xa1\f#{\x1a\xa7\u058b\x85\xa5\xbd\xfd\xfda9\x05\xe6\xff\xc2\xf4\x7f\xf3\xd2ÌJ\x89|4\xe6ViF$&`%\x18\xac\x87Ʈ\xbe\xc2\x7f\xe7\v}\x7f_85P\x7fi\xbc\xc4L\xba\xb0\x19hM\xc0\x19՛\xa05h\xb5s\x05\xa2\x1d\xf09C\xdb\xffC\xe1NA\x040)~\xfdZA\x10__\xbd\xcf4\xf9gR\xc96Q\xd57\x83\xb2\x85\xea\x8e\xe5\x05\x0f\n=\xfc\x86\x02\x18\xfa\xf4\xfer\xf8\xc6H_\xf6\x81Y\xb4\x04 \f\x8a\xba\xcc,\x13%{beKy\x90\xda\xee\f\x81c\xa0\x8e\xcf\x12Ф\"\x82qǀ\xa1\xff\x80\xe1ȗ\xc6m\xcb\x1c\xad\xe2\xe6}ѼꐓkB\x865\x1f\x13\xd6\xf0\xd8\xed\x8b\x17\xa9\x82\xfdMj=\x8e\xaf\xf0ȉ$\x16\xaa9N\xa8\xe1\xc8,\x16{\xf6~KN\x95\xc611\xf7\xd9*2^\xbe\x0e#\v?\xcb5\x17\xc7`\xe7\xec\xf5\x15\xafXU\xf1:\xb5\x14\x99\x15\x14/W\n\x99\x17}\x9eT\n\xb0\x1c\xb0LWA,\xd6><+\xa09iI\x8b5\r\xc7T2,R'O\xcc^\xadV\xe1\xd5*\x14^\xb7.a\x96\x8bf_\x1eSy\x10㤟h\xd30\xb1=d\x8a\\֙e\x9be\x96\xb9\x1dMd\xc03\xfdp\xa6\x8b\x0e'B_w\\:\x11I\x86\xb4%\x13F^\x92\x0fb\xef\xe1&\xe0\xf4\xc2G!\xcd\xc1A6;\xad\x1d\xe3\xbc\x7fZ\v\xc1\u0383\xf2g&5\xadݬ\xa6\xbc\xfd$]\xa5\x1a8\xe5'\x05\x8e_F0\xfa\xd9\xd1\xd7\xf4\xfc\xeb\x96\x1b\xd6p\xb0\x1e\xdd\x13+\x93g\xc8L\x05\xfb\x88\xe4\xbfI\xfb\xa5\x05\xb5'\xf2\tK\x18\xbc\xf7֝U\xf0\xeaF\xdb\x183(@\xaf\x8c\xa76\x15\x0eB\x99NA\x91\x0f\xc2\xf9\x12\xe3\xf9`\x1f\xab\xf9\xbaPͪs\x1b\x85%ǘ\xe8.d\xec\x9d\xe8\xb6\xe4\xf6\xe7\x16\xf5\x9f7p;>t[\xf4\x95\xf2\xfd\xd9ߨX\xff\x94\"\xfd\xbc\xed\xa0Ţ\xfcs\x05rK\xa1\\\xb6\xf7\x9aWt\x7f\xdc&\xea\x19\x8b\xec\xcfQ\\\x9f\x89\xa9\x9cb\xfa\xe3\xf0\xf4\n\xc5\xf3\xafZ4\xffZ\xc5\xf2\xd9E\xf2Y\xfb\x98ٛV\xb9ی'V}/\xef\xba\xcf\x17\xbdg\x14\xbbg\xec\xa4-/\xf2\x84\xe5e\x14\xb3\x1fWĞA\xb3\\Q|\xc5b\xf5W,R\x7f\xed\xe2\xf4\x05\xceZx}\\\x11\xfa\xc9;0a\xab\xffV\x96p'\x95Y\nN\xee\xc6\xdf'vR{\x01\x9b\xe4%\x11\xe1\xd3\xc4*1\xc4\xf0\xe1\xc5i\x8bJoz\x06w\xfa'Yڹ-\xed\xb1\u070f>?8\xab\xbc\x01\x05\xc2]\xf3\xf1\x9f\x0f_n#\xfc\x94\xcf\xeb=\xe3\xd1\xf5\x12\u0383)=r\xfc֜/fr\xd8B\x1f\xe0\x85\xf7Eh\xc3\xfe\x03ou{F:\xe8\xc3\xdd\r\xc2\b~\x1a^\x13\x17\xab(\xe2\x8e\xe5\x1a\xacŊ\xa8\x9a\x14\x8b\x9b\xcd\x00\xe2\xb0\xe2\xb7\x7f\x8d\x12\x94\xeeʬ`1Y\xa8\xf1\xb2\x82ww\xe3\xe615\xca'\xeb4\x8a=\x91\x8e#+\xa6\xcaUC\x95\xd9#\xdb\xe8\x8b\xc1\x1c\x82\x99\x99K\xe7L*\xd6\xc3k\xc0\x92\xe8\r\xb7\x7f\xe1^\xe4\xbe\x19\xee\xf6\x8eqw\xca<\xa6ϟ,\x9eg\x91\x0e\xc0`\x9d\xac\xa8z\x1e\xe4\x0e\x82\x8f\x19\x96\x8dҊݒ\x1a\x1c\xb8?i\xc5\xf8E/{\xfb:e:\x99Wl\x9d|\xb9\x96Cτ\xfa\xc1\x1d\t\xab\xda\x0e1uB\x81\xceb\xb8\x9dq\xf0c>\xb1\x90y5S\x9e\xc18\xe1:&\xc4W.\xaeH\xf2\x96\xa6̛\x98~SD\xcfh5]TP\xb6\x1cN\xbd\x87\xf5\xa1\xd7\x7f\xf9&\xd60Z\xc6]\xac\x16\xd9=\x03m=\xacᝯ\x9e\x12\x1er\x9f\x92SA8&lܕ\x8f\x85\xbb\x1d\xb8(@\xebM\xcbC\xe5h\xa1\x80\x1a(\xc3\xe7L\xc7\x19\x1fU\xfb\xd86\\\xd2\x12\x94s\xc9\x16\xd0\xfa_\x83\x8fG<[\xe0\xc3Vu\xd7\xed\xce^U\xfa,\xcd\xd5PE9\a\xfe\x89q\xd0?ʝ\xb0\xf3\xca\x10ȻT\xbf\xdeY٢U֬\xef\x89h\xeb5(\xa2\xc1\x98\xe9\x04\xdeF\xaa\xf9S+\x0e\xefL\x18\xd8B*\xe7\xb9S\xcc\xc0CC\x95\x06\x9cQ\xc6\n~\x1euq\x19\xc1\r\xa7[W\x9e\\\xb2\x82\x1a\x88\x06\x18G\x98\x9a>\xf6\xd7\b\x8b\xef\xb1ZTNlDd\v\xf5\xd41\xb9I\xb1\x9e\xba\xf29a\xaa\x93\x97>;\x8b\\\xd0\xc6\xe0\xa1D\xa4#\x12\xd1x\x18x\x91\xfa\xe8\xde\xe7\x01\xd8iN\xf3GK|\x11\xb36\xb4ND\t\xcbz\xe7\xfa\x10\f^ծ\xca^-t\xff\xd2\xdbX\xf4LvT\xc7\x03.I\u07fb\x83\xed\xc0\xa0\xabnACI\xe0\t\x04\xb1\xa2H\x19\x87r\x8eS\xbf\xe2\xe6\x9ez\x02\xf5\x83\x8ep\xb0:۲\xf8\x83\xa1\xcaĩ\x1f\xfa1.\x86\xbb\"%5\xb0\xb2\xbdOs\xdd\xd2WW+ub\x89\x06\x9e6\xf6\xe2Q\x84\xa3\x90\xd6\xfa\xb93\xc25hM\xb7!1\xb8\x03\x05d\v\xc2\xe2=\xee\xf7$=\xa6p\xcc\xda\x1b\x8bAb\x80\x16\xa6\xa5~\x00\xe7\xc2Ŋ\x96pg\x0f \xc5\xf0V\x1aʃ\x91\xb1|\x19?\xa8f.uy\b\x17\xdas\xbe\xbf\x18C\x1e\xfdRF\a\xbb\xea\xaeW\xf6\x9a\xa0\xbb\xd2cb\xa0\xb0\x13\x93\x04\x12of\xee|\x92\xa9{p\x97\xec\x1fB\xfd\x84\x93\xca\xc0\xf1\xe7\xee\xeb)<\xbai:\x87\x19D:\xd2$\x18|\x98*J\xc6\tS\x9f\xf1R\x9b\x8a\xea%\xf7\xf4\xce~\x13ݎ\x9e\xb9\x8aN\xe8\xfd\x84T\xa6\xef\x1eX\x91[\xd8%\x9e:daE\x02JU\xe2\x93\x1bq\xa7\xe4V\x81>d\xba\x15\x9e1gb\xfbI\xaa;\xden\x99\xf82}\x1ag\xee\xe3;\xaa\f\xb3L\xeb\xe6\x93\xe8{\x1dl\\\xe2\xddr\xef\xe9\x17LP\xce~M\xe9\xf2\xfe˥\x11f\xf4]\xe3\x91w\x8a\x85\n\x88_R\x80^C\xff\xa0{\xe6'\x8c{IneR\x8c}\xd1\x0e\x1b\x02e\x9a\xacA\x9b\x15l6R\x19\xb7\xa7\xbaZ\x11\xb6\t\x0e\x92\xd5\x10\x18'\xba_\x18!,\xb5\x19\x1a\xcb!\x82ò\xf1\xa9D\x85V\aCΚ\xee]F\x92\x16\x85\x8d\t\xe0\x9d64\x15\x9b\x10\x9cC\x1d^1\xe2\f:\x9f\xaa3\x18\xdc`D\xb4\xc5\xde)ʄ85v3\x1dv癚\xaf\x11ʔz\xf4\xeb\x1b\xfc8\x82/z\xf1\x1fY\xb2\x15\x15\x15\xdb\xc9Cƕ\x92\xed\xb6\n\xbc9\xe5\x10\x91\xb2\xc5ȹAU\xa0Ï9\x99V\x89^!\x85\xaf{\x9b\xd2\xd2q\xba\xd3>\xca3\x14\xb5\xea\x0e\x1bv\xaaj\xc6\xe6gg\t' .\xda\xfe\x04D\xaa\xf7\xa2\x98=\x16y\xb8Gu\x94k\x99DB\xd4\xc6/\x86\x84\bq\n\t}_\xa2\x8bx~7\x18\x99\xf2QNDǼ\x13\x83K\x9c\a\xb5\xbc\xe8\xbe\x134tw\x8eC\x87\x1e\x04\x7f'\xa5\xdd\x06\x10\x8e\x89|q\xect\xdc\xfb\xfb\x8dX\x9f\xa2\xb7\xf5\xf1\xe4\xd8\xf5\xdb\b\xc6\xe8X\xba\x8db\xbbaB\xbc\xf9\xf7l\x93\x92\x17\xf7\x8byk\x0e\xffp\xf0\xf6\x95\x8f\x97\xef\xa8\x12LlO\xc2\xc8Ͼo\"\x9e\xf7`\xcf\x19ч\x99\xbfXL\x9f4K\a\x0f\x91\xc1\xcb\x1e\x9e\xfdH\xfe\xc9\xff\x05\x00\x00\xff\xff\xbc\x9a$\xa6\xd7r\x00\x00"), []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xec=]s\x1c)\x92\xef\xfa\x15\x84\xeea?B\xdd^\xc7}ą\xde|\xb2gO\xb1\x1e[ai\xf4\xbctU\xb6\x9aQ\x15\xd4\x00\xd5r\xdf\xde\xfe\xf7\x8dL\xa0\xbe\xba\xe8\xa2Z-ygǼت\x86$\xc9L\xf2\x03\x12X,\x16g\xbc\x12\xf7\xa0\x8dP\xf2\x92\xf1J\xc0W\v\x12\xff2\xcb\xc7\xff6K\xa1\xdelߞ=\n\x99_\xb2\xab\xdaXU~\x01\xa3j\x9d\xc1{X\v)\xacP\xf2\xac\x04\xcbsn\xf9\xe5\x19c\\Je9~6\xf8'c\x99\x92V\xab\xa2\x00\xbdx\x00\xb9|\xacW\xb0\xaaE\x91\x83&\xe0\xa1\xebퟖo\xffk\xf9\x9fg\x8cI^\xc2%3\xd9\x06\xf2\xba\x00\xb3\xdcB\x01Z-\x85:3\x15d\b\xf4A\xab\xba\xbad\xed\x0f\xae\x91\xef\xd0!{\xeb\xdbӧB\x18\xfb\x97\xde\xe7\x8f\xc2X\xfa\xa9*j͋N\x7f\xf4\xd5\b\xf9P\x17\\\xb7\xdf\xcf\x183\x99\xaa\xe0\x92}®*\x9eA~Ƙǟ\xba^0\x9e\xe7D\x11^\xdch!-\xe8+U\xd4e\xa0Ă\xe5`2-*K#\xbe\xb5\xdcֆ\xa95\xb3\x1b\xe8\xf6\x83\xe5g\xa3\xe4\r\xb7\x9bK\xb64ToYm\xb8\t\xbf:\x129\x00\xfe\x93\xdd!n\xc6j!\x1f\xc6z{Ǯ\xb4\x92\f\xbeV\x1a\f\xa2\xccrb\xa0|`O\x1b\x90\xcc*\xa6kI\xa8\xfc\x0f\xcf\x1e\xebj\x04\x91\n\xb2\xe5\x00O\x8fI\xff\xe3\x14.w\x1b`\x057\x96YQ\x02\xe3\xbeC\xf6\xc4\r\xe1\xb0V\x9aٍ0\xd34A =l\x1d:\x1f\x87\x9f\x1dB9\xb7\xe0\xd1\xe9\x80\n»\xcc4\x90\xdcމ\x12\x8c\xe5e\x1f\xe6\xbb\aH\x00F$\xaaxmH8\xda\xd67\xddO\x0e\xc0J\xa9\x02\xb8\x80vX4\xb6\nu%\xa0\x80\xe6\f\xddN\x8d\x16FH\xb6\xae\xd1#]2\xd4\x12Q\x19\x11\xd2X\xe0\x11a>\x01\xef\xe0kV\xd49\xe4WEm,\xe8\xdbLU\x90\x87E\xa6Q͜\xca\xc3\x0f\a!\xfb\xf8\xa5\x10\x19 \x1f2WiA\x8b<1\xd1nC\x99]\x05n\xcd\tY\xed\x87\xd0\xc6(\x93\xbaŀņ\xe7\x7f<\xbf \t\xe8\xf7\xde\xef\xc70\xae\xa1!\xd3,\xddL\x16\x7f\xbc\x85\xb0PF\xa8;\xa9\xa3f\xf0\x9dk\xcdw\a\xb8\xde,\xa6\xbd\x00\xdfc\xb0\a\x9c\x97\xa1\xda7\xe2\xfd\xb0\xff\xdf\"\xf7O\xcboC\x8b\xce\\H\xe4s!\x8c\xed\xb1ٸU,$\xebX\b\xe9\t$\x1dLT\x93S\\\xfd'!\xe6I\xe7Nl\xb24\xb2\xe9'\xc0\xbf\x14%7J=\xa6P\xef\x7f\xb1^\xbb\x84\xc52\xda\x18a+\xd8\xf0\xadP\xda\f\x97I\xe1+d\xb5\x8dj\x16nY.\xd6k\xd0\b\x8b\x96\xf9\x9b]\x81C\xc4:\x1c\xbe\xb0\x8eʊV\x18\x8c\xabe:\xb2\x94\xa8\x11\x1b\n\x05\xa8Q\xa8\xce\xc1\xc1Ђ\x1c\x88\\lE^\xf3\x82|\t.37>\xde\xe0\x17\xd3j\x13\x02\xb1\x87\x7fT\xaa]q\x0eM\x18$2\xb1\xb7\xea\xa5$\xa0\x8f_bl\xb4_5N\x89\xb0\x94p\xb0od\xa6\xae\v0\xbe\xbb\x9c\xdc\xe4V']\xb4\xccrk\f\x05_A\xc1\f\x14\x90Y\xa5\xe3\x14J\x91\x03WR\x95n\x84\xb8#Z\xb6\x1fm\xb5\x83\x99\x00\xcb(\xc4݈l\xe3\xdcW\x144\x82\xc5r\x05\x86VExU\x15\x11\xd3ՖI\xe1\xf0\x9dM鍶$h\x90!ܘ.iK\xa2~n\xcb(\xd9۹٧\xfa\xf8:\xff(\xbe\xbf%\xa2\a\xabs\xa4\xb0Oh\x12F\xfb\x05\xc9\xf3!Jz\xa4\xb8\x00\xb3\xec\xac\xce\t\x1b\xbe\xa60\xb4\xe7?\xeem\xa5\xec\x11\xe5\xd7Ż\xe3&\xcc\f\xd6MΩ\x97e\\\xd3Ϳ\b\xdf\xc8d\xddz\x8b5\x8bg\x1f\xbb-/hW\xc03$\xbf`kQX \xa7j\nQ6\x83s\xa7$P\xaa\x05f\xb4Il\xb3͇f\xef(\xa1ŀVC\x00\xceA\x0fQ\x0e\xf1 \x01$k\\\v\xda4\x15\x1aJڌ\xa5H\xb2\xfb\x85\\\xc1w\x9f\xde\xc7c\xcfnI\x94ԽA%LZW\xde\r\x1c\xa3.\xae>T\t\xbf\x90\xbf\xd6\x04\x82n\x13\xfe\x82q\xf6\b;\xe7bqɐo)\x8b\xff|\xf8*\fv,s\xf6^\x81\xf9\xa4,}yQ*\xbbA\xbc\x06\x8d]O4A\xa5\xb3$H\xc4n\U00088ce5(\xa8\r?\x84a\xd7\x12C2G\xa2\x19\xddQ\xae\x90\xeb\xd2uVֆ\xb6Z\xa5\x92\v\xb7,6֛\xe7\x81\xd2=\x16\x9c\xa4c\xdf\xe9\x1d\x1a#\xf7\x8b\xcbZ*x\x06yآ\xa3t\x1an\xe1Ad3\xfa,A?\x00\xab\xd0,\xa4K\xcb\fE\xedG6_\xbc\xd2=\x87n\xf9\xbax\xacW\xa0%X0\v4k\v\x0fŪ2\x91.\xde&\x8c䜌\x95\x05\xce\xf5ĚAZ\x92\xaaG2r\x0eWO%\xd63\xc9D^\x04\xb9]IR\xd0Ml\x9dg\xbdf\xca\xcd1*\xa63\x16\xe7\x02\x94\x9c\xb6\xd6\xfe\x86\x96\x9ef\xe3\xdfYŅ6K\xf6\x8e2{\v\xe8\xfd\xe6\x17&;`\x12\xbb\xadh\x95\xfd\x97Zly\x81\xfe\a\x1a\bɠpވZ\xef\xf9j\x17\xeci\xa3\x8cs\x1b\x9aM\xbb\xf3Gع\x1d\xe5\xa4n\xbb\n\xeb\xfcZ\x9e;_fO\xf14\x8e\x8f\x92Ŏ\x9d\xd3o\xe7\xcfu\xeffH\xf4\x8c\xaa=Q.y\x95.ɔ7;'\xd0\xc0`=8DظI \xc5\x00a\x8a\x02ɢ\\)\x13I\x16\x89\xa0\x95 \xe87\xcaX\xb7\x0e\xd9\xf3\xf7G\x17*UX\x9cd|mA3c\x95\x0e)\x99\xa8\xf8S\x96\xe2\xbb\xe5n\x03\x06\xfc>\x94_\xf4t\x801\x8a=ou\x83\xb3*\xe7n/\x8c:\xe2\x19yOԶ\xd2*\x03\x13͋hK\xa2m\xeaQp\x9f\x0eͺ.w\xd1\xdf:Ik\xa7,J\x872ϑG\xd2\x1d\x11\x19}\xf8\xdaY\xa2F\xed\x82\x7f\xa7H\xeb182:\xafQ\x96|\x98\x0e\x9c\x8c\xee\x95k\x1d\xe6\x98\a\xe6\xc2-\xfdP\x93Ι\xe3u4\xa2\xfc\xcf\xe6ڔB^SG\xec\xed\v\xbaC^\x8b\xc7ң\xc6\xca\xf1N\xfaU\xe8\xac\xe5^\xf3\xc1\xe7\xd4)\xda\xf8\xd1\xd0c\xee\xfe\x9e\by\xd7R\xd9\xce2\xceL'\xbaR\xf9\xef\f[\vml\x17\rs \xb1j\x14\xd4\x11\xa1\xa7\xfc\xa0\xf5ё\xe7g\u05fa\xb3\xa0\xb8QO>qzN\xbc\x1dH\xba\xe1[\xf0\x99\xab 3UKZ\nC=\x80\xdd̀\xe8X\xe3\xac@\xa2\xbd\xeb4\x96u\x99N\x90\x05I\x92\x90\x93\xebf\xdd&?p\x91\xb6nŎc\xab=\x94\xc39V\x8e\x9fG!\xc1\xb3\x9bN_\U000af8acK\xc6K\xe4!\xb9\x1d\xa2\x84&\xa3ޱ\xbbI\xfb\xc4\x16d\xb4\xac\xc2YV\x15`\xc1\xa7m\xce\xc0#S҈\x1c\x1a\xd3\xefE@I\xc6ٚ\x8b\xa2\xd63\xb4\xeal\x92\xcf\r¼69}d\x95\x8eȂH\x94\xb8\xce>\xc3\v\x9e\xd6\xf8\x95\x9e\xe7Ǧ8\x8c\x1a\xe6\xfb\x8b\x95\x16\xca\x1d\x068\xbd\xcb\xe8ӎ\xb9\xdc}\xf7\x19\xbf\xfb\x8c\xdf}\xc69\x1d}\xf7\x19'\xcaw\x9f\xf1\xbb\xcfx\xb8|\xf7\x19S\xcaw\x9fq&\"\xdf\xcagL\xc1pAk\x9c\a*$a\x95\x98\n1\x85\xf6D_>\xe9ǟ\xd58I.\xf3\xf58ȑC<\x91\xe3\x171\xaf\xa35^Mr3\xce\xc00w\xdc)\xca\x04\x87\xf9\x04\xa7g\x02\x02\xa7?=s}\x10\xf2\tO\xcf\xf8!\xa4E\x18G\x9d\x9d\tD\x9a\x7fz\xe2\xc2'\x11\x95\xc0\xc3V\x8aK\xff\x88\x8d1&I\tx|\xe3\xe4\xf7\xbd\x8c\xc9\x17\x90\xa5W9\x913K\x9eFY\x7f\xfe\xc7\xf3_\a\x8bN˔(\x1b\xf6i\xeb\xd4xL?b,\xdfM\x8d\xecg\xa9\xfez\xa6\xc2Ie?\xf5DMC\xe4\b\xbc\xbeX\x0f\xa8\xfck\xd27\x16\xcaϕ\xb7\x96'8a\x7f=\x02/\xe9\x8c=7;\x99m\xb4\x92\xaa6~M\ba\xbd\xcbܽ\x03\x01dL\xd8G5\xc8\x7f\xb0\x8d\xaa#\xa76&H\x9b\x90E\x9bF\x90^R\xadO\x8c\x00˷o\x97\xfd_\xac\xf2)\xb6\xecI\xd8M\x04\x18\xddG\xc1\xf3\x1c\xe3\x82\u0381\x1e\xaf\a\xc2UIC\xa1\x8c\x00S\x9aIQ8\x89\r\x10z\xf2\xca>Wnu\xf0h\xbfiz\r+=\x11wn\xfam\x93-9\xed\xbe?#\xe9\xf6\xa4G\xa3\xbeYZ\xedqɴ\xa9+\x94\t\x89\xb3\xe9\xe9\xb2)lu%=I69BNM\x88\x9d\xbb\x02\xf1\xa2ɯ/\x93\xf2\x9aL\xb3\xb4\xf4ֹ\x14{\x95T\xd6WN`}\xbd\xb4\xd5\x19ɪ\xa7?\xf5\x92\xbe\x96~tveڲ\xcc\xe1\x84Ӥ4Ӥ\xa5\x9b\x94\x01\x1f5Ԥ\xf4ѹI\xa3I\x9cL\x9f\xae\xaf\x9a\x16\xfa\xaaɠ\xaf\x9f\x02:)m\x93\x15\xe6&y\x8e_r\x18ʴ\x03P|\v\xe1|.\x99\x94\xee\xb9\xe6ϊ;?\x0f`\xa1\xb0\x047\xf5\x15〲.\xac\xa8\x8a\xf6>\xb6X\xc0\xb9\x81]sY\xd1ϊ\x8e\xc8\xfb\x9b\xba>\x7fi$~9\x88j\xb8aOP\x14\x8c\xc7\xe6\xe6\x1e\x152w\x0fh\xa6\x16\x80\xb6\x11g\xb9\xbf\x8c\xc9_\x1ez\xe1\xa6\v\xdd\x06@\x16\xb6\x8c-\xf5qy\xf8\xa6\xaf\x83\x06,U\x8f\xedy\xe6.ޠo\xbfԠw\x8c\xee\x1dk|\xb3\xf6P\xa9\x9f\xe8\x06\x03Ӡ~\xbc:<\xb4g\xb2\x17\xe0\xb4ꁽ\x93\xce#\x18\xe2DmP\xef\xb4\x01\x1d*U\x8cӢ\xfdD@H\xd5@\x884Mq\xfe眲|\x89\xf0\xee\x14\x01^\x92\a4\xcf{\xfd\x86\xa7'\x8f=5\x99\x9e\x8c\x92tJ\xf2%½9\x01\xdf,\x7f5\xfd\x14\xe4\xfc\x8d\xe7\x17>\xf5\xf8R\xa7\x1dgP/\xf5t\xe3|ڽ\xd2i\xc6W?\xc5\xf8\x9a\xa7\x17g\x9dZLNϚ\x95q0'\xb5\xea\x19\xc7\xed\xd2r\t\xa6O!&\x9e>L\xcc4H\x1b\xfc\x91\xc3N<]8\xffTa\"\x7f\xe7L\xe9W>=\xf8ʧ\x06\xbf\xc5i\xc1\x04\tL\xa82\xffT\u0cf7\xa4\x94\xceAOn\xfb͑\xdaIyM\x8d\xe5\xfa\x88\r\xf6\xb5\xc2m\xb2X\xab\x17\x03\x90Y\xf2\x17\xf9ӣ\r\x87\xb6\xc1Q2;\x1eQo_\xb2u\xd7\xfa\x0e\xb1\x7f\xcd\xc1m]\x1a\xa88\x1a\x00\n\xdc(5+\xea*|\xe0\xd9f\xd0Æ\x1b\xb6V\xba䖝7\x9b\xc5o\\\a\xf8\xf7\xf9\x92\xb1\x1fT\x93\xabӽ/͈\xb2*v\x18\x89\xb1\xf3n\x83\xe7IIT:C\xcf7\xaa\x10Y\xc4\xe7\x1c\xbdW\xcf5ػl\x88n\xfe\xcb:\xd9\"\xb1\xc0\a\x9b\x8bp\xebb\xffJfw\x9f\xfb\x91k%\xbc\x12\x7f\xa6'\x95N\xb0\xea\xf6\xee\xe6\x9a`\x051\xa2\xb7\x9a\x9a\x04ņ\xe5+@\x97\xa1\x1d\xfb!}r\xbd\xeeA\xed\xe7\bw\x1f\xab\x80ܽL\x12\xdc\x16\xaf\x9a3\x85Z\xeb\xe6\xda\xe1r\xa8'\x94/.wL\xf9\xa7'\x84\xce\x17\x15\xd7v璉.zx\x04\xbb>\xb5jv\xd0Z\xed\xbf\xbc\xd2-=\xb2\x87GWh'{W\xf5\x93\a\x86\xf4|\x0eN\x87OUO\x9e\xa7~\x01\x9c\x0e\xbbP\v\xa2b\xe4\xa7h\x06\xe4\xc9W,\x8d\xbf\xa1\xffG\xb5\x85\xf7ѕ\xcb\xfe\xeb+\x83&#\xa9\x89\x01*]2\x1f\xa1`\x9b\x8fHw|?O\xed\xc5s\r\x03*\xfe\x8e\xf0\xe7,N\xde\xf6A\x8d?HB7\xa8\x87Nc^\x15=\xf5\xb4c7\xf7\x14\xb76\xaa\xd4O}\x1f\xb7\x86\xe5ɐ`\x10\x81%\xe4\xc17ZNEF\xab4\x7f\x80\x8fʽ\xad\x93\"&\xfd\x16\xbd\x97\x97\xbc\xe7\x16\xf2\xb5\xfd$\x8c)z?\xb6!\xc0\xf6|\xc6\xdeE\xff\x88\xed\x91O\x19X[\xba\x91ғ&\xef\xfd\xeb$\xa8\x8f\r \v\x02\x05\x1c\xb4\x15\xfew\xa3\x9e\xe8\x02\xfc\xf8\x1asx@\xa4\xf3\x86\x19\xd0A\x11J\xe1=j\x98uU(\x9e\x83\xbe\xa2GT\x12F\xfcS\xaf\xc1\xc0\x1d\xe8?\xc5\xe2\xedfd<\xa1\xe7\x17̒A\x8f\xae(\xa0\xf8A\x14`\x1c≦\xe1f\xbfec)\xear\xe5<\xd55\xfe\xd8tr\xc02\xbb\xa1\xd2\x06C\x05\x1a\xfdD\xb7\x15Q\x9b \xf9\x87\x89\xc1\x1a>\ni\xe1\x01\xc6c\xe8\t\x9b\xe0\xdeh \a (0\x8a\xf8\xfe\x12[y\xec\x11\xe4>\xdez \x03\xcdbdL\x8e\x95w\xabn\xee\xaf\f\xabeN\x1b\x00\xf7\x7f\xbe=J~\xb7\xbd\xf7e\x82NHQ\xef\xf7\xe3-;!BG;\x91O\x1fW\xe21X\xdc\x18\x95\t\x8a*\x9e\x84\xf5\xd79\xbe\xdc\x1d\xe2\x87\x02\xc4\x03\xd2Q\x1b\xf8\xfc$A\x7f\t\x16\xc8\\\xcbػ-\xd3\xda\xef\xa7=h\xd1\xf7Z\xac¾G`\f\x000\x15\xf6\xb9\x8c{\t(l\xaf\t\xd3H\x1c\xc4>\x01\xdcyG\xc8ik\x85\xb4\xe3\x9c!n\x9bVt\xd8tDCN\x8b\xed\xfd\x00\xc6 \x93\x9d\x1e}j\xaa\xb8Ӧ\x86\xfd^\x8cy\xa3\xb4c\x96\xe1@\xff\xb0\xf7kT\x83\x1f\xd4\xde1\xcd=\xaaF\xf6>\xd2CxyGr\xbc\x97\xde\xfdR\xaf\xda\a\x15\xd8\xdf\xfe~\xf6\x8f\x00\x00\x00\xff\xff)\x00\x87w>{\x00\x00"), []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xbcV\xcfo+5\x10\xbe\xe7\xaf\x18\x89+\xbb\xa1B \x94\x1b*\x1c*\xe0\xa9j\x9ezw\xbc\x93d\xa8\xd7^f\xc6)A\xfc\xf1\xc8\xf6n\x9b\xee:\xb4\x8f\x03\xbe\xad\xed\xf9\xe6\x9bo~x\x9b\xa6Y\x99\x81\x1e\x91\x85\x82߀\x19\b\xffT\xf4\xe9Kڧ\x1f\xa4\xa5\xb0>ݬ\x9e\xc8w\x1b\xb8\x8d\xa2\xa1\x7f@\t\x91-\xfe\x84{\xf2\xa4\x14\xfc\xaaG5\x9dQ\xb3Y\x01\x18\uf0da\xb4-\xe9\x13\xc0\x06\xaf\x1c\x9cCn\x0e\xe8ۧ\xb8\xc3]$\xd7!g\xf0\xc9\xf5\xe9\x9b\xf6\xe6\xfb\xf6\xbb\x15\x807=n@\x90ә\x1a\x8d\xc2\xf8GDQiO\xe8\x90CKa%\x03ڄ\x7f\xe0\x10\x87\r\xbc\x1e\x14\xfb\xd1w\xe1\xbd\xcdP\xdb\f\xf5P\xa0\xf2\xa9#\xd1_\xae\xdd\xf8\x95\xc6[\x83\x8bl\\\x9dP\xbe \xc7\xc0\xfa\xe9\xd5i\x03\"\\N\xc8\x1f\xa23\\5^\x01\x88\r\x03n \xdb\x0e\xc6b\xb7\x02\x18\x05\xc9Xͨ\xc5\xe9\xa6\xc0\xd9#\xf6\xa68\x01\b\x03\xfa\x1f\xef\xef\x1e\xbfݾ\xd9\x06\xe8P,ӠYֿ\x9b\x97}\xa8\x85\t$``\xa4\x04\x1a\xc0X\x8b\"`#3z\x85B\x19\xc8\xef\x03\xf79\xad`v!\xea\x05\xaa\x1e\x11\x1e\xb3\xfec\x98\xed\xcb\xe1\xc0a@V\x9a\xa4)\xeb\xa2\xe2.v\xff\x8dxZ)\xd6b\x05]*=\x94\xecy\xd4\v\xbbQ\x1e\b{\xd0#\t0\x0e\x8c\x82\xbe\x14c\xda6\x1e\xc2\xeew\xb4\xdaΠ\x8b.\x922\x19]\x97*\xf6\x84\xac\xc0h\xc3\xc1\xd3_/ؒ\x04JN\x9dѬ\x9dWdo\x1c\x9c\x8c\x8b\xf85\x18\xdf͐{s\x06\xc6\xe4\x13\xa2\xbf\xc0\xcb\x062\xe7\xf1[`\xccRo\xe0\xa8:\xc8f\xbd>\x90N}hC\xdfGOz^疢]\xd4\xc0\xb2\xee\xf0\x84n-th\f\xdb#)Z\x8d\x8ck3P\x93\x03\xf1\xb9\x17۾\xfb\x8a\xc7Ε7n\xf5\x9cjP\x94\xc9\x1f.\x0er\xeb|AzR#\x95b*P%\xc4\xd7,\xa4\xad$\xdd\xc3\xcf\xdb\xcf01)\x99*Iy\xbd\xba\xd0e\xcaOR\x93\xfc\x1e\xb9\xd8\xed9\xf4\x19\x13}7\x04\xf2\x9a?\xac\xa3\\\xb8qד\xcaT\xda)us\xd8\xdb<\xab`\x87\x10\x87\xce(v\xf3\vw\x1enM\x8f\xee\xd6\b\xfeϹJY\x91&%\xe1Cٺ\x9c\xc0\xf3\xcbEދ\x83iv^ImeJl\a\xb4)\xb9I\xdfdM{\xb2\xa5\xad\xf6\x81\xc1\xd4L\xda\x0f1\xc9\x16_\xc8e\x9cH\x85\xcdlN\xa5.\x7f\x9fM},哣\x11\x9co\xce8ݧ;s\xff\x8e\xf6h\xcf\xd6a\x81(S\bߧ\x92\x16\xfa\xd8/}6\xf0\t\x9f+\xbb\xf7\x1c҄\xc6\xf9\xa8\xb9Z\x1bP\x1e\xb1\x03\xf9E\xb8\xf3\xc8ʭ\xfc0.G~\x0eh\x04\x02\x8eާ\x96\x0e~\x01Yy\x11\x16wH\xb1\xaf\xb0\xa9\xf2\xb9\xf3\xfb\x90\xff\"Lrl\xb4\xb4\x13\x8e\xc9\x1e\xfd\x14^\x15\xc0\xeb\xb9.k9\xe7>$hY\xf9y\xfeo\xc6i.\x11c\xd5w\x93YU\x0f\x92ǚ\xe2\xf5\xfe\x1aYF\xe7\xcc\xce\xe1\x06\x94\xe3Һ\xd8\x1afs\x9eW\xcdTj\x9f\xa9GQ\xd3\x0f\xef\x14\xd0\xe2UH\xeb~\x81\x92\x9a\xe7\xf9\x88\xfeZ\x8b\xc0\xb3\x91W\xe7\x15\xc8\xdd\xf9\x9a\xe9\xed\xcb\xdf\xe6\xb2\xcfJ=o \xcd\xfaF\xa9\"䇔\xaa\xa6\xb4\xd4y\xf5\xb7f\xa1\xd2\xf6\xf2\xee4H\xde\xf4\xcb\xf4W\xb3\x8c\xe1*\x85j\x05,63|w\x11\x9eh`s\x98\x02\xfe'\x00\x00\xff\xff\xef\xf8\xa6>\x10\f\x00\x00"), []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\xbcVM\x8f\xdb6\x10\xbd\xfbW\f\x92kd7(Z\x14\xbe\x05\xdb\x1e\x82&\xc5\"N\xf7N\x93#{j\x8ad\x87C9.\xfa\xe3\v\x92Ү-\xcb\xc9nQT\x17\xc3\xe4\xf0q>\u07bca\xd34\v\x15\xe8\x019\x92wkP\x81\xf0\x8b\xa0\xcb\xff\xe2\xf2\xf0S\\\x92_\xf5o\x17\arf\rw)\x8a\xef>a\xf4\x895\xfe\x8c-9\x12\xf2nѡ(\xa3D\xad\x17\x00\xca9/*/\xc7\xfc\x17@{'\xec\xadEnv薇\xb4\xc5m\"k\x90\v\xf8xu\xff\xdd\xf2\xed\x8f\xcb\x1f\x16\x00Nu\xb8\x86\xde\xdb\xd4at*Ľ\x17\xebu\xc5\\\xf6h\x91\xfd\x92\xfc\"\x06\xd4\xf9\x8a\x1d\xfb\x14\xd6\xf0\xb4Q!\x86\xeb\xab\xeb\x0f\x05m3\xa0}\x18Њ\x81\xa5(\xbf~\xc5\xe8\x03E)\x86\xc1&V\xf6\xa6g\xc5&\xee=\xcboO\xb77\xd0G[w\xc8\xed\x92U|\xeb\xfc\x02 j\x1fp\r\xe5xP\x1a\xcd\x02`\xc8O\x81k\xc6Լ\xad\x88z\x8f\x9d\xaa\xf7\x00\xf8\x80\xee\xdd\xfd\xfb\x87\xef7\x17\xcb\x00\x06\xa3f\nR\xb2<\x1f\"P\x04\x05\xa3'p\xdc##<\x94|B\x14\xcf\x18\a\xa7\x1fA\x01F\xff\xe3\xf2q1\xb0\x0f\xc8Bc\xf0\xf5;\xe3\xd7\xd9\xeaį\xbf\x9b\x8b=\x80\x1cJ=\x05&\x13\r#\xc8\x1e\xc7t\xa0\x19\xa2\a߂\xec)\x02c`\x8c\xe8*\xf5\xf2\xb2r\xe0\xb7\x7f\xa0\x96\xe5\x04z\x83\x9car\xad\x925\x99\x9f=\xb2\x00\xa3\xf6;G\x7f=bG\x10_.\xb5J0\n\x90\x13d\xa7,\xf4\xca&|\x03ʙ\tr\xa7N\xc0\x98\xef\x84\xe4\xce\xf0ʁ8\xf5\xe3\xa3g\x04r\xad_\xc3^$\xc4\xf5j\xb5#\x19\xbbN\xfb\xaeK\x8e\xe4\xb4*\rD\xdb$\x9e\xe3\xca`\x8fv\x15i\xd7(\xd6{\x12Ԓ\x18W*PS\x02q\xb5K:\xf3\x9a\x87>\x8d\x17\xd7\xca)S,\n\x93\u06ddm\x94.yAyr\xc3T\xd6T\xa8\x1a\xe2S\x15\xf2RNݧ_6\x9fa\xf4\xa4V\xaa\x16\xe5\xc9\xf4*/c}r6ɵ\xc8\xf5\\˾+\x98\xe8L\xf0\xe4\xa4\xfcі\xd0\tĴ\xedH2\r\xfeL\x18%\x97n\n{W\x94\t\xb6\b)\x18%h\xa6\x06\xef\x1dܩ\x0e흊\xf8?\xd7*W%6\xb9\bϪֹ\xdeN\x8dkz\xcf\x1bu\x90\xc9\x1b\xa5\x9dW\x84M@}\xd1x\x19\x85Z\x1a\x14\xa2\xf5i\x8b\x15\x10|;ý\x17\xb9\x9c?t\xa9\x9b#\xe2\xbb^\x91U[{-\t\r\xfc\xee\xd4\xcdݛş\xad\xe7\xd5b̏=\xb3\x06\xe1T\xb1\a\x96\r+\xff\x04\x00\x00\xff\xffNy\xc1Q\xa1\x0e\x00\x00"), diff --git a/internal/resourcepolicies/resource_policies.go b/internal/resourcepolicies/resource_policies.go index 43895b695..8810f1c67 100644 --- a/internal/resourcepolicies/resource_policies.go +++ b/internal/resourcepolicies/resource_policies.go @@ -331,20 +331,20 @@ func GetResourcePoliciesFromBackup( if err != nil { logger.Errorf("Fail to get ResourcePolicies %s ConfigMap with error %s.", backup.Namespace+"/"+backup.Spec.ResourcePolicy.Name, err.Error()) - return nil, fmt.Errorf("fail to get ResourcePolicies %s ConfigMap with error %s", - backup.Namespace+"/"+backup.Spec.ResourcePolicy.Name, err.Error()) + return nil, fmt.Errorf("fail to get ResourcePolicies %s ConfigMap: %w", + backup.Namespace+"/"+backup.Spec.ResourcePolicy.Name, err) } resourcePolicies, err = getResourcePoliciesFromConfig(policiesConfigMap) if err != nil { logger.Errorf("Fail to read ResourcePolicies from ConfigMap %s with error %s.", backup.Namespace+"/"+backup.Name, err.Error()) - return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s with error %s", - backup.Namespace+"/"+backup.Name, err.Error()) + return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s: %w", + backup.Namespace+"/"+backup.Name, err) } else if err = resourcePolicies.Validate(); err != nil { logger.Errorf("Fail to validate ResourcePolicies in ConfigMap %s with error %s.", backup.Namespace+"/"+backup.Name, err.Error()) - return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s with error %s", - backup.Namespace+"/"+backup.Name, err.Error()) + return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s: %w", + backup.Namespace+"/"+backup.Name, err) } } @@ -425,6 +425,49 @@ func GetResourcePoliciesFromBackupWithGlobal( return backupPolicies, nil } +// GetResourcePoliciesFromRestore retrieves the resource policies from the ConfigMap referenced in the Restore spec. +func GetResourcePoliciesFromRestore( + ctx context.Context, + restore *velerov1api.Restore, + client crclient.Client, + logger logrus.FieldLogger, +) (resourcePolicies *Policies, err error) { + if restore.Spec.ResourcePolicy != nil { + if !strings.EqualFold(restore.Spec.ResourcePolicy.Kind, ConfigmapRefType) { + return nil, fmt.Errorf("invalid ResourcePolicy kind %q, only %q is supported", + restore.Spec.ResourcePolicy.Kind, ConfigmapRefType) + } + policiesConfigMap := &corev1api.ConfigMap{} + err = client.Get( + ctx, + crclient.ObjectKey{ + Namespace: restore.Namespace, + Name: restore.Spec.ResourcePolicy.Name, + }, + policiesConfigMap, + ) + if err != nil { + logger.Errorf("Fail to get ResourcePolicies %s ConfigMap with error %s.", + restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error()) + return nil, fmt.Errorf("fail to get ResourcePolicies %s ConfigMap: %w", + restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err) + } + resourcePolicies, err = getResourcePoliciesFromConfig(policiesConfigMap) + if err != nil { + logger.Errorf("Fail to read ResourcePolicies from ConfigMap %s with error %s.", + restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error()) + return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s: %w", + restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err) + } else if err = resourcePolicies.Validate(); err != nil { + logger.Errorf("Fail to validate ResourcePolicies in ConfigMap %s with error %s.", + restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error()) + return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s: %w", + restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err) + } + } + return resourcePolicies, nil +} + func getResourcePoliciesFromConfig(cm *corev1api.ConfigMap) (*Policies, error) { if cm == nil { return nil, fmt.Errorf("could not parse config from nil configmap") diff --git a/internal/resourcepolicies/resource_policies_test.go b/internal/resourcepolicies/resource_policies_test.go index cea7ed2cf..72253eaf9 100644 --- a/internal/resourcepolicies/resource_policies_test.go +++ b/internal/resourcepolicies/resource_policies_test.go @@ -16,6 +16,7 @@ limitations under the License. package resourcepolicies import ( + "context" "testing" "github.com/sirupsen/logrus" @@ -24,6 +25,8 @@ import ( corev1api "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" velerotest "github.com/vmware-tanzu/velero/pkg/test" @@ -494,6 +497,49 @@ volumePolicies: assert.Equal(t, p, resPolicies) } +func TestGetResourcePoliciesFromRestore(t *testing.T) { + // Create a test ConfigMap + cm := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v1 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: skip +`, + }, + } + + // Create a fake client + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm).Build() + logger := logrus.New() + + restore := velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "test-configmap", + }, + }, + } + + resPolicies, err := GetResourcePoliciesFromRestore(context.Background(), &restore, client, logger) + require.NoError(t, err) + assert.Equal(t, "v1", resPolicies.version) + assert.Len(t, resPolicies.volumePolicies, 1) +} + func TestGetMatchAction(t *testing.T) { testCases := []struct { name string diff --git a/pkg/apis/velero/v1/restore_types.go b/pkg/apis/velero/v1/restore_types.go index 5dd99edb7..f6e6bf9cf 100644 --- a/pkg/apis/velero/v1/restore_types.go +++ b/pkg/apis/velero/v1/restore_types.go @@ -125,6 +125,16 @@ type RestoreSpec struct { // +nullable ResourceModifier *corev1api.TypedLocalObjectReference `json:"resourceModifier,omitempty"` + // ResourcePolicy specifies the reference to a ConfigMap containing resource + // filter policies for this restore. The ConfigMap can contain a + // namespacedFilterPolicies section that specifies per-namespace resource type + // filters, label selectors, and resource name patterns, and a + // clusterScopedFilterPolicy section for per-kind filtering of cluster-scoped + // resources. The ConfigMap format is the same as for BackupSpec.ResourcePolicy. + // +optional + // +nullable + ResourcePolicy *corev1api.TypedLocalObjectReference `json:"resourcePolicy,omitempty"` + // UploaderConfig specifies the configuration for the restore. // +optional // +nullable diff --git a/pkg/apis/velero/v1/zz_generated.deepcopy.go b/pkg/apis/velero/v1/zz_generated.deepcopy.go index 0702f8623..c40fbb806 100644 --- a/pkg/apis/velero/v1/zz_generated.deepcopy.go +++ b/pkg/apis/velero/v1/zz_generated.deepcopy.go @@ -1415,6 +1415,11 @@ func (in *RestoreSpec) DeepCopyInto(out *RestoreSpec) { *out = new(corev1.TypedLocalObjectReference) (*in).DeepCopyInto(*out) } + if in.ResourcePolicy != nil { + in, out := &in.ResourcePolicy, &out.ResourcePolicy + *out = new(corev1.TypedLocalObjectReference) + (*in).DeepCopyInto(*out) + } if in.UploaderConfig != nil { in, out := &in.UploaderConfig, &out.UploaderConfig *out = new(UploaderConfigForRestore) diff --git a/pkg/builder/restore_builder.go b/pkg/builder/restore_builder.go index bad4327e9..22e880a98 100644 --- a/pkg/builder/restore_builder.go +++ b/pkg/builder/restore_builder.go @@ -19,6 +19,7 @@ package builder import ( "time" + corev1api "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" @@ -171,3 +172,12 @@ func (b *RestoreBuilder) ItemOperationTimeout(timeout time.Duration) *RestoreBui b.object.Spec.ItemOperationTimeout.Duration = timeout return b } + +// ResourcePoliciesConfigmap sets the Restore's resource policies configmap. +func (b *RestoreBuilder) ResourcePoliciesConfigmap(name string) *RestoreBuilder { + b.object.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{ + Kind: "configmap", + Name: name, + } + return b +} diff --git a/pkg/builder/restore_builder_test.go b/pkg/builder/restore_builder_test.go new file mode 100644 index 000000000..b45bd80c6 --- /dev/null +++ b/pkg/builder/restore_builder_test.go @@ -0,0 +1,36 @@ +/* +Copyright The Velero Contributors. + +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 builder + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRestoreBuilder_ResourcePoliciesConfigmap(t *testing.T) { + restore := ForRestore("velero", "my-restore"). + ResourcePoliciesConfigmap("my-policy-cm"). + Result() + + assert.Equal(t, "velero", restore.Namespace) + assert.Equal(t, "my-restore", restore.Name) + assert.NotNil(t, restore.Spec.ResourcePolicy) + assert.Equal(t, "configmap", restore.Spec.ResourcePolicy.Kind) + assert.Equal(t, "my-policy-cm", restore.Spec.ResourcePolicy.Name) + assert.Equal(t, (*string)(nil), restore.Spec.ResourcePolicy.APIGroup) +} From f8ebd8fa4ba49ae3e2d25ac9132a6e76c1399c9f Mon Sep 17 00:00:00 2001 From: Adam Zhang Date: Wed, 24 Jun 2026 12:02:12 +0800 Subject: [PATCH 3/4] add more test cases Signed-off-by: Adam Zhang --- .../resource_policies_test.go | 601 +++++++++++++++--- 1 file changed, 522 insertions(+), 79 deletions(-) diff --git a/internal/resourcepolicies/resource_policies_test.go b/internal/resourcepolicies/resource_policies_test.go index 72253eaf9..e8fa6ad11 100644 --- a/internal/resourcepolicies/resource_policies_test.go +++ b/internal/resourcepolicies/resource_policies_test.go @@ -212,6 +212,18 @@ volumePolicies: pvcAccessModes: ReadWriteOnce action: type: skip +`, + wantErr: true, + }, + { + name: "error format of pvcAccessModes (list with non-string)", + yamlData: `version: v1 +volumePolicies: + - conditions: + pvcAccessModes: + - 123 + action: + type: skip `, wantErr: true, }, @@ -410,14 +422,20 @@ func TestGetResourceMatchedAction(t *testing.T) { } func TestGetResourcePoliciesFromConfig(t *testing.T) { - // Create a test ConfigMap - cm := &corev1api.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-configmap", - Namespace: "test-namespace", - }, - Data: map[string]string{ - "test-data": `version: v1 + testCases := []struct { + name string + cm *corev1api.ConfigMap + expectedErr string + }{ + { + name: "valid configmap", + cm: &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v1 volumePolicies: - conditions: capacity: '0,10Gi' @@ -438,68 +456,102 @@ volumePolicies: action: type: skip `, + }, + }, + expectedErr: "", + }, + { + name: "nil configmap", + cm: nil, + expectedErr: "could not parse config from nil configmap", + }, + { + name: "empty data configmap", + cm: &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{}, + }, + expectedErr: "illegal resource policies test-namespace/test-configmap configmap", + }, + { + name: "multiple data configmap", + cm: &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "data1": "value1", + "data2": "value2", + }, + }, + expectedErr: "illegal resource policies test-namespace/test-configmap configmap", + }, + { + name: "invalid yaml data", + cm: &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v1 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: skip + invalid-key: value +`, + }, + }, + expectedErr: "failed to decode yaml data into resource policies", + }, + { + name: "build policy error", + cm: &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v1 +volumePolicies: + - conditions: + capacity: 'invalid-capacity' + csi: + driver: disks.csi.driver + action: + type: skip +`, + }, + }, + expectedErr: "wrong format of Capacity invalid-capacity", }, } - // Call the function and check for errors - resPolicies, err := getResourcePoliciesFromConfig(cm) - require.NoError(t, err) - - // Check that the returned resourcePolicies object contains the expected data - assert.Equal(t, "v1", resPolicies.version) - - assert.Len(t, resPolicies.volumePolicies, 3) - - policies := ResourcePolicies{ - Version: "v1", - VolumePolicies: []VolumePolicy{ - { - Conditions: map[string]any{ - "capacity": "0,10Gi", - "csi": map[string]any{ - "driver": "disks.csi.driver", - }, - }, - Action: Action{ - Type: Skip, - }, - }, - { - Conditions: map[string]any{ - "csi": map[string]any{ - "driver": "files.csi.driver", - "volumeAttributes": map[string]string{"protocol": "nfs"}, - }, - }, - Action: Action{ - Type: Skip, - }, - }, - { - Conditions: map[string]any{ - "pvcLabels": map[string]string{ - "environment": "production", - }, - }, - Action: Action{ - Type: Skip, - }, - }, - }, + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resPolicies, err := getResourcePoliciesFromConfig(tc.cm) + if tc.expectedErr == "" { + require.NoError(t, err) + assert.Equal(t, "v1", resPolicies.version) + assert.Len(t, resPolicies.volumePolicies, 3) + } else { + require.ErrorContains(t, err, tc.expectedErr) + assert.Nil(t, resPolicies) + } + }) } - - p := &Policies{} - err = p.BuildPolicy(&policies) - if err != nil { - t.Fatalf("failed to build policy: %v", err) - } - - assert.Equal(t, p, resPolicies) } -func TestGetResourcePoliciesFromRestore(t *testing.T) { - // Create a test ConfigMap - cm := &corev1api.ConfigMap{ +func TestGetResourcePoliciesFromBackup(t *testing.T) { + validCM := &corev1api.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", @@ -517,27 +569,353 @@ volumePolicies: }, } - // Create a fake client - client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm).Build() - logger := logrus.New() - - restore := velerov1api.Restore{ + invalidActionCM := &corev1api.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-action-configmap", Namespace: "test-namespace", - Name: "test-restore", }, - Spec: velerov1api.RestoreSpec{ - ResourcePolicy: &corev1api.TypedLocalObjectReference{ - Kind: ConfigmapRefType, - Name: "test-configmap", - }, + Data: map[string]string{ + "test-data": `version: v1 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: invalid-action +`, }, } - resPolicies, err := GetResourcePoliciesFromRestore(context.Background(), &restore, client, logger) - require.NoError(t, err) - assert.Equal(t, "v1", resPolicies.version) - assert.Len(t, resPolicies.volumePolicies, 1) + invalidVersionCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-version-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v2 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: skip +`, + }, + } + + emptyCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-configmap", + Namespace: "test-namespace", + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(validCM, invalidActionCM, invalidVersionCM, emptyCM).Build() + logger := logrus.New() + + testCases := []struct { + name string + backup velerov1api.Backup + expectedErr string + }{ + { + name: "valid configmap", + backup: velerov1api.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-backup", + }, + Spec: velerov1api.BackupSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "test-configmap", + }, + }, + }, + expectedErr: "", + }, + { + name: "invalid kind", + backup: velerov1api.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-backup", + }, + Spec: velerov1api.BackupSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: "Secret", + Name: "test-configmap", + }, + }, + }, + expectedErr: "", + }, + { + name: "configmap not found", + backup: velerov1api.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-backup", + }, + Spec: velerov1api.BackupSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "non-existent-configmap", + }, + }, + }, + expectedErr: "fail to get ResourcePolicies test-namespace/non-existent-configmap ConfigMap", + }, + { + name: "invalid action configmap", + backup: velerov1api.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-backup", + }, + Spec: velerov1api.BackupSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "invalid-action-configmap", + }, + }, + }, + expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/test-backup", + }, + { + name: "invalid version configmap", + backup: velerov1api.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-backup", + }, + Spec: velerov1api.BackupSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "invalid-version-configmap", + }, + }, + }, + expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/test-backup", + }, + { + name: "empty configmap", + backup: velerov1api.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-backup", + }, + Spec: velerov1api.BackupSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "empty-configmap", + }, + }, + }, + expectedErr: "fail to read the ResourcePolicies from ConfigMap test-namespace/test-backup", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resPolicies, err := GetResourcePoliciesFromBackup(tc.backup, client, logger) + if tc.expectedErr == "" { + require.NoError(t, err) + if tc.backup.Spec.ResourcePolicy != nil && tc.backup.Spec.ResourcePolicy.Kind == ConfigmapRefType { + assert.NotNil(t, resPolicies) + } else { + assert.Nil(t, resPolicies) + } + } else { + require.ErrorContains(t, err, tc.expectedErr) + assert.Nil(t, resPolicies) + } + }) + } +} + +func TestGetResourcePoliciesFromRestore(t *testing.T) { + validCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v1 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: skip +`, + }, + } + + invalidActionCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-action-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v1 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: invalid-action +`, + }, + } + + invalidVersionCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-version-configmap", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "test-data": `version: v2 +volumePolicies: + - conditions: + capacity: '0,10Gi' + csi: + driver: disks.csi.driver + action: + type: skip +`, + }, + } + + emptyCM := &corev1api.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-configmap", + Namespace: "test-namespace", + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(validCM, invalidActionCM, invalidVersionCM, emptyCM).Build() + logger := logrus.New() + + testCases := []struct { + name string + restore *velerov1api.Restore + expectedErr string + }{ + { + name: "valid configmap", + restore: &velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "test-configmap", + }, + }, + }, + expectedErr: "", + }, + { + name: "invalid kind", + restore: &velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: "Secret", + Name: "test-configmap", + }, + }, + }, + expectedErr: "invalid ResourcePolicy kind \"Secret\", only \"configmap\" is supported", + }, + { + name: "configmap not found", + restore: &velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "non-existent-configmap", + }, + }, + }, + expectedErr: "fail to get ResourcePolicies test-namespace/non-existent-configmap ConfigMap", + }, + { + name: "invalid action configmap", + restore: &velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "invalid-action-configmap", + }, + }, + }, + expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/invalid-action-configmap", + }, + { + name: "invalid version configmap", + restore: &velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "invalid-version-configmap", + }, + }, + }, + expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/invalid-version-configmap", + }, + { + name: "empty configmap", + restore: &velerov1api.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-restore", + }, + Spec: velerov1api.RestoreSpec{ + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: ConfigmapRefType, + Name: "empty-configmap", + }, + }, + }, + expectedErr: "fail to read the ResourcePolicies from ConfigMap test-namespace/empty-configmap", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resPolicies, err := GetResourcePoliciesFromRestore(context.Background(), tc.restore, client, logger) + if tc.expectedErr == "" { + require.NoError(t, err) + assert.NotNil(t, resPolicies) + } else { + require.ErrorContains(t, err, tc.expectedErr) + assert.Nil(t, resPolicies) + } + }) + } } func TestGetMatchAction(t *testing.T) { @@ -1834,6 +2212,17 @@ namespacedFilterPolicies: wantErr: true, errMsg: "invalid glob pattern", }, + { + name: "invalid - bad glob pattern in excludedNames", + yamlData: `version: v1 +namespacedFilterPolicies: +- namespaces: ["test"] + resourceFilters: + - kinds: ["Pod"] + excludedNames: ["[invalid"]`, + wantErr: true, + errMsg: "invalid glob pattern", + }, { name: "invalid - duplicate namespace pattern", yamlData: `version: v1 @@ -1847,6 +2236,16 @@ namespacedFilterPolicies: wantErr: true, errMsg: "duplicate namespace pattern", }, + { + name: "invalid - bad namespace pattern", + yamlData: `version: v1 +namespacedFilterPolicies: +- namespaces: ["prod**uction"] + resourceFilters: + - kinds: ["Pod"]`, + wantErr: true, + errMsg: "wildcard pattern contains consecutive asterisks", + }, } for _, tc := range testCases { @@ -1903,6 +2302,50 @@ namespacedFilterPolicies: assert.Equal(t, map[string]string{"app": "web"}, rf.LabelSelector) } +func TestClusterScopedFilterPoliciesAccessor(t *testing.T) { + yamlData := `version: v1 +clusterScopedFilterPolicy: + resourceFilters: + - kinds: ["ClusterRole"] + names: ["my-app-*"]` + + resPolicies, err := unmarshalResourcePolicies(&yamlData) + require.NoError(t, err) + + policies := &Policies{} + err = policies.BuildPolicy(resPolicies) + require.NoError(t, err) + + csfPolicy := policies.GetClusterScopedFilterPolicy() + require.NotNil(t, csfPolicy) + assert.Len(t, csfPolicy.ResourceFilters, 1) + + rf := csfPolicy.ResourceFilters[0] + assert.Equal(t, []string{"ClusterRole"}, rf.Kinds) + assert.Equal(t, []string{"my-app-*"}, rf.Names) +} + +func TestIncludeExcludePolicyAccessor(t *testing.T) { + yamlData := `version: v1 +includeExcludePolicy: + includedClusterScopedResources: + - ClusterRole + excludedClusterScopedResources: + - ClusterRoleBinding` + + resPolicies, err := unmarshalResourcePolicies(&yamlData) + require.NoError(t, err) + + policies := &Policies{} + err = policies.BuildPolicy(resPolicies) + require.NoError(t, err) + + iePolicy := policies.GetIncludeExcludePolicy() + require.NotNil(t, iePolicy) + assert.Equal(t, []string{"ClusterRole"}, iePolicy.IncludedClusterScopedResources) + assert.Equal(t, []string{"ClusterRoleBinding"}, iePolicy.ExcludedClusterScopedResources) +} + func TestFirstMatchSemantics(t *testing.T) { yamlData := `version: v1 namespacedFilterPolicies: From 7529b3df6da017395d7672bd6531c1de5a2932b4 Mon Sep 17 00:00:00 2001 From: Adam Zhang Date: Thu, 25 Jun 2026 11:52:20 +0800 Subject: [PATCH 4/4] enhance validation for restore resource policy For resource policy in restore, it will reject invalid sections such as volumePolicies or includeExcludePolicy, to prevent user from misuse backup side resource policy configmap. Signed-off-by: Adam Zhang --- .../resourcepolicies/resource_policies.go | 26 +++++- .../resource_policies_test.go | 37 ++++----- .../volume_resources_validator_test.go | 82 +++++++++++++++++++ 3 files changed, 121 insertions(+), 24 deletions(-) diff --git a/internal/resourcepolicies/resource_policies.go b/internal/resourcepolicies/resource_policies.go index 8810f1c67..867efc74a 100644 --- a/internal/resourcepolicies/resource_policies.go +++ b/internal/resourcepolicies/resource_policies.go @@ -303,6 +303,30 @@ func (p *Policies) Validate() error { return nil } +func (p *Policies) ValidateForRestore() error { + if p.version != currentSupportDataVersion { + return fmt.Errorf("incompatible version number %s with supported version %s", p.version, currentSupportDataVersion) + } + + if len(p.volumePolicies) > 0 { + return fmt.Errorf("volumePolicies are not supported for restore") + } + + if p.GetIncludeExcludePolicy() != nil { + return fmt.Errorf("includeExcludePolicy is not supported for restore") + } + + if err := p.validateClusterScopedFilterPolicy(); err != nil { + return errors.WithStack(err) + } + + if err := p.validateNamespacedFilterPolicies(); err != nil { + return errors.WithStack(err) + } + + return nil +} + func (p *Policies) GetIncludeExcludePolicy() *IncludeExcludePolicy { return p.includeExcludePolicy } @@ -458,7 +482,7 @@ func GetResourcePoliciesFromRestore( restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error()) return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s: %w", restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err) - } else if err = resourcePolicies.Validate(); err != nil { + } else if err = resourcePolicies.ValidateForRestore(); err != nil { logger.Errorf("Fail to validate ResourcePolicies in ConfigMap %s with error %s.", restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error()) return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s: %w", diff --git a/internal/resourcepolicies/resource_policies_test.go b/internal/resourcepolicies/resource_policies_test.go index e8fa6ad11..4b03b833c 100644 --- a/internal/resourcepolicies/resource_policies_test.go +++ b/internal/resourcepolicies/resource_policies_test.go @@ -744,31 +744,25 @@ func TestGetResourcePoliciesFromRestore(t *testing.T) { }, Data: map[string]string{ "test-data": `version: v1 -volumePolicies: - - conditions: - capacity: '0,10Gi' - csi: - driver: disks.csi.driver - action: - type: skip +namespacedFilterPolicies: + - namespaces: ["default"] + resourceFilters: + - kinds: ["Pod"] `, }, } - invalidActionCM := &corev1api.ConfigMap{ + invalidNfpCM := &corev1api.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-action-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "test-data": `version: v1 -volumePolicies: - - conditions: - capacity: '0,10Gi' - csi: - driver: disks.csi.driver - action: - type: invalid-action +namespacedFilterPolicies: + - namespaces: [] + resourceFilters: + - kinds: ["Pod"] `, }, } @@ -780,13 +774,10 @@ volumePolicies: }, Data: map[string]string{ "test-data": `version: v2 -volumePolicies: - - conditions: - capacity: '0,10Gi' - csi: - driver: disks.csi.driver - action: - type: skip +namespacedFilterPolicies: + - namespaces: ["default"] + resourceFilters: + - kinds: ["Pod"] `, }, } @@ -798,7 +789,7 @@ volumePolicies: }, } - client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(validCM, invalidActionCM, invalidVersionCM, emptyCM).Build() + client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(validCM, invalidNfpCM, invalidVersionCM, emptyCM).Build() logger := logrus.New() testCases := []struct { diff --git a/internal/resourcepolicies/volume_resources_validator_test.go b/internal/resourcepolicies/volume_resources_validator_test.go index f2812a786..f2e6bf0e0 100644 --- a/internal/resourcepolicies/volume_resources_validator_test.go +++ b/internal/resourcepolicies/volume_resources_validator_test.go @@ -568,3 +568,85 @@ func TestValidate(t *testing.T) { }) } } + +func TestValidateForRestore(t *testing.T) { + testCases := []struct { + name string + res *ResourcePolicies + wantErr bool + }{ + { + name: "valid restore policies", + res: &ResourcePolicies{ + Version: "v1", + ClusterScopedFilterPolicy: &ClusterScopedFilterPolicy{ + ResourceFilters: []ResourceFilter{ + { + Kinds: []string{"ClusterRole"}, + }, + }, + }, + NamespacedFilterPolicies: []NamespacedFilterPolicy{ + { + Namespaces: []string{"default"}, + ResourceFilters: []ResourceFilter{ + { + Kinds: []string{"Pod"}, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "unsupported volumePolicies for restore", + res: &ResourcePolicies{ + Version: "v1", + VolumePolicies: []VolumePolicy{ + { + Action: Action{Type: "skip"}, + Conditions: map[string]any{ + "capacity": "10Gi", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "unsupported includeExcludePolicy for restore", + res: &ResourcePolicies{ + Version: "v1", + IncludeExcludePolicy: &IncludeExcludePolicy{ + IncludedClusterScopedResources: []string{"persistentvolumes"}, + }, + }, + wantErr: true, + }, + { + name: "wrong version", + res: &ResourcePolicies{ + Version: "v2", + }, + wantErr: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + policies := &Policies{} + err1 := policies.BuildPolicy(tc.res) + err2 := policies.ValidateForRestore() + + if tc.wantErr { + if err1 == nil && err2 == nil { + t.Fatalf("Expected error %v, but not get error", tc.wantErr) + } + } else { + if err1 != nil || err2 != nil { + t.Fatalf("Expected error %v, but got error %v %v", tc.wantErr, err1, err2) + } + } + }) + } +}