From e9250388227475cc86cd7fec42af576a89b518a5 Mon Sep 17 00:00:00 2001 From: HaoXuAI Date: Sat, 22 Mar 2025 15:08:57 -0700 Subject: [PATCH] squash to one commit on yourBranch --- .devcontainer/devcontainer.json | 2 +- .../get-semantic-release-version/action.yml | 87 + .../fork_pr_integration_tests_aws.yml | 4 +- .../fork_pr_integration_tests_gcp.yml | 3 +- .../fork_pr_integration_tests_snowflake.yml | 3 +- .github/workflows/build_wheels.yml | 161 +- .github/workflows/java_master_only.yml | 16 +- .github/workflows/java_pr.yml | 22 +- .github/workflows/lint_pr.yml | 2 +- .github/workflows/linter.yml | 2 +- .github/workflows/master_only.yml | 42 +- .github/workflows/nightly-ci.yml | 4 +- .../operator-e2e-integration-tests.yml | 106 + .github/workflows/operator_pr.yml | 8 +- .github/workflows/pr_integration_tests.yml | 8 +- .../workflows/pr_local_integration_tests.yml | 16 +- .../pr_remote_rbac_integration_tests.yml | 58 + .github/workflows/publish.yml | 210 +- .github/workflows/publish_helm_charts.yml | 65 + .github/workflows/publish_images.yml | 82 + .github/workflows/publish_java_sdk.yml | 69 + .github/workflows/publish_python_sdk.yml | 49 + .github/workflows/release.yml | 114 +- .github/workflows/smoke_tests.yml | 15 +- .github/workflows/unit_tests.yml | 22 +- .github/workflows/update_stable_branch.yml | 40 + .gitignore | 7 +- .gitpod.yml | 2 +- .releaserc.js | 2 +- CHANGELOG.md | 173 + MANIFEST.in | 2 + Makefile | 234 +- OWNERS | 15 + README.md | 13 +- community/ADOPTERS.md | 21 +- community/maintainers.md | 38 +- docs/README.md | 35 +- docs/SUMMARY.md | 11 +- docs/blog/README.md | 21 + docs/blog/a-state-of-feast.md | 80 + docs/blog/announcing-feast-0-11.md | 65 + ...faster-feature-transformations-in-feast.md | 50 + docs/blog/feast-0-10-announcement.md | 163 + ...vers-and-feature-views-without-entities.md | 45 + ...st-0-14-adds-aws-lambda-feature-servers.md | 56 + ...ake-support-and-data-quality-monitoring.md | 37 + ...-20-adds-api-and-connector-improvements.md | 41 + docs/blog/feast-benchmarks.md | 65 + ...-joins-the-linux-foundation-for-ai-data.md | 37 + ...2-adds-aws-redshift-and-dynamodb-stores.md | 46 + docs/blog/feast-supports-vector-database.md | 43 + docs/blog/go-feature-server-benchmarks.md | 55 + ...how-danny-chiao-is-keeping-feast-simple.md | 31 + ...kubeflow-and-feast-with-david-aronchick.md | 31 + ...ime-fraud-prediction-using-feast-on-gcp.md | 50 + ...t-for-python-based-feast-feature-server.md | 61 + docs/blog/rbac-role-based-access-controls.md | 71 + ...g-feature-engineering-with-denormalized.md | 135 + docs/blog/the-future-of-feast.md | 38 + docs/blog/the-road-to-feast-1-0.md | 35 + docs/blog/what-is-a-feature-store.md | 85 + .../architecture/push-vs-pull-model.md | 2 +- .../architecture/write-patterns.md | 2 +- docs/getting-started/components/README.md | 8 + .../components/feature-server.md | 42 + .../components/offline-store.md | 2 +- .../components/open-telemetry.md | 149 + docs/getting-started/components/overview.md | 2 + .../concepts/data-ingestion.md | 2 +- docs/getting-started/concepts/dataset.md | 2 +- docs/getting-started/concepts/feature-view.md | 9 +- docs/getting-started/concepts/permission.md | 2 +- docs/getting-started/faq.md | 2 +- docs/getting-started/quickstart.md | 3 + .../adding-a-new-offline-store.md | 5 +- .../adding-support-for-a-new-online-store.md | 2 +- .../running-feast-in-production.md | 2 +- .../starting-feast-servers-tls-mode.md | 81 +- docs/project/development-guide.md | 61 +- docs/project/release-process.md | 38 + docs/reference/alpha-vector-database.md | 214 +- docs/reference/alpha-web-ui.md | 8 +- docs/reference/data-sources/README.md | 4 + docs/reference/data-sources/couchbase.md | 37 + docs/reference/data-sources/overview.md | 22 +- docs/reference/denormalized.md | 17 +- docs/reference/feast-cli-commands.md | 23 + docs/reference/feature-servers/README.md | 6 +- .../feature-servers/go-feature-server.md | 93 - .../feature-servers/python-feature-server.md | 15 +- .../feature-servers/registry-server.md | 26 + docs/reference/offline-stores/README.md | 4 + docs/reference/offline-stores/couchbase.md | 79 + docs/reference/offline-stores/overview.md | 42 +- docs/reference/online-stores/couchbase.md | 2 +- docs/reference/online-stores/milvus.md | 65 + docs/reference/online-stores/overview.md | 36 +- docs/reference/registries/remote.md | 28 + docs/roadmap.md | 13 +- .../validating-historical-features.md | 2 +- examples/README.md | 36 +- .../01_Credit_Risk_Data_Prep.ipynb | 757 ++ .../02_Deploying_the_Feature_Store.ipynb | 801 ++ .../03_Credit_Risk_Model_Training.ipynb | 1541 ++++ .../04_Credit_Risk_Model_Serving.ipynb | 697 ++ .../05_Credit_Risk_Cleanup.ipynb | 296 + examples/credit-risk-end-to-end/README.md | 39 + .../credit-risk-end-to-end/requirements.txt | 6 + .../operator-postgres-tls-demo/.gitignore | 4 + .../01-Install-postgres-tls-using-helm.ipynb | 557 ++ .../02-Install-feast.ipynb | 458 + .../03-Uninstall.ipynb | 134 + examples/operator-postgres-tls-demo/README.md | 50 + examples/operator-quickstart/.gitignore | 1 + examples/operator-quickstart/01-Install.ipynb | 401 + examples/operator-quickstart/02-Demo.ipynb | 669 ++ .../operator-quickstart/03-Uninstall.ipynb | 103 + examples/operator-quickstart/README.md | 7 + examples/operator-quickstart/feast.yaml | 55 + examples/operator-quickstart/postgres.yaml | 55 + examples/operator-quickstart/redis.yaml | 39 + examples/operator-rbac/03-uninstall.ipynb | 175 + .../operator-rbac/1-setup-operator-rbac.ipynb | 760 ++ examples/operator-rbac/2-client.ipynb | 828 ++ examples/operator-rbac/README.md | 6 + .../operator-rbac/client/feature_store.yaml | 15 + examples/operator-rbac/permissions_apply.py | 27 + examples/python-helm-demo/README.md | 159 +- .../data/driver_stats_with_string.parquet | Bin 35310 -> 35693 bytes .../feature_repo/feature_store.yaml | 10 - .../feature_repo/feature_store.yaml.template | 9 + examples/python-helm-demo/minio-dev.yaml | 128 + examples/python-helm-demo/minio.env | 7 + .../online_feature_store.yaml.template | 7 + .../python-helm-demo/test/feature_store.yaml | 7 + .../test_python_fetch.py | 10 +- examples/quickstart/quickstart.ipynb | 2145 +++-- examples/rag/README.md | 87 + examples/rag/__init__.py | 0 examples/rag/feature_repo/__init__.py | 0 ...ikipedia_summaries_with_embeddings.parquet | Bin 0 -> 2028051 bytes examples/rag/feature_repo/example_repo.py | 42 + examples/rag/feature_repo/feature_store.yaml | 17 + examples/rag/feature_repo/test_workflow.py | 74 + examples/rag/milvus-quickstart.ipynb | 1023 +++ go.mod | 64 +- go.sum | 1955 +---- go/README.md | 113 +- go/embedded/online_features.go | 36 +- go/infra/docker/feature-server/Dockerfile | 31 + go/internal/feast/errors.go | 22 + go/internal/feast/featurestore.go | 40 +- go/internal/feast/featurestore_test.go | 232 +- go/internal/feast/onlineserving/serving.go | 6 +- go/internal/feast/onlinestore/onlinestore.go | 4 +- .../feast/onlinestore/redisonlinestore.go | 123 +- .../onlinestore/redisonlinestore_test.go | 126 +- .../onlinestore/sqliteonlinestore_test.go | 3 +- go/internal/feast/registry/local.go | 2 +- go/internal/feast/registry/registry.go | 85 +- go/internal/feast/registry/repoconfig.go | 67 +- go/internal/feast/registry/repoconfig_test.go | 247 +- go/internal/feast/server/grpc_server.go | 22 +- go/internal/feast/server/grpc_server_test.go | 8 +- go/internal/feast/server/http_server.go | 130 +- go/internal/feast/server/http_server_test.go | 40 +- .../feast/server/logging/filelogsink.go | 8 +- go/internal/feast/server/logging/logger.go | 2 +- .../feast/server/logging/logger_test.go | 10 +- .../feast/server/logging/memorybuffer.go | 9 +- .../feast/server/logging/memorybuffer_test.go | 8 +- .../feast/server/logging/offlinestoresink.go | 8 +- go/internal/feast/server/server_commons.go | 31 + .../feast/transformation/transformation.go | 155 +- .../transformation/transformation_service.go | 205 + go/internal/test/feature_repo/example.py | 36 +- .../feature_repo/data/online_store_for_pg.db | 0 go/internal/test/go_integration_test_utils.go | 16 +- go/main.go | 180 + go/main_test.go | 71 + go/types/typeconversion.go | 6 +- go/types/typeconversion_test.go | 6 +- infra/charts/feast-feature-server/Chart.yaml | 2 +- infra/charts/feast-feature-server/README.md | 5 +- .../feast-feature-server/templates/route.yaml | 18 + infra/charts/feast-feature-server/values.yaml | 6 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 6 +- .../feast/charts/feature-server/Chart.yaml | 4 +- .../feast/charts/feature-server/README.md | 4 +- .../feast/charts/feature-server/values.yaml | 2 +- .../charts/transformation-service/Chart.yaml | 4 +- .../charts/transformation-service/README.md | 4 +- .../charts/transformation-service/values.yaml | 2 +- infra/charts/feast/requirements.yaml | 4 +- infra/feast-helm-operator/Makefile | 2 +- infra/feast-helm-operator/README.md | 2 +- .../config/manager/kustomization.yaml | 2 +- infra/feast-operator/.gitignore | 6 +- infra/feast-operator/.golangci.yml | 17 +- infra/feast-operator/Dockerfile | 9 +- infra/feast-operator/Makefile | 96 +- infra/feast-operator/README.md | 30 +- .../api/feastversion/version.go | 4 +- .../api/v1alpha1/featurestore_types.go | 383 +- .../api/v1alpha1/zz_generated.deepcopy.go | 585 +- infra/feast-operator/bundle.Dockerfile | 2 +- ...er-manager-metrics-service_v1_service.yaml | 2 +- .../feast-operator.clusterserviceversion.yaml | 96 +- .../manifests/feast.dev_featurestores.yaml | 7406 ++++++++++++++--- .../bundle/metadata/annotations.yaml | 2 +- infra/feast-operator/cmd/main.go | 55 +- .../config/component_metadata.yaml | 5 + .../crd/bases/feast.dev_featurestores.yaml | 7051 ++++++++++++++-- .../config/default/kustomization.yaml | 16 +- .../default/manager_auth_proxy_patch.yaml | 39 - .../config/default/manager_metrics_patch.yaml | 4 + .../metrics_service.yaml} | 2 +- .../default/related_image_fs_patch.tmpl | 5 + .../default/related_image_fs_patch.yaml | 5 + .../config/manager/kustomization.yaml | 2 +- .../config/manager/manager.yaml | 4 + .../config/overlays/odh/delete-namespace.yaml | 5 + .../config/overlays/odh/kustomization.yaml | 44 + .../config/overlays/odh/params.env | 2 + .../config/overlays/odh/params.yaml | 3 + .../config/prometheus/monitor.yaml | 11 +- .../config/rbac/auth_proxy_role.yaml | 20 - .../config/rbac/kustomization.yaml | 16 +- .../config/rbac/metrics_auth_role.yaml | 20 + ...ng.yaml => metrics_auth_role_binding.yaml} | 4 +- ...sterrole.yaml => metrics_reader_role.yaml} | 0 infra/feast-operator/config/rbac/role.yaml | 32 + .../config/samples/kustomization.yaml | 1 + .../v1alpha1_featurestore_all_servers.yaml | 13 + .../v1alpha1_featurestore_db_persistence.yaml | 57 + .../samples/v1alpha1_featurestore_git.yaml | 10 + .../v1alpha1_featurestore_git_repopath.yaml | 11 + .../v1alpha1_featurestore_git_token.yaml | 21 + .../samples/v1alpha1_featurestore_init.yaml | 9 + ...v1alpha1_featurestore_kubernetes_auth.yaml | 20 + ..._featurestore_objectstore_persistence.yaml | 16 + .../v1alpha1_featurestore_oidc_auth.yaml | 21 + ..._featurestore_postgres_db_volumes_tls.yaml | 83 + ...turestore_postgres_tls_volumes_ca_env.yaml | 84 + ...v1alpha1_featurestore_pvc_persistence.yaml | 48 + ...alpha1_featurestore_services_loglevel.yaml | 19 + infra/feast-operator/dist/install.yaml | 7303 ++++++++++++++-- infra/feast-operator/docs/api/markdown/ref.md | 605 ++ .../docs/crd-ref-templates/config.yaml | 8 + .../crd-ref-templates/markdown/gv_details.tpl | 19 + .../crd-ref-templates/markdown/gv_list.tpl | 15 + .../docs/crd-ref-templates/markdown/type.tpl | 33 + .../markdown/type_members.tpl | 8 + infra/feast-operator/go.mod | 63 +- infra/feast-operator/go.sum | 130 +- .../internal/controller/authz/authz.go | 206 + .../internal/controller/authz/authz_types.go | 28 + .../controller/featurestore_controller.go | 137 +- .../featurestore_controller_db_store_test.go | 725 ++ .../featurestore_controller_ephemeral_test.go | 458 + ...restore_controller_kubernetes_auth_test.go | 499 ++ .../featurestore_controller_loglevel_test.go | 250 + ...eaturestore_controller_objectstore_test.go | 371 + .../featurestore_controller_oidc_auth_test.go | 537 ++ .../featurestore_controller_pvc_test.go | 653 ++ .../featurestore_controller_test.go | 675 +- ...featurestore_controller_test_utils_test.go | 169 + .../featurestore_controller_tls_test.go | 443 + ...tore_controller_volume_volumemount_test.go | 215 + .../internal/controller/handler/handler.go | 28 + .../controller/handler/handler_types.go | 20 + .../internal/controller/services/client.go | 45 +- .../controller/services/repo_config.go | 384 +- .../controller/services/repo_config_test.go | 424 + .../internal/controller/services/services.go | 974 ++- .../controller/services/services_types.go | 162 +- .../controller/services/suite_test.go | 90 + .../internal/controller/services/tls.go | 260 + .../internal/controller/services/tls_test.go | 330 + .../internal/controller/services/util.go | 470 ++ .../internal/controller/suite_test.go | 10 +- .../test/api/featurestore_types_test.go | 479 ++ infra/feast-operator/test/api/suite_test.go | 89 + .../data-source-types/data-source-types.py | 18 + .../data_source_types_test.go | 88 + .../feast-operator/test/e2e/e2e_suite_test.go | 2 +- infra/feast-operator/test/e2e/e2e_test.go | 114 +- .../previous_version_suite_test.go | 32 + .../previous-version/previous_version_test.go | 49 + .../v1alpha1_default_featurestore.yaml | 13 + ...v1alpha1_remote_registry_featurestore.yaml | 15 + .../test/upgrade/upgrade_suite_test.go | 32 + .../test/upgrade/upgrade_test.go | 44 + infra/feast-operator/test/utils/test_util.go | 448 + infra/feast-operator/test/utils/utils.go | 37 +- infra/scripts/pixi/pixi.lock | 903 +- infra/scripts/pixi/pixi.toml | 2 +- infra/scripts/release/files_to_bump.txt | 3 + java/datatypes/pom.xml | 5 + java/pom.xml | 18 +- java/serving-client/pom.xml | 5 + java/serving/pom.xml | 17 +- protos/feast/core/DataSource.proto | 4 + protos/feast/core/Entity.proto | 4 + protos/feast/core/Feature.proto | 7 +- protos/feast/core/FeatureService.proto | 14 + protos/feast/core/FeatureView.proto | 4 + protos/feast/core/OnDemandFeatureView.proto | 4 + protos/feast/registry/RegistryServer.proto | 2 + protos/feast/serving/GrpcServer.proto | 2 + pyproject.toml | 176 +- ...stores.contrib.couchbase_offline_store.rst | 37 + ....contrib.couchbase_offline_store.tests.rst | 21 + .../feast.infra.offline_stores.contrib.rst | 9 + ...a.online_stores.cassandra_online_store.rst | 29 + ...a.online_stores.couchbase_online_store.rst | 29 + ...line_stores.elasticsearch_online_store.rst | 29 + ...a.online_stores.hazelcast_online_store.rst | 29 + ...infra.online_stores.hbase_online_store.rst | 29 + ...t.infra.online_stores.ikv_online_store.rst | 21 + ...nfra.online_stores.milvus_online_store.rst | 29 + ...infra.online_stores.mysql_online_store.rst | 29 + ...ra.online_stores.postgres_online_store.rst | 37 + ...nfra.online_stores.qdrant_online_store.rst | 29 + .../docs/source/feast.infra.online_stores.rst | 19 +- sdk/python/docs/source/feast.infra.rst | 8 + .../source/feast.infra.utils.couchbase.rst | 21 + sdk/python/docs/source/feast.infra.utils.rst | 1 + sdk/python/docs/source/feast.rst | 8 + sdk/python/feast/batch_feature_view.py | 99 +- sdk/python/feast/cli.py | 46 +- sdk/python/feast/driver_test_data.py | 2 +- .../embedded_go/online_features_service.py | 39 +- sdk/python/feast/entity.py | 11 +- sdk/python/feast/errors.py | 2 +- sdk/python/feast/feature_server.py | 210 +- sdk/python/feast/feature_store.py | 292 +- sdk/python/feast/feature_view.py | 8 +- sdk/python/feast/field.py | 21 + .../feature_servers/local_process/config.py | 5 +- .../feature_servers/multicloud/Dockerfile | 23 +- .../feature_servers/multicloud/Dockerfile.dev | 48 +- .../multicloud/requirements.txt | 2 + sdk/python/feast/infra/key_encoding_utils.py | 26 +- .../kubernetes/k8s_materialization_engine.py | 2 +- .../infra/materialization/snowflake_engine.py | 25 +- .../feast/infra/offline_stores/bigquery.py | 9 +- .../contrib/athena_offline_store/athena.py | 6 +- .../couchbase_columnar_repo_configuration.py | 20 + .../couchbase_offline_store/__init__.py | 0 .../couchbase_offline_store/couchbase.py | 729 ++ .../couchbase_source.py | 406 + .../couchbase_offline_store/tests/__init__.py | 0 .../tests/data_source.py | 213 + .../postgres_offline_store/postgres.py | 4 +- .../contrib/spark_offline_store/spark.py | 11 +- .../spark_offline_store/spark_source.py | 6 + .../contrib/trino_offline_store/trino.py | 4 +- sdk/python/feast/infra/offline_stores/dask.py | 12 +- .../feast/infra/offline_stores/duckdb.py | 7 +- .../feast/infra/offline_stores/file_source.py | 23 +- .../infra/offline_stores/offline_utils.py | 24 +- .../feast/infra/offline_stores/remote.py | 71 +- .../feast/infra/offline_stores/snowflake.py | 130 +- .../infra/offline_stores/snowflake_source.py | 12 +- .../couchbase_online_store/README.md | 4 +- .../couchbase_online_store/couchbase.py | 2 +- .../elasticsearch.py | 3 +- .../infra/online_stores/faiss_online_store.py | 3 +- .../milvus_online_store/__init__.py | 0 .../milvus_online_store/milvus.py | 632 ++ .../milvus_repo_configuration.py | 12 + .../feast/infra/online_stores/online_store.py | 58 +- .../postgres_online_store/postgres.py | 21 +- .../qdrant_online_store/qdrant.py | 9 +- .../singlestore_online_store/singlestore.py | 1 + .../feast/infra/online_stores/sqlite.py | 509 +- .../feast/infra/passthrough_provider.py | 41 +- sdk/python/feast/infra/provider.py | 43 +- .../feast/infra/registry/caching_registry.py | 25 +- sdk/python/feast/infra/registry/remote.py | 37 +- sdk/python/feast/infra/utils/aws_utils.py | 2 +- .../feast/infra/utils/couchbase/__init__.py | 0 .../infra/utils/couchbase/couchbase_utils.py | 13 + .../infra/utils/snowflake/snowflake_utils.py | 10 +- sdk/python/feast/nlp_test_data.py | 67 + sdk/python/feast/offline_server.py | 102 +- sdk/python/feast/on_demand_feature_view.py | 136 +- .../feast/protos/feast/core/DataSource_pb2.py | 4 +- .../protos/feast/core/DataSource_pb2.pyi | 15 + .../feast/protos/feast/core/Entity_pb2.py | 4 +- .../feast/protos/feast/core/Entity_pb2.pyi | 15 + .../protos/feast/core/FeatureService_pb2.py | 36 +- .../protos/feast/core/FeatureService_pb2.pyi | 46 +- .../protos/feast/core/FeatureView_pb2.py | 4 +- .../protos/feast/core/FeatureView_pb2.pyi | 15 + .../feast/protos/feast/core/Feature_pb2.py | 8 +- .../feast/protos/feast/core/Feature_pb2.pyi | 10 +- .../feast/core/OnDemandFeatureView_pb2.py | 4 +- .../feast/core/OnDemandFeatureView_pb2.pyi | 15 + .../feast/registry/RegistryServer_pb2.py | 5 +- .../protos/feast/serving/GrpcServer_pb2.py | 5 +- sdk/python/feast/registry_server.py | 7 +- sdk/python/feast/repo_config.py | 14 +- sdk/python/feast/repo_operations.py | 1 + sdk/python/feast/ssl_ca_trust_store_setup.py | 22 + sdk/python/feast/static/chat/index.html | 129 + sdk/python/feast/stream_feature_view.py | 45 +- .../feast/templates/cassandra/bootstrap.py | 2 +- .../feast/templates/couchbase/__init__.py | 0 .../feast/templates/couchbase/bootstrap.py | 108 + .../couchbase/feature_repo/__init__.py | 0 .../couchbase/feature_repo/example_repo.py | 134 + .../couchbase/feature_repo/feature_store.yaml | 17 + .../couchbase/feature_repo/test_workflow.py | 112 + .../feast/templates/couchbase/gitignore | 45 + sdk/python/feast/transformation/base.py | 119 + sdk/python/feast/transformation/factory.py | 22 + sdk/python/feast/transformation/mode.py | 9 + .../transformation/pandas_transformation.py | 86 +- .../transformation/python_transformation.py | 86 +- .../transformation/spark_transformation.py | 11 + .../transformation/sql_transformation.py | 8 + .../substrait_transformation.py | 85 +- sdk/python/feast/type_map.py | 98 +- sdk/python/feast/types.py | 42 +- sdk/python/feast/ui/package.json | 4 +- sdk/python/feast/ui/yarn.lock | 179 +- sdk/python/feast/ui_server.py | 2 + sdk/python/feast/utils.py | 417 +- sdk/python/pyproject.toml | 2 +- sdk/python/pytest.ini | 1 + .../requirements/py3.10-ci-requirements.txt | 560 +- .../requirements/py3.10-requirements.txt | 88 +- .../requirements/py3.11-ci-requirements.txt | 560 +- .../requirements/py3.11-requirements.txt | 87 +- .../requirements/py3.9-ci-requirements.txt | 534 +- .../requirements/py3.9-requirements.txt | 77 +- sdk/python/tests/conftest.py | 37 +- sdk/python/tests/data/data_creator.py | 4 +- sdk/python/tests/doctest/test_all.py | 8 +- .../example_repos/example_feature_repo_1.py | 9 +- .../example_feature_repo_with_bfvs.py | 4 + .../example_repos/example_rag_feature_repo.py | 47 + sdk/python/tests/foo_provider.py | 19 + sdk/python/tests/integration/conftest.py | 21 +- .../feature_repos/repo_configuration.py | 7 + .../universal/data_source_creator.py | 9 + .../universal/data_sources/file.py | 84 +- .../feature_repos/universal/feature_views.py | 20 +- .../universal/online_store/couchbase.py | 2 +- .../universal/online_store/milvus.py | 43 + .../materialization/test_snowflake.py | 6 +- ...t_validation.py => test_dqm_validation.py} | 0 .../offline_store/test_feature_logging.py | 8 + .../test_universal_historical_retrieval.py | 101 +- .../online_store/test_remote_online_store.py | 45 +- .../online_store/test_universal_online.py | 33 +- .../registration/test_universal_registry.py | 5 +- .../registration/test_universal_types.py | 10 +- sdk/python/tests/unit/cli/test_cli.py | 20 + .../unit/cli/test_cli_apply_duplicates.py | 20 +- .../contrib/spark_offline_store/test_spark.py | 121 + .../offline_stores/test_offline_store.py | 26 + .../infra/offline_stores/test_snowflake.py | 19 +- .../tests/unit/infra/registry/__init__.py | 0 .../unit/infra/registry/test_registry.py | 197 + .../unit/infra/test_inference_unit_tests.py | 17 - .../unit/infra/test_key_encoding_utils.py | 18 + .../utils/snowflake/test_snowflake_utils.py | 71 + .../test_local_feature_store.py | 3 +- .../online_store/test_online_retrieval.py | 757 +- .../auth/server/test_auth_registry_server.py | 5 +- .../unit/permissions/test_oidc_auth_client.py | 6 +- sdk/python/tests/unit/test_entity.py | 15 + sdk/python/tests/unit/test_feature_views.py | 4 + .../tests/unit/test_on_demand_feature_view.py | 92 +- .../test_on_demand_python_transformation.py | 330 +- ..._operations_validate_feast_project_name.py | 6 +- .../tests/unit/test_stream_feature_view.py | 39 +- .../tests/unit/test_unit_feature_store.py | 92 +- .../tests/utils/auth_permissions_util.py | 31 +- sdk/python/tests/utils/cli_repo_creator.py | 92 +- sdk/python/tests/utils/e2e_test_validation.py | 12 +- .../generate_self_signed_certifcate_util.py | 73 - .../tests/utils/ssl_certifcates_util.py | 174 + setup.py | 61 +- ui/.nvmrc | 2 +- ui/README.md | 4 +- ui/config/jest/cssTransform.js | 2 +- ui/jest.config.js | 12 +- ui/package.json | 24 +- ui/public/registry.db | Bin 5527 -> 6812 bytes ui/src/FeastUI.tsx | 15 +- ui/src/FeastUISansProviders.test.tsx | 6 +- ui/src/FeastUISansProviders.tsx | 14 +- ui/src/components/EuiCustomLink.jsx | 46 - ui/src/components/EuiCustomLink.tsx | 48 + .../components/FeaturesInServiceDisplay.tsx | 5 +- ui/src/components/FeaturesListDisplay.tsx | 3 +- ui/src/components/ObjectsCountStats.tsx | 8 +- ui/src/components/ProjectSelector.test.tsx | 2 +- ui/src/components/ProjectSelector.tsx | 2 +- ui/src/hacks/RouteAdapter.ts | 39 - ui/src/index.tsx | 11 +- ui/src/mocks/handlers.ts | 44 +- ui/src/pages/RootProjectSelectionPage.tsx | 6 +- ui/src/pages/Sidebar.tsx | 42 +- .../data-sources/DataSourcesListingTable.tsx | 5 +- .../pages/entities/EntitiesListingTable.tsx | 5 +- .../pages/entities/FeatureViewEdgesList.tsx | 5 +- .../FeatureServiceListingTable.tsx | 5 +- .../FeatureServiceOverviewTab.tsx | 4 +- .../ConsumingFeatureServicesList.tsx | 5 +- .../feature-views/FeatureViewListingTable.tsx | 5 +- .../RegularFeatureViewOverviewTab.tsx | 4 +- .../StreamFeatureViewOverviewTab.tsx | 3 +- .../FeatureViewProjectionDisplayPanel.tsx | 3 +- .../components/RequestDataDisplayPanel.tsx | 3 +- ui/src/pages/features/FeatureOverviewTab.tsx | 4 +- .../saved-data-sets/DatasetsListingTable.tsx | 5 +- ui/src/queries/useLoadRegistry.ts | 2 +- ui/src/setupTests.ts | 4 + ui/src/test-utils.tsx | 12 +- ui/src/utils/timestamp.ts | 4 +- ui/yarn.lock | 2133 +++-- 527 files changed, 58841 insertions(+), 11566 deletions(-) create mode 100644 .github/actions/get-semantic-release-version/action.yml create mode 100644 .github/workflows/operator-e2e-integration-tests.yml create mode 100644 .github/workflows/pr_remote_rbac_integration_tests.yml create mode 100644 .github/workflows/publish_helm_charts.yml create mode 100644 .github/workflows/publish_images.yml create mode 100644 .github/workflows/publish_java_sdk.yml create mode 100644 .github/workflows/publish_python_sdk.yml create mode 100644 .github/workflows/update_stable_branch.yml create mode 100644 OWNERS create mode 100644 docs/blog/README.md create mode 100644 docs/blog/a-state-of-feast.md create mode 100644 docs/blog/announcing-feast-0-11.md create mode 100644 docs/blog/faster-feature-transformations-in-feast.md create mode 100644 docs/blog/feast-0-10-announcement.md create mode 100644 docs/blog/feast-0-13-adds-on-demand-transforms-feature-servers-and-feature-views-without-entities.md create mode 100644 docs/blog/feast-0-14-adds-aws-lambda-feature-servers.md create mode 100644 docs/blog/feast-0-18-adds-snowflake-support-and-data-quality-monitoring.md create mode 100644 docs/blog/feast-0-20-adds-api-and-connector-improvements.md create mode 100644 docs/blog/feast-benchmarks.md create mode 100644 docs/blog/feast-joins-the-linux-foundation-for-ai-data.md create mode 100644 docs/blog/feast-release-0-12-adds-aws-redshift-and-dynamodb-stores.md create mode 100644 docs/blog/feast-supports-vector-database.md create mode 100644 docs/blog/go-feature-server-benchmarks.md create mode 100644 docs/blog/how-danny-chiao-is-keeping-feast-simple.md create mode 100644 docs/blog/kubeflow-and-feast-with-david-aronchick.md create mode 100644 docs/blog/machine-learning-data-stack-for-real-time-fraud-prediction-using-feast-on-gcp.md create mode 100644 docs/blog/performance-test-for-python-based-feast-feature-server.md create mode 100644 docs/blog/rbac-role-based-access-controls.md create mode 100644 docs/blog/streaming-feature-engineering-with-denormalized.md create mode 100644 docs/blog/the-future-of-feast.md create mode 100644 docs/blog/the-road-to-feast-1-0.md create mode 100644 docs/blog/what-is-a-feature-store.md create mode 100644 docs/getting-started/components/feature-server.md create mode 100644 docs/getting-started/components/open-telemetry.md rename docs/{reference => how-to-guides}/starting-feast-servers-tls-mode.md (60%) create mode 100644 docs/reference/data-sources/couchbase.md delete mode 100644 docs/reference/feature-servers/go-feature-server.md create mode 100644 docs/reference/feature-servers/registry-server.md create mode 100644 docs/reference/offline-stores/couchbase.md create mode 100644 docs/reference/online-stores/milvus.md create mode 100644 docs/reference/registries/remote.md create mode 100644 examples/credit-risk-end-to-end/01_Credit_Risk_Data_Prep.ipynb create mode 100644 examples/credit-risk-end-to-end/02_Deploying_the_Feature_Store.ipynb create mode 100644 examples/credit-risk-end-to-end/03_Credit_Risk_Model_Training.ipynb create mode 100644 examples/credit-risk-end-to-end/04_Credit_Risk_Model_Serving.ipynb create mode 100644 examples/credit-risk-end-to-end/05_Credit_Risk_Cleanup.ipynb create mode 100644 examples/credit-risk-end-to-end/README.md create mode 100644 examples/credit-risk-end-to-end/requirements.txt create mode 100644 examples/operator-postgres-tls-demo/.gitignore create mode 100644 examples/operator-postgres-tls-demo/01-Install-postgres-tls-using-helm.ipynb create mode 100644 examples/operator-postgres-tls-demo/02-Install-feast.ipynb create mode 100644 examples/operator-postgres-tls-demo/03-Uninstall.ipynb create mode 100644 examples/operator-postgres-tls-demo/README.md create mode 100644 examples/operator-quickstart/.gitignore create mode 100644 examples/operator-quickstart/01-Install.ipynb create mode 100644 examples/operator-quickstart/02-Demo.ipynb create mode 100644 examples/operator-quickstart/03-Uninstall.ipynb create mode 100644 examples/operator-quickstart/README.md create mode 100644 examples/operator-quickstart/feast.yaml create mode 100644 examples/operator-quickstart/postgres.yaml create mode 100644 examples/operator-quickstart/redis.yaml create mode 100644 examples/operator-rbac/03-uninstall.ipynb create mode 100644 examples/operator-rbac/1-setup-operator-rbac.ipynb create mode 100644 examples/operator-rbac/2-client.ipynb create mode 100644 examples/operator-rbac/README.md create mode 100644 examples/operator-rbac/client/feature_store.yaml create mode 100644 examples/operator-rbac/permissions_apply.py delete mode 100644 examples/python-helm-demo/feature_repo/feature_store.yaml create mode 100644 examples/python-helm-demo/feature_repo/feature_store.yaml.template create mode 100644 examples/python-helm-demo/minio-dev.yaml create mode 100644 examples/python-helm-demo/minio.env create mode 100644 examples/python-helm-demo/online_feature_store.yaml.template create mode 100644 examples/python-helm-demo/test/feature_store.yaml rename examples/python-helm-demo/{feature_repo => test}/test_python_fetch.py (73%) create mode 100644 examples/rag/README.md create mode 100644 examples/rag/__init__.py create mode 100644 examples/rag/feature_repo/__init__.py create mode 100644 examples/rag/feature_repo/data/city_wikipedia_summaries_with_embeddings.parquet create mode 100644 examples/rag/feature_repo/example_repo.py create mode 100644 examples/rag/feature_repo/feature_store.yaml create mode 100644 examples/rag/feature_repo/test_workflow.py create mode 100644 examples/rag/milvus-quickstart.ipynb create mode 100644 go/infra/docker/feature-server/Dockerfile create mode 100644 go/internal/feast/errors.go create mode 100644 go/internal/feast/server/server_commons.go create mode 100644 go/internal/feast/transformation/transformation_service.go create mode 100644 go/internal/test/flexible_coyote/feature_repo/data/online_store_for_pg.db create mode 100644 go/main.go create mode 100644 go/main_test.go create mode 100644 infra/charts/feast-feature-server/templates/route.yaml create mode 100644 infra/feast-operator/config/component_metadata.yaml delete mode 100644 infra/feast-operator/config/default/manager_auth_proxy_patch.yaml create mode 100644 infra/feast-operator/config/default/manager_metrics_patch.yaml rename infra/feast-operator/config/{rbac/auth_proxy_service.yaml => default/metrics_service.yaml} (94%) create mode 100644 infra/feast-operator/config/default/related_image_fs_patch.tmpl create mode 100644 infra/feast-operator/config/default/related_image_fs_patch.yaml create mode 100644 infra/feast-operator/config/overlays/odh/delete-namespace.yaml create mode 100644 infra/feast-operator/config/overlays/odh/kustomization.yaml create mode 100644 infra/feast-operator/config/overlays/odh/params.env create mode 100644 infra/feast-operator/config/overlays/odh/params.yaml delete mode 100644 infra/feast-operator/config/rbac/auth_proxy_role.yaml create mode 100644 infra/feast-operator/config/rbac/metrics_auth_role.yaml rename infra/feast-operator/config/rbac/{auth_proxy_role_binding.yaml => metrics_auth_role_binding.yaml} (84%) rename infra/feast-operator/config/rbac/{auth_proxy_client_clusterrole.yaml => metrics_reader_role.yaml} (100%) create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_all_servers.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_db_persistence.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_git.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_git_repopath.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_git_token.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_init.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_objectstore_persistence.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_oidc_auth.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_db_volumes_tls.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_pvc_persistence.yaml create mode 100644 infra/feast-operator/config/samples/v1alpha1_featurestore_services_loglevel.yaml create mode 100644 infra/feast-operator/docs/api/markdown/ref.md create mode 100644 infra/feast-operator/docs/crd-ref-templates/config.yaml create mode 100644 infra/feast-operator/docs/crd-ref-templates/markdown/gv_details.tpl create mode 100644 infra/feast-operator/docs/crd-ref-templates/markdown/gv_list.tpl create mode 100644 infra/feast-operator/docs/crd-ref-templates/markdown/type.tpl create mode 100644 infra/feast-operator/docs/crd-ref-templates/markdown/type_members.tpl create mode 100644 infra/feast-operator/internal/controller/authz/authz.go create mode 100644 infra/feast-operator/internal/controller/authz/authz_types.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_db_store_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_ephemeral_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_loglevel_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_objectstore_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_pvc_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_test_utils_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_tls_test.go create mode 100644 infra/feast-operator/internal/controller/featurestore_controller_volume_volumemount_test.go create mode 100644 infra/feast-operator/internal/controller/handler/handler.go create mode 100644 infra/feast-operator/internal/controller/handler/handler_types.go create mode 100644 infra/feast-operator/internal/controller/services/repo_config_test.go create mode 100644 infra/feast-operator/internal/controller/services/suite_test.go create mode 100644 infra/feast-operator/internal/controller/services/tls.go create mode 100644 infra/feast-operator/internal/controller/services/tls_test.go create mode 100644 infra/feast-operator/internal/controller/services/util.go create mode 100644 infra/feast-operator/test/api/featurestore_types_test.go create mode 100644 infra/feast-operator/test/api/suite_test.go create mode 100644 infra/feast-operator/test/data-source-types/data-source-types.py create mode 100644 infra/feast-operator/test/data-source-types/data_source_types_test.go create mode 100644 infra/feast-operator/test/previous-version/previous_version_suite_test.go create mode 100644 infra/feast-operator/test/previous-version/previous_version_test.go create mode 100644 infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml create mode 100644 infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_remote_registry_featurestore.yaml create mode 100644 infra/feast-operator/test/upgrade/upgrade_suite_test.go create mode 100644 infra/feast-operator/test/upgrade/upgrade_test.go create mode 100644 infra/feast-operator/test/utils/test_util.go create mode 100644 sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.rst create mode 100644 sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.tests.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.cassandra_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.couchbase_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.elasticsearch_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.hazelcast_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.hbase_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.ikv_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.milvus_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.mysql_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.postgres_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.online_stores.qdrant_online_store.rst create mode 100644 sdk/python/docs/source/feast.infra.utils.couchbase.rst create mode 100644 sdk/python/feast/infra/feature_servers/multicloud/requirements.txt create mode 100644 sdk/python/feast/infra/offline_stores/contrib/couchbase_columnar_repo_configuration.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/__init__.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/__init__.py create mode 100644 sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/data_source.py create mode 100644 sdk/python/feast/infra/online_stores/milvus_online_store/__init__.py create mode 100644 sdk/python/feast/infra/online_stores/milvus_online_store/milvus.py create mode 100644 sdk/python/feast/infra/online_stores/milvus_online_store/milvus_repo_configuration.py create mode 100644 sdk/python/feast/infra/utils/couchbase/__init__.py create mode 100644 sdk/python/feast/infra/utils/couchbase/couchbase_utils.py create mode 100644 sdk/python/feast/nlp_test_data.py create mode 100644 sdk/python/feast/ssl_ca_trust_store_setup.py create mode 100644 sdk/python/feast/static/chat/index.html create mode 100644 sdk/python/feast/templates/couchbase/__init__.py create mode 100644 sdk/python/feast/templates/couchbase/bootstrap.py create mode 100644 sdk/python/feast/templates/couchbase/feature_repo/__init__.py create mode 100644 sdk/python/feast/templates/couchbase/feature_repo/example_repo.py create mode 100644 sdk/python/feast/templates/couchbase/feature_repo/feature_store.yaml create mode 100644 sdk/python/feast/templates/couchbase/feature_repo/test_workflow.py create mode 100644 sdk/python/feast/templates/couchbase/gitignore create mode 100644 sdk/python/feast/transformation/base.py create mode 100644 sdk/python/feast/transformation/factory.py create mode 100644 sdk/python/feast/transformation/mode.py create mode 100644 sdk/python/feast/transformation/spark_transformation.py create mode 100644 sdk/python/feast/transformation/sql_transformation.py create mode 100644 sdk/python/tests/example_repos/example_rag_feature_repo.py create mode 100644 sdk/python/tests/integration/feature_repos/universal/online_store/milvus.py rename sdk/python/tests/integration/offline_store/{test_validation.py => test_dqm_validation.py} (100%) create mode 100644 sdk/python/tests/unit/infra/registry/__init__.py create mode 100644 sdk/python/tests/unit/infra/registry/test_registry.py create mode 100644 sdk/python/tests/unit/infra/utils/snowflake/test_snowflake_utils.py delete mode 100644 sdk/python/tests/utils/generate_self_signed_certifcate_util.py create mode 100644 sdk/python/tests/utils/ssl_certifcates_util.py delete mode 100644 ui/src/components/EuiCustomLink.jsx create mode 100644 ui/src/components/EuiCustomLink.tsx delete mode 100644 ui/src/hacks/RouteAdapter.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1b15dcf882a..4490890d001 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,7 +23,7 @@ // "forwardPorts": [], // Uncomment the next line to run commands after the container is created. - // "postCreateCommand": "make install-python-ci-dependencies-uv-venv" + // "postCreateCommand": "make install-python-dependencies-dev" // Configure tool-specific properties. // "customizations": {}, diff --git a/.github/actions/get-semantic-release-version/action.yml b/.github/actions/get-semantic-release-version/action.yml new file mode 100644 index 00000000000..89f6a8f81c1 --- /dev/null +++ b/.github/actions/get-semantic-release-version/action.yml @@ -0,0 +1,87 @@ +name: Get semantic release version +description: "" +inputs: + custom_version: # Optional input for a custom version + description: "Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing" + required: false + token: + description: "Personal Access Token" + required: true + default: "" +outputs: + release_version: + description: "The release version to use (e.g., v1.2.3)" + value: ${{ steps.get_release_version.outputs.release_version }} + version_without_prefix: + description: "The release version to use without 'v' (e.g., 1.2.3)" + value: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} + highest_semver_tag: + description: "The highest semantic version tag without the 'v' prefix (e.g., 1.2.3)" + value: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} +runs: + using: composite + steps: + - name: Get release version + id: get_release_version + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + GIT_AUTHOR_NAME: feast-ci-bot + GIT_AUTHOR_EMAIL: feast-ci-bot@willem.co + GIT_COMMITTER_NAME: feast-ci-bot + GIT_COMMITTER_EMAIL: feast-ci-bot@willem.co + run: | + if [[ -n "${{ inputs.custom_version }}" ]]; then + VERSION_REGEX="^v[0-9]+\.[0-9]+\.[0-9]+$" + echo "Using custom version: ${{ inputs.custom_version }}" + if [[ ! "${{ inputs.custom_version }}" =~ $VERSION_REGEX ]]; then + echo "Error: custom_version must match semantic versioning (e.g., v1.2.3)." + exit 1 + fi + echo "::set-output name=release_version::${{ inputs.custom_version }}" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + echo "Using tag reference: ${GITHUB_REF#refs/tags/}" + echo "::set-output name=release_version::${GITHUB_REF#refs/tags/}" + else + echo "Defaulting to branch name: ${GITHUB_REF#refs/heads/}" + echo "::set-output name=release_version::${GITHUB_REF#refs/heads/}" + fi + - name: Get release version without prefix + id: get_release_version_without_prefix + shell: bash + env: + RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} + run: | + if [[ "${RELEASE_VERSION}" == v* ]]; then + echo "::set-output name=version_without_prefix::${RELEASE_VERSION:1}" + else + echo "::set-output name=version_without_prefix::${RELEASE_VERSION}" + fi + - name: Get highest semver + id: get_highest_semver + shell: bash + env: + RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} + run: | + if [[ -n "${{ inputs.custom_version }}" ]]; then + HIGHEST_SEMVER_TAG="${{ inputs.custom_version }}" + echo "::set-output name=highest_semver_tag::$HIGHEST_SEMVER_TAG" + echo "Using custom version as highest semantic version: $HIGHEST_SEMVER_TAG" + else + source infra/scripts/setup-common-functions.sh + SEMVER_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if echo "${RELEASE_VERSION}" | grep -P "$SEMVER_REGEX" &>/dev/null ; then + echo ::set-output name=highest_semver_tag::$(get_tag_release -m) + echo "Using infra/scripts/setup-common-functions.sh to generate highest semantic version: $HIGHEST_SEMVER_TAG" + fi + fi + - name: Check output + shell: bash + env: + RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} + VERSION_WITHOUT_PREFIX: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} + HIGHEST_SEMVER_TAG: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} + run: | + echo $RELEASE_VERSION + echo $VERSION_WITHOUT_PREFIX + echo $HIGHEST_SEMVER_TAG \ No newline at end of file diff --git a/.github/fork_workflows/fork_pr_integration_tests_aws.yml b/.github/fork_workflows/fork_pr_integration_tests_aws.yml index 6eb8b8feff0..d0257ecaca9 100644 --- a/.github/fork_workflows/fork_pr_integration_tests_aws.yml +++ b/.github/fork_workflows/fork_pr_integration_tests_aws.yml @@ -73,7 +73,7 @@ jobs: sudo apt update sudo apt install -y -V libarrow-dev - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Setup Redis Cluster run: | docker pull vishnunair/docker-redis-cluster:latest @@ -85,5 +85,3 @@ jobs: pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "File and not Snowflake and not BigQuery and not minio_registry" pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "dynamo and not Snowflake and not BigQuery and not minio_registry" pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "Redshift and not Snowflake and not BigQuery and not minio_registry" - - diff --git a/.github/fork_workflows/fork_pr_integration_tests_gcp.yml b/.github/fork_workflows/fork_pr_integration_tests_gcp.yml index be9844a7e93..a6221d3b7ac 100644 --- a/.github/fork_workflows/fork_pr_integration_tests_gcp.yml +++ b/.github/fork_workflows/fork_pr_integration_tests_gcp.yml @@ -75,7 +75,7 @@ jobs: sudo apt update sudo apt install -y -V libarrow-dev - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Setup Redis Cluster run: | docker pull vishnunair/docker-redis-cluster:latest @@ -86,4 +86,3 @@ jobs: run: | pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "BigQuery and not dynamo and not Redshift and not Snowflake and not minio_registry" pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "File and not dynamo and not Redshift and not Snowflake and not minio_registry" - diff --git a/.github/fork_workflows/fork_pr_integration_tests_snowflake.yml b/.github/fork_workflows/fork_pr_integration_tests_snowflake.yml index a136b47b9e7..9698fe12cd7 100644 --- a/.github/fork_workflows/fork_pr_integration_tests_snowflake.yml +++ b/.github/fork_workflows/fork_pr_integration_tests_snowflake.yml @@ -65,7 +65,7 @@ jobs: sudo apt update sudo apt install -y -V libarrow-dev - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Setup Redis Cluster run: | docker pull vishnunair/docker-redis-cluster:latest @@ -82,4 +82,3 @@ jobs: run: | pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "Snowflake and not dynamo and not Redshift and not Bigquery and not gcp and not minio_registry" pytest -n 8 --cov=./ --cov-report=xml --color=yes sdk/python/tests --integration --durations=5 --timeout=1200 --timeout_method=thread -k "File and not dynamo and not Redshift and not Bigquery and not gcp and not minio_registry" - diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index 8e52ba12c9e..15a6571367c 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -1,58 +1,35 @@ -name: build_wheels +name: build wheels # Call this workflow from other workflows in the repository by specifying "uses: ./.github/workflows/build_wheels.yml" # Developers who are starting a new release should use this workflow to ensure wheels will be built correctly. # Devs should check out their fork, add a tag to the last master commit on their fork, and run the release off of their fork on the added tag to ensure wheels will be built correctly. on: - workflow_dispatch: - tags: - - 'v*.*.*' + workflow_dispatch: # Allows manual trigger of the workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string workflow_call: + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string jobs: - get-version: - runs-on: ubuntu-latest - outputs: - release_version: ${{ steps.get_release_version.outputs.release_version }} - version_without_prefix: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} - highest_semver_tag: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Get release version - id: get_release_version - run: echo ::set-output name=release_version::${GITHUB_REF#refs/*/} - - name: Get release version without prefix - id: get_release_version_without_prefix - env: - RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} - run: | - echo ::set-output name=version_without_prefix::${RELEASE_VERSION:1} - - name: Get highest semver - id: get_highest_semver - env: - RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} - run: | - source infra/scripts/setup-common-functions.sh - SEMVER_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' - if echo "${RELEASE_VERSION}" | grep -P "$SEMVER_REGEX" &>/dev/null ; then - echo ::set-output name=highest_semver_tag::$(get_tag_release -m) - fi - - name: Check output - id: check_output - env: - RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} - VERSION_WITHOUT_PREFIX: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} - HIGHEST_SEMVER_TAG: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} - run: | - echo $RELEASE_VERSION - echo $VERSION_WITHOUT_PREFIX - echo $HIGHEST_SEMVER_TAG - build-python-wheel: - name: Build wheels + name: Build wheels and source runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,54 +45,34 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Build UI run: make build-ui - - name: Build wheels - run: | - python -m pip install build - python -m build --wheel --outdir wheelhouse/ - - uses: actions/upload-artifact@v3 - with: - name: wheels - path: ./wheelhouse/*.whl - - build-source-distribution: - name: Build source distribution - runs-on: macos-13 - steps: - - uses: actions/checkout@v4 - - name: Setup Python - id: setup-python - uses: actions/setup-python@v5 + - id: get-version + uses: ./.github/actions/get-semantic-release-version with: - python-version: "3.11" - architecture: x64 - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version-file: './ui/.nvmrc' - registry-url: 'https://registry.npmjs.org' - - name: Build and install dependencies - # There's a `git restore` in here because `make install-go-ci-dependencies` is actually messing up go.mod & go.sum. - run: | - pip install -U pip setuptools wheel twine - make build-ui - git status - git restore go.mod go.sum - git restore sdk/python/feast/ui/yarn.lock - - name: Build + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} + - name: Checkout version and install dependencies + env: + VERSION: ${{ steps.get-version.outputs.release_version }} + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python3 setup.py sdist - - uses: actions/upload-artifact@v3 + git fetch --tags + git checkout ${VERSION} + python -m pip install build + - name: Build feast + run: python -m build + - uses: actions/upload-artifact@v4 with: - name: wheels + name: python-wheels path: dist/* # We add this step so the docker images can be built as part of the pre-release verification steps. build-docker-images: + name: Build Docker images runs-on: ubuntu-latest - needs: get-version + needs: [ build-python-wheel ] strategy: matrix: - component: [feature-server, feature-server-java, feature-transformation-server] + component: [ feature-server-dev, feature-server-java, feature-transformation-server, feast-operator ] env: REGISTRY: feastdev steps: @@ -124,19 +81,27 @@ jobs: uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 + - id: get-version + uses: ./.github/actions/get-semantic-release-version + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} - name: Build image + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} + RELEASE_VERSION: ${{ steps.get-version.outputs.release_version }} run: | + echo "Building docker image for ${{ matrix.component }} with version $VERSION_WITHOUT_PREFIX and release version $RELEASE_VERSION" make build-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${VERSION_WITHOUT_PREFIX} - env: - VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} verify-python-wheels: + name: Verify Python wheels runs-on: ${{ matrix.os }} - needs: [build-python-wheel, build-source-distribution, get-version] + needs: [ build-python-wheel ] strategy: matrix: - os: [ubuntu-latest, macos-13 ] - python-version: ["3.9", "3.10", "3.11"] + os: [ ubuntu-latest, macos-13 ] + python-version: [ "3.9", "3.10", "3.11" ] from-source: [ True, False ] env: # this script is for testing servers @@ -151,8 +116,8 @@ jobs: else echo "Succeeded!" fi - VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} steps: + - uses: actions/checkout@v4 - name: Setup Python id: setup-python uses: actions/setup-python@v5 @@ -161,7 +126,7 @@ jobs: architecture: x64 - uses: actions/download-artifact@v4.1.7 with: - name: wheels + name: python-wheels path: dist - name: Install OS X dependencies if: matrix.os == 'macos-13' @@ -178,13 +143,25 @@ jobs: if: ${{ matrix.from-source }} run: pip install dist/*tar.gz # Validate that the feast version installed is not development and is the correct version of the tag we ran it off of. + - id: get-version + uses: ./.github/actions/get-semantic-release-version + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} - name: Validate Feast Version + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} run: | + feast version + if ! VERSION_OUTPUT=$(feast version); then + echo "Error: Failed to get Feast version." + exit 1 + fi VERSION_REGEX='[0-9]+\.[0-9]+\.[0-9]+' OUTPUT_REGEX='^Feast SDK Version: "$VERSION_REGEX"$' - VERSION_OUTPUT=$(feast version) VERSION=$(echo $VERSION_OUTPUT | grep -oE "$VERSION_REGEX") OUTPUT=$(echo $VERSION_OUTPUT | grep -E "$REGEX") + echo "Installed Feast Version: $VERSION and using Feast Version: $VERSION_WITHOUT_PREFIX" if [ -n "$OUTPUT" ] && [ "$VERSION" = "$VERSION_WITHOUT_PREFIX" ]; then echo "Correct Feast Version Installed" else diff --git a/.github/workflows/java_master_only.yml b/.github/workflows/java_master_only.yml index 2775f500f32..0307034bdb1 100644 --- a/.github/workflows/java_master_only.yml +++ b/.github/workflows/java_master_only.yml @@ -16,7 +16,7 @@ jobs: component: [feature-server-java] env: MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar - REGISTRY: gcr.io/kf-feast + REGISTRY: quay.io/feastdev-ci steps: - uses: actions/checkout@v4 with: @@ -40,6 +40,12 @@ jobs: run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - name: Build image run: make build-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${GITHUB_SHA} + - name: Login to Quay.io + uses: docker/login-action@v3 + with: + registry: quay.io + username: ${{ secrets.QUAYIO_CI_USERNAME }} + password: ${{ secrets.QUAYIO_CI_TOKEN }} - name: Push image run: make push-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${GITHUB_SHA} - name: Push development Docker image @@ -72,13 +78,13 @@ jobs: java-version: '11' java-package: jdk architecture: x64 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-it-maven- - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-ut-maven-${{ hashFiles('**/pom.xml') }} @@ -86,7 +92,7 @@ jobs: ${{ runner.os }}-ut-maven- - name: Test java run: make test-java-with-coverage - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: java-coverage-report path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ @@ -126,7 +132,7 @@ jobs: key: ${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', env.PYTHON)) }} - name: Install Python dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - uses: actions/cache@v4 with: path: ~/.m2/repository diff --git a/.github/workflows/java_pr.yml b/.github/workflows/java_pr.yml index caf31ab47fc..40a2a7a7ec9 100644 --- a/.github/workflows/java_pr.yml +++ b/.github/workflows/java_pr.yml @@ -53,13 +53,13 @@ jobs: java-version: '11' java-package: jdk architecture: x64 - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-it-maven- - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-ut-maven-${{ hashFiles('**/pom.xml') }} @@ -67,7 +67,7 @@ jobs: ${{ runner.os }}-ut-maven- - name: Test java run: make test-java-with-coverage - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: java-coverage-report path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ @@ -84,7 +84,7 @@ jobs: component: [ feature-server-java ] env: MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar - REGISTRY: gcr.io/kf-feast + REGISTRY: quay.io/feastdev-ci steps: - uses: actions/checkout@v4 with: @@ -97,11 +97,11 @@ jobs: python-version: "3.11" architecture: x64 - name: Authenticate to Google Cloud - uses: 'google-github-actions/auth@v1' + uses: google-github-actions/auth@v2 with: credentials_json: '${{ secrets.GCP_SA_KEY }}' - name: Set up gcloud SDK - uses: google-github-actions/setup-gcloud@v1 + uses: google-github-actions/setup-gcloud@v2 with: project_id: ${{ secrets.GCP_PROJECT_ID }} - run: gcloud auth configure-docker --quiet @@ -137,18 +137,18 @@ jobs: with: python-version: '3.11' architecture: 'x64' - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: ~/.m2/repository key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-it-maven- - name: Authenticate to Google Cloud - uses: 'google-github-actions/auth@v1' + uses: google-github-actions/auth@v2 with: credentials_json: '${{ secrets.GCP_SA_KEY }}' - name: Set up gcloud SDK - uses: google-github-actions/setup-gcloud@v1 + uses: google-github-actions/setup-gcloud@v2 with: project_id: ${{ secrets.GCP_PROJECT_ID }} - name: Use gcloud CLI @@ -180,11 +180,11 @@ jobs: path: ${{ steps.uv-cache.outputs.dir }} key: ${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', env.PYTHON)) }} - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Run integration tests run: make test-java-integration - name: Save report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: it-report diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml index 81732258455..33fafdcd23d 100644 --- a/.github/workflows/lint_pr.yml +++ b/.github/workflows/lint_pr.yml @@ -14,7 +14,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v4 + - uses: amannn/action-semantic-pull-request@v5 with: # Must use uppercase subjectPattern: ^(?=[A-Z]).+$ diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index ded9931737a..e3d668b17c5 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -19,6 +19,6 @@ jobs: run: curl -LsSf https://astral.sh/uv/install.sh | sh - name: Install dependencies run: | - make install-python-ci-dependencies-uv + make install-python-dependencies-ci - name: Lint python run: make lint-python diff --git a/.github/workflows/master_only.yml b/.github/workflows/master_only.yml index 7166246da5f..840a8007236 100644 --- a/.github/workflows/master_only.yml +++ b/.github/workflows/master_only.yml @@ -65,7 +65,7 @@ jobs: path: ${{ steps.uv-cache.outputs.dir }} key: ${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', env.PYTHON)) }} - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Setup Redis Cluster run: | docker pull vishnunair/docker-redis-cluster:latest @@ -94,40 +94,32 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - component: [ feature-server-java, feature-transformation-server ] + component: [ feature-server-dev, feature-transformation-server, feast-operator ] env: - MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar - REGISTRY: gcr.io/kf-feast + REGISTRY: quay.io/feastdev-ci steps: - uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: install: true - - name: Login to DockerHub - uses: docker/login-action@v1 + - name: Login to Quay.io + uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Authenticate to Google Cloud - uses: 'google-github-actions/auth@v1' - with: - credentials_json: '${{ secrets.GCP_SA_KEY }}' - - name: Set up gcloud SDK - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: ${{ secrets.GCP_PROJECT_ID }} - - name: Use gcloud CLI - run: gcloud info - - run: gcloud auth configure-docker --quiet + registry: quay.io + username: ${{ secrets.QUAYIO_CI_USERNAME }} + password: ${{ secrets.QUAYIO_CI_TOKEN }} - name: Build image run: | make build-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${GITHUB_SHA} - name: Push image run: | - make push-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${GITHUB_SHA} - - docker tag ${REGISTRY}/${{ matrix.component }}:${GITHUB_SHA} ${REGISTRY}/${{ matrix.component }}:develop - docker push ${REGISTRY}/${{ matrix.component }}:develop \ No newline at end of file + if [[ "${{ matrix.component }}" == "feature-server-dev" ]]; then + docker tag ${REGISTRY}/feature-server:${GITHUB_SHA} ${REGISTRY}/feature-server:develop + docker push ${REGISTRY}/feature-server --all-tags + else + docker tag ${REGISTRY}/${{ matrix.component }}:${GITHUB_SHA} ${REGISTRY}/${{ matrix.component }}:develop + docker push ${REGISTRY}/${{ matrix.component }} --all-tags + fi diff --git a/.github/workflows/nightly-ci.yml b/.github/workflows/nightly-ci.yml index 11c91af2d7b..886aed44751 100644 --- a/.github/workflows/nightly-ci.yml +++ b/.github/workflows/nightly-ci.yml @@ -141,7 +141,7 @@ jobs: if: matrix.os == 'macos-13' run: brew install apache-arrow - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Setup Redis Cluster run: | docker pull vishnunair/docker-redis-cluster:latest @@ -154,4 +154,4 @@ jobs: SNOWFLAKE_CI_PASSWORD: ${{ secrets.SNOWFLAKE_CI_PASSWORD }} SNOWFLAKE_CI_ROLE: ${{ secrets.SNOWFLAKE_CI_ROLE }} SNOWFLAKE_CI_WAREHOUSE: ${{ secrets.SNOWFLAKE_CI_WAREHOUSE }} - run: make test-python-integration \ No newline at end of file + run: make test-python-integration diff --git a/.github/workflows/operator-e2e-integration-tests.yml b/.github/workflows/operator-e2e-integration-tests.yml new file mode 100644 index 00000000000..c23e8095bf7 --- /dev/null +++ b/.github/workflows/operator-e2e-integration-tests.yml @@ -0,0 +1,106 @@ +# .github/workflows/operator-e2e-integration-tests.yml +name: Operator e2e tests + +on: + push: + branches: + - main + pull_request: + types: + - opened + - synchronize + - labeled + paths-ignore: + - 'community/**' + - 'docs/**' + - 'examples/**' + +jobs: + operator-e2e-tests: + timeout-minutes: 40 + if: + ((github.event.action == 'labeled' && (github.event.label.name == 'approved' || github.event.label.name == 'lgtm' || github.event.label.name == 'ok-to-test')) || + (github.event.action != 'labeled' && (contains(github.event.pull_request.labels.*.name, 'ok-to-test') || contains(github.event.pull_request.labels.*.name, 'approved') || contains(github.event.pull_request.labels.*.name, 'lgtm')))) && + github.repository == 'feast-dev/feast' + runs-on: ubuntu-latest + + services: + kind: + # Specify the Kubernetes version + image: kindest/node:v1.30.6 + + env: + KIND_CLUSTER: "operator-e2e-cluster" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@v1.3.1 + with: + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: false + swap-storage: false + tool-cache: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.22.9 + + - name: Create KIND cluster + run: | + cat <> $GITHUB_OUTPUT - name: uv cache uses: actions/cache@v4 with: path: ${{ steps.uv-cache.outputs.dir }} - key: ${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', env.PYTHON)) }} + key: ${{ runner.os }}-${{ matrix.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', matrix.python-version)) }} - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Test local integration tests if: ${{ always() }} # this will guarantee that step won't be canceled and resources won't leak run: make test-python-integration-local + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: 1.22.9 + - name: Operator Data Source types test + run: make -C infra/feast-operator test-datasources diff --git a/.github/workflows/pr_remote_rbac_integration_tests.yml b/.github/workflows/pr_remote_rbac_integration_tests.yml new file mode 100644 index 00000000000..98fa5a52c58 --- /dev/null +++ b/.github/workflows/pr_remote_rbac_integration_tests.yml @@ -0,0 +1,58 @@ +name: pr-remote-rbac-integration-tests +# This runs the integration tests related to rbac functionality and remote registry and online features. + +on: + pull_request: + types: + - opened + - synchronize + - labeled + paths-ignore: + - 'community/**' + - 'docs/**' + - 'examples/**' + +jobs: + remote-rbac-integration-tests-python: + if: + ((github.event.action == 'labeled' && (github.event.label.name == 'approved' || github.event.label.name == 'lgtm' || github.event.label.name == 'ok-to-test')) || + (github.event.action != 'labeled' && (contains(github.event.pull_request.labels.*.name, 'ok-to-test') || contains(github.event.pull_request.labels.*.name, 'approved') || contains(github.event.pull_request.labels.*.name, 'lgtm')))) && + github.event.pull_request.base.repo.full_name == 'feast-dev/feast' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: [ "3.11" ] + os: [ ubuntu-latest ] + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ github.event.repository.full_name }} # Uses the full repository name + ref: ${{ github.ref }} # Uses the ref from the event + token: ${{ secrets.GITHUB_TOKEN }} # Automatically provided token + submodules: recursive + - name: Setup Python + uses: actions/setup-python@v5 + id: setup-python + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Get uv cache dir + id: uv-cache + run: | + echo "dir=$(uv cache dir)" >> $GITHUB_OUTPUT + - name: uv cache + uses: actions/cache@v4 + with: + path: ${{ steps.uv-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', matrix.python-version)) }} + - name: Install dependencies + run: make install-python-dependencies-ci + - name: Test rbac and remote feature integration tests + if: ${{ always() }} # this will guarantee that step won't be canceled and resources won't leak + run: make test-python-integration-rbac-remote diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7d5dca8e08b..3526ef4cd7b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,179 +4,49 @@ on: push: tags: - 'v*.*.*' + workflow_dispatch: # Allows manual trigger of the workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + workflow_call: # Allows trigger the workflow from other workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string jobs: - get-version: - if: github.repository == 'feast-dev/feast' - runs-on: ubuntu-latest - outputs: - release_version: ${{ steps.get_release_version.outputs.release_version }} - version_without_prefix: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} - highest_semver_tag: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} - steps: - - uses: actions/checkout@v4 - - name: Get release version - id: get_release_version - run: echo ::set-output name=release_version::${GITHUB_REF#refs/*/} - - name: Get release version without prefix - id: get_release_version_without_prefix - env: - RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} - run: | - echo ::set-output name=version_without_prefix::${RELEASE_VERSION:1} - - name: Get highest semver - id: get_highest_semver - env: - RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} - run: | - source infra/scripts/setup-common-functions.sh - SEMVER_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' - if echo "${RELEASE_VERSION}" | grep -P "$SEMVER_REGEX" &>/dev/null ; then - echo ::set-output name=highest_semver_tag::$(get_tag_release -m) - fi - - name: Check output - env: - RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} - VERSION_WITHOUT_PREFIX: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} - HIGHEST_SEMVER_TAG: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} - run: | - echo $RELEASE_VERSION - echo $VERSION_WITHOUT_PREFIX - echo $HIGHEST_SEMVER_TAG + publish-python-sdk: + uses: ./.github/workflows/publish_python_sdk.yml + secrets: inherit + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} build-publish-docker-images: - runs-on: ubuntu-latest - needs: [get-version, publish-python-sdk] - strategy: - matrix: - component: [feature-server, feature-server-java, feature-transformation-server, feast-helm-operator, feast-operator] - env: - MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar - REGISTRY: feastdev - steps: - - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Authenticate to Google Cloud - uses: 'google-github-actions/auth@v1' - with: - credentials_json: '${{ secrets.GCP_SA_KEY }}' - - name: Set up gcloud SDK - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: ${{ secrets.GCP_PROJECT_ID }} - - name: Use gcloud CLI - run: gcloud info - - run: gcloud auth configure-docker --quiet - - name: Build image - run: | - make build-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${VERSION_WITHOUT_PREFIX} - env: - RELEASE_VERSION: ${{ needs.get-version.outputs.release_version }} - VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} - HIGHEST_SEMVER_TAG: ${{ needs.get-version.outputs.highest_semver_tag }} - - name: Push versioned images - env: - RELEASE_VERSION: ${{ needs.get-version.outputs.release_version }} - VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} - HIGHEST_SEMVER_TAG: ${{ needs.get-version.outputs.highest_semver_tag }} - run: | - make push-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${VERSION_WITHOUT_PREFIX} - - echo "Only push to latest tag if tag is the highest semver version $HIGHEST_SEMVER_TAG" - if [ "${VERSION_WITHOUT_PREFIX}" = "${HIGHEST_SEMVER_TAG:1}" ] - then - docker tag feastdev/${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} feastdev/${{ matrix.component }}:latest - docker push feastdev/${{ matrix.component }}:latest - fi + uses: ./.github/workflows/publish_images.yml + needs: [ publish-python-sdk ] + secrets: inherit + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} publish-helm-charts: - if: github.repository == 'feast-dev/feast' - runs-on: ubuntu-latest - needs: get-version - env: - HELM_VERSION: v3.8.0 - VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} - steps: - - uses: actions/checkout@v4 - - name: Authenticate to Google Cloud - uses: 'google-github-actions/auth@v1' - with: - credentials_json: '${{ secrets.GCP_SA_KEY }}' - - name: Set up gcloud SDK - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: ${{ secrets.GCP_PROJECT_ID }} - - run: gcloud auth configure-docker --quiet - - name: Remove previous Helm - run: sudo rm -rf $(which helm) - - name: Install Helm - run: ./infra/scripts/helm/install-helm.sh - - name: Validate Helm chart prior to publishing - run: ./infra/scripts/helm/validate-helm-chart-publish.sh - - name: Validate all version consistency - run: ./infra/scripts/helm/validate-helm-chart-versions.sh $VERSION_WITHOUT_PREFIX - - name: Publish Helm charts - run: ./infra/scripts/helm/push-helm-charts.sh $VERSION_WITHOUT_PREFIX - - build_wheels: - uses: ./.github/workflows/build_wheels.yml - - publish-python-sdk: - if: github.repository == 'feast-dev/feast' - runs-on: ubuntu-latest - needs: [build_wheels] - steps: - - uses: actions/download-artifact@v4.1.7 - with: - name: wheels - path: dist - - uses: pypa/gh-action-pypi-publish@v1.4.2 - with: - user: __token__ - password: ${{ secrets.PYPI_PASSWORD }} - - publish-java-sdk: - if: github.repository == 'feast-dev/feast' - container: maven:3.6-jdk-11 - runs-on: ubuntu-latest - needs: get-version - steps: - - uses: actions/checkout@v4 - with: - submodules: 'true' - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: '11' - java-package: jdk - architecture: x64 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - architecture: 'x64' - - uses: actions/cache@v2 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-it-maven- - - name: Publish java sdk - env: - VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} - GPG_PUBLIC_KEY: ${{ secrets.GPG_PUBLIC_KEY }} - GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} - MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS }} - run: | - echo -n "$GPG_PUBLIC_KEY" > /root/public-key - echo -n "$GPG_PRIVATE_KEY" > /root/private-key - mkdir -p /root/.m2/ - echo -n "$MAVEN_SETTINGS" > /root/.m2/settings.xml - infra/scripts/publish-java-sdk.sh --revision ${VERSION_WITHOUT_PREFIX} --gpg-key-import-dir /root + uses: ./.github/workflows/publish_helm_charts.yml + needs: [ publish-python-sdk ] + secrets: inherit + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} diff --git a/.github/workflows/publish_helm_charts.yml b/.github/workflows/publish_helm_charts.yml new file mode 100644 index 00000000000..9d28e2efd2f --- /dev/null +++ b/.github/workflows/publish_helm_charts.yml @@ -0,0 +1,65 @@ +name: publish images + +on: + workflow_dispatch: # Allows manual trigger of the workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + workflow_call: # Allows trigger of the workflow from another workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + +jobs: + publish-helm-charts: + if: github.repository == 'feast-dev/feast' + runs-on: ubuntu-latest + env: + HELM_VERSION: v3.8.0 + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + - id: get-version + uses: ./.github/actions/get-semantic-release-version + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} + - name: Authenticate to Google Cloud + uses: 'google-github-actions/auth@v1' + with: + credentials_json: '${{ secrets.GCP_SA_KEY }}' + - name: Set up gcloud SDK + uses: google-github-actions/setup-gcloud@v1 + with: + project_id: ${{ secrets.GCP_PROJECT_ID }} + - run: gcloud auth configure-docker --quiet + - name: Remove previous Helm + run: sudo rm -rf $(which helm) + - name: Install Helm + run: ./infra/scripts/helm/install-helm.sh + - name: Validate Helm chart prior to publishing + run: ./infra/scripts/helm/validate-helm-chart-publish.sh + - name: Validate all version consistency + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} + run: ./infra/scripts/helm/validate-helm-chart-versions.sh $VERSION_WITHOUT_PREFIX + - name: Publish Helm charts + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} + run: ./infra/scripts/helm/push-helm-charts.sh $VERSION_WITHOUT_PREFIX + diff --git a/.github/workflows/publish_images.yml b/.github/workflows/publish_images.yml new file mode 100644 index 00000000000..f605fb20df1 --- /dev/null +++ b/.github/workflows/publish_images.yml @@ -0,0 +1,82 @@ +name: build and publish docker images + +on: + workflow_dispatch: # Allows manual trigger of the workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + workflow_call: # Allows trigger of the workflow from another workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + +jobs: + build-publish-docker-images: + if: github.repository == 'feast-dev/feast' + runs-on: ubuntu-latest + strategy: + matrix: + component: [ feature-server, feature-server-java, feature-transformation-server, feast-helm-operator, feast-operator ] + env: + MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar + REGISTRY: feastdev + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + - id: get-version + uses: ./.github/actions/get-semantic-release-version + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Authenticate to Google Cloud + uses: 'google-github-actions/auth@v1' + with: + credentials_json: '${{ secrets.GCP_SA_KEY }}' + - name: Set up gcloud SDK + uses: google-github-actions/setup-gcloud@v1 + with: + project_id: ${{ secrets.GCP_PROJECT_ID }} + - name: Use gcloud CLI + run: gcloud info + - run: gcloud auth configure-docker --quiet + - name: Build image + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} + run: | + make build-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${VERSION_WITHOUT_PREFIX} + - name: Push versioned images + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} + HIGHEST_SEMVER_TAG: ${{ steps.get-version.outputs.highest_semver_tag }} + run: | + make push-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${VERSION_WITHOUT_PREFIX} + + echo "Only push to latest tag if tag is the highest semver version $HIGHEST_SEMVER_TAG" + if [ "${VERSION_WITHOUT_PREFIX}" = "${HIGHEST_SEMVER_TAG:1}" ] + then + docker tag feastdev/${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} feastdev/${{ matrix.component }}:latest + docker push feastdev/${{ matrix.component }}:latest + fi diff --git a/.github/workflows/publish_java_sdk.yml b/.github/workflows/publish_java_sdk.yml new file mode 100644 index 00000000000..f89c384b126 --- /dev/null +++ b/.github/workflows/publish_java_sdk.yml @@ -0,0 +1,69 @@ +name: publish java sdk + +on: + workflow_dispatch: # Allows manual trigger of the workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + workflow_call: # Allows trigger of the workflow from another workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + + +jobs: + publish-java-sdk: + if: github.repository == 'feast-dev/feast' + container: maven:3.6-jdk-11 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'true' + - id: get-version + uses: ./.github/actions/get-semantic-release-version + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk + architecture: x64 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + architecture: 'x64' + - uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-maven- + - name: Publish java sdk + env: + VERSION_WITHOUT_PREFIX: ${{ steps.get-version.outputs.version_without_prefix }} + GPG_PUBLIC_KEY: ${{ secrets.GPG_PUBLIC_KEY }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS }} + run: | + echo -n "$GPG_PUBLIC_KEY" > /root/public-key + echo -n "$GPG_PRIVATE_KEY" > /root/private-key + mkdir -p /root/.m2/ + echo -n "$MAVEN_SETTINGS" > /root/.m2/settings.xml + infra/scripts/publish-java-sdk.sh --revision ${VERSION_WITHOUT_PREFIX} --gpg-key-import-dir /root diff --git a/.github/workflows/publish_python_sdk.yml b/.github/workflows/publish_python_sdk.yml new file mode 100644 index 00000000000..03d0e989b49 --- /dev/null +++ b/.github/workflows/publish_python_sdk.yml @@ -0,0 +1,49 @@ +name: publish python sdk + +on: + workflow_dispatch: # Allows manual trigger of the workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + + workflow_call: # Allows trigger of the workflow from another workflow + inputs: + custom_version: # Optional input for a custom version + description: 'Custom version to publish (e.g., v1.2.3) -- only edit if you know what you are doing' + required: false + type: string + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + +jobs: + build-wheels: + uses: ./.github/workflows/build_wheels.yml + secrets: inherit + with: + custom_version: ${{ github.event.inputs.custom_version }} + token: ${{ github.event.inputs.token }} + + publish-python-sdk: + if: github.repository == 'feast-dev/feast' + runs-on: ubuntu-latest + needs: [ build-wheels ] + steps: + - uses: actions/download-artifact@v4.1.7 + with: + name: python-wheels + path: dist + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@v1.4.2 + with: + user: __token__ + password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b0eccd9a92..1ae75382905 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,27 @@ on: required: true default: true type: boolean + workflow_call: + inputs: + dry_run: + description: 'Dry Run' + required: true + default: true + type: boolean + token: + description: 'Personal Access Token' + required: true + default: "" + type: string + publish_ui: + description: 'Publish to NPM?' + required: true + default: true + type: boolean jobs: - get_dry_release_versions: + if: github.repository == 'feast-dev/feast' runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ github.event.inputs.token }} @@ -36,7 +53,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version-file: './ui/.nvmrc' + node-version: "lts/*" - name: Release (Dry Run) id: get_versions run: | @@ -62,7 +79,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version-file: './ui/.nvmrc' + node-version: "lts/*" - name: Bump file versions run: python ./infra/scripts/release/bump_file_versions.py ${CURRENT_VERSION} ${NEXT_VERSION} - name: Install yarn dependencies @@ -91,14 +108,12 @@ jobs: - name: Install Go uses: actions/setup-go@v2 with: - go-version: 1.21.x + go-version: 1.22.9 - name: Build & version operator-specific release files - run: | - cd infra/feast-operator/ - make build-installer bundle + run: make -C infra/feast-operator build-installer bundle publish-web-ui-npm: - needs: [validate_version_bumps, get_dry_release_versions] + needs: [ validate_version_bumps, get_dry_release_versions ] runs-on: ubuntu-latest env: # This publish is working using an NPM automation token to bypass 2FA @@ -121,7 +136,7 @@ jobs: run: yarn build:lib - name: Publish UI package working-directory: ./ui - if: github.event.inputs.dry_run == 'false' && github.event.inputs.publish_ui == 'true' + if: github.event.inputs.dry_run == 'false' && github.event.inputs.publish_ui == 'true' run: npm publish env: # This publish is working using an NPM automation token to bypass 2FA @@ -138,25 +153,62 @@ jobs: GIT_COMMITTER_NAME: feast-ci-bot GIT_COMMITTER_EMAIL: feast-ci-bot@willem.co steps: - - name: Checkout - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version-file: './ui/.nvmrc' - - name: Set up Homebrew - id: set-up-homebrew - uses: Homebrew/actions/setup-homebrew@master - - name: Setup Helm-docs - run: | - brew install norwoodj/tap/helm-docs - - name: Release (Dry Run) - if: github.event.inputs.dry_run == 'true' - run: | - npx -p @semantic-release/changelog -p @semantic-release/git -p @semantic-release/exec -p semantic-release semantic-release --dry-run - - name: Release - if: github.event.inputs.dry_run == 'false' - run: | - npx -p @semantic-release/changelog -p @semantic-release/git -p @semantic-release/exec -p semantic-release semantic-release + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version-file: './ui/.nvmrc' + - name: Set up Homebrew + id: set-up-homebrew + uses: Homebrew/actions/setup-homebrew@master + - name: Setup Helm-docs + run: | + brew install norwoodj/tap/helm-docs + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: 1.22.9 + - name: Release (Dry Run) + if: github.event.inputs.dry_run == 'true' + run: | + npx -p @semantic-release/changelog -p @semantic-release/git -p @semantic-release/exec -p semantic-release semantic-release --dry-run + - name: Release + if: github.event.inputs.dry_run == 'false' + run: | + npx -p @semantic-release/changelog -p @semantic-release/git -p @semantic-release/exec -p semantic-release semantic-release + + + update_stable_branch: + name: Update Stable Branch after release + if: github.event.inputs.dry_run == 'false' + runs-on: ubuntu-latest + needs: release + env: + GITHUB_TOKEN: ${{ github.event.inputs.token }} + GIT_AUTHOR_NAME: feast-ci-bot + GIT_AUTHOR_EMAIL: feast-ci-bot@willem.co + GIT_COMMITTER_NAME: feast-ci-bot + GIT_COMMITTER_EMAIL: feast-ci-bot@willem.co + GITHUB_REPOSITORY: ${{ github.repository }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Git credentials + run: | + git config --global user.name "$GIT_AUTHOR_NAME" + git config --global user.email "$GIT_AUTHOR_EMAIL" + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY} + + - name: Fetch all branches + run: git fetch --all + + - name: Reset stable branch to match release branch + run: | + git checkout -B stable origin/${GITHUB_REF#refs/heads/} + git push origin stable --force \ No newline at end of file diff --git a/.github/workflows/smoke_tests.yml b/.github/workflows/smoke_tests.yml index 782f8b3f511..a7eb1966269 100644 --- a/.github/workflows/smoke_tests.yml +++ b/.github/workflows/smoke_tests.yml @@ -1,6 +1,11 @@ name: smoke-tests -on: [pull_request] +on: + pull_request: + paths-ignore: + - 'community/**' + - 'docs/**' + - 'examples/**' jobs: unit-test-python: runs-on: ${{ matrix.os }} @@ -26,13 +31,15 @@ jobs: - name: Get uv cache dir id: uv-cache run: | - echo "::set-output name=dir::$(uv cache dir)" + echo "dir=$(uv cache dir)" >> $GITHUB_OUTPUT - name: uv cache uses: actions/cache@v4 with: path: ${{ steps.uv-cache.outputs.dir }} key: ${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', env.PYTHON)) }} - name: Install dependencies - run: make install-python-dependencies-uv + run: | + uv pip sync --system sdk/python/requirements/py${{ matrix.python-version }}-requirements.txt + uv pip install --system --no-deps . - name: Test Imports - run: python -c "from feast import cli" \ No newline at end of file + run: python -c "from feast import cli" diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index af23c8d808c..3ece863de3b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -1,6 +1,11 @@ name: unit-tests -on: [pull_request] +on: + pull_request: + paths-ignore: + - 'community/**' + - 'docs/**' + - 'examples/**' jobs: unit-test-python: runs-on: ${{ matrix.os }} @@ -8,10 +13,15 @@ jobs: fail-fast: false matrix: python-version: [ "3.9", "3.10", "3.11"] - os: [ ubuntu-latest, macos-13 ] + os: [ ubuntu-latest, macos-13, macos-14 ] exclude: - os: macos-13 python-version: "3.9" + - os: macos-14 + python-version: "3.9" + - os: macos-14 + python-version: "3.10" + env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python-version }} @@ -28,15 +38,15 @@ jobs: curl -LsSf https://astral.sh/uv/install.sh | sh - name: Get uv cache dir id: uv-cache - run: | - echo "::set-output name=dir::$(uv cache dir)" + run: | + echo "dir=$(uv cache dir)" >> $GITHUB_OUTPUT - name: uv cache uses: actions/cache@v4 with: path: ${{ steps.uv-cache.outputs.dir }} key: ${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-uv-${{ hashFiles(format('**/py{0}-ci-requirements.txt', env.PYTHON)) }} - name: Install dependencies - run: make install-python-ci-dependencies-uv + run: make install-python-dependencies-ci - name: Test Python run: make test-python-unit @@ -47,7 +57,7 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version-file: './ui/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/update_stable_branch.yml b/.github/workflows/update_stable_branch.yml new file mode 100644 index 00000000000..b09af9cc2eb --- /dev/null +++ b/.github/workflows/update_stable_branch.yml @@ -0,0 +1,40 @@ +name: Update Stable Branch + +on: + workflow_dispatch: + inputs: + token: + description: 'GitHub token to authenticate' + required: true + type: string + +jobs: + update_stable_branch: + name: Update Stable Branch after release + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ github.event.inputs.token }} + GIT_AUTHOR_NAME: feast-ci-bot + GIT_AUTHOR_EMAIL: feast-ci-bot@willem.co + GIT_COMMITTER_NAME: feast-ci-bot + GIT_COMMITTER_EMAIL: feast-ci-bot@willem.co + GITHUB_REPOSITORY: ${{ github.repository }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Git credentials + run: | + git config --global user.name "$GIT_AUTHOR_NAME" + git config --global user.email "$GIT_AUTHOR_EMAIL" + git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY} + + - name: Fetch all branches + run: git fetch --all + + - name: Reset stable branch to match release branch + run: | + git checkout -B stable origin/${GITHUB_REF#refs/heads/} + git push origin stable --force diff --git a/.gitignore b/.gitignore index d558463c657..e33fb46cb07 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ scratch* ### Local Environment ### *local*.env +tools ### Secret ### **/service_account.json @@ -101,6 +102,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage.out *.cover .hypothesis/ .pytest_cache/ @@ -222,4 +224,7 @@ ui/.vercel **/yarn-error.log* # Go subprocess binaries (built during feast pip package building) -sdk/python/feast/binaries/ \ No newline at end of file +sdk/python/feast/binaries/ + +# ignore the bin directory under feast operator. +infra/feast-operator/bin \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml index 480baefede4..6e0c28da94d 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -8,7 +8,7 @@ tasks: uv pip install pre-commit pre-commit install --hook-type pre-commit --hook-type pre-push source .venv/bin/activate - export PYTHON=3.10 && make install-python-ci-dependencies-uv-venv + export PYTHON=3.10 && make install-python-dependencies-dev # git config --global alias.ci 'commit -s' # git config --global alias.sw switch # git config --global alias.st status diff --git a/.releaserc.js b/.releaserc.js index f2be2440057..61c6813442d 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -41,7 +41,7 @@ module.exports = { "verifyReleaseCmd": "./infra/scripts/validate-release.sh ${nextRelease.type} " + current_branch, // Bump all version files and build UI / update yarn.lock / helm charts - "prepareCmd": "python ./infra/scripts/release/bump_file_versions.py ${lastRelease.version} ${nextRelease.version}; make build-ui; make build-helm-docs" + "prepareCmd": "python ./infra/scripts/release/bump_file_versions.py ${lastRelease.version} ${nextRelease.version}; make build-ui; make build-helm-docs; make -C infra/feast-operator build-installer bundle; rm -rf infra/feast-operator/bin" }], ["@semantic-release/release-notes-generator", { diff --git a/CHANGELOG.md b/CHANGELOG.md index 8368cf67185..053edc3df50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,178 @@ # Changelog +# [0.46.0](https://github.com/feast-dev/feast/compare/v0.45.0...v0.46.0) (2025-02-17) + + +### Bug Fixes + +* Add scylladb to online stores list in docs ([#5061](https://github.com/feast-dev/feast/issues/5061)) ([08183ed](https://github.com/feast-dev/feast/commit/08183ed38581eb655e2f6055c50b9223fcf9662e)) +* Changed feast operator to set status of featurestore cr to ready based on deployment.status = available ([#5020](https://github.com/feast-dev/feast/issues/5020)) ([fce0d35](https://github.com/feast-dev/feast/commit/fce0d35bc00553269fff6abb7a16897577a2421f)) +* Ensure Postgres queries are committed or autocommit is used ([#5039](https://github.com/feast-dev/feast/issues/5039)) ([46f8d7a](https://github.com/feast-dev/feast/commit/46f8d7aa87cfaf36d17c162c4f41cd983a2938d5)) +* Fixing the release workflow to refresh the stable branch when the release is not running in the dry run mode. ([#5057](https://github.com/feast-dev/feast/issues/5057)) ([a13fa9b](https://github.com/feast-dev/feast/commit/a13fa9bd18be94b349954e5db66fd30ba4db1d1e)) +* Operator - make onlineStore the default service ([#5044](https://github.com/feast-dev/feast/issues/5044)) ([6c92447](https://github.com/feast-dev/feast/commit/6c92447d1507bff02451f77f134df0a24cbd8036)) +* Operator - resolve infinite reconciler loop in authz controller ([#5056](https://github.com/feast-dev/feast/issues/5056)) ([11e4548](https://github.com/feast-dev/feast/commit/11e45482b0cace1f3c3a0ddc567a8a1172d6792a)) +* Resolve module on windows ([#4827](https://github.com/feast-dev/feast/issues/4827)) ([efbffa4](https://github.com/feast-dev/feast/commit/efbffa4be0f38166ff35f133a9b69bcbd243debd)) +* Setting the github_token explicitly to see if that solves the problem. ([#5012](https://github.com/feast-dev/feast/issues/5012)) ([3834ffa](https://github.com/feast-dev/feast/commit/3834ffa31f52b9a68b27a9f898538827ee8e5c39)) +* Validate entities when running get_online_features ([#5031](https://github.com/feast-dev/feast/issues/5031)) ([3bb0dca](https://github.com/feast-dev/feast/commit/3bb0dca1692fb7087e967a9fc33a4b08720b13d2)) + + +### Features + +* Add SQLite retrieve_online_documents_v2 ([#5032](https://github.com/feast-dev/feast/issues/5032)) ([0fffe21](https://github.com/feast-dev/feast/commit/0fffe211be9db18d318634f47bc9401fd6e218a0)) +* Adding Click command to display configuration details ([#5036](https://github.com/feast-dev/feast/issues/5036)) ([ae68e4d](https://github.com/feast-dev/feast/commit/ae68e4de0c184dc2990ea7e8d08d2d7f1613b06f)) +* Adding volumes and volumeMounts support to Feature Store CR. ([#4983](https://github.com/feast-dev/feast/issues/4983)) ([ec6f1b7](https://github.com/feast-dev/feast/commit/ec6f1b750ed49ef36c5e3aa9f8db1d030bf80047)) +* Moving the job to seperate action so that we can test it easily. ([#5013](https://github.com/feast-dev/feast/issues/5013)) ([b9325b7](https://github.com/feast-dev/feast/commit/b9325b7f42b8866fa43b1c7567e3288dd589020f)) +* Operator - make server container creation explicit in the CR ([#5024](https://github.com/feast-dev/feast/issues/5024)) ([b16fb40](https://github.com/feast-dev/feast/commit/b16fb400fd63fdc0168cb1f845638fc003724fd4)) + +# [0.45.0](https://github.com/feast-dev/feast/compare/v0.44.0...v0.45.0) (2025-02-04) + + +### Features + +* Changing refresh stable branch from step to a job. Using github credentials bot so that we can push the changes. ([#5011](https://github.com/feast-dev/feast/issues/5011)) ([7335e26](https://github.com/feast-dev/feast/commit/7335e266455561ebcb5ce8e318a79661e509a1c2)) + +# [0.44.0](https://github.com/feast-dev/feast/compare/v0.43.0...v0.44.0) (2025-02-04) + + +### Bug Fixes + +* Adding periodic check to fix the sporadic failures of the operator e2e tests. ([#4952](https://github.com/feast-dev/feast/issues/4952)) ([1d086be](https://github.com/feast-dev/feast/commit/1d086beb9f9726f68ababace87c58c2cc6412ca3)) +* Adding the feast-operator/bin to the .gitignore directory. Somehow it… ([#5005](https://github.com/feast-dev/feast/issues/5005)) ([1a027ee](https://github.com/feast-dev/feast/commit/1a027eec3dc38ce8a949aca842c91742b0f68b47)) +* Changed Env Vars for e2e tests ([#4975](https://github.com/feast-dev/feast/issues/4975)) ([fa0084f](https://github.com/feast-dev/feast/commit/fa0084f2ed0e9d41ff813538ee63dd4ee7371e6c)) +* Fix GitHub Actions to pass authentication ([#4963](https://github.com/feast-dev/feast/issues/4963)) ([22b9138](https://github.com/feast-dev/feast/commit/22b9138a3c0040f5779f7218522f2d96e750fbbf)), closes [#4937](https://github.com/feast-dev/feast/issues/4937) [#4939](https://github.com/feast-dev/feast/issues/4939) [#4941](https://github.com/feast-dev/feast/issues/4941) [#4940](https://github.com/feast-dev/feast/issues/4940) [#4943](https://github.com/feast-dev/feast/issues/4943) [#4944](https://github.com/feast-dev/feast/issues/4944) [#4945](https://github.com/feast-dev/feast/issues/4945) [#4946](https://github.com/feast-dev/feast/issues/4946) [#4947](https://github.com/feast-dev/feast/issues/4947) [#4948](https://github.com/feast-dev/feast/issues/4948) [#4951](https://github.com/feast-dev/feast/issues/4951) [#4954](https://github.com/feast-dev/feast/issues/4954) [#4957](https://github.com/feast-dev/feast/issues/4957) [#4958](https://github.com/feast-dev/feast/issues/4958) [#4959](https://github.com/feast-dev/feast/issues/4959) [#4960](https://github.com/feast-dev/feast/issues/4960) [#4962](https://github.com/feast-dev/feast/issues/4962) +* Fix showing selected navigation item in UI sidebar ([#4969](https://github.com/feast-dev/feast/issues/4969)) ([8ac6a85](https://github.com/feast-dev/feast/commit/8ac6a8547361708fec00a11a33c48ca3ae25f311)) +* Invalid column names in get_historical_features when there are field mappings on join keys ([#4886](https://github.com/feast-dev/feast/issues/4886)) ([c9aca2d](https://github.com/feast-dev/feast/commit/c9aca2d42254d1c4dfcc778b0d90303329901bd0)) +* Read project data from the 'projects' key while loading the registry state in the Feast UI ([#4772](https://github.com/feast-dev/feast/issues/4772)) ([cb81939](https://github.com/feast-dev/feast/commit/cb8193945932b98d5b8f750ac07d58c034870565)) +* Remove grpcurl dependency from Operator ([#4972](https://github.com/feast-dev/feast/issues/4972)) ([439e0b9](https://github.com/feast-dev/feast/commit/439e0b98819ef222b35617dfd6c97f04ca049f2f)) +* Removed the dry-run flag to test and we will add it back later. ([#5007](https://github.com/feast-dev/feast/issues/5007)) ([d112b52](https://github.com/feast-dev/feast/commit/d112b529d618f19a5602039b6d347915d7e75b88)) +* Render UI navigation items as links instead of buttons ([#4970](https://github.com/feast-dev/feast/issues/4970)) ([1267703](https://github.com/feast-dev/feast/commit/1267703d099491393ca212c38f1a63a36fe6c443)) +* Resolve Operator CRD bloat due to long field descriptions ([#4985](https://github.com/feast-dev/feast/issues/4985)) ([7593bb3](https://github.com/feast-dev/feast/commit/7593bb3ec8871dbb83403461e0b6f6863d64abc6)) +* Update manifest to add feature server image for odh ([#4973](https://github.com/feast-dev/feast/issues/4973)) ([6a1c102](https://github.com/feast-dev/feast/commit/6a1c1029b5462aaa42c82fdad421176ad1692f81)) +* Updating release workflows to refer to yml instead of yaml ([#4935](https://github.com/feast-dev/feast/issues/4935)) ([02b0a68](https://github.com/feast-dev/feast/commit/02b0a68a435ab01f26b20824f3f8a4dd4e21da8d)) +* Use locally built feast-ui package in dev feature-server image ([#4998](https://github.com/feast-dev/feast/issues/4998)) ([0145e55](https://github.com/feast-dev/feast/commit/0145e5501e2c7854628d204cb515270fac3bee7d)) + + +### Features + +* Added OWNERS file for OpenshiftCI ([#4991](https://github.com/feast-dev/feast/issues/4991)) ([86a2ee8](https://github.com/feast-dev/feast/commit/86a2ee8e3ce1cd4432749928fda7a4386dc7ce0f)) +* Adding Milvus demo to examples ([#4910](https://github.com/feast-dev/feast/issues/4910)) ([2daf852](https://github.com/feast-dev/feast/commit/2daf8527c4539a007d639ac6e3061767a9c45110)) +* Adding retrieve_online_documents endpoint ([#5002](https://github.com/feast-dev/feast/issues/5002)) ([6607d3d](https://github.com/feast-dev/feast/commit/6607d3dfa1041638d3896b25cb98677412889724)) +* Adding support to return additional features from vector retrieval for Milvus db ([#4971](https://github.com/feast-dev/feast/issues/4971)) ([6ce08d3](https://github.com/feast-dev/feast/commit/6ce08d31863b12a7a92bf5207172a05f8da077d1)) +* Creating/updating the stable branch after the release. ([#5003](https://github.com/feast-dev/feast/issues/5003)) ([e9b53cc](https://github.com/feast-dev/feast/commit/e9b53cc83ee51b906423ec2e1fac36e159d55db2)) +* Implementing online_read for MilvusOnlineStore ([#4996](https://github.com/feast-dev/feast/issues/4996)) ([92dde13](https://github.com/feast-dev/feast/commit/92dde1311c419dc3d8cbb534ed2e706fdeae1e26)) +* Improve exception message for unsupported Snowflake data types ([#4779](https://github.com/feast-dev/feast/issues/4779)) ([5992364](https://github.com/feast-dev/feast/commit/59923645e4f6a64a49bcecb7da503528af850d0f)) +* Operator add feast ui deployment ([#4930](https://github.com/feast-dev/feast/issues/4930)) ([b026d0c](https://github.com/feast-dev/feast/commit/b026d0ce30d7ce9b621679fbb33f2a9c0edaad84)) +* Updating documents to highlight v2 api for Vector Similarity Se… ([#5000](https://github.com/feast-dev/feast/issues/5000)) ([32b82a4](https://github.com/feast-dev/feast/commit/32b82a4b59bceaf9eb6662f35e77d0cae0d36550)) + +# [0.43.0](https://github.com/feast-dev/feast/compare/v0.42.0...v0.43.0) (2025-01-20) + + +### Bug Fixes + +* Add k8s module to feature-server image ([#4839](https://github.com/feast-dev/feast/issues/4839)) ([f565565](https://github.com/feast-dev/feast/commit/f565565e0132ea5170221dc6af2e93a5dc3e750d)) +* Adding input to workflow ([e3e8c97](https://github.com/feast-dev/feast/commit/e3e8c975b4b9891913d0be8d50df909d4d243191)) +* Change image push to use --all-tags option ([#4926](https://github.com/feast-dev/feast/issues/4926)) ([02458fd](https://github.com/feast-dev/feast/commit/02458fd7aad49d5daa5b9836f5abdc4dd81d07bb)) +* Fix integration build/push for images ([#4923](https://github.com/feast-dev/feast/issues/4923)) ([695e49b](https://github.com/feast-dev/feast/commit/695e49bd93a4c8af2ce5839586295b5e74e1b98e)) +* Fix integration operator push ([#4924](https://github.com/feast-dev/feast/issues/4924)) ([13c7267](https://github.com/feast-dev/feast/commit/13c7267b555cca4f3361f34fb384a6fd9f27dedf)) +* Fix release.yml ([#4845](https://github.com/feast-dev/feast/issues/4845)) ([b4768a8](https://github.com/feast-dev/feast/commit/b4768a81b94352de037dc305df309fcf06fd2973)) +* Fixing some of the warnings with the github actions ([#4763](https://github.com/feast-dev/feast/issues/4763)) ([1119439](https://github.com/feast-dev/feast/commit/1119439c49bc90e62f02da078901509c1d740236)) +* Improve status.applied updates & add offline pvc unit test ([#4871](https://github.com/feast-dev/feast/issues/4871)) ([3f49517](https://github.com/feast-dev/feast/commit/3f49517dfeabea5ffbd3f6b589cc0f2280ee4018)) +* Made fixes to Go Operator DB persistence ([#4830](https://github.com/feast-dev/feast/issues/4830)) ([cdc0753](https://github.com/feast-dev/feast/commit/cdc075360242bfdf3812d394a3c9c550f81b0f98)) +* Make transformation_service_endpoint configuration optional ([#4880](https://github.com/feast-dev/feast/issues/4880)) ([c62377b](https://github.com/feast-dev/feast/commit/c62377bc095a83022d13e5a8a3a9413d7e0f3e2c)) +* Move pre-release image builds to quay.io, retire gcr.io pushes ([#4922](https://github.com/feast-dev/feast/issues/4922)) ([40b975b](https://github.com/feast-dev/feast/commit/40b975b8468de2678af8b191e93495e51af0b6aa)) +* Performance regression in /get-online-features ([#4892](https://github.com/feast-dev/feast/issues/4892)) ([0db56a2](https://github.com/feast-dev/feast/commit/0db56a2cb5888bc21dbdb331e2b5fc3d33508424)) +* Refactor Operator to deploy all feast services to the same Deployment/Pod ([#4863](https://github.com/feast-dev/feast/issues/4863)) ([88854dd](https://github.com/feast-dev/feast/commit/88854dd56fd0becf4a5d5293735a1c9ba394d53d)) +* Remove unnecessary google cloud steps & upgrade docker action versions ([#4925](https://github.com/feast-dev/feast/issues/4925)) ([32aaf9a](https://github.com/feast-dev/feast/commit/32aaf9aba96c53e1c69577312982472182e99659)) +* Remove verifyClient TLS offlineStore option from the Operator ([#4847](https://github.com/feast-dev/feast/issues/4847)) ([79fa247](https://github.com/feast-dev/feast/commit/79fa247026dd95e75a19308d437997310d061b35)) +* Resolving syntax error while querying a feature view with column name starting with a number and BigQuery as data source ([#4908](https://github.com/feast-dev/feast/issues/4908)) ([d3495a0](https://github.com/feast-dev/feast/commit/d3495a09083b1e6a746fff8444f0bbb887d6ac8b)) +* Updated python-helm-demo example to use MinIO instead of GS ([#4691](https://github.com/feast-dev/feast/issues/4691)) ([31afd99](https://github.com/feast-dev/feast/commit/31afd99c0969002fe04982e40cf7a857960f7abf)) + + +### Features + +* Add date field support to spark ([#4913](https://github.com/feast-dev/feast/issues/4913)) ([a8aeb79](https://github.com/feast-dev/feast/commit/a8aeb79830f12358c2355be44fca68e61992cb46)) +* Add date support when converting from python to feast types ([#4918](https://github.com/feast-dev/feast/issues/4918)) ([bd9f071](https://github.com/feast-dev/feast/commit/bd9f071017756e205fbabe6af0d38dfaa9be3d7b)) +* Add duckdb extra to multicloud release image ([#4862](https://github.com/feast-dev/feast/issues/4862)) ([b539eba](https://github.com/feast-dev/feast/commit/b539ebaad5ec2c1a199fe08ceccd206754ce82f0)) +* Add milvus package to release image & option to Operator ([#4870](https://github.com/feast-dev/feast/issues/4870)) ([ef724b6](https://github.com/feast-dev/feast/commit/ef724b66bd4d5f355b055d6d81525c4a17ce94c1)) +* Add Milvus Vector Database Implementation ([#4751](https://github.com/feast-dev/feast/issues/4751)) ([22c7b58](https://github.com/feast-dev/feast/commit/22c7b58f9590a357eaa57c77d5ed351f1fa07501)) +* Add online/offline replica support ([#4812](https://github.com/feast-dev/feast/issues/4812)) ([b97da6c](https://github.com/feast-dev/feast/commit/b97da6ca3a08e3f0fc35552dd7f0bd3b59083f35)) +* Added pvc accessModes support ([#4851](https://github.com/feast-dev/feast/issues/4851)) ([a73514c](https://github.com/feast-dev/feast/commit/a73514cd4f7fecbc89679566e0f8a0af16b6b06d)) +* Adding EnvFrom support for the OptionalConfigs type to the Go Operator ([#4909](https://github.com/feast-dev/feast/issues/4909)) ([e01e510](https://github.com/feast-dev/feast/commit/e01e51076f5d8fe5be459037bd254e6f94e0cb0f)) +* Adding Feature Server to components docs ([#4868](https://github.com/feast-dev/feast/issues/4868)) ([f95e54b](https://github.com/feast-dev/feast/commit/f95e54bdbee80be6b0e290a02e56f92daac2cf64)) +* Adding features field to retrieve_online_features to return mor… ([#4869](https://github.com/feast-dev/feast/issues/4869)) ([7df287e](https://github.com/feast-dev/feast/commit/7df287e8c0f5ec3ab3fa88fd5576f636053a3769)) +* Adding packages for Milvus Online Store ([#4854](https://github.com/feast-dev/feast/issues/4854)) ([49171bd](https://github.com/feast-dev/feast/commit/49171bd53fb8bfc325eb7167cac8cae18a28bd63)) +* Adding vector_search parameter to fields ([#4855](https://github.com/feast-dev/feast/issues/4855)) ([739eaa7](https://github.com/feast-dev/feast/commit/739eaa78e6d995ee0750292d2f8d81886a3f9829)) +* Feast Operator support log level configuration for services ([#4808](https://github.com/feast-dev/feast/issues/4808)) ([19424bc](https://github.com/feast-dev/feast/commit/19424bcc975d90d922791b5bd0da6ac13955c0c5)) +* Go Operator - Parsing the output to go structs ([#4832](https://github.com/feast-dev/feast/issues/4832)) ([732865f](https://github.com/feast-dev/feast/commit/732865f20e7fae7a46f54be7bc469ce2b3bc44e2)) +* Implement `date_partition_column` for `SparkSource` ([#4844](https://github.com/feast-dev/feast/issues/4844)) ([c5ffa03](https://github.com/feast-dev/feast/commit/c5ffa037cb030c64d6e25995199cf762cc0e9b2a)) +* Loading the CA trusted store certificate into Feast to verify the public certificate. ([#4852](https://github.com/feast-dev/feast/issues/4852)) ([132ce2a](https://github.com/feast-dev/feast/commit/132ce2a6c9e3ff8544680d5237e9e1523d988d7e)) +* Operator E2E test to validate FeatureStore custom resource using remote registry ([#4822](https://github.com/feast-dev/feast/issues/4822)) ([d558ef7](https://github.com/feast-dev/feast/commit/d558ef7e19aa561c37c38d4d0da2b8c1467414f5)) +* Operator improvements ([#4928](https://github.com/feast-dev/feast/issues/4928)) ([7a1f4dd](https://github.com/feast-dev/feast/commit/7a1f4dd8b96a40d055467e1e5f72c91167e40484)) +* Removing the tls_verify_client flag from feast cli for offline server. ([#4842](https://github.com/feast-dev/feast/issues/4842)) ([8320e23](https://github.com/feast-dev/feast/commit/8320e23eb85cc419ef8aa0fdc07efa81857e0345)) +* Separating the RBAC and Remote related integration tests. ([#4905](https://github.com/feast-dev/feast/issues/4905)) ([76e1e21](https://github.com/feast-dev/feast/commit/76e1e2178c285886136e8f2fc4436302e4291715)) +* Snyk vulnerability issues fix. ([#4867](https://github.com/feast-dev/feast/issues/4867)) ([dbc9207](https://github.com/feast-dev/feast/commit/dbc92070c8ef6b9e4e53d89ec03090bf30bd0f60)), closes [#6](https://github.com/feast-dev/feast/issues/6) [#3](https://github.com/feast-dev/feast/issues/3) [#4](https://github.com/feast-dev/feast/issues/4) +* Use ASOF JOIN in Snowflake offline store query ([#4850](https://github.com/feast-dev/feast/issues/4850)) ([8f591a2](https://github.com/feast-dev/feast/commit/8f591a235ba5bd9d1bc598195f46c7e12e437a2c)) + + +### Reverts + +* Revert "chore: Add Milvus to pr_integration_tests.yml" ([#4900](https://github.com/feast-dev/feast/issues/4900)) ([07958f7](https://github.com/feast-dev/feast/commit/07958f71cd89984325ec3ca2006b17fe5d333d02)), closes [#4891](https://github.com/feast-dev/feast/issues/4891) + +# [0.42.0](https://github.com/feast-dev/feast/compare/v0.41.0...v0.42.0) (2024-12-05) + + +### Bug Fixes + +* Add adapters for sqlite datetime conversion ([#4797](https://github.com/feast-dev/feast/issues/4797)) ([e198b17](https://github.com/feast-dev/feast/commit/e198b173be6355c1f169aeaae2b503f2273f23f1)) +* Added grpcio extras to default feature-server image ([#4737](https://github.com/feast-dev/feast/issues/4737)) ([e9cd373](https://github.com/feast-dev/feast/commit/e9cd3733f041da99bb1e84843ffe5af697085c34)) +* Changing node version in release ([7089918](https://github.com/feast-dev/feast/commit/7089918509404b3d217e7a2a0161293a8d6cb8aa)) +* Feast create empty online table when FeatureView attribute online=False ([#4666](https://github.com/feast-dev/feast/issues/4666)) ([237c453](https://github.com/feast-dev/feast/commit/237c453c2da7d549b9bdb2c044ba284fbb9d9ba7)) +* Fix db store types in Operator CRD ([#4798](https://github.com/feast-dev/feast/issues/4798)) ([f09339e](https://github.com/feast-dev/feast/commit/f09339eda24785d0a57feb4cf785f297d1a02ccb)) +* Fix the config issue for postgres ([#4776](https://github.com/feast-dev/feast/issues/4776)) ([a36f7e5](https://github.com/feast-dev/feast/commit/a36f7e50d97c85595cbaa14165901924efa61cbb)) +* Fixed example materialize-incremental and improved explanation ([#4734](https://github.com/feast-dev/feast/issues/4734)) ([ca8a7ab](https://github.com/feast-dev/feast/commit/ca8a7ab888b53fe43db6e6437e7070c83e00c10d)) +* Fixed SparkSource docstrings so it wouldn't used inhereted class docstrings ([#4722](https://github.com/feast-dev/feast/issues/4722)) ([32e6aa1](https://github.com/feast-dev/feast/commit/32e6aa1e7c752551d455c5efd0974a938d756210)) +* Fixing PGVector integration tests ([#4778](https://github.com/feast-dev/feast/issues/4778)) ([88a0320](https://github.com/feast-dev/feast/commit/88a03205a4ecbd875e808f6e8f86fef4f93e6da6)) +* Incorrect type passed to assert_permissions in materialize endpoints ([#4727](https://github.com/feast-dev/feast/issues/4727)) ([b72c2da](https://github.com/feast-dev/feast/commit/b72c2daac80ac22d1d8160f155bb55a1bdbf16f7)) +* Issue of DataSource subclasses using parent abstract class docstrings ([#4730](https://github.com/feast-dev/feast/issues/4730)) ([b24acd5](https://github.com/feast-dev/feast/commit/b24acd50149cb4737d5c27aa3236881f8ad26fea)) +* Operator envVar positioning & tls.SecretRef.Name ([#4806](https://github.com/feast-dev/feast/issues/4806)) ([1115d96](https://github.com/feast-dev/feast/commit/1115d966df8ecff5553ae0c0879559f9ad735245)) +* Populates project created_time correctly according to created ti… ([#4686](https://github.com/feast-dev/feast/issues/4686)) ([a61b93c](https://github.com/feast-dev/feast/commit/a61b93c666a79ec72b48d0927b2a4e1598f6650b)) +* Reduce feast-server container image size & fix dev image build ([#4781](https://github.com/feast-dev/feast/issues/4781)) ([ccc9aea](https://github.com/feast-dev/feast/commit/ccc9aea6ee0a720c6dfddf9eaa6805e7b63fa7f1)) +* Removed version func from feature_store.py ([#4748](https://github.com/feast-dev/feast/issues/4748)) ([f902bb9](https://github.com/feast-dev/feast/commit/f902bb9765a2efd4b1325de80e3b4f2101bb3911)) +* Support registry instantiation for read-only users ([#4719](https://github.com/feast-dev/feast/issues/4719)) ([ca3d3c8](https://github.com/feast-dev/feast/commit/ca3d3c8f474ff6bf9f716c37df236bbc41bbd0d2)) +* Syntax Error in BigQuery While Retrieving Columns that Start wit… ([#4713](https://github.com/feast-dev/feast/issues/4713)) ([60fbc62](https://github.com/feast-dev/feast/commit/60fbc62080950549f28b9411e00926be168bea56)) +* Update release version in a pertinent Operator file ([#4708](https://github.com/feast-dev/feast/issues/4708)) ([764a8a6](https://github.com/feast-dev/feast/commit/764a8a657c045e99575bb8cfdc51afd9c61fa8e2)) + + +### Features + +* Add api contract to fastapi docs ([#4721](https://github.com/feast-dev/feast/issues/4721)) ([1a165c7](https://github.com/feast-dev/feast/commit/1a165c734ad8ee3923c786d80a00e4040cb1b1c8)) +* Add Couchbase as an online store ([#4637](https://github.com/feast-dev/feast/issues/4637)) ([824859b](https://github.com/feast-dev/feast/commit/824859b813a1d756887f1006fb25914a2018d097)) +* Add Operator support for spec.feastProject & status.applied fields ([#4656](https://github.com/feast-dev/feast/issues/4656)) ([430ac53](https://github.com/feast-dev/feast/commit/430ac535a5bd8311a485e51011a9602ca441d2d3)) +* Add services functionality to Operator ([#4723](https://github.com/feast-dev/feast/issues/4723)) ([d1d80c0](https://github.com/feast-dev/feast/commit/d1d80c0d208e25b92047fe5f162c67c00c69bb43)) +* Add TLS support to the Operator ([#4796](https://github.com/feast-dev/feast/issues/4796)) ([a617a6c](https://github.com/feast-dev/feast/commit/a617a6c8d67c6baaa6f9c1cc78b7799d72de48a3)) +* Added feast Go operator db stores support ([#4771](https://github.com/feast-dev/feast/issues/4771)) ([3302363](https://github.com/feast-dev/feast/commit/3302363e2f149715e1c0fb5597d0b91a97756db2)) +* Added support for setting env vars in feast services in feast controller ([#4739](https://github.com/feast-dev/feast/issues/4739)) ([84b24b5](https://github.com/feast-dev/feast/commit/84b24b547e40bab4fad664bb77cd864613267aad)) +* Adding docs outlining native Python transformations on singletons ([#4741](https://github.com/feast-dev/feast/issues/4741)) ([0150278](https://github.com/feast-dev/feast/commit/01502785109dfd64e3db03c855a34d9cab1a9073)) +* Adding first feast operator e2e test. ([#4791](https://github.com/feast-dev/feast/issues/4791)) ([8339f8d](https://github.com/feast-dev/feast/commit/8339f8d55c7263becda42ab41961224091dee727)) +* Adding github action to run the operator end-to-end tests. ([#4762](https://github.com/feast-dev/feast/issues/4762)) ([d8ccb00](https://github.com/feast-dev/feast/commit/d8ccb005ab8db0e79885b43aa430b78d1fbba379)) +* Adding ssl support for registry server. ([#4718](https://github.com/feast-dev/feast/issues/4718)) ([ccf7a55](https://github.com/feast-dev/feast/commit/ccf7a55e11165f4663384c580003cb809b5e0f83)) +* Adding SSL support for the React UI server and feast UI command. ([#4736](https://github.com/feast-dev/feast/issues/4736)) ([4a89252](https://github.com/feast-dev/feast/commit/4a89252cb18715458d724e5b54c77ed0de27cf3f)) +* Adding support for native Python transformations on a single dictionary ([#4724](https://github.com/feast-dev/feast/issues/4724)) ([9bbc1c6](https://github.com/feast-dev/feast/commit/9bbc1c61c7bbc38fce5568e6427257cf4d683fb2)) +* Adding TLS support for offline server. ([#4744](https://github.com/feast-dev/feast/issues/4744)) ([5d8d03f](https://github.com/feast-dev/feast/commit/5d8d03ff2086256aa2977e5ec2ecdc048154dc1f)) +* Building the feast image ([#4775](https://github.com/feast-dev/feast/issues/4775)) ([6635dde](https://github.com/feast-dev/feast/commit/6635dde9618d000d0567791018779fc188c893d8)) +* File persistence definition and implementation ([#4742](https://github.com/feast-dev/feast/issues/4742)) ([3bad4a1](https://github.com/feast-dev/feast/commit/3bad4a135cdd9184f1b8e3c9c52470552cf2799d)) +* Object store persistence in operator ([#4758](https://github.com/feast-dev/feast/issues/4758)) ([0ae86da](https://github.com/feast-dev/feast/commit/0ae86da3ab931832b0dfe357c0be82997d37430d)) +* OIDC authorization in Feast Operator ([#4801](https://github.com/feast-dev/feast/issues/4801)) ([eb111d6](https://github.com/feast-dev/feast/commit/eb111d673ee5cea2cfadda55d0917a591cd6c377)) +* Operator will create k8s serviceaccount for each feast service ([#4767](https://github.com/feast-dev/feast/issues/4767)) ([cde5760](https://github.com/feast-dev/feast/commit/cde5760cc94cccd4cbeed918acca09d1b106d7e5)) +* Printing more verbose logs when we start the offline server ([#4660](https://github.com/feast-dev/feast/issues/4660)) ([9d8d3d8](https://github.com/feast-dev/feast/commit/9d8d3d88a0ecccef4d610baf84f1b409276044dd)) +* PVC configuration and impl ([#4750](https://github.com/feast-dev/feast/issues/4750)) ([785a190](https://github.com/feast-dev/feast/commit/785a190b50873bca2704c835027290787fe56656)) +* Qdrant vectorstore support ([#4689](https://github.com/feast-dev/feast/issues/4689)) ([86573d2](https://github.com/feast-dev/feast/commit/86573d2778cb064fb7a930dfe08e84465084523f)) +* RBAC Authorization in Feast Operator ([#4786](https://github.com/feast-dev/feast/issues/4786)) ([0ef5acc](https://github.com/feast-dev/feast/commit/0ef5acccc09a4a4a379a84cdacb0f5b7d9e8df70)) +* Support for nested timestamp fields in Spark Offline store ([#4740](https://github.com/feast-dev/feast/issues/4740)) ([d4d94f8](https://github.com/feast-dev/feast/commit/d4d94f8ed76f72625305ad6e80337670664ba9b0)) +* Update the go feature server from Expedia code repo. ([#4665](https://github.com/feast-dev/feast/issues/4665)) ([6406625](https://github.com/feast-dev/feast/commit/6406625ff8895fa65b11d587246f7d1f5feaecba)) +* Updated feast Go operator db stores ([#4809](https://github.com/feast-dev/feast/issues/4809)) ([2c5a6b5](https://github.com/feast-dev/feast/commit/2c5a6b554cf6170b2590f32124cd7b84121cb864)) +* Updated sample secret following review ([#4811](https://github.com/feast-dev/feast/issues/4811)) ([dc9f825](https://github.com/feast-dev/feast/commit/dc9f8259ee6a2043a6fce88ea0d0a5e59494ef76)) + # [0.41.0](https://github.com/feast-dev/feast/compare/v0.40.0...v0.41.0) (2024-10-26) diff --git a/MANIFEST.in b/MANIFEST.in index 96f7c38c8a5..c43708cdc6f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,5 @@ prune infra prune examples graft sdk/python/feast/ui/build +graft sdk/python/feast/embedded_go/lib +recursive-include sdk/python/feast/static * diff --git a/Makefile b/Makefile index 4f0f8876154..c33685ef2cb 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,13 @@ # limitations under the License. # -ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) + +# install tools in project (tool) dir to not pollute the system +TOOL_DIR := $(ROOT_DIR)/tools +export GOBIN=$(TOOL_DIR)/bin +export PATH := $(TOOL_DIR)/bin:$(PATH) + MVN := mvn -f java/pom.xml ${MAVEN_EXTRA_OPTS} OS := linux ifeq ($(shell uname -s), Darwin) @@ -23,7 +29,16 @@ endif TRINO_VERSION ?= 376 PYTHON_VERSION = ${shell python --version | grep -Eo '[0-9]\.[0-9]+'} +PYTHON_VERSIONS := 3.9 3.10 3.11 + +define get_env_name +$(subst .,,py$(1)) +endef + + # General +$(TOOL_DIR): + mkdir -p $@/bin format: format-python format-java @@ -35,50 +50,50 @@ protos: compile-protos-python compile-protos-docs build: protos build-java build-docker -# Python SDK +# Python SDK - local +# formerly install-python-ci-dependencies-uv-venv +# editable install +install-python-dependencies-dev: + uv pip sync sdk/python/requirements/py$(PYTHON_VERSION)-ci-requirements.txt + uv pip install --no-deps -e . -install-python-dependencies-uv: - uv pip sync --system sdk/python/requirements/py$(PYTHON_VERSION)-requirements.txt - uv pip install --system --no-deps . +# Python SDK - system +# the --system flag installs dependencies in the global python context +# instead of a venv which is useful when working in a docker container or ci. -install-python-dependencies-uv-venv: - uv pip sync sdk/python/requirements/py$(PYTHON_VERSION)-requirements.txt - uv pip install --no-deps . +# Used in github actions/ci +# formerly install-python-ci-dependencies-uv +install-python-dependencies-ci: + uv pip sync --system sdk/python/requirements/py$(PYTHON_VERSION)-ci-requirements.txt + uv pip install --system --no-deps -e . +# Used by multicloud/Dockerfile.dev install-python-ci-dependencies: python -m piptools sync sdk/python/requirements/py$(PYTHON_VERSION)-ci-requirements.txt pip install --no-deps -e . -install-python-ci-dependencies-uv: - uv pip sync --system sdk/python/requirements/py$(PYTHON_VERSION)-ci-requirements.txt - uv pip install --system --no-deps -e . - -install-python-ci-dependencies-uv-venv: - uv pip sync sdk/python/requirements/py$(PYTHON_VERSION)-ci-requirements.txt - uv pip install --no-deps -e . - -lock-python-ci-dependencies: - uv pip compile --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py$(PYTHON_VERSION)-ci-requirements.txt - -compile-protos-python: - python infra/scripts/generate_protos.py - +# Currently used in test-end-to-end.sh install-python: python -m piptools sync sdk/python/requirements/py$(PYTHON_VERSION)-requirements.txt python setup.py develop -lock-python-dependencies: - uv pip compile --system --no-strip-extras setup.py --output-file sdk/python/requirements/py$(PYTHON_VERSION)-requirements.txt - lock-python-dependencies-all: - # Remove all existing requirements because we noticed the lock file is not always updated correctly. Removing and running the command again ensures that the lock file is always up to date. - rm -r sdk/python/requirements/* - pixi run --environment py39 --manifest-path infra/scripts/pixi/pixi.toml "uv pip compile -p 3.9 --system --no-strip-extras setup.py --output-file sdk/python/requirements/py3.9-requirements.txt" - pixi run --environment py39 --manifest-path infra/scripts/pixi/pixi.toml "uv pip compile -p 3.9 --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py3.9-ci-requirements.txt" - pixi run --environment py310 --manifest-path infra/scripts/pixi/pixi.toml "uv pip compile -p 3.10 --system --no-strip-extras setup.py --output-file sdk/python/requirements/py3.10-requirements.txt" - pixi run --environment py310 --manifest-path infra/scripts/pixi/pixi.toml "uv pip compile -p 3.10 --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py3.10-ci-requirements.txt" - pixi run --environment py311 --manifest-path infra/scripts/pixi/pixi.toml "uv pip compile -p 3.11 --system --no-strip-extras setup.py --output-file sdk/python/requirements/py3.11-requirements.txt" - pixi run --environment py311 --manifest-path infra/scripts/pixi/pixi.toml "uv pip compile -p 3.11 --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py3.11-ci-requirements.txt" + # Remove all existing requirements because we noticed the lock file is not always updated correctly. + # Removing and running the command again ensures that the lock file is always up to date. + rm -rf sdk/python/requirements/* 2>/dev/null || true + + $(foreach ver,$(PYTHON_VERSIONS),\ + pixi run --environment $(call get_env_name,$(ver)) --manifest-path infra/scripts/pixi/pixi.toml \ + "uv pip compile -p $(ver) --system --no-strip-extras setup.py \ + --output-file sdk/python/requirements/py$(ver)-requirements.txt" && \ + pixi run --environment $(call get_env_name,$(ver)) --manifest-path infra/scripts/pixi/pixi.toml \ + "uv pip compile -p $(ver) --system --no-strip-extras setup.py --extra ci \ + --output-file sdk/python/requirements/py$(ver)-ci-requirements.txt" && \ + ) true + + +compile-protos-python: + python infra/scripts/generate_protos.py benchmark-python: IS_TEST=True python -m pytest --integration --benchmark --benchmark-autosave --benchmark-save-data sdk/python/tests @@ -90,15 +105,28 @@ test-python-unit: python -m pytest -n 8 --color=yes sdk/python/tests test-python-integration: - python -m pytest -n 8 --integration --color=yes --durations=10 --timeout=1200 --timeout_method=thread --dist loadgroup \ + python -m pytest --tb=short -v -n 8 --integration --color=yes --durations=10 --timeout=1200 --timeout_method=thread --dist loadgroup \ -k "(not snowflake or not test_historical_features_main)" \ + -m "not rbac_remote_integration_test" \ + --log-cli-level=INFO -s \ sdk/python/tests test-python-integration-local: FEAST_IS_LOCAL_TEST=True \ FEAST_LOCAL_ONLINE_CONTAINER=True \ - python -m pytest -n 8 --color=yes --integration --durations=10 --timeout=1200 --timeout_method=thread --dist loadgroup \ + python -m pytest --tb=short -v -n 8 --color=yes --integration --durations=10 --timeout=1200 --timeout_method=thread --dist loadgroup \ + -k "not test_lambda_materialization and not test_snowflake_materialization" \ + -m "not rbac_remote_integration_test" \ + --log-cli-level=INFO -s \ + sdk/python/tests + +test-python-integration-rbac-remote: + FEAST_IS_LOCAL_TEST=True \ + FEAST_LOCAL_ONLINE_CONTAINER=True \ + python -m pytest --tb=short -v -n 8 --color=yes --integration --durations=10 --timeout=1200 --timeout_method=thread --dist loadgroup \ -k "not test_lambda_materialization and not test_snowflake_materialization" \ + -m "rbac_remote_integration_test" \ + --log-cli-level=INFO -s \ sdk/python/tests test-python-integration-container: @@ -242,7 +270,7 @@ test-python-universal-postgres-online: test-python-universal-pgvector-online: PYTHONPATH='.' \ - FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.pgvector_repo_configuration \ + FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.postgres_online_store.pgvector_repo_configuration \ PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.postgres \ python -m pytest -n 8 --integration \ -k "not test_universal_cli and \ @@ -256,10 +284,13 @@ test-python-universal-postgres-online: not gcs_registry and \ not s3_registry and \ not test_universal_types and \ + not test_validation and \ + not test_spark_materialization_consistency and \ + not test_historical_features_containing_backfills and \ not test_snowflake" \ sdk/python/tests - test-python-universal-mysql-online: +test-python-universal-mysql-online: PYTHONPATH='.' \ FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.mysql_online_store.mysql_repo_configuration \ PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.mysql \ @@ -283,7 +314,11 @@ test-python-universal-cassandra: FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.cassandra_online_store.cassandra_repo_configuration \ PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.cassandra \ python -m pytest -x --integration \ - sdk/python/tests + sdk/python/tests/integration/offline_store/test_feature_logging.py \ + --ignore=sdk/python/tests/integration/offline_store/test_validation.py \ + -k "not test_snowflake and \ + not test_spark_materialization_consistency and \ + not test_universal_materialization" test-python-universal-hazelcast: PYTHONPATH='.' \ @@ -321,7 +356,7 @@ test-python-universal-cassandra-no-cloud-providers: not test_snowflake" \ sdk/python/tests - test-python-universal-elasticsearch-online: +test-python-universal-elasticsearch-online: PYTHONPATH='.' \ FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.elasticsearch_online_store.elasticsearch_repo_configuration \ PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.elasticsearch \ @@ -340,6 +375,14 @@ test-python-universal-cassandra-no-cloud-providers: not test_snowflake" \ sdk/python/tests +test-python-universal-milvus-online: + PYTHONPATH='.' \ + FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.milvus_online_store.milvus_repo_configuration \ + PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.milvus \ + python -m pytest -n 8 --integration \ + -k "test_retrieve_online_milvus_documents" \ + sdk/python/tests --ignore=sdk/python/tests/integration/offline_store/test_dqm_validation.py + test-python-universal-singlestore-online: PYTHONPATH='.' \ FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.singlestore_repo_configuration \ @@ -351,7 +394,7 @@ test-python-universal-singlestore-online: not test_snowflake" \ sdk/python/tests - test-python-universal-qdrant-online: +test-python-universal-qdrant-online: PYTHONPATH='.' \ FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.qdrant_online_store.qdrant_repo_configuration \ PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.qdrant \ @@ -359,9 +402,36 @@ test-python-universal-singlestore-online: -k "test_retrieve_online_documents" \ sdk/python/tests/integration/online_store/test_universal_online.py +# To use Couchbase as an offline store, you need to create an Couchbase Capella Columnar cluster on cloud.couchbase.com. +# Modify environment variables COUCHBASE_COLUMNAR_CONNECTION_STRING, COUCHBASE_COLUMNAR_USER, and COUCHBASE_COLUMNAR_PASSWORD +# with the details from your Couchbase Columnar Cluster. +test-python-universal-couchbase-offline: + PYTHONPATH='.' \ + FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.offline_stores.contrib.couchbase_columnar_repo_configuration \ + PYTEST_PLUGINS=feast.infra.offline_stores.contrib.couchbase_offline_store.tests \ + COUCHBASE_COLUMNAR_CONNECTION_STRING=couchbases:// \ + COUCHBASE_COLUMNAR_USER=username \ + COUCHBASE_COLUMNAR_PASSWORD=password \ + python -m pytest -n 8 --integration \ + -k "not test_historical_retrieval_with_validation and \ + not test_historical_features_persisting and \ + not test_universal_cli and \ + not test_go_feature_server and \ + not test_feature_logging and \ + not test_reorder_columns and \ + not test_logged_features_validation and \ + not test_lambda_materialization_consistency and \ + not test_offline_write and \ + not test_push_features_to_offline_store and \ + not gcs_registry and \ + not s3_registry and \ + not test_snowflake and \ + not test_universal_types" \ + sdk/python/tests + test-python-universal-couchbase-online: PYTHONPATH='.' \ - FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.contrib.couchbase_repo_configuration \ + FULL_REPO_CONFIGS_MODULE=sdk.python.feast.infra.online_stores.couchbase_online_store.couchbase_repo_configuration \ PYTEST_PLUGINS=sdk.python.tests.integration.feature_repos.universal.online_store.couchbase \ python -m pytest -n 8 --integration \ -k "not test_universal_cli and \ @@ -428,18 +498,19 @@ kill-trino-locally: # Docker -build-docker: build-feature-server-python-aws-docker build-feature-transformation-server-docker build-feature-server-java-docker +build-docker: build-feature-server-docker build-feature-transformation-server-docker build-feature-server-java-docker build-feast-operator-docker push-ci-docker: docker push $(REGISTRY)/feast-ci:$(VERSION) push-feature-server-docker: - docker push $(REGISTRY)/feature-server:$$VERSION + docker push $(REGISTRY)/feature-server:$(VERSION) build-feature-server-docker: - docker buildx build --build-arg VERSION=$$VERSION \ - -t $(REGISTRY)/feature-server:$$VERSION \ - -f sdk/python/feast/infra/feature_servers/multicloud/Dockerfile --load . + docker buildx build \ + -t $(REGISTRY)/feature-server:$(VERSION) \ + -f sdk/python/feast/infra/feature_servers/multicloud/Dockerfile \ + --load sdk/python/feast/infra/feature_servers/multicloud push-feature-transformation-server-docker: docker push $(REGISTRY)/feature-transformation-server:$(VERSION) @@ -484,10 +555,18 @@ build-feast-operator-docker: # Dev images build-feature-server-dev: - docker buildx build --build-arg VERSION=dev \ + docker buildx build \ -t feastdev/feature-server:dev \ -f sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev --load . +build-feature-server-dev-docker: + docker buildx build \ + -t $(REGISTRY)/feature-server:$(VERSION) \ + -f sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev --load . + +push-feature-server-dev-docker: + docker push $(REGISTRY)/feature-server:$(VERSION) + build-java-docker-dev: make build-java-no-tests REVISION=dev docker buildx build --build-arg VERSION=dev \ @@ -536,3 +615,64 @@ build-helm-docs: # Note: requires node and yarn to be installed build-ui: cd $(ROOT_DIR)/sdk/python/feast/ui && yarn upgrade @feast-dev/feast-ui --latest && yarn install && npm run build --omit=dev + + + +# Go SDK & embedded +PB_REL = https://github.com/protocolbuffers/protobuf/releases +PB_VERSION = 3.11.2 +PB_ARCH := $(shell uname -m) +ifeq ($(PB_ARCH), arm64) + PB_ARCH=aarch_64 +endif +PB_PROTO_FOLDERS=core registry serving types storage + +$(TOOL_DIR)/protoc-$(PB_VERSION)-$(OS)-$(PB_ARCH).zip: $(TOOL_DIR) + cd $(TOOL_DIR) && \ + curl -LO $(PB_REL)/download/v$(PB_VERSION)/protoc-$(PB_VERSION)-$(OS)-$(PB_ARCH).zip + +.PHONY: install-go-proto-dependencies +install-go-proto-dependencies: $(TOOL_DIR)/protoc-$(PB_VERSION)-$(OS)-$(PB_ARCH).zip + unzip -u $(TOOL_DIR)/protoc-$(PB_VERSION)-$(OS)-$(PB_ARCH).zip -d $(TOOL_DIR) + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.31.0 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0 + +.PHONY: compile-protos-go +compile-protos-go: install-go-proto-dependencies + $(foreach folder,$(PB_PROTO_FOLDERS), \ + protoc --proto_path=$(ROOT_DIR)/protos \ + --go_out=$(ROOT_DIR)/go/protos \ + --go_opt=module=github.com/feast-dev/feast/go/protos \ + --go-grpc_out=$(ROOT_DIR)/go/protos \ + --go-grpc_opt=module=github.com/feast-dev/feast/go/protos $(ROOT_DIR)/protos/feast/$(folder)/*.proto; ) true + +#install-go-ci-dependencies: + # go install golang.org/x/tools/cmd/goimports + # python -m pip install "pybindgen==0.22.1" "grpcio-tools>=1.56.2,<2" "mypy-protobuf>=3.1" + +.PHONY: build-go +build-go: compile-protos-go + go build -o feast ./go/main.go + +.PHONY: install-feast-ci-locally +install-feast-ci-locally: + uv pip install -e ".[ci]" + +.PHONY: test-go +test-go: compile-protos-go install-feast-ci-locally compile-protos-python + CGO_ENABLED=1 go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html + +.PHONY: format-go +format-go: + gofmt -s -w go/ + +.PHONY: lint-go +lint-go: compile-protos-go + go vet ./go/internal/feast + +.PHONY: build-go-docker-dev +build-go-docker-dev: + docker buildx build --build-arg VERSION=dev \ + -t feastdev/feature-server-go:dev \ + -f go/infra/docker/feature-server/Dockerfile --load . + diff --git a/OWNERS b/OWNERS new file mode 100644 index 00000000000..852b3fdf8c6 --- /dev/null +++ b/OWNERS @@ -0,0 +1,15 @@ +# This file is being used by RedHat for running e2e CI + +approvers: +- redhathameed +- tmihalac +- accorvin +- amsharma3 +- franciscojavierarceo +options: {} +reviewers: +- redhathameed +- tmihalac +- accorvin +- amsharma3 +- franciscojavierarceo \ No newline at end of file diff --git a/README.md b/README.md index e02fb978d7b..e820d3152d3 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ The list below contains the functionality that contributors are planning to deve * We welcome contribution to all items in the roadmap! +* **Natural Language Processing** + * [x] Vector Search (Alpha release. See [RFC](https://docs.google.com/document/d/18IWzLEA9i2lDWnbfbwXnMCg3StlqaLVI-uRpQjr_Vos/edit#heading=h.9gaqqtox9jg6)) + * [ ] [Enhanced Feature Server and SDK for native support for NLP](https://github.com/feast-dev/feast/issues/4964) * **Data Sources** * [x] [Snowflake source](https://docs.feast.dev/reference/data-sources/snowflake) * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) @@ -160,6 +163,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/data-sources/postgres) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/data-sources/spark) + * [x] [Couchbase (contrib plugin)](https://docs.feast.dev/reference/data-sources/couchbase) * [x] Kafka / Kinesis sources (via [push support into the online store](https://docs.feast.dev/reference/data-sources/push)) * **Offline Stores** * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) @@ -170,6 +174,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/offline-stores/postgres) * [x] [Trino (contrib plugin)](https://github.com/Shopify/feast-trino) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/offline-stores/spark) + * [x] [Couchbase (contrib plugin)](https://docs.feast.dev/reference/offline-stores/couchbase) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/customizing-feast/adding-a-new-offline-store) * **Online Stores** @@ -184,12 +189,14 @@ The list below contains the functionality that contributors are planning to deve * [x] [Azure Cache for Redis (community plugin)](https://github.com/Azure/feast-azure) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/online-stores/postgres) * [x] [Cassandra / AstraDB (contrib plugin)](https://docs.feast.dev/reference/online-stores/cassandra) + * [x] [ScyllaDB (contrib plugin)](https://docs.feast.dev/reference/online-stores/scylladb) + * [x] [Couchbase (contrib plugin)](https://docs.feast.dev/reference/online-stores/couchbase) * [x] [Custom online store support](https://docs.feast.dev/how-to-guides/customizing-feast/adding-support-for-a-new-online-store) * **Feature Engineering** - * [x] On-demand Transformations (Beta release. See [RFC](https://docs.google.com/document/d/1lgfIw0Drc65LpaxbUu49RCeJgMew547meSJttnUqz7c/edit#)) + * [x] On-demand Transformations (On Read) (Beta release. See [RFC](https://docs.google.com/document/d/1lgfIw0Drc65LpaxbUu49RCeJgMew547meSJttnUqz7c/edit#)) * [x] Streaming Transformations (Alpha release. See [RFC](https://docs.google.com/document/d/1UzEyETHUaGpn0ap4G82DHluiCj7zEbrQLkJJkKSv4e8/edit)) * [ ] Batch transformation (In progress. See [RFC](https://docs.google.com/document/d/1964OkzuBljifDvkV-0fakp2uaijnVzdwWNGdz7Vz50A/edit)) - * [ ] Persistent On-demand Transformations (Beta release. See [GitHub Issue](https://github.com/feast-dev/feast/issues/4376)) + * [x] On-demand Transformations (On Write) (Beta release. See [GitHub Issue](https://github.com/feast-dev/feast/issues/4376)) * **Streaming** * [x] [Custom streaming ingestion job support](https://docs.feast.dev/how-to-guides/customizing-feast/creating-a-custom-provider) * [x] [Push based streaming data ingestion to online store](https://docs.feast.dev/reference/data-sources/push) @@ -212,8 +219,6 @@ The list below contains the functionality that contributors are planning to deve * [x] DataHub integration (see [DataHub Feast docs](https://datahubproject.io/docs/generated/ingestion/sources/feast/)) * [x] Feast Web UI (Beta release. See [docs](https://docs.feast.dev/reference/alpha-web-ui)) * [ ] Feast Lineage Explorer -* **Natural Language Processing** - * [x] Vector Search (Alpha release. See [RFC](https://docs.google.com/document/d/18IWzLEA9i2lDWnbfbwXnMCg3StlqaLVI-uRpQjr_Vos/edit#heading=h.9gaqqtox9jg6)) ## 🎓 Important Resources diff --git a/community/ADOPTERS.md b/community/ADOPTERS.md index 5ef285b41b8..38da2012efe 100644 --- a/community/ADOPTERS.md +++ b/community/ADOPTERS.md @@ -4,13 +4,14 @@ Below are the adopters of Feast. If you are using Feast please add yourself into the following list by a pull request. Please keep the list in alphabetical order. -| Organization | Contact | GitHub Username | -| ------------ | ------- | ------- | -| Affirm | Francisco Javier Arceo | franciscojavierarceo | -| Bank of Georgia | Tornike Gurgenidze | tokoko | -| Get Ground | Zhiling Chen | zhilingc | -| Gojek | Pradithya Aria Pura | pradithya | -| Twitter | David Liu | mavysavydav| -| SeatGeek | Rob Howley | robhowley | -| Shopify | Matt Delacour | MattDelac | -| Snowflake | Miles Adkins | sfc-gh-madkins | +| Organization | Contact | GitHub Username | +|-----------------|------------------------|----------------------| +| Affirm | Francisco Javier Arceo | franciscojavierarceo | +| Bank of Georgia | Tornike Gurgenidze | tokoko | +| Get Ground | Zhiling Chen | zhilingc | +| Gojek | Pradithya Aria Pura | pradithya | +| Picnic | Tom Steenbergen | TomSteenbergen | +| Twitter | David Liu | mavysavydav | +| SeatGeek | Rob Howley | robhowley | +| Shopify | Matt Delacour | MattDelac | +| Snowflake | Miles Adkins | sfc-gh-madkins | diff --git a/community/maintainers.md b/community/maintainers.md index 5ccd347be00..779689851d7 100644 --- a/community/maintainers.md +++ b/community/maintainers.md @@ -9,29 +9,29 @@ In alphabetical order | Name | GitHub Username | Email | Organization | | -------------- | ---------------- |-----------------------------| ------------------ | | Achal Shah | `achals` | achals@gmail.com | Tecton | -| Edson Tirelli | `etirelli` | ed.tirelli@gmail.com | Red Hat | | Francisco Javier Arceo | `franciscojavierarceo` | arceofrancisco@gmail.com | Affirm | | Hao Xu | `HaoXuAI` | sduxuhao@gmail.com | JPMorgan | -| Jeremy Ary | `jeremyary` | jeremy.ary@gmail.com | Red Hat | | Shuchu Han | `shuchu` | shuchu.han@gmail.com | Independent | | Willem Pienaar | `woop` | will.pienaar@gmail.com | Cleric | -| Zhiling Chen | `zhilingc` | chnzhlng@gmail.com | GetGround | -| Tornike Gurgenidze | `tokoko` | togurgenidze@gmail.com | Bank of Georgia | +| Zhiling Chen | `zhilingc` | chnzhlng@gmail.com | GetGround | +| Tornike Gurgenidze | `tokoko` | togurgenidze@gmail.com | Bank of Georgia | ## Emeritus Maintainers -| Name | GitHub Username | Email | Organization | -|---------------------|-----------------|-----------------------------|-------------------| -| Oleg Avdeev | oavdeev | oleg.v.avdeev@gmail.com | Tecton | -| Oleksii Moskalenko | pyalex | moskalenko.alexey@gmail.com | Tecton | -| Jay Parthasarthy | jparthasarthy | jparthasarthy@gmail.com | Tecton | -| Danny Chiao | adchia | danny@tecton.ai | Tecton | -| Pradithya Aria Pura | pradithya | pradithya.aria@gmail.com | Gojek | -| Tsotne Tabidze | tsotnet | tsotnet@gmail.com | Tecton | -| Abhin Chhabra | chhabrakadabra | chhabra.abhin@gmail.com | Shopify | -| Danny Chiao | adchia | danny@tecton.ai | Tecton | -| David Liu | mavysavydav | davidyliuliu@gmail.com | Twitter | -| Matt Delacour | MattDelac | mdelacour@hey.com | Shopify | -| Miles Adkins | sfc-gh-madkins | miles.adkins@snowflake.com | Snowflake | -| Felix Wang | `felixwang9817` | wangfelix98@gmail.com | Tecton | -| Kevin Zhang | `kevjumba` | kevin.zhang.13499@gmail.com | Tecton | +| Name | GitHub Username | Email | Organization | +|---------------------|----------------|---------------------------|-------------------| +| Edson Tirelli | `etirelli` | ed.tirelli@gmail.com | Red Hat | +| Jeremy Ary | `jeremyary` | jeremy.ary@gmail.com | Red Hat | +| Oleg Avdeev | oavdeev | oleg.v.avdeev@gmail.com | Tecton | +| Oleksii Moskalenko | pyalex | moskalenko.alexey@gmail.com | Tecton | +| Jay Parthasarthy | jparthasarthy | jparthasarthy@gmail.com | Tecton | +| Danny Chiao | adchia | danny@tecton.ai | Tecton | +| Pradithya Aria Pura | pradithya | pradithya.aria@gmail.com | Gojek | +| Tsotne Tabidze | tsotnet | tsotnet@gmail.com | Tecton | +| Abhin Chhabra | chhabrakadabra | chhabra.abhin@gmail.com | Shopify | +| Danny Chiao | adchia | danny@tecton.ai | Tecton | +| David Liu | mavysavydav | davidyliuliu@gmail.com | Twitter | +| Matt Delacour | MattDelac | mdelacour@hey.com | Shopify | +| Miles Adkins | sfc-gh-madkins | miles.adkins@snowflake.com | Snowflake | +| Felix Wang | `felixwang9817` | wangfelix98@gmail.com | Tecton | +| Kevin Zhang | `kevjumba` | kevin.zhang.13499@gmail.com | Tecton | diff --git a/docs/README.md b/docs/README.md index 5e36e1ce40a..02ecaefa10c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,12 +11,12 @@ for historical feature extraction used in model training and an (2) [online stor for serving features at low-latency in production systems and applications. Feast is a configurable operational data system that re-uses existing infrastructure to manage and serve machine learning -features to realtime models. For more details please review our [architecture](getting-started/architecture/overview.md). +features to realtime models. For more details, please review our [architecture](getting-started/architecture/overview.md). Concretely, Feast provides: -* A python SDK for programtically defining features, entities, sources, and (optionally) transformations -* A python SDK for for reading and writing features to configured offline and online data stores +* A Python SDK for programmatically defining features, entities, sources, and (optionally) transformations +* A Python SDK for reading and writing features to configured offline and online data stores * An [optional feature server](reference/feature-servers/README.md) for reading and writing features (useful for non-python languages) * A [UI](reference/alpha-web-ui.md) for viewing and exploring information about features defined in the project * A [CLI tool](reference/feast-cli-commands.md) for viewing and updating feature information @@ -24,8 +24,8 @@ Concretely, Feast provides: Feast allows ML platform teams to: * **Make features consistently available for training and low-latency serving** by managing an _offline store_ (to process historical data for scale-out batch scoring or model training), a low-latency _online store_ (to power real-time prediction)_,_ and a battle-tested _feature server_ (to serve pre-computed features online). -* **Avoid data leakage** by generating point-in-time correct feature sets so data scientists can focus on feature engineering rather than debugging error-prone dataset joining logic. This ensure that future feature values do not leak to models during training. -* **Decouple ML from data infrastructure** by providing a single data access layer that abstracts feature storage from feature retrieval, ensuring models remain portable as you move from training models to serving models, from batch models to realtime models, and from one data infra system to another. +* **Avoid data leakage** by generating point-in-time correct feature sets so data scientists can focus on feature engineering rather than debugging error-prone dataset joining logic. This ensures that future feature values do not leak to models during training. +* **Decouple ML from data infrastructure** by providing a single data access layer that abstracts feature storage from feature retrieval, ensuring models remain portable as you move from training models to serving models, from batch models to real-time models, and from one data infra system to another. {% hint style="info" %} **Note:** Feast today primarily addresses _timestamped structured data_. @@ -42,25 +42,28 @@ serving system must make a request to the feature store to retrieve feature valu ## Who is Feast for? -Feast helps ML platform/MLOps teams with DevOps experience productionize real-time models. Feast also helps these teams -build a feature platform that improves collaboration between data engineers, software engineers, machine learning -engineers, and data scientists. +Feast helps ML platform/MLOps teams with DevOps experience productionize real-time models. Feast also helps these teams build a feature platform that improves collaboration between data engineers, software engineers, machine learning engineers, and data scientists. -Feast is likely **not** the right tool if you -* are in an organization that’s just getting started with ML and is not yet sure what the business impact of ML is +* *For Data Scientists*: Feast is a tool where you can easily define, store, and retrieve your features for both model development and model deployment. By using Feast, you can focus on what you do best: build features that power your AI/ML models and maximize the value of your data. +    +* *For MLOps Engineers*: Feast is a library that allows you to connect your existing infrastructure (e.g., online database, application server, microservice, analytical database, and orchestration tooling) that enables your Data Scientists to ship features for their models to production using a friendly SDK without having to be concerned with software engineering challenges that occur from serving real-time production systems. By using Feast, you can focus on maintaining a resilient system, instead of implementing features for Data Scientists. +    +* *For Data Engineers*: Feast provides a centralized catalog for storing feature definitions, allowing one to maintain a single source of truth for feature data. It provides the abstraction for reading and writing to many different types of offline and online data stores. Using either the provided Python SDK or the feature server service, users can write data to the online and/or offline stores and then read that data out again in either low-latency online scenarios for model inference, or in batch scenarios for model training. + +* *For AI Engineers*: Feast provides a platform designed to scale your AI applications by enabling seamless integration of richer data and facilitating fine-tuning. With Feast, you can optimize the performance of your AI models while ensuring a scalable and efficient data pipeline. ## What Feast is not? ### Feast is not -* **an** [**ETL**](https://en.wikipedia.org/wiki/Extract,\_transform,\_load) / [**ELT**](https://en.wikipedia.org/wiki/Extract,\_load,\_transform) **system.** Feast is not a general purpose data pipelining system. Users often leverage tools like [dbt](https://www.getdbt.com) to manage upstream data transformations. Feast does support some [transformations](getting-started/architecture/feature-transformetion.md). -* **a data orchestration tool:** Feast does not manage or orchestrate complex workflow DAGs. It relies on upstream data pipelines to produce feature values and integrations with tools like [Airflow](https://airflow.apache.org) to make features consistently available. -* **a data warehouse:** Feast is not a replacement for your data warehouse or the source of truth for all transformed data in your organization. Rather, Feast is a light-weight downstream layer that can serve data from an existing data warehouse (or other data sources) to models in production. -* **a database:** Feast is not a database, but helps manage data stored in other systems (e.g. BigQuery, Snowflake, DynamoDB, Redis) to make features consistently available at training / serving time +* **An** [**ETL**](https://en.wikipedia.org/wiki/Extract,\_transform,\_load) / [**ELT**](https://en.wikipedia.org/wiki/Extract,\_load,\_transform) **system.** Feast is not a general purpose data pipelining system. Users often leverage tools like [dbt](https://www.getdbt.com) to manage upstream data transformations. Feast does support some [transformations](getting-started/architecture/feature-transformation.md). +* **A data orchestration tool:** Feast does not manage or orchestrate complex workflow DAGs. It relies on upstream data pipelines to produce feature values and integrations with tools like [Airflow](https://airflow.apache.org) to make features consistently available. +* **A data warehouse:** Feast is not a replacement for your data warehouse or the source of truth for all transformed data in your organization. Rather, Feast is a lightweight downstream layer that can serve data from an existing data warehouse (or other data sources) to models in production. +* **A database:** Feast is not a database, but helps manage data stored in other systems (e.g. BigQuery, Snowflake, DynamoDB, Redis) to make features consistently available at training / serving time ### Feast does not _fully_ solve * **reproducible model training / model backtesting / experiment management**: Feast captures feature and model metadata, but does not version-control datasets / labels or manage train / test splits. Other tools like [DVC](https://dvc.org/), [MLflow](https://www.mlflow.org/), and [Kubeflow](https://www.kubeflow.org/) are better suited for this. -* **batch feature engineering**: Feast supports on demand and streaming transformations. Feast is also investing in supporting batch transformations. +* **batch feature engineering**: Feast supports on-demand and streaming transformations. Feast is also investing in supporting batch transformations. * **native streaming feature integration:** Feast enables users to push streaming features, but does not pull from streaming sources or manage streaming pipelines. * **lineage:** Feast helps tie feature values to model versions, but is not a complete solution for capturing end-to-end lineage from raw data sources to model versions. Feast also has community contributed plugins with [DataHub](https://datahubproject.io/docs/generated/ingestion/sources/feast/) and [Amundsen](https://github.com/amundsen-io/amundsen/blob/4a9d60176767c4d68d1cad5b093320ea22e26a49/databuilder/databuilder/extractor/feast\_extractor.py). * **data quality / drift detection**: Feast has experimental integrations with [Great Expectations](https://greatexpectations.io/), but is not purpose built to solve data drift / data quality issues. This requires more sophisticated monitoring across data pipelines, served feature values, labels, and model versions. @@ -72,7 +75,7 @@ Many companies have used Feast to power real-world ML use cases such as: * Personalizing online recommendations by leveraging pre-computed historical user or item features. * Online fraud detection, using features that compare against (pre-computed) historical transaction patterns * Churn prediction (an offline model), generating feature values for all users at a fixed cadence in batch -* Credit scoring, using pre-computed historical features to compute probability of default +* Credit scoring, using pre-computed historical features to compute the probability of default ## How can I get started? diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 325b9673538..8db4143697e 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,6 +1,7 @@ # Table of contents * [Introduction](README.md) +* [Blog](blog/README.md) * [Community & getting help](community.md) * [Roadmap](roadmap.md) * [Changelog](https://github.com/feast-dev/feast/blob/master/CHANGELOG.md) @@ -18,21 +19,25 @@ * [Role-Based Access Control (RBAC)](getting-started/architecture/rbac.md) * [Concepts](getting-started/concepts/README.md) * [Overview](getting-started/concepts/overview.md) + * [Project](getting-started/concepts/project.md) * [Data ingestion](getting-started/concepts/data-ingestion.md) * [Entity](getting-started/concepts/entity.md) * [Feature view](getting-started/concepts/feature-view.md) * [Feature retrieval](getting-started/concepts/feature-retrieval.md) * [Point-in-time joins](getting-started/concepts/point-in-time-joins.md) - * [Permission](getting-started/concepts/permission.md) * [\[Alpha\] Saved dataset](getting-started/concepts/dataset.md) + * [Permission](getting-started/concepts/permission.md) + * [Tags](getting-started/concepts/tags.md) * [Components](getting-started/components/README.md) * [Overview](getting-started/components/overview.md) * [Registry](getting-started/components/registry.md) * [Offline store](getting-started/components/offline-store.md) * [Online store](getting-started/components/online-store.md) + * [Feature server](getting-started/components/feature-server.md) * [Batch Materialization Engine](getting-started/components/batch-materialization-engine.md) * [Provider](getting-started/components/provider.md) * [Authorization Manager](getting-started/components/authz_manager.md) + * [OpenTelemetry Integration](getting-started/components/open-telemetry.md) * [Third party integrations](getting-started/third-party-integrations.md) * [FAQ](getting-started/faq.md) @@ -64,6 +69,7 @@ * [Adding a new online store](how-to-guides/customizing-feast/adding-support-for-a-new-online-store.md) * [Adding a custom provider](how-to-guides/customizing-feast/creating-a-custom-provider.md) * [Adding or reusing tests](how-to-guides/adding-or-reusing-tests.md) +* [Starting Feast servers in TLS(SSL) Mode](how-to-guides/starting-feast-servers-tls-mode.md) ## Reference @@ -82,6 +88,7 @@ * [PostgreSQL (contrib)](reference/data-sources/postgres.md) * [Trino (contrib)](reference/data-sources/trino.md) * [Azure Synapse + Azure SQL (contrib)](reference/data-sources/mssql.md) + * [Couchbase (contrib)](reference/data-sources/couchbase.md) * [Offline stores](reference/offline-stores/README.md) * [Overview](reference/offline-stores/overview.md) * [Dask](reference/offline-stores/dask.md) @@ -89,6 +96,7 @@ * [BigQuery](reference/offline-stores/bigquery.md) * [Redshift](reference/offline-stores/redshift.md) * [DuckDB](reference/offline-stores/duckdb.md) + * [Couchbase Columnar (contrib)](reference/offline-stores/couchbase.md) * [Spark (contrib)](reference/offline-stores/spark.md) * [PostgreSQL (contrib)](reference/offline-stores/postgres.md) * [Trino (contrib)](reference/offline-stores/trino.md) @@ -112,6 +120,7 @@ * [Hazelcast](reference/online-stores/hazelcast.md) * [ScyllaDB](reference/online-stores/scylladb.md) * [SingleStore](reference/online-stores/singlestore.md) + * [Milvus](reference/online-stores/milvus.md) * [Registries](reference/registries/README.md) * [Local](reference/registries/local.md) * [S3](reference/registries/s3.md) diff --git a/docs/blog/README.md b/docs/blog/README.md new file mode 100644 index 00000000000..cc42cfe442f --- /dev/null +++ b/docs/blog/README.md @@ -0,0 +1,21 @@ +# Blog Posts + +Welcome to the Feast blog! Here you'll find articles about feature store development, new features, and community updates. + +## Featured Posts + +{% content-ref url="what-is-a-feature-store.md" %} +[what-is-a-feature-store.md](what-is-a-feature-store.md) +{% endcontent-ref %} + +{% content-ref url="the-future-of-feast.md" %} +[the-future-of-feast.md](the-future-of-feast.md) +{% endcontent-ref %} + +{% content-ref url="feast-supports-vector-database.md" %} +[feast-supports-vector-database.md](feast-supports-vector-database.md) +{% endcontent-ref %} + +{% content-ref url="rbac-role-based-access-controls.md" %} +[rbac-role-based-access-controls.md](rbac-role-based-access-controls.md) +{% endcontent-ref %} diff --git a/docs/blog/a-state-of-feast.md b/docs/blog/a-state-of-feast.md new file mode 100644 index 00000000000..7343effdcb4 --- /dev/null +++ b/docs/blog/a-state-of-feast.md @@ -0,0 +1,80 @@ +# A State of Feast + +*January 21, 2021* | *Willem Pienaar* + +## Introduction + +Two years ago we first announced the launch of Feast, an open source feature store for machine learning. Feast is an operational data system that solves some of the key challenges that ML teams encounter while productionizing machine learning systems. + +Recognizing that ML and Feast have advanced since we launched, we take a moment today to discuss the past, present and future of Feast. We consider the more significant lessons we learned while building Feast, where we see the project heading, and why teams should consider adopting Feast as part of their operational ML stacks. + +## Background + +Feast was developed to address the challenges faced while productionizing data for machine learning. In our original [Google Cloud article](https://cloud.google.com/blog/products/ai-machine-learning/introducing-feast-an-open-source-feature-store-for-machine-learning), we highlighted some of these challenges, namely: + +1. Features aren't reused. +2. Feature definitions are inconsistent across teams. +3. Getting features into production is hard. +4. Feature values are inconsistent between training and serving. + +Whereas an industry to solve data transformations and data-quality problems already existed, our focus for shaping Feast was to overcome operational ML hurdles that exist between data science and ML engineering. Toward that end, our initial aim was to provide: + +1. Registry: The registry is a common catalog with which to explore, develop, collaborate on, and publish new feature definitions within and across teams. It is the central interface for all interactions with the feature store. +2. Ingestion: A means for continually ingesting batch and streaming data and storing consistent copies in both an offline and online store. This layer automates most data-management work and ensures that features are always available for serving. +3. Serving: A feature-retrieval interface which provides a temporally consistent view of features for both training and online serving. Serving improves iteration speed by minimizing coupling to data infrastructure, and prevents training-serving skew through consistent data access. + +Guided by this design, we co-developed and shipped Feast with our friends over at Google. We then open sourced the project in early 2019, and have since been running Feast in production and at scale. In our follow up blog post, [Bridging ML Models and Data](https://blog.gojekengineering.com/feast-bridging-ml-models-and-data), we touched on the impact Feast has had at companies like Gojek. + +## Feast today + +Teams, large and small, are increasingly searching for ways to simplify the productionization and maintenance of their ML systems at scale. Since open sourcing Feast, we've seen both the demand for these tools and the activity around this project soar. Working alongside our open source community, we've released key pieces of our stack throughout the last year, and steadily expanded Feast into a robust feature store. Highlights include: + +* Point-in-time correct queries that prevent feature data leakage. +* A query optimized table-based data model in the form of feature sets. +* Storage connectors with implementations for Cassandra and Redis Cluster. +* Statistics generation and data validation through TFDV integration. +* Authentication and authorization support for SDKs and APIs. +* Diagnostic tooling through request/response logging, audit logs, and Statsd integration. + +Feast has grown more rapidly than initially anticipated, with multiple large companies, including Agoda, Gojek, Farfetch, Postmates, and Zulily adopting and/or contributing to the project. We've also been working closely with other open source teams, and we are excited to share that Feast is now a [component in Kubeflow](https://www.kubeflow.org/docs/components/feature-store/). Over the coming months we will be enhancing this integration, making it easier for users to deploy Feast and Kubeflow together. + +## Lessons learned + +Through frequent engagement with our community and by way of running Feast in production ourselves, we've learned critical lessons: + +Feast requires too much infrastructure: Requiring users provision a large system is a big ask. A minimal Feast deployment requires Kafka, Zookeeper, Postgres, Redis, and multiple Feast services. + +Feast lacks composability: Requiring all infrastructural components be present in order to have a functional system removes all modularity. + +Ingestion is too complex: Incorporating a Kafka-based stream-first ingestion layer trivializes data consistency across stores, but the complete ingestion flow from source to sink can still mysteriously fail at multiple points. + +Our technology choices hinder generalization: Leveraging technologies like BigQuery, Apache Beam on Dataflow, and Apache Kafka has allowed us to move faster in delivering functionality. However, these technologies now impede our ability to generalize to other clouds or deployment environments. + +## The future of Feast + +> *"Always in motion is the future."* +> – Yoda, The Empire Strikes Back + +While feature stores have already become essential systems at large technology companies, we believe their widespread adoption will begin in 2021. We also foresee the release of multiple managed feature stores over the next year, as vendors seek to enter the burgeoning operational ML market. + +As we've discussed, feature stores serve both offline and production ML needs, and therefore are primarily built by engineers for engineers. What we need, however, is a feature store that's purpose-built for data-science workflows. Feast will move away from an infrastructure-centric approach toward a more localized experience that does just this: builds on teams' existing data-science workflows. + +The lessons we've learned during the preceding two years have crystallized a vision for what Feast should become: a light-weight modular feature store. One that's easy to pick up, adds value to teams large and small, and can be progressively applied to production use cases that span multiple teams, projects, and cloud-environments. We aim to reach this by applying the following design principles: + +1. Python-first: First-class support for running a minimal version of Feast entirely from a notebook, with all infrastructural dependencies becoming optional enhancements. + * Encourages quick evaluation of the software and ensures Feast is user friendly + * Minimizes the operational burden of running the system in production + * Simplifies testing, developing, and maintaining Feast + +## Next Steps + +Our vision for Feast is not only ambitious, but actionable. Our next release, Feast 0.8, is the product of collaborating with both our open source community and our friends over at [Tecton](https://tecton.ai/). + +1. Python-first: We are migrating all core logic to Python, starting with training dataset retrieval and job management, providing a more responsive development experience. +2. Modular ingestion: We are shifting to managing batch and streaming ingestion separately, leading to more actionable metrics, logs, and statistics and an easier to understand and operate system. +3. Support for AWS: We are replacing GCP-specific technologies like Beam on Dataflow with Spark and adding native support for running Feast on AWS, our first steps toward cloud-agnosticism. +4. Data-source integrations: We are introducing support for a host of new data sources (Kinesis, Kafka, S3, GCS, BigQuery) and data formats (Parquet, JSON, Avro), ensuring teams can seamlessly integrate Feast into their existing data-infrastructure. + +## Get involved + +We've been inspired by the soaring community interest in and contributions to Feast. If you're curious to learn more about our mission to build a best-in-class feature store, or are looking to build your own: Check out our resources, say hello, and get involved! diff --git a/docs/blog/announcing-feast-0-11.md b/docs/blog/announcing-feast-0-11.md new file mode 100644 index 00000000000..b3b1f2f4aca --- /dev/null +++ b/docs/blog/announcing-feast-0-11.md @@ -0,0 +1,65 @@ +# Announcing Feast 0.11 + +*June 23, 2021* | *Jay Parthasarthy & Willem Pienaar* + +Feast 0.11 is here! This is the first release after the major changes introduced in Feast 0.10. We've focused on two areas in particular: + +1. Introducing a new online store, Redis, which supports feature serving at high throughput and low latency. +2. Improving the Feast user experience through reduced boilerplate, smoother workflows, and improved error messages. A key addition here is the introduction of *feature inferencing,* which allows Feast to dynamically discover data schemas in your source data. + +Let's get into it! + +### Support for Redis as an online store 🗝 + +Feast 0.11 introduces support for Redis as an online store, allowing teams to easily scale up Feast to support high volumes of online traffic. Using Redis with Feast is as easy as adding a few lines of configuration to your feature_store.yaml file: + +```yaml +project: fraud +registry: data/registry.db +provider: local +online_store: + type: redis + connection_string: localhost:6379 +``` + +Feast is then able to read and write from Redis as its online store. + +```bash +$ feast materialize + +Materializing 3 feature views to 2021-06-15 18:43:03+00:00 into the redis online store. + +user_account_features from 2020-06-16 18:43:04 to 2021-06-15 18:43:13: +100%|███████████████████████| 9944/9944 [00:04<00:00, 20065.15it/s] +user_transaction_count_7d from 2021-06-08 18:43:21 to 2021-06-15 18:43:03: +100%|███████████████████████| 9674/9674 [00:04<00:00, 19943.82it/s] +``` + +We're also working on making it easier for teams to add their own storage and compute systems through plugin interfaces. Please see this RFC for more details on the proposal. + +### Feature Inferencing 🔎 + +Before 0.11, users had to define each feature individually when defining Feature Views. Now, Feast infers the schema of a Feature View based on upstream data sources, significantly reducing boilerplate. + +Before: +```python +driver_hourly_stats_view = FeatureView( + name="driver_hourly_stats", + entities=["driver_id"], + ttl=timedelta(days=1), + features=[ + Feature(name="conv_rate", dtype=ValueType.FLOAT), + ], + input=BigQuerySource(table_ref="feast-oss.demo_data.driver_hourly_stats"), +) +``` + +Aside from these additions, a wide variety of small bug fixes, and UX improvements made it into this release. [Check out the changelog](https://github.com/feast-dev/feast/blob/master/CHANGELOG.md) for a full list of what's new. + +Special thanks and a big shoutout to the community contributors whose changes made it into this release: [MattDelac](https://github.com/MattDelac), [mavysavydav](https://github.com/mavysavydav), [szalai1](https://github.com/szalai1), [rightx2](https://github.com/rightx2) + +### Help us design Feast for AWS 🗺️ + +The 0.12 release will include native support for AWS. We are looking to meet with teams that are considering using Feast to gather feedback and help shape the product as design partners. We often help our design partners out with architecture or design reviews. If this sounds helpful to you, [join us in Slack](http://slack.feastsite.wpenginepowered.com/), or [book a call with Feast maintainers here](https://calendly.com/d/gc29-y88c/feast-chat-w-willem). + +### Feast from around the web 📣 diff --git a/docs/blog/faster-feature-transformations-in-feast.md b/docs/blog/faster-feature-transformations-in-feast.md new file mode 100644 index 00000000000..689a5b84abd --- /dev/null +++ b/docs/blog/faster-feature-transformations-in-feast.md @@ -0,0 +1,50 @@ +# Faster Feature Transformations in Feast 🏎️💨 + +*December 5, 2024* | *Francisco Javier Arceo, Shuchu Han* + +*Thank you to [Shuchu Han](https://www.linkedin.com/in/shuchu/), [Ross Briden](https://www.linkedin.com/in/ross-briden/), [Ankit Nadig](https://www.linkedin.com/in/ankit-nadig/), and the folks at Affirm for inspiring this work and creating an initial proof of concept.* + +Feature engineering is at the core of building high-performance machine learning models. The Feast team has introduced two major enhancements to [On Demand Feature Views](https://docs.feast.dev/reference/beta-on-demand-feature-views) (ODFVs), pushing the boundaries of efficiency and flexibility for data scientists and engineers. Here's a closer look at these exciting updates: + +## 1. Transformations with Native Python + +Traditionally, transformations in ODFVs were limited to Pandas-based operations. While powerful, Pandas transformations can be computationally expensive for certain use cases. Feast now introduces Native Python Mode, a feature that allows users to write transformations using pure Python. + +Key benefits of Native Python Mode include: + +* Blazing Speed: Transformations using Native Python are nearly 10x faster compared to Pandas for many operations. +* Intuitive Design: This mode supports list-based and singleton (row-level) transformations, making it easier for data scientists to think in terms of individual rows rather than entire datasets. +* Versatility: Users can now switch between batch and singleton transformations effortlessly, catering to both historical and online retrieval scenarios. + +Using the cProfile library and snakeviz we were able to profile the runtime for the ODFV transformation using both Pandas and Native python and observed a nearly 10x reduction in speed. + +## 2. Transformations on Writes + +Until now, ODFVs operated solely as transformations on reads, applying logic during online feature retrieval. While this ensured flexibility, it sometimes came at the cost of increased latency during retrieval. Feast now supports transformations on writes, enabling users to apply transformations during data ingestion and store the transformed features in the online store. + +Why does this matter? + +* Reduced Online Latency: With transformations pre-applied at ingestion, online retrieval becomes a straightforward lookup, significantly improving performance for latency-sensitive applications. +* Operational Flexibility: By toggling the write_to_online_store parameter, users can choose whether transformations should occur at write time (to optimize reads) or at read time (to preserve data freshness). + +Here's an example of applying transformations during ingestion: + +```python +@on_demand_feature_view( + sources=[driver_hourly_stats_view], +) + +df = pd.DataFrame() +df["conv_rate_adjusted"] = features_df["conv_rate"] * 1.1 +return df +``` + +With this new capability, data engineers can optimize online retrieval performance without sacrificing the flexibility of on-demand transformations. + +### The Future of ODFVs and Feature Transformations + +These enhancements bring ODFVs closer to the goal of seamless feature engineering at scale. By combining high-speed Python-based transformations with the ability to optimize retrieval latency, Feast empowers teams to build more efficient, responsive, and production-ready feature pipelines. + +For more detailed examples and use cases, check out the [documentation for On Demand Feature Views](https://docs.feast.dev/reference/beta-on-demand-feature-views). Whether you're a data scientist prototyping features or an engineer optimizing a production system, the new ODFV capabilities offer the tools you need to succeed. + +The future of Feature Transformations in Feast will be to unify feature transformations and feature views to allow for a simpler API. If you have thoughts or interest in giving feedback to the maintainers, feel free to comment directly on [the GitHub Issue](https://github.com/feast-dev/feast/issues/4584) or in [the RFC](https://docs.google.com/document/d/1KXCXcsXq1bU...). diff --git a/docs/blog/feast-0-10-announcement.md b/docs/blog/feast-0-10-announcement.md new file mode 100644 index 00000000000..54b10880d14 --- /dev/null +++ b/docs/blog/feast-0-10-announcement.md @@ -0,0 +1,163 @@ +# Announcing Feast 0.10 + +*April 15, 2021* | *Jay Parthasarthy & Willem Pienaar* + +Today, we're announcing Feast 0.10, an important milestone towards our vision for a lightweight feature store. Feast is an open source feature store that helps you serve features in production. It prevents feature leakage by building training datasets from your batch data, automates the process of loading and serving features in an online feature store, and ensures your models in production have a consistent view of feature data. + +With Feast 0.10, we've dramatically simplified the process of managing a feature store. This new release allows you to: + +* Run a minimal local feature store from your notebook +* Deploy a production-ready feature store into a cloud environment in 30 seconds +* Operate a feature store without Kubernetes, Spark, or self-managed infrastructure + +We think Feast 0.10 is the simplest and fastest way to productionize features. Let's get into it! + +## The challenge with feature stores + +In our previous post, [A State of Feast](https://blog.feastsite.wpenginepowered.com/post), we shared our vision for building a feature store that is accessible to all ML teams. Since then, we've been working towards this vision by shipping support for AWS, Azure, and on-prem deployments. + +Over the last couple of months we've seen a surge of interest in Feast. ML teams are increasingly being tasked with building production ML systems, and many are looking for an open source tool to help them operationalize their feature data in a structured way. However, many of these teams still can't afford to run their own feature stores: + +> "Feature stores are big infrastructure!" + +The conventional wisdom is that feature stores should be built and operated as platforms. It's not surprising why many have this notion. Feature stores require access to compute layers, offline and online databases, and need to directly interface with production systems. + +This infrastructure-centric approach means that operating your own feature store is a daunting task. Many teams simply don't have the resources to deploy and manage a feature store. Instead, ML teams are being forced to hack together their own custom scripts or end up delaying their projects as they wait for engineering support. + +## Towards a simpler feature store + +Our vision for Feast is to provide a feature store that a single data scientist can deploy for a single ML project, but can also scale up for use by large platform teams. We've made all infrastructure optional in Feast 0.10. That means no Spark, no Kubernetes, and no APIs, unless you need them. If you're just starting out we won't ask you to deploy and manage a platform. + +Additionally, we've pulled out the core of our software into a single Python framework. This framework allows teams to define features and declaratively provision a feature store based on those definitions, to either local or cloud environments. If you're just starting out with feature stores, you'll only need to manage a Git repository and run the Feast CLI or SDK, nothing more. + +Feast 0.10 introduces a first-class local mode: not installed through Docker containers, but through pip. It allows users to start a minimal feature store entirely from a notebook, allowing for rapid development against sample data and for testing against the same ML frameworks they're using in production. Finally, we've also begun adding first-class support for managed services. Feast 0.10 ships with native support for GCP, with more providers on the way. Platform teams running Feast at scale get the best of both worlds: a feature store that is able to scale up to production workloads by leveraging serverless technologies, with the flexibility to deploy the complete system to Kubernetes if needed. + +## The new experience + +Machine learning teams today are increasingly being tasked with building models that serve predictions online. These teams are also sitting on a wealth of feature data in warehouses like BigQuery, Snowflake, and Redshift. It's natural to use these features for model training, but hard to serve these features online at low latency. + +## 1. Create a feature repository + +Installing Feast is now as simple as: +```bash +pip install feast +``` + +We'll scaffold a feature repository based on a GCP template: +```bash +feast init driver_features -t gcp +``` + +A feature repository consists of a *feature_store.yaml*, and a collection of feature definitions. +``` +driver_features/ +└── feature_store.yaml +└── driver_features.py +``` + +The *feature_store.yaml* file contains infrastructural configuration necessary to set up a feature store. The *project* field is used to uniquely identify a feature store, the *registry* is a source of truth for feature definitions, and the *provider* specifies the environment in which our feature store will run. + +feature_store.yaml: +```yaml +project: driver_features +registry: gs://driver-fs/ +provider: gcp +``` + +The feature repository also contains Python based feature definitions, like *driver_features.py*. This file contains a single entity and a single feature view. Together they describe a collection of features in BigQuery that can be used for model training or serving. + +## 2. Set up a feature store + +Next we run *apply* to set up our feature store on GCP. +```bash +feast apply +``` + +Running Feast apply will register our feature definitions with the GCS feature registry and prepare our infrastructure for writing and reading features. Apply can be run idempotently, and is meant to be executed from CI when feature definitions change. + +At this point we haven't moved any data. We've only stored our feature definition metadata in the object store registry (GCS) and Feast has configured our infrastructure (Firestore in this case). + +## 3. Build a training dataset + +Feast is able to build training datasets from our existing feature data, including data at rest in our upstream tables in BigQuery. Now that we've registered our feature definitions with Feast we are able to build a training dataset. + +From our training pipeline: +```python +# Connect to the feature registry +fs = FeatureStore( + RepoConfig( + registry="gs://driver-fs/", + project="driver_features" + ) +) + +# Load our driver events table. This dataframe will be enriched with features from BigQuery +driver_events = pd.read_csv("driver_events.csv") + +# Build a training dataset from features in BigQuery +training_df = fs.get_historical_features( + feature_refs=[ + "driver_hourly_stats:conv_rate", + "driver_hourly_stats:acc_rate" + ], + entity_df=driver_events +).to_df() + +# Train a model, and ship it into production +model = ml.fit(training_data) +``` + +The code snippet above will join the user provided dataframe driver_events to our driver_stats BigQuery table in a point-in-time correct way. Feast is able to use the temporal properties (event timestamps) of feature tables to reconstruct a view of features at a specific point in time, from any amount of feature tables or views. + +## 4. Load features into the online store + +At this point we have trained our model and we are ready to serve it. However, our online feature store contains no data. In order to load features into the feature store we run *materialize-incremental* from the command line. + +Feast provides materialization commands that load features from an offline store into an online store. The default GCP provider exports features from BigQuery and writes them directly into Firestore using an in-memory process. Teams running at scale may want to leverage cloud-based ingestion by using a different provider configuration. + +## 5. Read features at low latency + +Now that our online store has been populated with the latest feature data, it's possible for our ML model services to read online features for prediction. + +From our model serving service: +```python +# Connect to the feature store +fs = feast.FeatureStore( + RepoConfig(registry="gs://driver-fs/", project="driver_features") +) + +# Query Firestore for online feature values +online_features = fs.get_online_features( + feature_refs=[ + "driver_hourly_stats:conv_rate", + "driver_hourly_stats:acc_rate" + ], + entity_rows=[{"driver_id": 1001}, {"driver_id": 1002}], +).to_dict() + +# Make a prediction +model.predict(online_features) +``` + +## 6. That's it + +At this point, you can schedule a Feast materialization job and set up our CI pipelines to update our infrastructure as feature definitions change. + +## What's next + +Our vision for Feast is to build a simple yet scalable feature store. With 0.10, we've shipped local workflows, infrastructure pluggability, and removed all infrastructural overhead. But we're still just beginning this journey, and there's still lots of work left to do. + +Over the next few months we will focus on making Feast as accessible to teams as possible. This means adding support for more data sources, streams, and cloud providers, but also means working closely with our users in unlocking new operational ML use cases and integrations. + +Feast is a community driven project, which means extensibility is always a key focus area for us. We want to make it super simple for you to add new data stores, compute layers, or bring Feast to a new stack. We've already seen teams begin development towards community providers for 0.10 during pre-release, and we welcome community contributions in this area. + +The next few months are going to be big ones for the Feast project. Stay tuned for more news, and we'd love for you to get started using Feast 0.10 today! + +## Get started + +* ✨ Try out our [quickstart](https://docs.feastsite.wpenginepowered.com/quickstart) if you're new to Feast, or learn more about Feast through our [documentation](https://docs.feastsite.wpenginepowered.com). +* 👋 Join our [Slack](http://slack.feastsite.wpenginepowered.com/) and say hello! Slack is the best forum for you to get in touch with Feast maintainers, and we love hearing feedback from teams trying out 0.10 Feast. +* 📢 Register for [apply()](https://www.applyconf.com/) – the ML data engineering conference, where we'll [demo Feast 0.10](https://www.applyconf.com/agenda/rethinking-feature-stores) and discuss [future developments for AWS](https://www.applyconf.com/agenda/bringing-feast-to-aws). +* 🔥 For teams that want to continue to run Feast on Kubernetes with Spark, have a look at our installation guides and Helm charts. + +🛠️ Thinking about contributing to Feast? Check out our [code on GitHub](https://github.com/feast-dev/feast)! diff --git a/docs/blog/feast-0-13-adds-on-demand-transforms-feature-servers-and-feature-views-without-entities.md b/docs/blog/feast-0-13-adds-on-demand-transforms-feature-servers-and-feature-views-without-entities.md new file mode 100644 index 00000000000..63dd71a52b6 --- /dev/null +++ b/docs/blog/feast-0-13-adds-on-demand-transforms-feature-servers-and-feature-views-without-entities.md @@ -0,0 +1,45 @@ +# Feast 0.13 adds on-demand transforms, feature servers, and feature views without entities + +*October 2, 2021* | *Danny Chiao, Tsotne Tabidze, Achal Shah, and Felix Wang* + +We are delighted to announce the release of [Feast 0.13](https://github.com/feast-dev/feast/releases/tag/v0.13.0), which introduces: + +* [Experimental] On demand feature views, which allow for consistently applied transformations in both training and online paths. This also introduces the concept of request data, which is data only available at the time of the prediction request, as potential inputs into these transformations +* [Experimental] Python feature servers, which allow you to quickly deploy a local HTTP server to serve online features. Serverless deployments and java feature servers to come soon! +* Feature views without entities, which allow you to specify features that should only be joined on event timestamps. You do not need lists of entities / entity values when defining and retrieving features from these feature views. + +Experimental features are subject to API changes in the near future as we collect feedback. If you have thoughts, please don't hesitate to reach out to the Feast team! + +### [Experimental] On demand feature views + +On demand feature views allows users to use existing features and request data to transform and create new features. Users define Python transformation logic which is executed in both historical retrieval and online retrieval paths.‌ This unlocks many use cases including fraud detection and recommender systems, and reduces training / serving skew by allowing for consistently applied transformations. Example features may include: + +* Transactional features such as `transaction_amount_greater_than_7d_average` where the inputs to features are part of the transaction, booking, or order event. +* Features requiring the current location or time such as `user_account_age`, `distance_driver_customer` +* Feature crosses where the keyspace is too large to precompute such as `movie_category_x_movie_rating` or `lat_bucket_x_lon_bucket` + +Currently, these transformations are executed locally. Future milestones include building a feature transformation server for executing transformations at higher scale. + +First, we define the transformations: + +```python +# Define a request data source which encodes features / information only +# available at request time (e.g. part of the user initiated HTTP request) +input_request = RequestDataSource( + name="vals_to_add", + schema={ + "val_to_add": ValueType.INT64, + } +) +``` + +See [On demand feature view](https://docs.feastsite.wpenginepowered.com/reference/on-demand-feature-view) for detailed info on how to use this functionality. + +### [Experimental] Python feature server + +The Python feature server provides an HTTP endpoint that serves features from the feature store. This enables users to retrieve features from Feast using any programming language that can make HTTP requests. As of now, it's only possible to run the server locally. A remote serverless feature server is currently being developed. Additionally, a low latency java feature server is in development. + +```bash +$ feast init feature_repo +Creating a new Feast repository in /home/tsotne/feast/feature_repo. +``` diff --git a/docs/blog/feast-0-14-adds-aws-lambda-feature-servers.md b/docs/blog/feast-0-14-adds-aws-lambda-feature-servers.md new file mode 100644 index 00000000000..45062d2d54e --- /dev/null +++ b/docs/blog/feast-0-14-adds-aws-lambda-feature-servers.md @@ -0,0 +1,56 @@ +# Feast 0.14 adds AWS Lambda feature servers + +*October 23, 2021* | *Tsotne Tabidze, Felix Wang* + +We are delighted to announce the release of [Feast 0.14](https://github.com/feast-dev/feast/releases/tag/v0.14.0), which introduces a new feature and several important improvements: + +* [Experimental] AWS Lambda feature servers, which allow you to quickly deploy an HTTP server to serve online features on AWS Lambda. GCP Cloud Run and Java feature servers are coming soon! +* Bug fixes around performance. The core online serving path is now significantly faster. +* Improvements for developer experience. The integration tests are now faster, and temporary tables created during integration tests are immediately dropped after the test. + +Experimental features are subject to API changes in the near future as we collect feedback. If you have thoughts, please don't hesitate to reach out to the Feast team! + +### [Experimental] AWS Lambda feature servers + +Prior to Feast 0.13, the only way for users to retrieve online features was to use the Python SDK. This was restrictive, so Feast 0.13 introduced local Python feature servers, allowing users to deploy a local HTTP server to serve their online features. Feast 0.14 now allows users to deploy a feature server on AWS Lambda to quickly serve features at scale. The new AWS Lambda feature servers are available for feature stores using the AWS provider. + +To deploy a feature server to AWS Lambda, they must be enabled and be given the appropriate permissions: + +```yaml +project: dev +registry: s3://feast/registries/dev +provider: aws +online_store: + region: us-west-2 +offline_store: + cluster_id: feast + region: us-west-2 + user: admin + database: feast + s3_staging_location: s3://feast/redshift/tests/staging_location + iam_role: arn:aws:iam::{aws_account}:role/redshift_s3_access_role +flags: + alpha_features: true + aws_lambda_feature_server: true +feature_server: + enabled: True + execution_role_name: arn:aws:iam::{aws_account}:role/lambda_execution_role +``` + +Calling `feast apply` will then deploy the feature server. The precise endpoint can be determined with by calling `feast endpoint`, and the endpoint can then be queried as follows: + +See [AWS Lambda feature server](https://docs.feastsite.wpenginepowered.com/reference/feature-servers/aws-lambda) for detailed info on how to use this functionality. + +### Performance bug fixes and developer experience improvements + +The provider for a feature store is now cached instead of being instantiated repeatedly, making the core online serving path 30% faster. + +Integration tests now run significantly faster on Github Actions due to caching. Also, tables created during integration tests were previously not always cleaned up properly; now they are always deleted immediately after the integration tests finish. + +### What's next + +We are collaborating with the community on supporting streaming sources, low latency serving, a Python feature transformation server for on demand transforms, improved support for Kubernetes deployments, and more. + +In addition, there is active community work on building Hive, Snowflake, Azure, Astra, Presto, and Alibaba Cloud connectors. If you have thoughts on what to build next in Feast, please fill out this [form](https://docs.google.com/forms/d/e/1FAIpQLSfa1nR). + +Download Feast 0.14 today from [PyPI](https://pypi.org/project/feast/) (or pip install feast) and try it out! Let us know on our [slack channel](http://slack.feastsite.wpenginepowered.com/). diff --git a/docs/blog/feast-0-18-adds-snowflake-support-and-data-quality-monitoring.md b/docs/blog/feast-0-18-adds-snowflake-support-and-data-quality-monitoring.md new file mode 100644 index 00000000000..4b4321e3259 --- /dev/null +++ b/docs/blog/feast-0-18-adds-snowflake-support-and-data-quality-monitoring.md @@ -0,0 +1,37 @@ +# Feast 0.18 adds Snowflake support and data quality monitoring + +*February 14, 2022* | *Felix Wang* + +We are delighted to announce the release of Feast [0.18](https://github.com/feast-dev/feast/releases/tag/v0.18.0), which introduces several new features and other improvements: + +* Snowflake offline store, which allows you to define and use features stored in Snowflake. +* [Experimental] Saved Datasets, which allow training datasets to be persisted in an offline store. +* [Experimental] Data quality monitoring, which allows you to validate your training data with Great Expectations. Future work will allow you to detect issues with upstream data pipelines and check for training-serving skew. +* Python feature server graduation from alpha status. +* Performance improvements to on demand feature views, protobuf serialization and deserialization, and the Python feature server. + +Experimental features are subject to API changes in the near future as we collect feedback. If you have thoughts, please don't hesitate to reach out to the Feast team through our [Slack](http://slack.feastsite.wpenginepowered.com/)! + +### Snowflake offline store + +Prior to Feast 0.18, Feast had first-class support for Google BigQuery and AWS Redshift as offline stores. In addition, there were various plugins for Snowflake, Azure, Postgres, and Hive. Feast 0.18 introduces first-class support for Snowflake as an offline store, so users can more easily leverage features defined in Snowflake. The Snowflake offline store can be used with the AWS, GCP, and Azure providers. + +### [Experimental] Saved Datasets + +Training datasets generated via `get_historical_features` can now be persisted in an offline store and reused later. This functionality will be primarily needed to generate reference datasets for validation purposes (see next section) but also could be useful in other use cases like caching results of a computationally intensive point-in-time join. + +### [Experimental] Data quality monitoring + +Feast 0.18 includes the first milestone of our data quality monitoring work. Many users have requested ways to validate their training and serving data, as well as monitor for training-serving skew. Feast 0.18 allows users to validate their training data through an integration with [Great Expectations](https://greatexpectations.io/). Users can declare one of the previously generated training datasets as a reference for this validation by persisting it as a "saved dataset" (see previous section). More details about future milestones of data quality monitoring can be found [here](https://docs.feastsite.wpenginepowered.com/v/master/reference/data-quality). There's also a [tutorial on validating historical features](https://docs.feastsite.wpenginepowered.com/v/master/how-to-guides/validation/validating-historical-features) that demonstrates all new concepts in action. + +### Performance improvements + +The Feast team and community members have made several significant performance improvements. For example, the Python feature server performance was improved by switching to a more efficient serving interface. Improving our protobuf serialization and deserialization logic led to speedups in on demand feature views. The Datastore implementation was also sped up by batching operations. For more details, please see our [blog post](https://feastsite.wpenginepowered.com/blog/feast-benchmarks/) with detailed benchmarks! + +### What's next + +We are collaborating with the community on the first milestone of the `feast plan` command, future milestones of data quality monitoring, and a consolidation of our online serving logic into Golang. + +In addition, there is active community work on adding support for Snowflake as an online store, merging the Azure plugin into the main Feast repo, and more. If you have thoughts on what to build next in Feast, please fill out this [form](https://docs.google.com/forms/d/e/1FAIpQLSfa1nR). + +Download Feast 0.18 today from [PyPI](https://pypi.org/project/feast/) diff --git a/docs/blog/feast-0-20-adds-api-and-connector-improvements.md b/docs/blog/feast-0-20-adds-api-and-connector-improvements.md new file mode 100644 index 00000000000..a15482b6344 --- /dev/null +++ b/docs/blog/feast-0-20-adds-api-and-connector-improvements.md @@ -0,0 +1,41 @@ +# Feast 0.20 adds API and connector improvements + +*April 21, 2022* | *Danny Chiao* + +We are delighted to announce the release of Feast 0.20, which introduces many new features and enhancements: + +* Many connector improvements and bug fixes (DynamoDB, Snowflake, Spark, Trino) + * Note: Trino has been officially bundled into Feast. You can now run this with `pip install "feast[trino]"`! +* Feast API changes +* [Experimental] Feast UI as an importable npm module +* [Experimental] Python SDK with embedded Go mode + +### Connector optimizations & bug fixes + +Key changes: + +* DynamoDB online store implementation is now much more efficient with batch feature retrieval (thanks [@TremaMiguel](https://github.com/TremaMiguel)!). As per updates on the [benchmark blog post](https://feastsite.wpenginepowered.com/blog/feast-benchmarks/), DynamoDB now is much more performant at high batch sizes for online feature retrieval! +* Snowflake offline store connector supports key pair authentication. +* Contrib plugins (documentation still pending, but see [old docs](https://github.com/Shopify/feast-trino)) + +### Feast API simplification + +In planning for upcoming functionality (data quality monitoring, batch + stream transformations), certain parts of the Feast API are changing. As part of this change, Feast 0.20 addresses API inconsistencies. No existing feature repos will be broken, and we intend to provide a migration script to help upgrade to the latest syntax. + +Key changes: + +* Naming changes (e.g. `FeatureView` changes from features -> schema) +* All Feast objects will be defined with keyword args (in practice not impacting users unless they use positional args) +* Key Feast object metadata will be consistently exposed through constructors (e.g. owner, description, name) +* [Experimental] Pushing transformed features (e.g. from a stream) directly to the online store: + * Favoring push sources + +### [Experimental] Feast Web UI + +See [https://github.com/feast-dev/feast/tree/master/ui](https://github.com/feast-dev/feast/tree/master/ui) to check out the new Feast Web UI! You can generate registry dumps via the Feast CLI and stand up the server at a local endpoint. You can also embed the UI as a React component and add custom tabs. + +### What's next + +In response to survey results (fill out this [form](https://forms.gle/9SpCeJnq3MayAqHe6) to give your input), the Feast community will be diving much more deeply into data quality monitoring, batch + stream transformations, and more performant / scalable materialization. + +The community is also actively involved in many efforts. Join [#feast-web-ui](https://tectonfeast.slack.com/channels/feast-web-ui) to get involved with helping on the Feast Web UI. diff --git a/docs/blog/feast-benchmarks.md b/docs/blog/feast-benchmarks.md new file mode 100644 index 00000000000..49cd0624ed4 --- /dev/null +++ b/docs/blog/feast-benchmarks.md @@ -0,0 +1,65 @@ +# Serving features in milliseconds with Feast feature store + +*February 1, 2022* | *Tsotne Tabidze, Oleksii Moskalenko, Danny Chiao* + +Feature stores are operational ML systems that serve data to models in production. The speed at which a feature store can serve features can have an impact on the performance of a model and user experience. In this blog post, we show how fast Feast is at serving features in production and describe considerations for deploying Feast. + +## Updates +Apr 19: Updated DynamoDB benchmarks for Feast 0.20 given batch retrieval improvements + +## Background + +One of the most common questions Feast users ask in our [community Slack](http://slack.feastsite.wpenginepowered.com/) is: how scalable / performant is Feast? (spoiler alert: Feast is *very* fast, serving features at <1.5ms @p99 when using Redis in the below benchmarks) + +In a survey conducted last year ([results](https://docs.google.com/forms/d/e/1FAIpQLScV2RX)), we saw that most users were tackling challenging problems like recommender systems (e.g. recommending items to buy) and fraud detection, and had strict latency requirements. + +Over 80% of survey respondents needed features to be read at less than 100ms (@p99). Taking into account that most users in this survey were supporting recommender systems, which often require ranking 100s-1000s of entities simultaneously, this becomes even more strict. Feature serving latency scales with batch size because of the need to query features for random entities and other sources of tail latency. + +In this blog, we present results from a benchmark suite ([RFC](https://docs.google.com/document/d/12UuvTQnTTCJ)), describe the benchmark setup, and provide recommendations for how to deploy Feast to meet different operational goals. + +## Considerations when deploying Feast + +There are a couple of decisions users need to make when deploying Feast to support online inference. There are two key decisions when it comes to performance: + +1. How to deploy a feature server +2. Choice of online store + +Each approach comes with different tradeoffs in terms of performance, scalability, flexibility, and ease of use. This post aims to help users decide between these approaches and enable users to easily set up their own benchmarks to see if Feast meets their own latency requirements. + +### How to deploy a feature server + +While all users setup a Feast feature repo in the same way (using the Python SDK to define and materialize features), users retrieve features from Feast in a few different ways (see also [Running Feast in Production](https://docs.feastsite.wpenginepowered.com/how-to-guides/running-feast-in-production)): + +1. Deploy a Java gRPC feature server (Beta) +2. Deploy a Python HTTP feature server +3. Deploy a serverless Python HTTP feature server on AWS Lambda +4. Use the Python client SDK to directly fetch features +5. (Advanced) Build a custom client (e.g in Go or Java) to directly read the registry and read from an online store + +The first four above come for free with Feast, while the fifth requires custom work. All options communicate with the same Feast registry component (managed by feast apply) to understand where features are stored. + +Deploying a feature server service (compared to using a Feast client that directly communicates with online stores) can enable many improvements such as better caching (e.g. across clients), improved data access management, rate limiting, centralized monitoring, supporting client libraries across multiple languages, etc. However, this comes at the cost of increased architectural complexity. Serverless architectures are on the other end of the spectrum, enabling simple deployments at the cost of latency overhead. + +### Choice of online stores + +Feast is highly pluggable and extensible and supports serving features from a range of online stores (e.g. Amazon DynamoDB, Google Cloud Datastore, Redis, PostgreSQL). Many users build their own plugins to support their specific needs / online stores. [Building a Feature Store](https://www.tecton.ai/blog/how-to-build-a-feature-store/) dives into some of the trade-offs between online stores. Easier to manage solutions like DynamoDB or Datastore often lose against Redis in terms of read performance and cost. Each store also has its own API idiosyncrasies that can impact performance. The Feast community is continuously optimizing store-specific performance. + +## Benchmark Results + +The raw data exists at [https://github.com/feast-dev/feast-benchmarks](https://github.com/feast-dev/feast-benchmarks). We choose a subset of comparisons here to answer some of the most common questions we hear from the community. + +### Summary + +* The Java feature server is very fast (e.g. p99 latency is ~1.3 ms for a single row fetch of 250 features) + * Note: The Java feature server is in Beta and does not support new functionality such as the more scalable SQL registry. + +The Beta Feast Java feature server with Redis provides very low latency retrieval (p99 < 1.5ms for single row retrieval of 250 features), but at increased architectural complexity, less first class support for functionality (e.g. no SQL registry support), and more overhead in managing Redis clusters. Using a Python server with other managed online stores like DynamoDB or Datastore is easier to manage. + +Note: there are managed services for Redis like Redis Enterprise Cloud which remove the additional complexity associated with managing Redis clusters and provide additional benefits. + +### What's next + +The community is always improving Feast performance, and we'll post updates to performance improvements in the future. Future improvements in the works include: + +* Improved on demand transformation performance +* Improved pooling of clients (e.g. we've seen that caching Google clients significantly improves response times and reduces memory consumption) diff --git a/docs/blog/feast-joins-the-linux-foundation-for-ai-data.md b/docs/blog/feast-joins-the-linux-foundation-for-ai-data.md new file mode 100644 index 00000000000..19af5632b8a --- /dev/null +++ b/docs/blog/feast-joins-the-linux-foundation-for-ai-data.md @@ -0,0 +1,37 @@ +# Feast Joins The Linux Foundation for AI & Data + +*January 22, 2021* | *Christina Harter* + +([Original post](https://lfaidata.foundation/blog/2020/11/10/feast-joins-lf-ai-data-as-new-incubation-project/)) + +LF AI & Data Foundation—the organization building an ecosystem to sustain open source innovation in artificial intelligence (AI), machine learning (ML), deep learning (DL), and Data open source projects—today is announcing FEAST as its latest Incubation Project. [Feast](https://feastsite.wpenginepowered.com/) (Feature Store) is an open source feature store for machine learning. + +Today, teams running operational machine learning systems are faced with many technical and organizational challenges: + +1. Models don't have a consistent view of feature data and are tightly coupled to data infrastructure. +2. Deploying new features in production is difficult. +3. Feature leakage decreases model accuracy. +4. Features aren't reused across projects. +5. Operational teams can't monitor the quality of data served to models. + +Developed collaboratively between [Gojek](https://www.gojek.com/) and [Google Cloud](https://cloud.google.com/) in 2018, Feast was open sourced in early 2019. The project sets out to address these challenges as follows: + +1. Providing a single data access layer that decouples models from the infrastructure used to generate, store, and serve feature data. +2. Decoupling the creation of features from the consumption of features through a centralized store, thereby allowing teams to ship features into production with minimal engineering support. +3. Providing point-in-time correct retrieval of feature data for both model training and online serving. +4. Encouraging reuse of features by allowing organizations to build a shared foundation of features. +5. Providing data-centric operational monitoring that ensures operational teams can run production machine learning systems confidently at scale. + +"Feast was created to address the data challenges we faced at Gojek while scaling machine learning for ride-hailing, food delivery, digital payments, fraud detection, and a myriad of other use cases" said Willem Pienaar, creator of Feast. "After open sourcing the project we've seen an explosion of demand for the software, leading to strong adoption and community growth. Entering the LF AI & Data Foundation is an important step for us toward decentralized governance and wider industry adoption and development." + +Jeremy Lewi, Kubeflow founder, said "Feast entering the LF AI & Data Foundation is both a major milestone for the project and recognition of the strides the project has made toward solving some of the hardest problems in productionizing data for machine learning. Technologies like Feast have the potential to shape the machine learning stack of the future, and with its incubation in LF AI & Data, the project now has the ideal environment to expand its community in building a best-in-class open source feature store." + +Dr. Ibrahim Haddad, Executive Director of LF AI & Data, said: "We are very excited to welcome FEAST to LF AI & Data and help it thrive in a vendor-neutral environment under an open governance model. With the addition of FEAST, we are increasing the number of hosted projects under the Data category and look forward to tighter collaboration between our data projects and all other projects to drive innovation in data, analytics, and AI open source technologies." + +LF AI & Data supports projects via a wide range of services, and the first step is joining as an Incubation Project. LF AI & Data will support the neutral open governance for FEAST to help foster the growth of the project. Check out the [Documentation](https://docs.feastsite.wpenginepowered.com/) to start working with FEAST today. Learn more about FEAST on their [GitHub](https://github.com/feast-dev/feast) and be sure to join the [FEAST-Announce](https://lists.lfaidata.foundation/g/feast-announce) and [FEAST-Technical-Discuss](https://lists.lfaidata.foundation/g/feast-technical-discuss) mail lists to join the community and stay connected on the latest updates. + +A warm welcome to FEAST! We look forward to the project's continued growth and success as part of the LF AI & Data Foundation. To learn about how to host an open source project with us, visit the [LF AI & Data website](https://lfaidata.foundation/proposal-and-hosting/). + +FEAST Key Links: +* [Website](https://feastsite.wpenginepowered.com/) +* [GitHub](https://github.com/feast-dev/feast) diff --git a/docs/blog/feast-release-0-12-adds-aws-redshift-and-dynamodb-stores.md b/docs/blog/feast-release-0-12-adds-aws-redshift-and-dynamodb-stores.md new file mode 100644 index 00000000000..c79008caaf8 --- /dev/null +++ b/docs/blog/feast-release-0-12-adds-aws-redshift-and-dynamodb-stores.md @@ -0,0 +1,46 @@ +# Feast 0.12 adds AWS Redshift and DynamoDB stores + +*August 11, 2021* | *Jules S. Damji, Tsotne Tabidze, and Achal Shah* + +We are delighted to announce [Feast 0.12](https://github.com/feast-dev/feast/blob/master/CHANGELOG.md) is released! With this release, Feast users can take advantage of AWS technologies such as Redshift and DynamoDB as feature store backends to power their machine learning models. We want to share three key additions that extend Feast's ecosystem and facilitate a convenient way to group features via a Feature Service for serving: + +1. Adding [AWS Redshift](https://aws.amazon.com/redshift/), a cloud data warehouse, as an offline store, which supports features serving for training and batch inference at high throughput + +Let's briefly take a peek at each and how easily you can use them through simple declarative APIs and configuration changes. + +### AWS Redshift as a feature store data source and an offline store + +Redshift data source allows you to fetch historical feature values from Redshift for building training datasets and materializing features into an online store (see below how to materialize). A data source is defined as part of the [Feast Declarative API](https://rtd.feastsite.wpenginepowered.com/en/latest/) in the feature repo directory's Python files. For example, `aws_datasource.py` defines a table from which we want to fetch features. + +```python +from feast import RedshiftSource + +my_redshift_source = RedshiftSource(table="redshift_driver_table") +``` + +### AWS DynamoDB as an online store + +To allow teams to scale up and support high volumes of online transactions requests for machine learning (ML) predictions, Feast now supports a scalable DynamoDB to serve fresh features to your model in production in the AWS cloud. To enable DynamoDB as your online store, just change `featore_store.yaml`: + +```yaml +project: fraud_detection +registry: data/registry.db +provider: aws +online_store: + type: dynamodb + region: us-west-2 +``` + +To materialize your features into your DynamoDB online store, simply issue the command: + +```bash +$ feast materialize +``` + +Use a Feature Service when you want to logically group features from multiple Feature Views. This way, when requested from Feast, all features will be returned from the feature store. `feature_store.get_historical_features(...)` and `feature_store.get_online_features(...)` + +### What's next + +We are working on a Feast tutorial use case on AWS, meanwhile you can check out other [tutorials in documentation](https://docs.feastsite.wpenginepowered.com/). For more documentation about the aforementioned features, check the following Feast links: + +* [Online stores](https://docs.feastsite.wpenginepowered.com/reference/online-stores/) diff --git a/docs/blog/feast-supports-vector-database.md b/docs/blog/feast-supports-vector-database.md new file mode 100644 index 00000000000..6463a271ae3 --- /dev/null +++ b/docs/blog/feast-supports-vector-database.md @@ -0,0 +1,43 @@ +# Feast Launches Support for Vector Databases 🚀 + +*July 25, 2024* | *Daniel Dowler, Francisco Javier Arceo* + +## Feast and Vector Databases + +With the rise of generative AI applications, the need to serve vectors has grown quickly. We are pleased to announce that Feast now supports (as an experimental feature in Alpha) embedding vector features for popular GenAI use-cases such as RAG (retrieval augmented generation). + +An important consideration is that GenAI applications using embedding vectors stand to benefit from a formal feature framework, just as traditional ML applications do. We are excited about adding support for embedding vector features because of the opportunity to improve GenAI backend operations. The integration of embedding vectors as features into Feast, allows GenAI developers to take advantage of MLOps best practices, lowering development time, improving quality of work, and sets the stage for [Retrieval Augmented Fine Tuning](https://techcommunity.microsoft.com/t5/ai-ai-platform-blog/retrieval-augmented-fine-tuning-raft-with-azure-ai/ba-p/3979114). + +## Setting Up a Document Embedding Feature View + +The [feast-workshop repo example](https://github.com/feast-dev/feast-workshop/tree/main) shows how Feast users can define feature views with vector database sources. They can easily convert text queries to embedding vectors, which are then matched against a vector database to retrieve closest vector records. All of this works seamlessly within the Feast toolset, so that vector features become a natural addition to the Feast feature store solution. + +Defining a feature backed by a vector database is very similar to defining other types of features in Feast. Specifically, we can use the FeatureView class with an Array type field. + +```python +from datetime import timedelta +from feast import FeatureView +from feast.types import Array, Float32 +from feast.field import Field + +for key, value in sorted(features.items()): + print(key, " : ", value) + +print_online_features(features) +``` + +## Supported Vector Databases + +The Feast development team has conducted preliminary testing with the following vector stores: + +* SQLite +* Postgres with the PGVector extension +* Elasticsearch + +There are many more vector store solutions available, and we are excited about discovering how Feast may work with them to support vector feature use-cases. We welcome community contributions in this area–if you have any thoughts feel free to join the conversation on GitHub + +## Final Thoughts + +Feast brings formal feature operations support to AI/ML teams, enabling them to produce models faster and at higher levels of quality. The need for feature store support naturally extends to vector embeddings as features from vector databases (i.e., online stores). Vector storage and retrieval is an active space with lots of development and solutions. We are excited by where the space is moving, and look forward to Feast's role in operationalizing embedding vectors as first class features in the MLOps ecosystem. + +If you are new to feature stores and MLOps, this is a great time to give Feast a try. Check out [Feast documentation](https://feast.dev/) and the [Feast GitHub](https://github.com/feast-dev/feast) page for more on getting started. Big thanks to [Hao Xu](https://www.linkedin.com/in/hao-xu-a04436103/) and the community for their contributions to this effort. diff --git a/docs/blog/go-feature-server-benchmarks.md b/docs/blog/go-feature-server-benchmarks.md new file mode 100644 index 00000000000..1bd2539cab2 --- /dev/null +++ b/docs/blog/go-feature-server-benchmarks.md @@ -0,0 +1,55 @@ +# Go feature server benchmarks + +*July 19, 2022* | *Felix Wang* + +## Background + +The Feast team published a [blog post](https://feastsite.wpenginepowered.com/blog/feast-benchmarks/) several months ago with latency benchmarks for all of our online feature retrieval options. Since then, we have built a Go feature server. It is currently in alpha mode, and only supports Redis as an online store. The docs are [here](https://docs.feastsite.wpenginepowered.com/reference/feature-servers/go-feature-server/). We recommend teams that require extremely low-latency feature serving to try the Go feature server. To test it, we ran our benchmarks against it; the results are presented below. + +## Benchmark Setup + +See [https://github.com/feast-dev/feast-benchmarks](https://github.com/feast-dev/feast-benchmarks) for the exact benchmark code. The feature servers were deployed in Docker on AWS EC2 instances (c5.4xlarge, 16vCPU, 64GiB memory). + +## Data and query patterns + +Feast's feature retrieval primarily manages retrieving the latest values of a given feature for specified entities. In this benchmark, the online stores contain: + +* 25 feature views (with 10 features per feature view) for a total of 250 features +* 1M entity rows + +As described in [RFC-031](https://docs.google.com/document/d/12UuvTQnTTCJ), we simulate different query patterns by additionally varying by number of entity rows in a request (i.e. *batch size*), requests per second, and the concurrency of the feature server. The goal here is to have numbers that apply to a diverse set of teams, regardless of their scale and typical query patterns. Users are welcome to extend the benchmark suite to better test their own setup. + +## Online store setup + +These benchmarks only used Redis as an online store. We used a single Redis server, run locally with Docker Compose on an EC2 instance. This should closely approximate usage of a separate Redis server in AWS. Typical network latency within the same availability zone in AWS is [< 1-2 ms](https://aws.amazon.com/blogs/architecture/improving-performance-and-reducing-cost-using-availability-zone-affinity/). In these benchmarks, we did not hit limits that required use of a Redis cluster. With higher batch sizes, the benchmark suite would likely only work with Redis clusters. Redis clusters should improve Feast's performance. + +## Benchmark Results + +### Summary + +* The Go feature server is very fast (e.g. p99 latency is ~3.9 ms for a single row fetch of 250 features) +* For the same number of features and batch size, the Go feature server is about 3-5x faster than the Python feature server + * Despite this, there are still compelling reasons to use Python, depending on your situation (e.g. simplicity of deployment) +* Feature server latency… + * scales linearly (moderate slope) with batch size + * scales linearly (low slope) with number of features + * does not substantially change as requests per seconds increase + +### Latency when varying by batch size + +For this comparison, we check retrieval of 50 features across 5 feature views. At p99, we see that Go significantly outperforms Python, by ~3-5x. It also scales much better with batch size. + +| Batch size | 1 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 | +|------------|---|----|----|----|----|----|----|----|----|----|----| +| Python | 7.23 | 15.14 | 23.96 | 32.80 | 41.44 | 50.43 | 59.88 | 94.57 | 103.28 | 111.93 | 124.87 | +| Go | 4.32 | 3.88 | 6.09 | 8.16 | 10.13 | 12.32 | 14.3 | 16.28 | 18.53 | 20.27 | 22.18 | + +### Latency when varying by number of requested features + +The Go feature server scales a bit better than the Python feature server in terms of supporting a large number of features: +p99 retrieval times (ms), varying by number of requested features (batch size = 1) + +| Num features | 50 | 100 | 150 | 200 | 250 | +|-------------|----|----|-----|-----|-----| +| Python | 8.42 | 10.28 | 13.36 | 16.69 | 45.41 | +| Go | 1.78 | 2.43 | 2.98 | 3.33 | 3.92 | diff --git a/docs/blog/how-danny-chiao-is-keeping-feast-simple.md b/docs/blog/how-danny-chiao-is-keeping-feast-simple.md new file mode 100644 index 00000000000..24955b5aa8c --- /dev/null +++ b/docs/blog/how-danny-chiao-is-keeping-feast-simple.md @@ -0,0 +1,31 @@ +# How Danny Chiao is Keeping Feast Simple + +*March 2, 2022* | *Claire Besset* + +Tecton Engineer Danny Chiao recently appeared on *The Feast Podcast* to have a conversation with host Demetrios Brinkmann, head of the [MLOps Community](https://mlops.community). Demetrios and Danny spent an hour together discussing why Danny left Google to work on Feast, what it's like to be a leader in an open-source community, and what the future holds for Feast and MLOps. You can read about the highlights from their conversation below, or listen to the full episode [here](https://anchor.fm/featurestore). + +## From Google to Feast + +Prior to joining Tecton, Danny spent 7.5 years at Google, working on everything from Google+ to Android to Google Workspace. As a machine learning engineer, he worked with stakeholders from both product and research teams. Bridging gaps between these teams was a two-way challenge: product teams needed help applying learnings from research teams, and research teams needed to be convinced to take on projects from the product space. + +In addition, it was difficult to share data from enterprise Google products with research teams due to security and privacy mandates. Danny's experience working on multiple ML products and interfacing between diverse stakeholder groups would later prove to be highly valuable in his role in the Feast open source community. + +What prompted Danny to leave Google and join Tecton? He noticed how the ML landscape outside of Google was starting to look very different from how it did internally. While Google was still using ETL jobs to read data from data lakes or databases and perform massive transformations, other companies were taking advantage of new data warehouse technologies: "I was hearing that the ecosystem for iterating, developing, and shipping models was oddly enough more mature outside of Google…Internally, a lot of these massive systems are dependent on the infrastructure, so you can't iterate as quickly." + +Excited by the innovations in ML infrastructure that were appearing in the broader community, Danny moved to Tecton to work on [Feast](https://www.tecton.ai/blog/feast-announcement/), an open-source feature store. [Feature stores](https://www.tecton.ai/blog/what-is-a-feature-store/) act as a central hub for feature data across an ML project's lifecycle, and are responsible for transforming raw data into features, storing and managing features, and serving features for training and prediction. Feature stores are quickly becoming a critical infrastructure for data science teams putting ML into production. + +## What it's like to work in the Feast open source community + +As a leader in the Feast community, Danny splits his time between engineering projects and community engagement. In working with the community, Danny is learning about the current and emerging use cases for Feast. One of the big challenges with Feast is its broad user-base: "We have users coming to us like, 'Hey, I don't have that much data. I don't have super-strict latency requirements. I don't need a lot of complexity.' Then you have the Twitters of the world who are like, 'Hey, we need massive scale, massive low latency.' There's definitely that tug." + +There are also diverse usecases for Feast, from recommender systems, to fraud detection, to credit scoring, to biotech. The solution has been to keep Feast as simple and streamlined as possible. It should be flexible and extensible enough to meet the needs of its broad community, but it also aims to be accessible for small companies just beginning machine learning operations. As Danny says, "You can't get all these new users to come in and enjoy value if it's going to take a really, really long time to stand something up." + +This was the vision behind the release of Feast 0.10, which is Python-centric and can run on a developer's local machine. Overall, Danny holds a very positive outlook on the future of collaboration within Feast, noting how the diversity of the community can be an asset: "If you can motivate the right people and drive people towards the same vision, then you can do things way faster than if you were just a small team executing on it." + +## The future for Feast + +What's on the docket for Feast development this year? They're working with companies like Twitter and Redis to get benchmarks on how performant Feast is and harden the serving layer. Danny's excited to work on data quality monitoring and make that practice more standardized in the community. He's also looking forward to the launch of the Feast Web UI, because users have been asking for easier ways to discover and share features and data pipelines. + +True to the vision of keeping Feast simple, the team is focused on targeting new users in the ML space and getting them from zero-to-one. This is the plan for a world where machine learning is becoming even more ubiquitous. "It's going to become something that is just expected of companies," Demetrios said. "Right now, it doesn't feel like we've even gotten at 2% of what is potentially possible if every single business is going to be using machine learning." Fortunately, feature stores are a technology that can dramatically shorten the time it takes a new company to begin realizing value from machine learning. + +From meeting the machine learning needs of a broad user base to helping new teams get started with ML, there's a lot of exciting work to be done at Feast! You can learn more about the Feast project on our [website](https://www.tecton.ai/feast/), or read updates in Danny's community newsletter on the [Feast google group](https://groups.google.com/g/feast-dev/). diff --git a/docs/blog/kubeflow-and-feast-with-david-aronchick.md b/docs/blog/kubeflow-and-feast-with-david-aronchick.md new file mode 100644 index 00000000000..ca8ec914696 --- /dev/null +++ b/docs/blog/kubeflow-and-feast-with-david-aronchick.md @@ -0,0 +1,31 @@ +# Kubeflow + FEAST With David Aronchick, Co-creator of Kubeflow + +*April 29, 2022* | *demetrios* + +A recent episode of *The Feast Podcast* featured the co-creator of [Kubeflow](https://www.kubeflow.org/), David Aronchick, along with hosts Willem Pienaar and Demetrios Brinkmann. David, Willem, and Demetrios talked about the complexities of setting up machine learning (ML) infrastructure today and what's needed in the future to improve this process. You can read about the highlights from the podcast below or listen to the full episode [here](https://anchor.fm/featurestore/episodes/Kubeflo...). + +## Creation and philosophy behind Kubeflow + +[Kubeflow](https://www.kubeflow.org/) is a project that improves the deployment process of ML workflows on [Kubernetes](https://kubernetes.io/), a system for managing containers. It's an open-source platform originally based on Google's internal method to deploy [TensorFlow](https://www.tensorflow.org/) models, and is available for public use. It can deploy systems everywhere that Kubernetes is supported: e.g. on-premise installations, Google Cloud, AWS, and Azure. + +For machine learning practitioners, training is usually done in one of two ways. If the data set is small, users typically work in a Jupyter notebook, which allows them to quickly iterate on the necessary parameters without having to do much manual setup. On the other hand, if the data set is very large, distributed training is required with many physical or virtual machines. + +Originally, Kubeflow started as a way to connect the two worlds, so one could start with a Jupyter notebook and then move into distributed training with more features, pipelines, and feature stores as the data set grows. By itself, Kubeflow did not provide these additional capabilities, but wanted to partner with a service that did — hence, the beginning of a great collaboration with [Feast](https://www.tecton.ai/feast/). David described how Kubeflow is built on a mix of services: Kubeflow defines the pipeline language, Feast provides the feature store, [Argo](https://argoproj.github.io/workflows/) does work under the hood, [Katib](https://www.kubeflow.org/docs/components/katib/...) provides a hyperparameter sweep, and [Seldon](https://www.kubeflow.org/docs/external-add-ons/...) provides an inference endpoint. As Kubeflow becomes more mature, the goal is to restructure from a monolithic infrastructure where many services are installed at once to become more clean and specialized, so users only install the services they need. Currently we can see that happening with the graduation of KServe. + +## Improving the collaboration between data scientists and software engineers + +Next, David discussed how data scientists and software engineers work together to build and deploy ML systems. Data scientists fine tune the parameters of the model while engineers work on productionizing the model — that is, making sure it runs smoothly without interruptions. Unfortunately, the production deploy process cannot be fully automated yet. One of the core problems is that the APIs for ML systems are complicated to use, which is a hindrance to data scientists. + +A lot of work in ML is closer to science, where hypotheses are made and tested, as opposed to software development, where there is an iterative process and new versions are always being shipped. If you start building a distributed model based on a large data set, it may be hard or impossible to work in an interactive notebook like Jupyter unless a completely new, smaller, model is created. + +The general process for ML practitioners is a pipeline, but the individual steps are often not clearly described so it is difficult to map each step to the correct tool for the job. A data scientist's daily work can often look like downloading a CSV, deleting a column of data, uploading it to a feature store, running a Python script, and then doing training. Willem stated the need for a better solution: "Small groups should be able to independently deploy solutions for specific use cases that solve business problems." David wants to make this pipeline easier to perform with existing tools: "While there certainly are components of that available in Kubeflow and Kubernetes and others, I'm thinking about what that next abstraction layer looks like and it should be available shortly." + +## What's needed to accelerate the industry + +The landscape of ML operations platforms is very complex. There are several infrastructure options out there: Kubernetes was chosen as the backbone of Kubeflow because it's simple to set up and tweak. Willem talked about the consolidation of ML and ML operations tools: "It's going to happen eventually because there's just too many out there, and they're not all going to make it. Right now, it's the breeding grounds, and then it's going to be survival of the fittest." We can already see this playing out with DataRobot acquiring Algorithmia, Snowflake purchasing Streamlit and a few days ago, Databricks buying Cortex Labs. + +For open-source projects like Kubeflow, there should be a working group around core components that establishes standards. It isn't necessary to have one person who makes all of the decisions in this space. If a new feature is needed, code discussions are 10% of the problem but the majority of the work is around deciding implementation details and making sure that it works. The fastest way to get something done is just to build it yourself and try to get it merged. + +David mentioned that to really improve the ecosystem for ML, we "need to develop not just a standard layer for describing the entire platform, but also a system that describes many of the common objects in machine learning: a feature, a feature store, a data set, a training run, an experiment, a serving inference, and so on. It will spur innovation because it defines a set of clear contracts that users can produce and consume." Currently, this is hard to do programmatically because the variety of systems means that auxiliary tools need to be written to connect data sets. + +If expanding the future of ML infrastructure sounds exciting to you, there's a lot of contributions that are needed! You can learn more about [Feast](https://www.tecton.ai/feast/), the feature store connected to Kubeflow, and start using it today. Jump in our [slack](http://slack.feastsite.wpenginepowered.com/) and say hi! diff --git a/docs/blog/machine-learning-data-stack-for-real-time-fraud-prediction-using-feast-on-gcp.md b/docs/blog/machine-learning-data-stack-for-real-time-fraud-prediction-using-feast-on-gcp.md new file mode 100644 index 00000000000..729787dc4a2 --- /dev/null +++ b/docs/blog/machine-learning-data-stack-for-real-time-fraud-prediction-using-feast-on-gcp.md @@ -0,0 +1,50 @@ +# Machine learning data stack for real-time fraud detection using Feast on GCP + +*September 8, 2021* | *Jay Parthasarthy and Jules S. Damji* + +A machine learning (ML) model decides whether your transaction is blocked or approved every time you purchase using your credit card. Fraud detection is a canonical use case for real-time ML. Predictions are made upon each request quickly while you wait at a point of sale for payment approval. + +Even though this is a common problem with ML, companies often build custom tooling to tackle these predictions. Like most ML problems, the hard part of fraud prediction is in the data. The fundamental data challenges are the following: + +1. Some data needed for prediction is available as part of the transaction request. This data is the easy part of passing to the model. +2. Other data (for example, a user's historical purchases) provides a high signal for predictions, but it isn't available as part of the transaction request. This data takes time to look up: it's stored in a batch system like a data warehouse. This data is challenging to fetch since it requires a system to handle many queries per second (QPS). +3. Together, they comprise ML features as signals to the model for predicting whether the requested transaction is fraudulent. + +[Feast](https://feastsite.wpenginepowered.com/) is an open-source feature store that helps teams use batch data for real-time ML applications. It's used as part of fraud [prediction and other high-volume transactions systems](https://www.youtube.com/watch?v=ED81DvicQuQ) to prevent fraud for billions of dollars worth of transactions at companies like [Gojek](https://www.gojek.com/en-id/) and [Postmates](https://postmates.com/). In this blog, we discuss how we can use Feast to build a stack for fraud predictions. You can also follow along on Google Cloud Platform (GCP) by running this [Colab tutorial notebook](https://colab.research.google.com/github/feast-dev/feast-fraud-tutorial). + +## Generic data stack for fraud detection + +Here's what a generic stack for fraud prediction looks like: + +## 1. Generating batch features from data sources + +The first step in deploying an ML model is to generate features from raw data stored in an offline system, such as a data warehouse (DWH) or a modern data lake. After that, we use these features in our ML model for training and inference. But before we get into the specifics of fraud detection related to our example below, let's quickly understand some high-level concepts. + +Data sources: This data repository records all historical transactions data for a user, account information, and any indication of user fraud history. Usually, it's a data warehouse (DHW) with respective tables. The diagram above shows that features are generated from these data sources and put into another offline store (or the same store). Using transformational queries, like SQL, this data, joined from multiple tables, could be injected or stored as another table in a DWH— refined and computed as features. + +Features used: In the fraud use case, one set of the raw data is a record of historical transactions. This record includes data about the transaction: +* Amount of transaction +* Timestamp when the event occurred +* User account information + +## 3. Materialize features to low-latency online stores + +We have a model that's ready for real-time inference. However, we won't be able to make predictions in real-time if we need to fetch or compute data out of the data warehouse on each request because it's slow. + +Feast allows you to make real-time predictions based on warehouse data by materializing it into an [online store](https://docs.feastsite.wpenginepowered.com/concepts/registry). Using the Feast CLI, you can incrementally materialize your data, from the current time on since the previous materialized data: + +```bash +feast materialize-incremental $(date -u +"%Y-%m-%dT%H:%M:%S") +``` + +With our feature values loaded into the online store, a low-latency key-value store, as shown in the diagram above, we can retrieve new data when a new transaction request arrives in our system. + +Note that the feast materialize-incremental command needs to be run regularly so that the online store can continue to contain fresh feature values. We suggest that you integrate this command into your company's scheduler (e.g., Airflow.) + +## Conclusion + +In summation, we outlined a general data stack for real-time fraudulent prediction use cases. We implemented an end-to-end fraud prediction system using [Feast on GCP](https://github.com/feast-dev/feast-fraud-tutorial) as part of our tutorial. + +We'd love to hear how your organization's setup differs. This setup roughly corresponds to the most common patterns we've seen from our users, but things are usually more complicated as teams introduce feature logging, streaming features, and operational databases. + +You can bootstrap a simple stack illustrated in this blog by running our [tutorial notebook on GCP](https://colab.research.google.com/github/feast-dev/feast-fraud-tutorial). From there, you can integrate your prediction service into your production application and start making predictions in real-time. We can't wait to see what you build with Feast, and please share with the [Feast community](http://slack.feastsite.wpenginepowered.com/). diff --git a/docs/blog/performance-test-for-python-based-feast-feature-server.md b/docs/blog/performance-test-for-python-based-feast-feature-server.md new file mode 100644 index 00000000000..f9ef252e932 --- /dev/null +++ b/docs/blog/performance-test-for-python-based-feast-feature-server.md @@ -0,0 +1,61 @@ +# Performance Test for the Python-Based Feast Feature Server: Comparison Between DataStax Astra DB (Based on Apache Cassandra), Google Datastore & Amazon DynamoDB + +*April 17, 2023* | *Stefano Lottini* + +## Introduction + +Feature stores are an essential part of the modern stack around machine learning (ML); in particular, the effort aimed at rationalizing the access patterns to the features associated with ML models by the various functions revolving around it (from data engineers to data scientists). At its core, a feature store provides a layer atop a persistent data store (a database) that facilitates shared access to the features associated with the entities belonging to a business domain, making it easier to retrieve them consistently for both training and prediction. + +Out of several well-established feature stores available today, the most popular open-source solution is arguably [Feast](https://feastsite.wpenginepowered.com/). With its active base of contributors and support for a growing list of backends to choose from, ML practitioners don't have to worry about the boilerplate setup of their data system and can focus on delivering their product—all while retaining the freedom to choose the backend that best suits their needs. + +Last year, the Feast team published [extensive benchmarks](https://feastsite.wpenginepowered.com/blog/feast-benchmarks/) comparing the performance of the feature store when using different storage layers for retrieval of "online" features (that is, up-to-date reads to calculate inferences, as opposed to batch or historical "offline" reads). The storage backends used in the test, each powered by its own Feast plugin, were: Redis (running locally), Google Datastore, and Amazon DynamoDB—the latter on the same cloud region as the testing client. The main takeaways were: + +* Redis yields the lowest response times (but at a cost; see below) +* Among the cloud DB vendors, DynamoDB is noticeably faster than Datastore +* Latencies increase with the number of features needed and, albeit less so, with the number of rows ("entities") + +Moreover, Feast offers an SDK for both Java and Python. Although choosing the Java stack for the feature server results in faster responses, the vast majority of Feast users work with a Python-centered stack. So in our tests, we'll focus on Python start-to-end setups. + +Surveys done by Feast also showed that more than 60% of the interviewees required that P99 latency stay below 50 ms. These ultra-low-latency ML use cases often fall in the [fraud detection](https://www.tecton.ai/blog/how-to-build-a-fraud-detection-ml-system/) and [recommender system](https://www.tecton.ai/blog/guide-to-building-online-recommender-systems/) categories. + +## Feature stores & Cassandra / Astra DB + +The need for persistent data stores is ubiquitous in any ML application—and it comes in all sizes and shapes, of which the "feature store" pattern is but a certain, albeit very common, instance. As is discussed at length in the Feast blog post, many factors influence the architectural choices for ML-based systems. Besides serving latency, there are considerations about fault tolerance and data redundancy, ease of use, pricing, ease of integration with the rest of the stack, and so forth. For example, in some cases, it may be convenient to employ an in-memory store such as Redis, trading data durability and ease of scaling for reduced response times. + +In this [recently published guide](https://planetcassandra.org/post/practitioners-guide-to-cassandra-for-ml/), the author highlights the fact that a feature store lies at the core of most ML-centered architectures lies, possibly (and, looking forward, more and more so) augmented with real-time capabilities owing to a combination of CDC (Change Data Capture), event-streaming technologies, and sometimes in-memory cache layers. The guide makes the case that Cassandra and DataStax's cloud-based DBaaS [Astra DB](https://astra.datastax.com/) (which is built on Cassandra) are great databases to build a feature store on top of, owing to the world-class fault tolerance, 100% uptime, and extremely low latencies it can offer out of the box. + +We then set out to extend the performance measurements to Astra DB, with the intent to provide hard data corroborating our claim that Cassandra and Astra DB are performant first-class choices for an online feature store. In other words, once the plugin made its way to Feast, we took the next logical step: running the very same testing already done for the other DBaaS choice, but this time on Astra DB. The next section reports on our findings. + +## Performance benchmarks for Feast on Astra DB + +The Feast team published a Github [repository](https://github.com/feast-dev/feast-benchmarks) with the code used for the benchmarks. We added coverage for Astra DB (plus a one-node Cassandra cluster running locally, serving the purpose of a functional test) and upgraded the Feast version used in all benchmarks to use v0.26 consistently. + +*Note: The original tests used v0.20 for DynamoDB, v0.17 for Datastore and v0.21 for Redis. Because we reproduced all pre-existing benchmarks, finding most values to be in acceptable agreement (see below for more remarks on this point), we are confident that upgrading the Feast version does not significantly alter the performance.* + +The tests have been run on comparable AWS and GCP machines (respectively c5.4xlarge and c2-standard-16 instances) running in the same region as the cloud database (thereby mimicking the desired architecture for a production system). We did not change any benchmark parameter in order to keep the comparison meaningful, even with prior results. As stated earlier, we focused on the Python feature server, which has a wider adoption among the Feast community and supports a broader ecosystem of plugins. + +Here's how we conducted the benchmarking. First, a moderate amount of synthetic "feature data" (10k entities with 250 integer features each, for a total of about 11 MB) was materialized to the online store. Then various one-minute test runs were performed, each with a certain choice of feature-retrieval parameters, all while collecting statistics (in particular, high percentiles) on the response time of these retrieval operations. The parameters that varied between runs were: + +* batch size (1 to 100 entities per request) + +Let's go back to the Cassandra plugin for Feast and examine some properties of how it was structured. + +First, one might notice that, regardless of which features are requested at runtime, the whole partition (i.e., all features for a given entity) is read. This was chosen to avoid using IN clauses when querying Cassandra; these are indeed discouraged unless the number of values is very, very small (as a rule of thumb, less than half a dozen). Moreover, since one does not know at write-time which features will be read together, there is no preferred way to arrange the clustering column(s) to have these features grouped together in the partition (as done, for example, with Facebook's ["feature re-ordering"](https://engineering.fb.com/2022/09/19/ml-applications/feature-store-announcement/) which purportedly results in a 30%-70% latency reduction). A reasonable compromise was then to always read the whole partition and apply client-side post-query filtering to avoid burdening the query coordinators with additional work—at the cost, of course, of increased network throughput. + +Second, when features from multiple entities are needed, the plugin makes good use of the execute_concurrently_with_args primitive offered by the Cassandra Python driver, thereby spawning one thread per partition and firing all requests at once (up to a maximum concurrency threshold, which can be configured). This leverages the excellent support for concurrency by the Cassandra architecture, which accounts for the observed moderate dependency of latencies on the batch size. + +## Conclusion + +We put the Cassandra plugin for Feast to test in the same way as other DBaaS plugins were tested; that is, using the Astra DB cloud database built on Cassandra, and we ran the same benchmarks that were applied to Redis, Datastore, and DynamoDB. + +Besides broadly confirming the previous results published by the Feast team, our main finding is that the performance with Astra DB is on par with that of AWS DynamoDB and noticeably better than that of Google Datastore. + +All these tests target the Python implementation. As mentioned in the Feast article, switching to a Java feature server greatly improves the performance, but requires a more convoluted setup and architecture and overall more expertise both for setup and maintenance. + +Other evidence points to the fact that, *if one is mainly concerned about performance*, replacing any feature store with a direct-to-DB implementation may be the best choice. In this regard, our extensive investigations clearly make the case that Cassandra is a good fit for ML applications, regardless of whether a feature store is involved or not. + +Some results might be made statistically stronger by more extensive tests, which could be a task for a future iteration of these performance benchmarks. It is possible that longer runs and/or much larger amounts of stored data would better highlight the underlying patterns in how the response times behave as a function of batch size and/or number of requested features. + +## Acknowledgements + +The author would like to thank Alan Ho, Scott Regan, and Jonathan Shook for a critical reading of this manuscript, and the Feast team for a pleasant and fruitful collaboration around the development (first) and the benchmarking (afterwards) of the Cassandra / Astra DB plugin for the namesake feature store. diff --git a/docs/blog/rbac-role-based-access-controls.md b/docs/blog/rbac-role-based-access-controls.md new file mode 100644 index 00000000000..683dd9c6940 --- /dev/null +++ b/docs/blog/rbac-role-based-access-controls.md @@ -0,0 +1,71 @@ +# Feast Launches Role Based Access Control (RBAC)! 🚀 + +*November 21, 2024* | *Daniele Martinoli, Francisco Javier Arceo* + +Feast is proud to introduce Role-Based Access Control (RBAC), a game-changing feature for secure and scalable feature store management. With RBAC, administrators can define granular access policies, ensuring each team has the appropriate permissions to access and manage only the data they need. Built on Kubernetes RBAC and OpenID Connect (OIDC), this powerful model enhances data governance, fosters collaboration, and makes Feast a trusted solution for teams handling sensitive, proprietary data. + +## What is the Feast Permission Model? + +Feast now supports Role Based Access Controls (RBAC) so you can secure and govern your data. If you ever wanted to securely partition your feature store across different teams, the new Feast permissions model is here to make that possible! + +This powerful feature allows administrators to configure granular authorization policies, letting them decide which users and groups can access specific resources and what operations they can perform. + +The default implementation is based on Role-Based Access Control (RBAC): user roles determine whether a user has permission to perform specific functions on registered resources. + +## Why is RBAC important for Feast? + +Feature stores often operate on sensitive, proprietary data and we want to make sure teams are able to govern the access and control of that data thoughtfully, while benefiting from transparent code and an open source community like Feast. + +That's why we built RBAC using [Kubernetes RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) and [OpenID Connect protocol (OIDC)](https://auth0.com/docs/authenticate/protocols/openid-connect), ensuring secure, fine-grained access control in Feast. + +## What are the Benefits of using Feast Permissions? + +Using the Feast Permissions Model offers two key benefits: + +1. Securely share and partition your feature store: grant each team only the minimum privileges necessary to access and manage the relevant resources. +2. Adopt a Service-Oriented Architecture and leverage the benefits of a distributed system. + +## How Feast Uses RBAC + +### Permissions as Feast resources + +The RBAC configuration is defined using a new Feast object type called "Permission". Permissions are registered in the Feast registry and are defined and applied like all the other registry objects, using Python code. + +A permission is defined by these three components: + +* A resource: a Feast object that we want to secure against unauthorized access. It's identified by the matching type(s), a possibly empty list of name patterns and a dictionary of required tags. +* An action: a logical operation performed on the secured resource, such as managing the resource state with CREATE, DESCRIBE, UPDATE or DELETE, or accessing the resource data with READ and WRITE (differentiated by ONLINE and OFFLINE store types) +* A policy: the rule to enforce authorization decisions based on the current user. The default implementation uses role-based policies. + +The resource types supported by the permission framework are those defining the customer feature store: + +* Project +* Entity +* Clients use the feature store transparently, with authorization headers automatically injected in every request. +* Service-to-service communications are permitted automatically. + +Currently, only the following Python servers are supported in an authorized environment: +- Online REST feature server +- Offline Arrow Flight feature server +- gRPC Registry server + +### Configuring Feast Authorization + +For backward compatibility, by default no authorizations are enforced. The authorization functionality must be explicitly enabled using the auth configuration section in feature_store.yaml. Of course, all server and client applications must have a consistent configuration. + +Currently, feast supports [OIDC](https://auth0.com/docs/authenticate/protocols/openid-connect) and [Kubernetes RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) authentication/authorization. + +* With OIDC authorization, the client uses an OIDC server to fetch a JSON Web Token (JWT), which is then included in every request. On the server side, the token is parsed to extract user roles and validate them against the configured permissions. +* With Kubernetes authorization, the client injects its service account JWT token into the request. The server then extracts the service account name from the token and uses it to look up the associated role in the Kubernetes RBAC resources. + +### Inspecting and Troubleshooting the Permissions Model + +The feast CLI includes a new permissions command to list the registered permissions, with options to identify the matching resources for each configured permission and the existing resources that are not covered by any permission. + +For troubleshooting purposes, it also provides a command to list all the resources and operations allowed to any managed role. + +## How Can I Get Started? + +This new feature includes working examples for both supported authorization protocols. You can start by experimenting with these examples to see how they fit your own feature store and assess their benefits. + +As this is a completely new functionality, your feedback will be extremely valuable. It will help us adapt the feature to meet real-world requirements and better serve our customers. diff --git a/docs/blog/streaming-feature-engineering-with-denormalized.md b/docs/blog/streaming-feature-engineering-with-denormalized.md new file mode 100644 index 00000000000..ace3ec2df6c --- /dev/null +++ b/docs/blog/streaming-feature-engineering-with-denormalized.md @@ -0,0 +1,135 @@ +# Streaming Feature Engineering with Denormalized + +*December 17, 2024* | *Matt Green* + +Learn how to use Feast with [Denormalized](https://www.denormalized.io/) + +Thank you to [Matt Green](https://www.linkedin.com/in/mgreen9/) and [Francisco Javier Arceo](https://www.linkedin.com/in/franciscojavierarceo) for their contributions! + +## Introduction + +Feature stores have become a critical component of the modern AI stack where they serve as a centralized repository for model features. Typically, they consist of both an offline store for aggregating large amounts of data while training models, and an online store, which allows for low latency delivery of specific features when running inference. + +A popular open source example is [Feast](https://feast.dev/), which allows users to store features together by ingesting data from different data sources. While Feast allows you to define features and query data stores using those definitions, it relies on external systems to calculate and update online features. This post will demonstrate how to use [denormalized](https://www.denormalized.io/) to build real-time feature pipelines. + +The full working example is available at the [feast-dev/feast-denormalized-tutorial](https://github.com/feast-dev/feast-denormalized-tutorial) repo. Instructions for configuring and running the example can be found in the README file. + +## The Problem + +Fraud detection is a classic example of a model that uses real-time features. Imagine you are building a model to detect fraudulent user sessions. One feature you would be interested in is the number of login attempts made by a user and how many of those were successful. You could calculate this feature by looking back in time over a sliding interval (AKA "a sliding window"). If you notice a large amount of failed login attempts over the previous 5 seconds, you might infer the account is being brute-forced and choose to invalidate the session and lock the account. + +To simulate this scenario, we wrote a simple script that emits fake login events to a Kafka cluster: [session_generator](https://github.com/feast-dev/feast-denormalized-tutorial). + +This script will emit json events according to the following schema: + +```python +@dataclass +timestamp: datetime +user_id: str +ip_address: str +success: bool +``` + +## Configuring the Feature Store with Feast + +Before we can start writing our features, we need to first configure the feature store. Feast makes this easy using a Python API. In Feast, features are referred to as Fields and are grouped into FeatureViews. FeatureViews have corresponding PushSources for ingesting data from online sources (i.e., we can push data to Feast). We also define an offline data store using the FileSource class, though we won't be using that in this example. + +```python +file_sources = [] +push_sources = [] +feature_views = [] + +for i in [1, 5, 10, 15]: + file_source = FileSource( + path=str(Path(__file__).parent / f"./data/auth_attempt_{i}.parquet"), + timestamp_field="timestamp", + ) + file_sources.append(file_source) + + push_source = PushSource( + name=f"auth_attempt_push_{i}", + batch_source=file_source, + ) + push_sources.append(push_source) + + feature_views.append( + FeatureView( + name=f"auth_attempt_view_w{i}", + entities=[auth_attempt], + schema=[ + Field(name="user_id", dtype=feast_types.String,), + Field(name="timestamp", dtype=feast_types.String,), + Field(name=f"{i}_success", dtype=feast_types.Int32,), + Field(name=f"{i}_total", dtype=feast_types.Int32,), + Field(name=f"{i}_ratio", dtype=feast_types.Float32,), + ], + source=push_source, + online=True, + ) + ) +``` + +The code creates 4 different FeatureViews each containing their own features. As discussed previously, fraud features can be calculated over a sliding interval. It can be useful to not only look at recent failed authentication attempts but also the aggregate of attempts made over longer time intervals. This could be useful when trying to detect things like credential testing which can happen over a longer period of time. + +In our example, we're creating 4 different FeatureViews that will ultimately be populated by 4 different window lengths. This can help our model detect various types of attacks over different time intervals. Before we can use our features, we'll need to run `feast apply` to set-up the online datastore. + +## Writing the Pipelines with Denormalized + +Now that we have our online data store configured, we need to write our data pipelines for computing the features. Simply speaking, these pipelines need to: + +1. Read messages from Kafka +2. Aggregate those messages over a varied timeframe +3. Write the resulting aggregate value to the feature store + +Denormalized makes this really easy. First, we create our DataStream object from a Context(): + +```python +ds = FeastDataStream( + Context().from_topic( + config.kafka_topic, + feature_service, f"auth_attempt_push*{config.feature_prefix}" + ) +) +``` + +This will start the Denormalized Rust stream processing engine, which is powered by DataFusion so it's ultra-fast. + +## Running Multiple Pipelines + +The write_feast_feature() method is a blocking call that continuously executes one pipeline to produce a set of features across a single sliding window. If we want to calculate features for using different sliding window lengths, will need to configure and start multiple pipelines. We can easily do this using the multiprocessing library in python: + +```python +for window_length in [1, 5, 10, 15]: + config = PipelineConfig( + window_length_ms=window_length * 1000, + slide_length_ms=1000, + feature_prefix=f"{window_length}", + kafka_bootstrap_servers=args.kafka_bootstrap_servers, + kafka_topic=args.kafka_topic, + ) + process = multiprocessing.Process( + target=run_pipeline, + args=(config,), + name=f"PipelineProcess-{window_length}", + daemon=False, + ) + processes.append(process) + +for p in processes: + try: + p.start() + except Exception as e: + logger.error(f"Failed to start process {p.name}: {e}") + cleanup_processes(processes) + return +``` + +For each group of features we defined earlier, we spin up a different system process with a different window length. Each process will then execute its own instance of the Denormalized stream processing engine, which has its own thread pools for effective parallelization of work. + +While this example demonstrates how you can easily run multiple Denormalized pipelines, in a production environment, you'd probably want each pipeline running in its own container. + +## Final Thoughts + +We've demonstrated how you can easily create real-time features using Feast and Denormalized. While working with streaming data can be a challenge, modern python libraries backed by fast native code are making it easier than ever to quickly iterate on model inputs. + +Denormalized is currently in the early stages of development. If you have any feedback or questions, feel free to reach out at [hello@denormalized.io](mailto:hello@denormalized.io). diff --git a/docs/blog/the-future-of-feast.md b/docs/blog/the-future-of-feast.md new file mode 100644 index 00000000000..f08547a1bf7 --- /dev/null +++ b/docs/blog/the-future-of-feast.md @@ -0,0 +1,38 @@ +# The Future of Feast + +*February 23, 2024* | *Willem Pienaar* + +AI has taken center stage with the rise of large language models, but production ML systems remain the lifeblood of most AI powered companies today. At the heart of these products are feature stores like Feast, serving real-time, batch, and streaming data points to ML models. I'd like to spend a moment taking stock on what we've accomplished over the last six years and what the growing Feast community has to look forward to. + +## Act 1: Gojek and Google + +Feast was started in 2018 as a [collaboration](https://cloud.google.com/blog/products/ai-machine-learning/introducing-feast-an-open-source-feature-store-for-machine-learning) between Gojek and our friends at Google Cloud. The primary motivation behind the project was to reign in the rampant duplication of feature engineering across the Southeast Asian decacorn's many ML teams. + +Almost immediately, the key challenge with feature stores became clear: Can it be generalized across various ML use cases? + +The natural way to answer that question is to battle test the software out in the open. So in late 2018, spurred on by our friends in the Kubeflow project, we open sourced Feast. A community quickly formed around the project. This group was mostly made up of software engineers at data rich technology companies, trying to find a way to help their ML teams productionize models at a much higher pace. + +Having a community centric approach is in the DNA of the project. All of our RFCs, discussions, designs, community calls, and code are open source. The project became a vehicle for ML platform teams globally to collaborate. Many teams saw the project as a means of stress testing their internal feature store designs, while others like Agoda, Zulily, Farfetch, and Postmates adopted the project wholesale and became core contributors. + +As time went by the demand grew for the project to have neutral ownership and formal governance. This led to us [entering the project into the Linux Foundation for AI in 2020](https://lfaidata.foundation/blog/2020/11/10/feast-joins-lf-ai-data-as-new-incubation-project/). + +## Act 2: Rise of the Feature Store + +By 2020, the demand for feature stores had reached a fever pitch. If you were dealing with more than just an Excel sheet of data, you were likely planning to either build or buy a feature store. A category formed around feature stores and MLOps. Being a neutrally governed open source project brought in a raft of contributions, which helped the project generalize not just to different data platforms and vendors, but also different use cases and deployment patterns. A few of the highlights include: + +* We worked closely with AI teams at [Snowflake](https://quickstarts.snowflake.com/guide/getting_started_with_feast/), [Azure](https://techcommunity.microsoft.com/t5/ai-customer-engineering-team/using-feast-feature-store-with-azure-ml/ba-p/2908404) + +It's also important to mention that by far the biggest contributor to Feast was [Tecton](https://www.tecton.ai/), who invested considerable resources into the project and helped create the category. + +Today, the project is battle hardened and stable. It's seen adoption and/or contribution from companies like Adyen, Affirm, Better, Cloudflare, Discover, Experian, Lowes, Red Hat, Robinhood, Palo Alto Networks, Porch, Salesforce, Seatgeek, Shopify, and Twitter, just to name a few. + +## Act 3: The Road to 1.0 + +The rate of change in AI has accelerated, and nowhere is it moving faster than in open source. Keeping up with this rate of change for AI infra requires the best minds, so with that we'd like to introduce a set of contributors who will be graduating to official project maintainers: + +* [Francisco Javier Arceo](https://www.linkedin.com/in/franciscojavierarceo/) – Engineering Manager, [Affirm](https://www.affirm.com/) +* [Hao Xu](https://www.linkedin.com/in/hao-xu-a04436103/) – Lead Software Engineer, J.P. Morgan + +Over the next few months these maintainers will focus on bringing the project to a major 1.0 release. In our next post we will take a closer look at what the road to 1.0 looks like. + +If you'd like to get involved, try out the project [over at GitHub](https://github.com/feast-dev/feast) or join our [Slack](https://feastopensource.slack.com) community! diff --git a/docs/blog/the-road-to-feast-1-0.md b/docs/blog/the-road-to-feast-1-0.md new file mode 100644 index 00000000000..27be07aadea --- /dev/null +++ b/docs/blog/the-road-to-feast-1-0.md @@ -0,0 +1,35 @@ +# The road to Feast 1.0 + +*February 28, 2024* | *Edson Tirelli* + +### Past Achievements and a Bright Future + +In the [previous blog](https://feast.dev/blog/the-future-of-feast/) we recapped Feast's journey over the last 6 years and hinted about what is coming in the future. We also announced a new group of maintainers that joined the project to help drive it to the 1.0 milestone. Today, we will drill down a little bit into the goals for the project towards that milestone. + +### The Goals for Feast 1.0 + +* Tighter Integration with [**Kubeflow**](https://www.kubeflow.org/): Recognizing the growing importance of Kubernetes in the ML workflow, a primary objective is to achieve a closer integration with [Kubeflow](https://www.kubeflow.org/). This will enable smoother workflows and enhanced scalability for ML projects. + +* Development of Enterprise Features: With the aim to make Feast more robust for enterprise usage, we are focusing on developing features that cater to the complex needs of large-scale organizations. These include advanced security measures, scalability enhancements, and improved data management capabilities. + +* Graduation from [**LF AI and Data Foundation Incubation**](https://landscape.lfai.foundation/?selected=feast): Currently incubating under the [LF AI and Data Foundation](https://landscape.lfai.foundation/?selected=feast), we are setting our sights on graduating Feast to become a fully-fledged project under the foundation. This step will mark a significant milestone in our journey, recognizing the maturity and stability of Feast. + +* Research and Development for Novel Use Cases: Keeping pace with the rapidly evolving ML landscape (e.g., Large Language Models and Retrieval Augmented Generation), we are committed to exploring new research areas. Our aim is to adapt Feast to support novel use cases, keeping it at the forefront of technology. + +* Support for Latest ML Model Advancements: As ML models become more sophisticated, Feast will evolve to support these advancements. This includes accommodating new model architectures and training techniques. + +This new phase is not just about setting goals but laying down a concrete roadmap to achieve Feast version 1.0. This version will encapsulate all our efforts towards making Feast more integrated, enterprise-ready, and aligned with the latest ML advancements. + +### Why Invest in Feast? + +Many industry applications of machine learning require intensely sophisticated data pipelines. Over the last decade, the data infrastructure and analytics community collaborated together to build powerful frameworks like dbt that enabled analytics to flourish. We believe Feast can do the same for the machine learning community–particularly those that spend most of their time on data pipelining and feature engineering. We believe Feast is a core foundation in the future of machine learning and we will build it to offer a standard set of patterns that will enable ML Engineering and ML Ops teams to leverage those patterns and industry best practices to avoid common pitfalls, while (1) offering the flexibility of choosing their own infrastructure and (2) providing ML Practitioners with a Python-based interface. + +### In Conclusion + +This transition marks a pivotal moment in Feast's journey. We are excited about the opportunities and challenges ahead. With the support of the ML community, the dedication of our new maintainers, and the clear vision set by our steward committee, Feast is poised to reach new heights and continue to be a pivotal tool in the ML ecosystem. + +We invite everyone to join us in this exciting journey and contribute to the future of Feast. Together, let's shape the next chapter in the evolution of feature stores and machine learning. + +For updates and discussions, join our [Slack channel](http://feastopensource.slack.com/) and follow our [GitHub repository](https://github.com/feast-dev/feast/). + +*This post reflects the collective vision and aspirations of the new Feast steward committee. For more detailed discussions and contributions, please reach out to us on our [community channels](https://docs.feast.dev/community).* diff --git a/docs/blog/what-is-a-feature-store.md b/docs/blog/what-is-a-feature-store.md new file mode 100644 index 00000000000..720d6ab1ab2 --- /dev/null +++ b/docs/blog/what-is-a-feature-store.md @@ -0,0 +1,85 @@ +# What is a Feature Store? + +*January 21, 2021* | *Willem Pienaar & Mike Del Balso* + +Blog co-authored with Mike Del Balso, Co-Founder and CEO of Tecton, and cross-posted [here](https://www.tecton.ai/blog/what-is-a-feature-store/) + +Data teams are starting to realize that operational machine learning requires solving data problems that extend far beyond the creation of data pipelines. In [Why We Need DevOps for ML Data](https://www.tecton.ai/blog/devops-ml-data/), Tecton highlighted some of the key data challenges that teams face when productionizing ML systems: + +* Accessing the right raw data +* Building features from raw data +* Combining features into training data +* Calculating and serving features in production +* Monitoring features in production + +Production data systems, whether for large scale analytics or real-time streaming, aren't new. However, *operational machine learning* — ML-driven intelligence built into customer-facing applications — is new for most teams. The challenge of deploying machine learning to production for operational purposes (e.g. recommender systems, fraud detection, personalization, etc.) introduces new requirements for our data tools. + +A new kind of ML-specific data infrastructure is emerging to make that possible. Increasingly Data Science and Data Engineering teams are turning towards feature stores to manage the data sets and data pipelines needed to productionize their ML applications. This post describes the key components of a modern feature store and how the sum of these parts act as a force multiplier on organizations, by reducing duplication of data engineering efforts, speeding up the machine learning lifecycle, and unlocking a new kind of collaboration across data science teams. + +Quick refresher: in ML, a feature is data used as an input signal to a predictive model. For example, if a credit card company is trying to predict whether a transaction is fraudulent, a useful feature might be *whether the transaction is happening in a foreign country*, or *how the size of this transaction compares to the customer's typical transaction*. When we refer to a feature, we're usually referring to the concept of that signal (e.g. "transaction_in_foreign_country"), not a specific value of the feature (e.g. not "transaction #1364 was in a foreign country"). + +## Enter the feature store + +*"The interface between models and data"* + +We first introduced feature stores in our blog post describing Uber's [Michelangelo](https://eng.uber.com/michelangelo-machine-learning-platform/) platform. Feature stores have since emerged as a necessary component of the operational machine learning stack. + +Feature stores make it easy to: +1. Productionize new features without extensive engineering support +2. Automate feature computation, backfills, and logging +3. Share and reuse feature pipelines across teams +4. Track feature versions, lineage, and metadata +5. Achieve consistency between training and serving data +6. Monitor the health of feature pipelines in production + +Feature stores aim to solve the full set of data management problems encountered when building and operating operational ML applications. A feature store is an ML-specific data system that: + +* Runs data pipelines that transform raw data into feature values +* Stores and manages the feature data itself, and +* Serves feature data consistently for training and inference purposes + +Feature stores bring economies of scale to ML organizations by enabling collaboration. When a feature is registered in a feature store, it becomes available for immediate reuse by other models across the organization. This reduces duplication of data engineering efforts and allows new ML projects to bootstrap with a library of curated production-ready features. + +## Components of a Feature Store + +There are 5 main components of a modern feature store: Transformation, Storage, Serving, Monitoring, and Feature Registry. + +In the following sections we'll give an overview of the purpose and typical capabilities of each of these sections. + +## Serving + +Models need access to fresh feature values for inference. Feature stores accomplish this by regularly recomputing features on an ongoing basis. Transformation jobs are orchestrated to ensure new data is processed and turned into fresh new feature values. These jobs are executed on data processing engines (e.g. Spark or Pandas) to which the feature store is connected. + +Model development introduces different transformation requirements. When iterating on a model, new features are often engineered to be used in training datasets that correspond to historical events (e.g. all purchases in the past 6 months). To support these use cases, feature stores make it easy to run "backfill jobs" that generate and persist historical values of a feature for training. Some feature stores automatically backfill newly registered features for preconfigured time ranges for registered training datasets. + +Transformation code is reused across environments preventing training-serving skew and frees teams from having to rewrite code from one environment to the next. Feature stores manage all feature-related resources (compute, storage, serving) holistically across the feature lifecycle. Automating repetitive engineering tasks needed to productionize a feature, they enable a simple and fast path-to-production. Management optimizations (e.g. retiring features that aren't being used by any models, or deduplicating feature transformations across models) can bring significant efficiencies, especially as teams grow increasingly the complexity of managing features manually. + +## Monitoring + +When something goes wrong in an ML system, it's usually a data problem. Feature stores are uniquely positioned to detect and surface such issues. They can calculate metrics on the features they store and serve that describe correctness and quality. Feature stores monitor these metrics to provide a signal of the overall health of an ML application. + +Feature data can be validated based on user defined schemas or other structural criteria. Data quality is tracked by monitoring for drift and training-serving skew. E.g. feature data served to models are compared to data on which the model was trained to detect inconsistencies that could degrade model performance. + +When running production systems, it's also important to monitor operational metrics. Feature stores track operational metrics relating to core functionality. E.g. metrics relating to feature storage (availability, capacity, utilization, staleness) or feature serving (throughput, latency, error rates). Other metrics describe the operations of important adjacent system components. For example, operational metrics for external data processing engines (e.g. job success rate, throughput, processing lag and rate). + +Feature stores make these metrics available to existing monitoring infrastructure. This allows ML application health to be monitored and managed with existing observability tools in the production stack. Having visibility into which features are used by which models, feature stores can automatically aggregate alerts and health metrics into views relevant to specific users, models, or consumers. + +It's not essential that all feature stores implement such monitoring internally, but they should at least provide the interfaces into which data quality monitoring systems can plug. Different ML use cases can have different, specialized monitoring needs so pluggability here is important. + +## Registry + +A critical component in all feature stores is a centralized registry of standardized feature definitions and metadata. The registry acts as a single source of truth for information about a feature in an organization. + +The registry is a central interface for user interactions with the feature store. Teams use the registry as a common catalog to explore, develop, collaborate on, and publish new definitions within and across teams. + +The registry allows for important metadata to be attached to feature definitions. This provides a route for tracking ownership, project or domain specific information, and a path to easily integrate with adjacent systems. This includes information about dependencies and versions which is used for lineage tracking. + +To help with common debugging, compliance, and auditing workflows, the registry acts as an immutable record of what's available analytically and what's actually running in production. + +## Where to go to get started + +We see features stores as the heart of the data flow in modern ML applications. They are quickly proving to be [critical infrastructure](https://a16z.com/2020/10/15/the-emerging-architectures-of-modern-data/) for data science teams putting ML into production. We expect 2021 to be a year of massive feature store adoption, as machine learning becomes a key differentiator for technology companies. + +There are a few options for getting started with feature stores: + +* [Feast](https://feastsite.wpenginepowered.com/) is a great option if you already have transformation pipelines to compute your features, but need a great storage and serving layer to help you use them in production. Feast is GCP/AWS only today, but we're working hard to make Feast available as a light-weight feature store for all environments. Stay tuned. diff --git a/docs/getting-started/architecture/push-vs-pull-model.md b/docs/getting-started/architecture/push-vs-pull-model.md index b205e97fc51..f1bd05a3e75 100644 --- a/docs/getting-started/architecture/push-vs-pull-model.md +++ b/docs/getting-started/architecture/push-vs-pull-model.md @@ -25,4 +25,4 @@ Implicit in the Push model are decisions about _how_ and _when_ to push feature From a developer's perspective, there are three ways to push feature values to the online store with different tradeoffs. -They are discussed further in the [Write Patterns](getting-started/architecture/write-patterns.md) section. +They are discussed further in the [Write Patterns](write-patterns.md) section. diff --git a/docs/getting-started/architecture/write-patterns.md b/docs/getting-started/architecture/write-patterns.md index 4674b5504d3..f92b4e9d83b 100644 --- a/docs/getting-started/architecture/write-patterns.md +++ b/docs/getting-started/architecture/write-patterns.md @@ -1,6 +1,6 @@ # Writing Data to Feast -Feast uses a [Push Model](getting-started/architecture/push-vs-pull-model.md) to push features to the online store. +Feast uses a [Push Model](push-vs-pull-model.md) to push features to the online store. This has two important consequences: (1) communication patterns between the Data Producer (i.e., the client) and Feast (i.e,. the server) and (2) feature computation and _feature value_ write patterns to Feast's online store. diff --git a/docs/getting-started/components/README.md b/docs/getting-started/components/README.md index e1c000abced..1b224056298 100644 --- a/docs/getting-started/components/README.md +++ b/docs/getting-started/components/README.md @@ -12,6 +12,10 @@ [online-store.md](online-store.md) {% endcontent-ref %} +{% content-ref url="feature-server.md" %} +[feature-server.md](feature-server.md) +{% endcontent-ref %} + {% content-ref url="batch-materialization-engine.md" %} [batch-materialization-engine.md](batch-materialization-engine.md) {% endcontent-ref %} @@ -23,3 +27,7 @@ {% content-ref url="authz_manager.md" %} [authz_manager.md](authz_manager.md) {% endcontent-ref %} + +{% content-ref url="open-telemetry.md" %} +[open-telemetry.md](open-telemetry.md) +{% endcontent-ref %} diff --git a/docs/getting-started/components/feature-server.md b/docs/getting-started/components/feature-server.md new file mode 100644 index 00000000000..4d961054ecb --- /dev/null +++ b/docs/getting-started/components/feature-server.md @@ -0,0 +1,42 @@ +# Feature Server + +The Feature Server is a core architectural component in Feast, designed to provide low-latency feature retrieval and updates for machine learning applications. + +It is a REST API server built using [FastAPI](https://fastapi.tiangolo.com/) and exposes a limited set of endpoints to serve features, push data, and support materialization operations. The server is scalable, flexible, and designed to work seamlessly with various deployment environments, including local setups and cloud-based systems. + +## Motivation + +In machine learning workflows, real-time access to feature values is critical for enabling low-latency predictions. The Feature Server simplifies this requirement by: + +1. **Serving Features:** Allowing clients to retrieve feature values for specific entities in real-time, reducing the complexity of direct interactions with the online store. +2. **Data Integration:** Providing endpoints to push feature data directly into the online or offline store, ensuring data freshness and consistency. +3. **Scalability:** Supporting horizontal scaling to handle high request volumes efficiently. +4. **Standardized API:** Exposing HTTP/JSON endpoints that integrate seamlessly with various programming languages and ML pipelines. +5. **Secure Communication:** Supporting TLS (SSL) for secure data transmission in production environments. + +## Architecture + +The Feature Server operates as a stateless service backed by two key components: + +- **[Online Store](./online-store.md):** The primary data store used for low-latency feature retrieval. +- **[Registry](./registry.md):** The metadata store that defines feature sets, feature views, and their relationships to entities. + +## Key Features + +1. **RESTful API:** Provides standardized endpoints for feature retrieval and data pushing. +2. **CLI Integration:** Easily managed through the Feast CLI with commands like `feast serve`. +3. **Flexible Deployment:** Can be deployed locally, via Docker, or on Kubernetes using Helm charts. +4. **Scalability:** Designed for distributed deployments to handle large-scale workloads. +5. **TLS Support:** Ensures secure communication in production setups. + +## Endpoints Overview + +| Endpoint | Description | +|------------------------------|-------------------------------------------------------------------------| +| `/get-online-features` | Retrieves feature values for specified entities and feature references. | +| `/push` | Pushes feature data to the online and/or offline store. | +| `/materialize` | Materializes features within a specific time range to the online store. | +| `/materialize-incremental` | Incrementally materializes features up to the current timestamp. | +| `/retrieve-online-documents` | Supports Vector Similarity Search for RAG (Alpha end-ponit) | +| `/docs` | API Contract for available endpoints | + diff --git a/docs/getting-started/components/offline-store.md b/docs/getting-started/components/offline-store.md index 48470c6547a..c04773e66b4 100644 --- a/docs/getting-started/components/offline-store.md +++ b/docs/getting-started/components/offline-store.md @@ -8,7 +8,7 @@ Offline stores are primarily used for two reasons: 1. Building training datasets from time-series features. 2. Materializing \(loading\) features into an online store to serve those features at low-latency in a production setting. -Offline stores are configured through the [feature\_store.yaml](../../reference/offline-stores/). +Offline stores are configured through the [feature\_store.yaml](../../reference/feature-repository/feature-store-yaml.md). When building training datasets or materializing features into an online store, Feast will use the configured offline store with your configured data sources to execute the necessary data operations. Only a single offline store can be used at a time. diff --git a/docs/getting-started/components/open-telemetry.md b/docs/getting-started/components/open-telemetry.md new file mode 100644 index 00000000000..bdffad1d27b --- /dev/null +++ b/docs/getting-started/components/open-telemetry.md @@ -0,0 +1,149 @@ +# OpenTelemetry Integration + +The OpenTelemetry integration in Feast provides comprehensive monitoring and observability capabilities for your feature serving infrastructure. This component enables you to track key metrics, traces, and logs from your Feast deployment. + +## Motivation + +Monitoring and observability are critical for production machine learning systems. The OpenTelemetry integration addresses these needs by: + +1. **Performance Monitoring:** Track CPU and memory usage of feature servers +2. **Operational Insights:** Collect metrics to understand system behavior and performance +3. **Troubleshooting:** Enable effective debugging through distributed tracing +4. **Resource Optimization:** Monitor resource utilization to optimize deployments +5. **Production Readiness:** Provide enterprise-grade observability capabilities + +## Architecture + +The OpenTelemetry integration in Feast consists of several components working together: + +- **OpenTelemetry Collector:** Receives, processes, and exports telemetry data +- **Prometheus Integration:** Enables metrics collection and monitoring +- **Instrumentation:** Automatic Python instrumentation for tracking metrics +- **Exporters:** Components that send telemetry data to monitoring systems + +## Key Features + +1. **Automated Instrumentation:** Python auto-instrumentation for comprehensive metric collection +2. **Metric Collection:** Track key performance indicators including: + - Memory usage + - CPU utilization + - Request latencies + - Feature retrieval statistics +3. **Flexible Configuration:** Customizable metric collection and export settings +4. **Kubernetes Integration:** Native support for Kubernetes deployments +5. **Prometheus Compatibility:** Integration with Prometheus for metrics visualization + +## Setup and Configuration + +To add monitoring to the Feast Feature Server, follow these steps: + +### 1. Deploy Prometheus Operator +Follow the [Prometheus Operator documentation](https://github.com/prometheus-operator/prometheus-operator/blob/main/Documentation/user-guides/getting-started.md) to install the operator. + +### 2. Deploy OpenTelemetry Operator +Before installing the OpenTelemetry Operator: +1. Install `cert-manager` +2. Validate that the `pods` are running +3. Apply the OpenTelemetry operator: +```bash +kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml +``` + +For additional installation steps, refer to the [OpenTelemetry Operator documentation](https://github.com/open-telemetry/opentelemetry-operator). + +### 3. Configure OpenTelemetry Collector +Add the OpenTelemetry Collector configuration under the metrics section in your values.yaml file: + +```yaml +metrics: + enabled: true + otelCollector: + endpoint: "otel-collector.default.svc.cluster.local:4317" # sample + headers: + api-key: "your-api-key" +``` + +### 4. Add Instrumentation Configuration +Add the following annotations and environment variables to your deployment.yaml: + +```yaml +template: + metadata: + annotations: + instrumentation.opentelemetry.io/inject-python: "true" +``` + +```yaml +- name: OTEL_EXPORTER_OTLP_ENDPOINT + value: http://{{ .Values.service.name }}-collector.{{ .Release.namespace }}.svc.cluster.local:{{ .Values.metrics.endpoint.port}} +- name: OTEL_EXPORTER_OTLP_INSECURE + value: "true" +``` + +### 5. Add Metric Checks +Add metric checks to all manifests and deployment files: + +```yaml +{{ if .Values.metrics.enabled }} +apiVersion: opentelemetry.io/v1alpha1 +kind: Instrumentation +metadata: + name: feast-instrumentation +spec: + exporter: + endpoint: http://{{ .Values.service.name }}-collector.{{ .Release.Namespace }}.svc.cluster.local:4318 + env: + propagators: + - tracecontext + - baggage + python: + env: + - name: OTEL_METRICS_EXPORTER + value: console,otlp_proto_http + - name: OTEL_LOGS_EXPORTER + value: otlp_proto_http + - name: OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED + value: "true" +{{end}} +``` + +### 6. Add Required Manifests +Add the following components to your chart: +- Instrumentation +- OpenTelemetryCollector +- ServiceMonitors +- Prometheus Instance +- RBAC rules + +### 7. Deploy Feast +Deploy Feast with metrics enabled: + +```bash +helm install feast-release infra/charts/feast-feature-server --set metric=true --set feature_store_yaml_base64="" +``` + +## Usage + +To enable OpenTelemetry monitoring in your Feast deployment: + +1. Set `metrics.enabled=true` in your Helm values +2. Configure the OpenTelemetry Collector endpoint +3. Deploy with proper annotations and environment variables + +Example configuration: +```yaml +metrics: + enabled: true + otelCollector: + endpoint: "otel-collector.default.svc.cluster.local:4317" +``` + +## Monitoring + +Once configured, you can monitor various metrics including: + +- `feast_feature_server_memory_usage`: Memory utilization of the feature server +- `feast_feature_server_cpu_usage`: CPU usage statistics +- Additional custom metrics based on your configuration + +These metrics can be visualized using Prometheus and other compatible monitoring tools. diff --git a/docs/getting-started/components/overview.md b/docs/getting-started/components/overview.md index ac0b99de8ab..05c7503d842 100644 --- a/docs/getting-started/components/overview.md +++ b/docs/getting-started/components/overview.md @@ -13,6 +13,7 @@ * **Deploy Model:** The trained model binary (and list of features) are deployed into a model serving system. This step is not executed by Feast. * **Prediction:** A backend system makes a request for a prediction from the model serving service. * **Get Online Features:** The model serving service makes a request to the Feast Online Serving service for online features using a Feast SDK. +* **Feature Retrieval:** The online serving service retrieves the latest feature values from the online store and returns them to the model serving service. ## Components @@ -24,6 +25,7 @@ A complete Feast deployment contains the following components: * Materialize (load) feature values into the online store. * Build and retrieve training datasets from the offline store. * Retrieve online features. +* **Feature Server:** The Feature Server is a REST API server that serves feature values for a given entity key and feature reference. The Feature Server is designed to be horizontally scalable and can be deployed in a distributed manner. * **Stream Processor:** The Stream Processor can be used to ingest feature data from streams and write it into the online or offline stores. Currently, there's an experimental Spark processor that's able to consume data from Kafka. * **Batch Materialization Engine:** The [Batch Materialization Engine](batch-materialization-engine.md) component launches a process which loads data into the online store from the offline store. By default, Feast uses a local in-process engine implementation to materialize data. However, additional infrastructure can be used for a more scalable materialization process. * **Online Store:** The online store is a database that stores only the latest feature values for each entity. The online store is either populated through materialization jobs or through [stream ingestion](../../reference/data-sources/push.md). diff --git a/docs/getting-started/concepts/data-ingestion.md b/docs/getting-started/concepts/data-ingestion.md index 3dd3fbbd927..55b54045d21 100644 --- a/docs/getting-started/concepts/data-ingestion.md +++ b/docs/getting-started/concepts/data-ingestion.md @@ -16,7 +16,7 @@ Feast supports primarily **time-stamped** tabular data as data sources. There ar * **Stream data sources**: Feast does **not** have native streaming integrations. It does however facilitate making streaming features available in different environments. There are two kinds of sources: * **Push sources** allow users to push features into Feast, and make it available for training / batch scoring ("offline"), for realtime feature serving ("online") or both. * **\[Alpha] Stream sources** allow users to register metadata from Kafka or Kinesis sources. The onus is on the user to ingest from these sources, though Feast provides some limited helper methods to ingest directly from Kafka / Kinesis topics. -* **(Experimental) Request data sources:** This is data that is only available at request time (e.g. from a user action that needs an immediate model prediction response). This is primarily relevant as an input into [**on-demand feature views**](../../../docs/reference/alpha-on-demand-feature-view.md), which allow light-weight feature engineering and combining features across sources. +* **(Experimental) Request data sources:** This is data that is only available at request time (e.g. from a user action that needs an immediate model prediction response). This is primarily relevant as an input into [**on-demand feature views**](../../../docs/reference/beta-on-demand-feature-view.md), which allow light-weight feature engineering and combining features across sources. ## Batch data ingestion diff --git a/docs/getting-started/concepts/dataset.md b/docs/getting-started/concepts/dataset.md index 829ad4284e5..3fabc48a140 100644 --- a/docs/getting-started/concepts/dataset.md +++ b/docs/getting-started/concepts/dataset.md @@ -7,7 +7,7 @@ Dataset's metadata is stored in the Feast registry and raw data (features, entit Dataset can be created from: 1. Results of historical retrieval -2. \[planned] Logging request (including input for [on demand transformation](../../reference/alpha-on-demand-feature-view.md)) and response during feature serving +2. \[planned] Logging request (including input for [on demand transformation](../../reference/beta-on-demand-feature-view.md)) and response during feature serving 3. \[planned] Logging features during writing to online store (from batch source or stream) ### Creating a saved dataset from historical retrieval diff --git a/docs/getting-started/concepts/feature-view.md b/docs/getting-started/concepts/feature-view.md index 6ebe4feacff..faaaf54408a 100644 --- a/docs/getting-started/concepts/feature-view.md +++ b/docs/getting-started/concepts/feature-view.md @@ -6,7 +6,14 @@ **Note**: Feature views do not work with non-timestamped data. A workaround is to insert dummy timestamps. {% endhint %} -A feature view is an object that represents a logical group of time-series feature data as it is found in a [data source](data-ingestion.md). Depending on the kind of feature view, it may contain some lightweight (experimental) feature transformations (see [\[Alpha\] On demand feature views](feature-view.md#alpha-on-demand-feature-views)). +A **feature view** is defined as a *collection of features*. + +- In the online settings, this is a *stateful* collection of +features that are read when the `get_online_features` method is called. +- In the offline setting, this is a *stateless* collection of features that are created when the `get_historical_features` +method is called. + +A feature view is an object representing a logical group of time-series feature data as it is found in a [data source](data-ingestion.md). Depending on the kind of feature view, it may contain some lightweight (experimental) feature transformations (see [\[Beta\] On demand feature views](../../reference/beta-on-demand-feature-view.md)). Feature views consist of: diff --git a/docs/getting-started/concepts/permission.md b/docs/getting-started/concepts/permission.md index a6353579687..8db67032878 100644 --- a/docs/getting-started/concepts/permission.md +++ b/docs/getting-started/concepts/permission.md @@ -69,7 +69,7 @@ Permission( name="feature-reader", types=[FeatureView, FeatureService], policy=RoleBasedPolicy(roles=["super-reader"]), - actions=[AuthzedAction.DESCRIBE, READ], + actions=[AuthzedAction.DESCRIBE, *READ], ) ``` diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 6567ae181da..b790d6dd719 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -55,7 +55,7 @@ Yes. In earlier versions of Feast, we used Feast Spark to manage ingestion from There are several kinds of transformations: -* On demand transformations (See [docs](../reference/alpha-on-demand-feature-view.md)) +* On demand transformations (See [docs](../reference/beta-on-demand-feature-view.md)) * These transformations are Pandas transformations run on batch data when you call `get_historical_features` and at online serving time when you call \`get\_online\_features. * Note that if you use push sources to ingest streaming features, these transformations will execute on the fly as well * Batch transformations (WIP, see [RFC](https://docs.google.com/document/d/1964OkzuBljifDvkV-0fakp2uaijnVzdwWNGdz7Vz50A/edit)) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index d35446ce7f0..a83897005fd 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -10,6 +10,9 @@ Feast (Feature Store) is an open-source feature store designed to facilitate the * *For Data Engineers*: Feast provides a centralized catalog for storing feature definitions allowing one to maintain a single source of truth for feature data. It provides the abstraction for reading and writing to many different types of offline and online data stores. Using either the provided python SDK or the feature server service, users can write data to the online and/or offline stores and then read that data out again in either low-latency online scenarios for model inference, or in batch scenarios for model training. +* *For AI Engineers*: Feast provides a platform designed to scale your AI applications by enabling seamless integration of richer data and facilitating fine-tuning. With Feast, you can optimize the performance of your AI models while ensuring a scalable and efficient data pipeline. + + For more info refer to [Introduction to feast](../README.md) ## Prerequisites diff --git a/docs/how-to-guides/customizing-feast/adding-a-new-offline-store.md b/docs/how-to-guides/customizing-feast/adding-a-new-offline-store.md index 28592f0cd1a..c8e0258fdf7 100644 --- a/docs/how-to-guides/customizing-feast/adding-a-new-offline-store.md +++ b/docs/how-to-guides/customizing-feast/adding-a-new-offline-store.md @@ -440,11 +440,10 @@ test-python-universal-spark: ### 7. Dependencies -Add any dependencies for your offline store to our `sdk/python/setup.py` under a new `__REQUIRED` list with the packages and add it to the setup script so that if your offline store is needed, users can install the necessary python packages. These packages should be defined as extras so that they are not installed by users by default. You will need to regenerate our requirements files. To do this, create separate pyenv environments for python 3.8, 3.9, and 3.10. In each environment, run the following commands: +Add any dependencies for your offline store to our `sdk/python/setup.py` under a new `__REQUIRED` list with the packages and add it to the setup script so that if your offline store is needed, users can install the necessary python packages. These packages should be defined as extras so that they are not installed by users by default. You will need to regenerate our requirements files: ``` -export PYTHON= -make lock-python-ci-dependencies +make lock-python-ci-dependencies-all ``` ### 8. Add Documentation diff --git a/docs/how-to-guides/customizing-feast/adding-support-for-a-new-online-store.md b/docs/how-to-guides/customizing-feast/adding-support-for-a-new-online-store.md index 5e26f133cef..ee75aa6b74f 100644 --- a/docs/how-to-guides/customizing-feast/adding-support-for-a-new-online-store.md +++ b/docs/how-to-guides/customizing-feast/adding-support-for-a-new-online-store.md @@ -25,7 +25,7 @@ OnlineStore class names must end with the OnlineStore suffix! ### Contrib online stores -New online stores go in `sdk/python/feast/infra/online_stores/contrib/`. +New online stores go in `sdk/python/feast/infra/online_stores/`. #### What is a contrib plugin? diff --git a/docs/how-to-guides/running-feast-in-production.md b/docs/how-to-guides/running-feast-in-production.md index 021a10ac1ca..aa277c2540a 100644 --- a/docs/how-to-guides/running-feast-in-production.md +++ b/docs/how-to-guides/running-feast-in-production.md @@ -203,7 +203,7 @@ feature_vector = fs.get_online_features( ).to_dict() ``` -### 4.2. Deploy Feast feature servers on Kubernetes +### 4.2. Deploy Feast feature servers on Kubernetes (Deprecated replaced by [feast-operator](../../infra/feast-operator/README.md)) To deploy a Feast feature server on Kubernetes, you can use the included [helm chart + tutorial](https://github.com/feast-dev/feast/tree/master/infra/charts/feast-feature-server) (which also has detailed instructions and an example tutorial). diff --git a/docs/reference/starting-feast-servers-tls-mode.md b/docs/how-to-guides/starting-feast-servers-tls-mode.md similarity index 60% rename from docs/reference/starting-feast-servers-tls-mode.md rename to docs/how-to-guides/starting-feast-servers-tls-mode.md index 366cd79d564..a868e17cf96 100644 --- a/docs/reference/starting-feast-servers-tls-mode.md +++ b/docs/how-to-guides/starting-feast-servers-tls-mode.md @@ -1,7 +1,9 @@ # Starting feast servers in TLS (SSL) mode. TLS (Transport Layer Security) and SSL (Secure Sockets Layer) are both protocols encrypts communications between a client and server to provide enhanced security.TLS or SSL words used interchangeably. This article is going to show the sample code to start all the feast servers such as online server, offline server, registry server and UI server in TLS mode. -Also show examples related to feast clients to communicate with the feast servers started in TLS mode. +Also show examples related to feast clients to communicate with the feast servers started in TLS mode. + +We assume you have basic understanding of feast terminology before going through this tutorial, if you are new to feast then we would recommend to go through existing [starter tutorials](./../../examples) of feast. ## Obtaining a self-signed TLS certificate and key In development mode we can generate a self-signed certificate for testing. In an actual production environment it is always recommended to get it from a trusted TLS certificate provider. @@ -17,15 +19,32 @@ The above command will generate two files You can use the public or private keys generated from above command in the rest of the sections in this tutorial. ## Create the feast demo repo for the rest of the sections. -create a feast repo using `feast init` command and use this repo as a demo for subsequent sections. +Create a feast repo and initialize using `feast init` and `feast apply` command and use this repo as a demo for subsequent sections. ```shell feast init feast_repo_ssl_demo -``` -Output is -``` +#output will be something similar as below Creating a new Feast repository in /Documents/Src/feast/feast_repo_ssl_demo. + +cd feast_repo_ssl_demo/feature_repo +feast apply + +#output will be something similar as below +Applying changes for project feast_repo_ssl_demo + +Created project feast_repo_ssl_demo +Created entity driver +Created feature view driver_hourly_stats +Created feature view driver_hourly_stats_fresh +Created on demand feature view transformed_conv_rate +Created on demand feature view transformed_conv_rate_fresh +Created feature service driver_activity_v1 +Created feature service driver_activity_v3 +Created feature service driver_activity_v2 + +Created sqlite table feast_repo_ssl_demo_driver_hourly_stats_fresh +Created sqlite table feast_repo_ssl_demo_driver_hourly_stats ``` You need to execute the feast cli commands from `feast_repo_ssl_demo/feature_repo` directory created from the above `feast init` command. @@ -68,7 +87,7 @@ entity_key_serialization_version: 2 auth: type: no_auth ``` -{% endcode %} + `cert` is an optional configuration to the public certificate path when the online server starts in TLS(SSL) mode. Typically, this file ends with `*.crt`, `*.cer`, or `*.pem`. @@ -106,14 +125,55 @@ entity_key_serialization_version: 2 auth: type: no_auth ``` -{% endcode %} `cert` is an optional configuration to the public certificate path when the registry server starts in TLS(SSL) mode. Typically, this file ends with `*.crt`, `*.cer`, or `*.pem`. ## Starting feast offline server in TLS mode -TBD +To start the offline server in TLS mode, you need to provide the private and public keys using the `--key` and `--cert` arguments with the `feast serve_offline` command. +```shell +feast serve_offline --key /path/to/key.pem --cert /path/to/cert.pem +``` +You will see the output something similar to as below. Note the server url starts in the `https` mode. + +```shell +11/07/2024 11:10:01 AM feast.offline_server INFO: Found SSL certificates in the args so going to start offline server in TLS(SSL) mode. +11/07/2024 11:10:01 AM feast.offline_server INFO: Offline store server serving at: grpc+tls://127.0.0.1:8815 +11/07/2024 11:10:01 AM feast.offline_server INFO: offline server starting with pid: [11606] +``` + +### Feast client connecting to remote offline sever started in TLS mode. + +Sometimes you may need to pass the self-signed public key to connect to the remote registry server started in SSL mode if you have not added the public key to the certificate store. +You have to add `scheme` to `https`. + +feast client example: + +```yaml +project: feast-project +registry: + registry_type: remote + path: https://localhost:6570 + cert: /path/to/cert.pem +provider: local +online_store: + path: http://localhost:6566 + type: remote + cert: /path/to/cert.pem +entity_key_serialization_version: 2 +offline_store: + type: remote + host: localhost + port: 8815 + scheme: https + cert: /path/to/cert.pem +auth: + type: no_auth +``` + +`cert` is an optional configuration to the public certificate path when the registry server starts in TLS(SSL) mode. Typically, this file ends with `*.crt`, `*.cer`, or `*.pem`. +`scheme` should be `https`. By default, it will be `http` so you have to explicitly configure to `https` if you are planning to connect to remote offline server which is started in TLS mode. ## Starting feast UI server (react app) in TLS mode To start the feast UI server in TLS mode, you need to provide the private and public keys using the `--key` and `--cert` arguments with the `feast ui` command. @@ -129,3 +189,8 @@ INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on https://0.0.0.0:8888 (Press CTRL+C to quit) ``` + + +## Adding public key to CA trust store and configuring the feast to use the trust store. +You can pass the public key for SSL verification using the `cert` parameter, however, it is sometimes difficult to maintain individual certificates and pass them individually. +The alternative recommendation is to add the public certificate to CA trust store and set the path as an environment variable (e.g., `FEAST_CA_CERT_FILE_PATH`). Feast will use the trust store path in the `FEAST_CA_CERT_FILE_PATH` environment variable. \ No newline at end of file diff --git a/docs/project/development-guide.md b/docs/project/development-guide.md index b6137741906..5b2d0a521e8 100644 --- a/docs/project/development-guide.md +++ b/docs/project/development-guide.md @@ -54,8 +54,8 @@ See [Contribution process](./contributing.md) and [Community](../community.md) f ## Making a pull request We use the convention that the assignee of a PR is the person with the next action. -If the assignee is empty it means that no reviewer has been found yet. -If a reviewer has been found, they should also be the assigned the PR. +If the assignee is empty it means that no reviewer has been found yet. +If a reviewer has been found, they should also be the assigned the PR. Finally, if there are comments to be addressed, the PR author should be the one assigned the PR. PRs that are submitted by the general public need to be identified as `ok-to-test`. Once enabled, [Prow](https://github.com/kubernetes/test-infra/tree/master/prow) will run a range of tests to verify the submission, after which community members will help to review the pull request. @@ -120,51 +120,39 @@ Note that this means if you are midway through working through a PR and rebase, ## Feast Python SDK and CLI ### Environment Setup -Setting up your development environment for Feast Python SDK and CLI: -1. Ensure that you have Docker installed in your environment. Docker is used to provision service dependencies during testing, and build images for feature servers and other components. +#### Tools +- Docker: Docker is used to provision service dependencies during testing, and build images for feature servers and other components. - Please note that we use [Docker with BuiltKit](https://docs.docker.com/develop/develop-images/build_enhancements/). - _Alternatively_ - To use [podman](https://podman.io/) on a Fedora or RHEL machine, follow this [guide](https://github.com/feast-dev/feast/issues/4190) -2. Ensure that you have `make` and Python (3.9 or above) installed. -3. _Recommended:_ Create a virtual environment to isolate development dependencies to be installed - ```sh - # create & activate a virtual environment - python -m venv venv/ - source venv/bin/activate - ``` -4. (M1 Mac only): Follow the [dev guide](https://github.com/feast-dev/feast/issues/2105) -5. Install uv. It is recommended to use uv for managing python dependencies. +- `make` is used to run various scripts +- [uv](https://docs.astral.sh/) for managing python dependencies. [installation instructions](https://docs.astral.sh/uv/getting-started/installation/) +- (M1 Mac only): Follow the [dev guide if you have issues](https://github.com/feast-dev/feast/issues/2105) +- (Optional): Node & Yarn (needed for building the feast UI) +- (Optional): [Pixi](https://pixi.sh/latest/) for recompile python lock files. Only when you make changes to requirements or simply want to update python lock files to reflect latest versioons. + +### Quick start +- create a new virtual env: `uv venv --python 3.11` (Replace the python version with your desired version) +- activate the venv: `source venv/bin/activate` +- Install dependencies `make install-python-dependencies-dev` + +### building the UI ```sh -curl -LsSf https://astral.sh/uv/install.sh | sh -``` -or -```ssh -pip install uv -``` -6. (Optional): Install Node & Yarn. Then run the following to build Feast UI artifacts for use in `feast ui` -``` make build-ui ``` -7. (Optional) install pixi. pixi is necessary to run step 8 for all python versions at once. -```sh -curl -fsSL https://pixi.sh/install.sh | bash -``` -8. (Optional): Recompile python lock files. Only when you make changes to requirements or simply want to update python lock files to reflect latest versioons. -```sh -make lock-python-dependencies-all -``` -9. Install development dependencies for Feast Python SDK and CLI. This will install package versions from the lock file, install editable version of feast and compile protobufs. -If running inside a virtual environment: +### Recompiling python lock files +Recompile python lock files. This only needs to be run when you make changes to requirements or simply want to update python lock files to reflect latest versions. + ```sh -make install-python-ci-dependencies-uv-venv +make lock-python-dependencies-all ``` -Otherwise: +### Building protos ```sh -make install-python-ci-dependencies-uv +make compile-protos-python ``` -10. Spin up Docker Image +### Building a docker image for development ```sh docker build -t docker-whale -f ./sdk/python/feast/infra/feature_servers/multicloud/Dockerfile . ``` @@ -405,7 +393,7 @@ It will: ### Testing with Github Actions workflows -Please refer to the maintainers [doc](maintainers.md) if you would like to locally test out the github actions workflow changes. +Please refer to the maintainers [doc](maintainers.md) if you would like to locally test out the github actions workflow changes. This document will help you setup your fork to test the ci integration tests and other workflows without needing to make a pull request against feast-dev master. ## Feast Data Storage Format @@ -414,4 +402,3 @@ Feast data storage contracts are documented in the following locations: * [Feast Offline Storage Format](https://github.com/feast-dev/feast/blob/master/docs/specs/offline_store_format.md): Used by BigQuery, Snowflake \(Future\), Redshift \(Future\). * [Feast Online Storage Format](https://github.com/feast-dev/feast/blob/master/docs/specs/online_store_format.md): Used by Redis, Google Datastore. - diff --git a/docs/project/release-process.md b/docs/project/release-process.md index 251b9338f0a..2cddca508cf 100644 --- a/docs/project/release-process.md +++ b/docs/project/release-process.md @@ -1,5 +1,43 @@ # Release process +The release process is automated through a GitHub Action called [release.yml](https://github.com/feast-dev/feast/blob/master/.github/workflows/release.yml). +Here's a diagram of the workflows: + +```mermaid +graph LR + A[get_dry_release_versions] --> B[validate_version_bumps] + B --> C[publish-web-ui-npm] + C --> D[release] +``` + +The release step will trigger an automated chore commit by the CI-bot ([example](https://github.com/feast-dev/feast/commit/121617053344117cdbfbb480882b10cc176245ac)). + +After the `release` step and release commit, the `publish` step will be triggered ([example](https://github.com/feast-dev/feast/actions/runs/13143995111)). + +The `publish` worfklow triggers this flow: + +```mermaid +graph TD + A[publish.yml] -->|triggers| B[publish_python_sdk.yml] + B -->|needs| C[publish_images.yml] + B -->|needs| D[publish_helm_charts.yml] + + subgraph B[publish_python_sdk.yml] + direction LR + B1[Checkout code] --> B2[Set up Python] --> B3[Install dependencies] --> B4[Run tests] --> B5[Build wheels] --> B6[Publish to PyPI] + end + + subgraph C[publish_images.yml] + direction LR + C1[Checkout code] --> C2[Set up Docker] --> C3[Build Docker images] --> C4[Push Docker images] + end + + subgraph D[publish_helm_charts.yml] + direction LR + D1[Checkout code] --> D2[Set up Helm] --> D3[Package Helm charts] --> D4[Publish Helm charts] + end +``` + ## Release process For Feast maintainers, these are the concrete steps for making a new release. diff --git a/docs/reference/alpha-vector-database.md b/docs/reference/alpha-vector-database.md index ae6b47f0422..861c3fcb114 100644 --- a/docs/reference/alpha-vector-database.md +++ b/docs/reference/alpha-vector-database.md @@ -7,20 +7,35 @@ Vector database allows user to store and retrieve embeddings. Feast provides gen ## Integration Below are supported vector databases and implemented features: -| Vector Database | Retrieval | Indexing | -|-----------------|-----------|----------| -| Pgvector | [x] | [ ] | -| Elasticsearch | [x] | [x] | -| Milvus | [ ] | [ ] | -| Faiss | [ ] | [ ] | -| SQLite | [x] | [ ] | -| Qdrant | [x] | [x] | +| Vector Database | Retrieval | Indexing | V2 Support* | Online Read | +|-----------------|-----------|----------|-------------|-------------| +| Pgvector | [x] | [ ] | [] | [] | +| Elasticsearch | [x] | [x] | [] | [] | +| Milvus | [x] | [x] | [x] | [x] | +| Faiss | [ ] | [ ] | [] | [] | +| SQLite | [x] | [ ] | [x] | [x] | +| Qdrant | [x] | [x] | [] | [] | + +*Note: V2 Support means the SDK supports retrieval of features along with vector embeddings from vector similarity search. Note: SQLite is in limited access and only working on Python 3.10. It will be updated as [sqlite_vec](https://github.com/asg017/sqlite-vec/) progresses. -## Example +{% hint style="danger" %} +We will be deprecating the `retrieve_online_documents` method in the SDK in the future. +We recommend using the `retrieve_online_documents_v2` method instead, which offers easier vector index configuration +directly in the Feature View and the ability to retrieve standard features alongside your vector embeddings for richer context injection. + +Long term we will collapse the two methods into one, but for now, we recommend using the `retrieve_online_documents_v2` method. +Beyond that, we will then have `retrieve_online_documents` and `retrieve_online_documents_v2` simply point to `get_online_features` for +backwards compatibility and the adopt industry standard naming conventions. +{% endhint %} + +**Note**: Milvus and SQLite implement the v2 `retrieve_online_documents_v2` method in the SDK. This will be the longer-term solution so that Data Scientists can easily enable vector similarity search by just flipping a flag. -See [https://github.com/feast-dev/feast-workshop/blob/rag/module_4_rag](https://github.com/feast-dev/feast-workshop/blob/rag/module_4_rag) for an example on how to use vector database. +## Examples + +- See the v0 [Rag Demo](https://github.com/feast-dev/feast-workshop/blob/rag/module_4_rag) for an example on how to use vector database using the `retrieve_online_documents` method (planning migration and deprecation (planning migration and deprecation). +- See the v1 [Milvus Quickstart](../../examples/rag/milvus-quickstart.ipynb) for a quickstart guide on how to use Feast with Milvus using the `retrieve_online_documents_v2` method. ### **Prepare offline embedding dataset** Run the following commands to prepare the embedding dataset: @@ -31,28 +46,26 @@ python batch_score_documents.py The output will be stored in `data/city_wikipedia_summaries.csv.` ### **Initialize Feast feature store and materialize the data to the online store** -Use the feature_store.yaml file to initialize the feature store. This will use the data as offline store, and Pgvector as online store. +Use the feature_store.yaml file to initialize the feature store. This will use the data as offline store, and Milvus as online store. ```yaml -project: feast_demo_local +project: local_rag provider: local -registry: - registry_type: sql - path: postgresql://@localhost:5432/feast +registry: data/registry.db online_store: - type: postgres + type: milvus + path: data/online_store.db vector_enabled: true - vector_len: 384 - host: 127.0.0.1 - port: 5432 - database: feast - user: "" - password: "" + embedding_dim: 384 + index_type: "IVF_FLAT" offline_store: type: file -entity_key_serialization_version: 2 +entity_key_serialization_version: 3 +# By default, no_auth for authentication and authorization, other possible values kubernetes and oidc. Refer the documentation for more details. +auth: + type: no_auth ``` Run the following command in terminal to apply the feature store configuration: @@ -63,75 +76,128 @@ feast apply Note that when you run `feast apply` you are going to apply the following Feature View that we will use for retrieval later: ```python -city_embeddings_feature_view = FeatureView( - name="city_embeddings", - entities=[item], +document_embeddings = FeatureView( + name="embedded_documents", + entities=[item, author], schema=[ - Field(name="Embeddings", dtype=Array(Float32)), + Field( + name="vector", + dtype=Array(Float32), + # Look how easy it is to enable RAG! + vector_index=True, + vector_search_metric="COSINE", + ), + Field(name="item_id", dtype=Int64), + Field(name="author_id", dtype=String), + Field(name="created_timestamp", dtype=UnixTimestamp), + Field(name="sentence_chunks", dtype=String), + Field(name="event_timestamp", dtype=UnixTimestamp), ], - source=source, - ttl=timedelta(hours=2), + source=rag_documents_source, + ttl=timedelta(hours=24), ) ``` -Then run the following command in the terminal to materialize the data to the online store: - -```shell -CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S") -feast materialize-incremental $CURRENT_TIME +Let's use the SDK to write a data frame of embeddings to the online store: +```python +store.write_to_online_store(feature_view_name='city_embeddings', df=df) ``` ### **Prepare a query embedding** +During inference (e.g., during when a user submits a chat message) we need to embed the input text. This can be thought of as a feature transformation of the input data. In this example, we'll do this with a small Sentence Transformer from Hugging Face. + ```python -from batch_score_documents import run_model, TOKENIZER, MODEL +import torch +import torch.nn.functional as F +from feast import FeatureStore +from pymilvus import MilvusClient, DataType, FieldSchema from transformers import AutoTokenizer, AutoModel - -question = "the most populous city in the U.S. state of Texas?" +from example_repo import city_embeddings_feature_view, item + +TOKENIZER = "sentence-transformers/all-MiniLM-L6-v2" +MODEL = "sentence-transformers/all-MiniLM-L6-v2" + +def mean_pooling(model_output, attention_mask): + token_embeddings = model_output[ + 0 + ] # First element of model_output contains all token embeddings + input_mask_expanded = ( + attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() + ) + return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp( + input_mask_expanded.sum(1), min=1e-9 + ) + +def run_model(sentences, tokenizer, model): + encoded_input = tokenizer( + sentences, padding=True, truncation=True, return_tensors="pt" + ) + # Compute token embeddings + with torch.no_grad(): + model_output = model(**encoded_input) + + sentence_embeddings = mean_pooling(model_output, encoded_input["attention_mask"]) + sentence_embeddings = F.normalize(sentence_embeddings, p=2, dim=1) + return sentence_embeddings + +question = "Which city has the largest population in New York?" tokenizer = AutoTokenizer.from_pretrained(TOKENIZER) model = AutoModel.from_pretrained(MODEL) -query_embedding = run_model(question, tokenizer, model) -query = query_embedding.detach().cpu().numpy().tolist()[0] +query_embedding = run_model(question, tokenizer, model).detach().cpu().numpy().tolist()[0] ``` -### **Retrieve the top 5 similar documents** -First create a feature store instance, and use the `retrieve_online_documents` API to retrieve the top 5 similar documents to the specified query. +### **Retrieve the top K similar documents** +First create a feature store instance, and use the `retrieve_online_documents_v2` API to retrieve the top 5 similar documents to the specified query. ```python -from feast import FeatureStore -store = FeatureStore(repo_path=".") -features = store.retrieve_online_documents( - feature="city_embeddings:Embeddings", - query=query, - top_k=5 -).to_dict() - -def print_online_features(features): - for key, value in sorted(features.items()): - print(key, " : ", value) - -print_online_features(features) +context_data = store.retrieve_online_documents_v2( + features=[ + "city_embeddings:vector", + "city_embeddings:item_id", + "city_embeddings:state", + "city_embeddings:sentence_chunks", + "city_embeddings:wiki_summary", + ], + query=query_embedding, + top_k=3, + distance_metric='COSINE', +).to_df() ``` +### **Generate the Response** +Let's assume we have a base prompt and a function that formats the retrieved documents called `format_documents` that we +can then use to generate the response with OpenAI's chat completion API. +```python +FULL_PROMPT = format_documents(rag_context_data, BASE_PROMPT) -### Configuration +from openai import OpenAI -We offer [PGVector](https://github.com/pgvector/pgvector), [SQLite](https://github.com/asg017/sqlite-vec), [Elasticsearch](https://www.elastic.co) and [Qdrant](https://qdrant.tech/) as Online Store options for Vector Databases. - -#### Installation with SQLite +client = OpenAI( + api_key=os.environ.get("OPENAI_API_KEY"), +) +response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": FULL_PROMPT}, + {"role": "user", "content": question} + ], +) -If you are using `pyenv` to manage your Python versions, you can install the SQLite extension with the following command: -```bash -PYTHON_CONFIGURE_OPTS="--enable-loadable-sqlite-extensions" \ - LDFLAGS="-L/opt/homebrew/opt/sqlite/lib" \ - CPPFLAGS="-I/opt/homebrew/opt/sqlite/include" \ - pyenv install 3.10.14 +# And this will print the content. Look at the examples/rag/milvus-quickstart.ipynb for an end-to-end example. +print('\n'.join([c.message.content for c in response.choices])) ``` -And you can the Feast install package via: + +### Configuration and Installation + +We offer [Milvus](https://milvus.io/), [PGVector](https://github.com/pgvector/pgvector), [SQLite](https://github.com/asg017/sqlite-vec), [Elasticsearch](https://www.elastic.co) and [Qdrant](https://qdrant.tech/) as Online Store options for Vector Databases. + +Milvus offers a convenient local implementation for vector similarity search. To use Milvus, you can install the Feast package with the Milvus extra. + +#### Installation with Milvus ```bash -pip install feast[sqlite_vec] +pip install feast[milvus] ``` - #### Installation with Elasticsearch ```bash @@ -143,3 +209,17 @@ pip install feast[elasticsearch] ```bash pip install feast[qdrant] ``` +#### Installation with SQLite + +If you are using `pyenv` to manage your Python versions, you can install the SQLite extension with the following command: +```bash +PYTHON_CONFIGURE_OPTS="--enable-loadable-sqlite-extensions" \ + LDFLAGS="-L/opt/homebrew/opt/sqlite/lib" \ + CPPFLAGS="-I/opt/homebrew/opt/sqlite/include" \ + pyenv install 3.10.14 +``` + +And you can the Feast install package via: +```bash +pip install feast[sqlite_vec] +``` diff --git a/docs/reference/alpha-web-ui.md b/docs/reference/alpha-web-ui.md index 02dd107f1b4..80c5b824c5a 100644 --- a/docs/reference/alpha-web-ui.md +++ b/docs/reference/alpha-web-ui.md @@ -100,9 +100,9 @@ yarn start The advantage of importing Feast UI as a module is in the ease of customization. The `` component exposes a `feastUIConfigs` prop thorough which you can customize the UI. Currently it supports a few parameters. -**Fetching the Project List** +##### Fetching the Project List -You can use `projectListPromise` to provide a promise that overrides where the Feast UI fetches the project list from. +By default, the Feast UI fetches the project list from the app root path. You can use `projectListPromise` to provide a promise that overrides where it's fetched from. ```jsx ``` -**Custom Tabs** +##### Custom Tabs You can add custom tabs for any of the core Feast objects through the `tabsRegistry`. -``` +```jsx const tabsRegistry = { RegularFeatureViewCustomTabs: [ { diff --git a/docs/reference/data-sources/README.md b/docs/reference/data-sources/README.md index e69fbab8e36..09df6b861e8 100644 --- a/docs/reference/data-sources/README.md +++ b/docs/reference/data-sources/README.md @@ -34,6 +34,10 @@ Please see [Data Source](../../getting-started/concepts/data-ingestion.md) for a [kinesis.md](kinesis.md) {% endcontent-ref %} +{% content-ref url="couchbase.md" %} +[couchbase.md](couchbase.md) +{% endcontent-ref %} + {% content-ref url="spark.md" %} [spark.md](spark.md) {% endcontent-ref %} diff --git a/docs/reference/data-sources/couchbase.md b/docs/reference/data-sources/couchbase.md new file mode 100644 index 00000000000..596e33cf50d --- /dev/null +++ b/docs/reference/data-sources/couchbase.md @@ -0,0 +1,37 @@ +# Couchbase Columnar source (contrib) + +## Description + +Couchbase Columnar data sources are [Couchbase Capella Columnar](https://docs.couchbase.com/columnar/intro/intro.html) collections that can be used as a source for feature data. **Note that Couchbase Columnar is available through [Couchbase Capella](https://cloud.couchbase.com/).** + +## Disclaimer + +The Couchbase Columnar data source does not achieve full test coverage. +Please do not assume complete stability. + +## Examples + +Defining a Couchbase Columnar source: + +```python +from feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase_source import ( + CouchbaseColumnarSource, +) + +driver_stats_source = CouchbaseColumnarSource( + name="driver_hourly_stats_source", + query="SELECT * FROM Default.Default.`feast_driver_hourly_stats`", + database="Default", + scope="Default", + collection="feast_driver_hourly_stats", + timestamp_field="event_timestamp", + created_timestamp_column="created", +) +``` + +The full set of configuration options is available [here](https://rtd.feast.dev/en/master/#feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase_source.CouchbaseColumnarSource). + +## Supported Types + +Couchbase Capella Columnar data sources support `BOOLEAN`, `STRING`, `BIGINT`, and `DOUBLE` primitive types. +For a comparison against other batch data sources, please see [here](overview.md#functionality-matrix). diff --git a/docs/reference/data-sources/overview.md b/docs/reference/data-sources/overview.md index 5c2fdce9fd1..9880d388dde 100644 --- a/docs/reference/data-sources/overview.md +++ b/docs/reference/data-sources/overview.md @@ -18,14 +18,14 @@ Details for each specific data source can be found [here](README.md). Below is a matrix indicating which data sources support which types. -| | File | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | -| :-------------------------------- | :-- | :-- |:----------| :-- | :-- | :-- | :-- | -| `bytes` | yes | yes | yes | yes | yes | yes | yes | -| `string` | yes | yes | yes | yes | yes | yes | yes | -| `int32` | yes | yes | yes | yes | yes | yes | yes | -| `int64` | yes | yes | yes | yes | yes | yes | yes | -| `float32` | yes | yes | yes | yes | yes | yes | yes | -| `float64` | yes | yes | yes | yes | yes | yes | yes | -| `bool` | yes | yes | yes | yes | yes | yes | yes | -| `timestamp` | yes | yes | yes | yes | yes | yes | yes | -| array types | yes | yes | yes | no | yes | yes | no | \ No newline at end of file +| | File | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | Couchbase | +| :-------------------------------- | :-- | :-- |:----------| :-- | :-- | :-- | :-- |:----------| +| `bytes` | yes | yes | yes | yes | yes | yes | yes | yes | +| `string` | yes | yes | yes | yes | yes | yes | yes | yes | +| `int32` | yes | yes | yes | yes | yes | yes | yes | yes | +| `int64` | yes | yes | yes | yes | yes | yes | yes | yes | +| `float32` | yes | yes | yes | yes | yes | yes | yes | yes | +| `float64` | yes | yes | yes | yes | yes | yes | yes | yes | +| `bool` | yes | yes | yes | yes | yes | yes | yes | yes | +| `timestamp` | yes | yes | yes | yes | yes | yes | yes | yes | +| array types | yes | yes | yes | no | yes | yes | no | no | diff --git a/docs/reference/denormalized.md b/docs/reference/denormalized.md index 281e97de553..9ac39947f05 100644 --- a/docs/reference/denormalized.md +++ b/docs/reference/denormalized.md @@ -6,8 +6,8 @@ Denormalized makes it easy to compute real-time features and write them directly ## Prerequisites -- Python 3.8+ -- Kafka cluster (local or remote) +- Python 3.12+ +- Kafka cluster (local or remote) OR docker installed For a full working demo, check out the [feast-example](https://github.com/probably-nothing-labs/feast-example) repo. @@ -39,6 +39,13 @@ my-feature-project/ └── main.py # Pipeline runner ``` +3. Run a test Kafka instance in docker + +`docker run --rm -p 9092:9092 emgeee/kafka_emit_measurements:latest` + +This will spin up a docker container that runs a kafka instance and run a simple script to emit fake data to two topics. + + ## Define Your Features In `feature_repo/sensor_data.py`, define your feature view and entity: @@ -85,7 +92,7 @@ sample_event = { } # Create a stream from your Kafka topic -ds = FeastDataStream(Context().from_topic("temperature", json.dumps(sample_event), "localhost:9092")) +ds = FeastDataStream(Context().from_topic("temperature", json.dumps(sample_event), "localhost:9092", "occurred_at_ms")) # Define your feature computations ds = ds.window( @@ -106,7 +113,9 @@ feature_store = FeatureStore(repo_path="feature_repo/") ds.write_feast_feature(feature_store, "push_sensor_statistics") ``` + + ## Need Help? - Email us at hello@denormalized.io -- Check out more examples on our [GitHub](https://github.com/probably-nothing-labs/denormalized) +- Check out more examples on our [GitHub](https://github.com/probably-nothing-labs/denormalized/tree/main/py-denormalized/python/examples) diff --git a/docs/reference/feast-cli-commands.md b/docs/reference/feast-cli-commands.md index 8f1a7c302e6..712df18a6b6 100644 --- a/docs/reference/feast-cli-commands.md +++ b/docs/reference/feast-cli-commands.md @@ -19,6 +19,7 @@ Options: Commands: apply Create or update a feature store deployment + configuration Display Feast configuration entities Access entities feature-views Access feature views init Create a new Feast repository @@ -61,6 +62,28 @@ feast apply `feast apply` \(when configured to use cloud provider like `gcp` or `aws`\) will create cloud infrastructure. This may incur costs. {% endhint %} +## Configuration + +Display the actual configuration being used by Feast, including both user-provided configurations and default configurations applied by Feast. + +```bash +feast configuration +``` + +```yaml +project: foo +registry: data/registry.db +provider: local +online_store: + type: sqlite + path: data/online_store.db +offline_store: + type: dask +entity_key_serialization_version: 2 +auth: + type: no_auth +``` + ## Entities List all registered entities diff --git a/docs/reference/feature-servers/README.md b/docs/reference/feature-servers/README.md index 2ceaf5807f3..156e60c7431 100644 --- a/docs/reference/feature-servers/README.md +++ b/docs/reference/feature-servers/README.md @@ -1,4 +1,4 @@ -# Feature servers +# Feast servers Feast users can choose to retrieve features from a feature server, as opposed to through the Python SDK. @@ -12,4 +12,8 @@ Feast users can choose to retrieve features from a feature server, as opposed to {% content-ref url="offline-feature-server.md" %} [offline-feature-server.md](offline-feature-server.md) +{% endcontent-ref %} + +{% content-ref url="registry-server.md" %} +[registry-server.md](registry-server.md) {% endcontent-ref %} \ No newline at end of file diff --git a/docs/reference/feature-servers/go-feature-server.md b/docs/reference/feature-servers/go-feature-server.md deleted file mode 100644 index 8209799086a..00000000000 --- a/docs/reference/feature-servers/go-feature-server.md +++ /dev/null @@ -1,93 +0,0 @@ -# Go feature server - -## Overview - -The Go feature server is an HTTP/gRPC endpoint that serves features. -It is written in Go, and is therefore significantly faster than the Python feature server. -See this [blog post](https://feast.dev/blog/go-feature-server-benchmarks/) for more details on the comparison between Python and Go. -In general, we recommend the Go feature server for all production use cases that require extremely low-latency feature serving. -Currently only the Redis and SQLite online stores are supported. - -## CLI - -By default, the Go feature server is turned off. -To turn it on you can add `go_feature_serving: True` to your `feature_store.yaml`: - -{% code title="feature_store.yaml" %} -```yaml -project: my_feature_repo -registry: data/registry.db -provider: local -online_store: - type: redis - connection_string: "localhost:6379" -go_feature_serving: True -``` -{% endcode %} - -Then the `feast serve` CLI command will start the Go feature server. -As with Python, the Go feature server uses port 6566 by default; the port be overridden with a `--port` flag. -Moreover, the server uses HTTP by default, but can be set to use gRPC with `--type=grpc`. - -Alternatively, if you wish to experiment with the Go feature server instead of permanently turning it on, you can just run `feast serve --go`. - -## Installation - -The Go component comes pre-compiled when you install Feast with Python versions 3.8-3.10 on macOS or Linux (on x86). -In order to install the additional Python dependencies, you should install Feast with -``` -pip install feast[go] -``` -You must also install the Apache Arrow C++ libraries. -This is because the Go feature server uses the cgo memory allocator from the Apache Arrow C++ library for interoperability between Go and Python, to prevent memory from being accidentally garbage collected when executing on-demand feature views. -You can read more about the usage of the cgo memory allocator in these [docs](https://pkg.go.dev/github.com/apache/arrow/go/arrow@v0.0.0-20211112161151-bc219186db40/cdata#ExportArrowRecordBatch). - -For macOS, run `brew install apache-arrow`. -For linux users, you have to install `libarrow-dev`. -``` -sudo apt update -sudo apt install -y -V ca-certificates lsb-release wget -wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb -sudo apt install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb -sudo apt update -sudo apt install -y -V libarrow-dev # For C++ -``` -For developers, if you want to build from source, run `make compile-go-lib` to build and compile the go server. In order to build the go binaries, you will need to install the `apache-arrow` c++ libraries. - -## Alpha features - -### Feature logging - -The Go feature server can log all requested entities and served features to a configured destination inside an offline store. -This allows users to create new datasets from features served online. Those datasets could be used for future trainings or for -feature validations. To enable feature logging we need to edit `feature_store.yaml`: -```yaml -project: my_feature_repo -registry: data/registry.db -provider: local -online_store: - type: redis - connection_string: "localhost:6379" -go_feature_serving: True -feature_server: - feature_logging: - enabled: True -``` - -Feature logging configuration in `feature_store.yaml` also allows to tweak some low-level parameters to achieve the best performance: -```yaml -feature_server: - feature_logging: - enabled: True - flush_interval_secs: 300 - write_to_disk_interval_secs: 30 - emit_timeout_micro_secs: 10000 - queue_capacity: 10000 -``` -All these parameters are optional. - -### Python SDK retrieval - -The logic for the Go feature server can also be used to retrieve features during a Python `get_online_features` call. -To enable this behavior, you must add `go_feature_retrieval: True` to your `feature_store.yaml`. -You must also have all the dependencies installed as detailed above. diff --git a/docs/reference/feature-servers/python-feature-server.md b/docs/reference/feature-servers/python-feature-server.md index d7374852495..348bfdcd3d8 100644 --- a/docs/reference/feature-servers/python-feature-server.md +++ b/docs/reference/feature-servers/python-feature-server.md @@ -226,13 +226,14 @@ feast serve --key /path/to/key.pem --cert /path/to/cert.pem ## API Endpoints and Permissions -| Endpoint | Resource Type | Permission | Description | -| ---------------------------- |---------------------------------|-------------------------------------------------------| ------------------------------------------------------------------------ | -| /get-online-features | FeatureView,OnDemandFeatureView | Read Online | Get online features from the feature store | -| /push | FeatureView | Write Online, Write Offline, Write Online and Offline | Push features to the feature store (online, offline, or both) | -| /write-to-online-store | FeatureView | Write Online | Write features to the online store | -| /materialize | FeatureView | Write Online | Materialize features within a specified time range | -| /materialize-incremental | FeatureView | Write Online | Incrementally materialize features up to a specified timestamp | +| Endpoint | Resource Type | Permission | Description | +|----------------------------|---------------------------------|-------------------------------------------------------|----------------------------------------------------------------| +| /get-online-features | FeatureView,OnDemandFeatureView | Read Online | Get online features from the feature store | +| /retrieve-online-documents | FeatureView | Read Online | Retrieve online documents from the feature store for RAG | +| /push | FeatureView | Write Online, Write Offline, Write Online and Offline | Push features to the feature store (online, offline, or both) | +| /write-to-online-store | FeatureView | Write Online | Write features to the online store | +| /materialize | FeatureView | Write Online | Materialize features within a specified time range | +| /materialize-incremental | FeatureView | Write Online | Incrementally materialize features up to a specified timestamp | ## How to configure Authentication and Authorization ? diff --git a/docs/reference/feature-servers/registry-server.md b/docs/reference/feature-servers/registry-server.md new file mode 100644 index 00000000000..9707a597035 --- /dev/null +++ b/docs/reference/feature-servers/registry-server.md @@ -0,0 +1,26 @@ +# Registry server + +## Description + +The Registry server uses the gRPC communication protocol to exchange data. +This enables users to communicate with the server using any programming language that can make gRPC requests. + +## How to configure the server + +## CLI + +There is a CLI command that starts the Registry server: `feast serve_registry`. By default, remote Registry Server uses port 6570, the port can be overridden with a `--port` flag. +To start the Registry Server in TLS mode, you need to provide the private and public keys using the `--key` and `--cert` arguments. +More info about TLS mode can be found in [feast-client-connecting-to-remote-registry-sever-started-in-tls-mode](../../how-to-guides/starting-feast-servers-tls-mode.md#starting-feast-registry-server-in-tls-mode) + +## How to configure the client + +Please see the detail how to configure Remote Registry client [remote.md](../registries/remote.md) + +# Registry Server Permissions and Access Control + +Please refer the [page](./../registry/registry-permissions.md) for more details on API Endpoints and Permissions. + +## How to configure Authentication and Authorization ? + +Please refer the [page](./../../../docs/getting-started/concepts/permission.md) for more details on how to configure authentication and authorization. \ No newline at end of file diff --git a/docs/reference/offline-stores/README.md b/docs/reference/offline-stores/README.md index 2b62c4e1f11..ab25fe9a276 100644 --- a/docs/reference/offline-stores/README.md +++ b/docs/reference/offline-stores/README.md @@ -26,6 +26,10 @@ Please see [Offline Store](../../getting-started/components/offline-store.md) fo [duckdb.md](duckdb.md) {% endcontent-ref %} +{% content-ref url="couchbase.md" %} +[couchbase.md](couchbase.md) +{% endcontent-ref %} + {% content-ref url="spark.md" %} [spark.md](spark.md) {% endcontent-ref %} diff --git a/docs/reference/offline-stores/couchbase.md b/docs/reference/offline-stores/couchbase.md new file mode 100644 index 00000000000..3ae0f68d4c2 --- /dev/null +++ b/docs/reference/offline-stores/couchbase.md @@ -0,0 +1,79 @@ +# Couchbase Columnar offline store (contrib) + +## Description + +The Couchbase Columnar offline store provides support for reading [CouchbaseColumnarSources](../data-sources/couchbase.md). **Note that Couchbase Columnar is available through [Couchbase Capella](https://cloud.couchbase.com/).** +* Entity dataframes can be provided as a SQL++ query or can be provided as a Pandas dataframe. A Pandas dataframe will be uploaded to Couchbase Capella Columnar as a collection. + +## Disclaimer + +The Couchbase Columnar offline store does not achieve full test coverage. +Please do not assume complete stability. + +## Getting started + +In order to use this offline store, you'll need to run `pip install 'feast[couchbase]'`. You can get started by then running `feast init -t couchbase`. + +To get started with Couchbase Capella Columnar: +1. Sign up for a [Couchbase Capella](https://cloud.couchbase.com/) account +2. [Deploy a Columnar cluster](https://docs.couchbase.com/columnar/admin/prepare-project.html) +3. [Create an Access Control Account](https://docs.couchbase.com/columnar/admin/auth/auth-data.html) + - This account should be able to read and write. + - For testing purposes, it is recommended to assign all roles to avoid any permission issues. +4. [Configure allowed IP addresses](https://docs.couchbase.com/columnar/admin/ip-allowed-list.html) + - You must allow the IP address of the machine running Feast. + + +## Example + +{% code title="feature_store.yaml" %} +```yaml +project: my_project +registry: data/registry.db +provider: local +offline_store: + type: couchbase.offline + connection_string: COUCHBASE_COLUMNAR_CONNECTION_STRING # Copied from Settings > Connection String page in Capella Columnar console, starts with couchbases:// + user: COUCHBASE_COLUMNAR_USER # Couchbase cluster access name from Settings > Access Control page in Capella Columnar console + password: COUCHBASE_COLUMNAR_PASSWORD # Couchbase password from Settings > Access Control page in Capella Columnar console + timeout: 120 # Timeout in seconds for Columnar operations, optional +online_store: + path: data/online_store.db +``` +{% endcode %} + +Note that `timeout`is an optional parameter. +The full set of configuration options is available in [CouchbaseColumnarOfflineStoreConfig](https://rtd.feast.dev/en/master/#feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase.CouchbaseColumnarOfflineStoreConfig). + + +## Functionality Matrix + +The set of functionality supported by offline stores is described in detail [here](overview.md#functionality). +Below is a matrix indicating which functionality is supported by the Couchbase Columnar offline store. + +| | Couchbase Columnar | +| :----------------------------------------------------------------- |:-------------------| +| `get_historical_features` (point-in-time correct join) | yes | +| `pull_latest_from_table_or_query` (retrieve latest feature values) | yes | +| `pull_all_from_table_or_query` (retrieve a saved dataset) | yes | +| `offline_write_batch` (persist dataframes to offline store) | no | +| `write_logged_features` (persist logged features to offline store) | no | + +Below is a matrix indicating which functionality is supported by `CouchbaseColumnarRetrievalJob`. + +| | Couchbase Columnar | +| ----------------------------------------------------- |--------------------| +| export to dataframe | yes | +| export to arrow table | yes | +| export to arrow batches | no | +| export to SQL | yes | +| export to data lake (S3, GCS, etc.) | yes | +| export to data warehouse | yes | +| export as Spark dataframe | no | +| local execution of Python-based on-demand transforms | yes | +| remote execution of Python-based on-demand transforms | no | +| persist results in the offline store | yes | +| preview the query plan before execution | yes | +| read partitioned data | yes | + +To compare this set of functionality against other offline stores, please see the full [functionality matrix](overview.md#functionality-matrix). diff --git a/docs/reference/offline-stores/overview.md b/docs/reference/offline-stores/overview.md index 182eac65864..191ccd21a64 100644 --- a/docs/reference/offline-stores/overview.md +++ b/docs/reference/offline-stores/overview.md @@ -31,28 +31,28 @@ Details for each specific offline store, such as how to configure it in a `featu Below is a matrix indicating which offline stores support which methods. -| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | -| :-------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | -| `get_historical_features` | yes | yes | yes | yes | yes | yes | yes | -| `pull_latest_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | -| `pull_all_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | -| `offline_write_batch` | yes | yes | yes | yes | no | no | no | -| `write_logged_features` | yes | yes | yes | yes | no | no | no | +| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | Couchbase | +| :-------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | +| `get_historical_features` | yes | yes | yes | yes | yes | yes | yes | yes | +| `pull_latest_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | yes | +| `pull_all_from_table_or_query` | yes | yes | yes | yes | yes | yes | yes | yes | +| `offline_write_batch` | yes | yes | yes | yes | no | no | no | no | +| `write_logged_features` | yes | yes | yes | yes | no | no | no | no | Below is a matrix indicating which `RetrievalJob`s support what functionality. -| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | DuckDB | -| --------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- | -| export to dataframe | yes | yes | yes | yes | yes | yes | yes | yes | -| export to arrow table | yes | yes | yes | yes | yes | yes | yes | yes | -| export to arrow batches | no | no | no | yes | no | no | no | no | -| export to SQL | no | yes | yes | yes | yes | no | yes | no | -| export to data lake (S3, GCS, etc.) | no | no | yes | no | yes | no | no | no | -| export to data warehouse | no | yes | yes | yes | yes | no | no | no | -| export as Spark dataframe | no | no | yes | no | no | yes | no | no | -| local execution of Python-based on-demand transforms | yes | yes | yes | yes | yes | no | yes | yes | -| remote execution of Python-based on-demand transforms | no | no | no | no | no | no | no | no | -| persist results in the offline store | yes | yes | yes | yes | yes | yes | no | yes | -| preview the query plan before execution | yes | yes | yes | yes | yes | yes | yes | no | -| read partitioned data | yes | yes | yes | yes | yes | yes | yes | yes | +| | Dask | BigQuery | Snowflake | Redshift | Postgres | Spark | Trino | DuckDB | Couchbase | +| --------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| export to dataframe | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| export to arrow table | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| export to arrow batches | no | no | no | yes | no | no | no | no | no | +| export to SQL | no | yes | yes | yes | yes | no | yes | no | yes | +| export to data lake (S3, GCS, etc.) | no | no | yes | no | yes | no | no | no | yes | +| export to data warehouse | no | yes | yes | yes | yes | no | no | no | yes | +| export as Spark dataframe | no | no | yes | no | no | yes | no | no | no | +| local execution of Python-based on-demand transforms | yes | yes | yes | yes | yes | no | yes | yes | yes | +| remote execution of Python-based on-demand transforms | no | no | no | no | no | no | no | no | no | +| persist results in the offline store | yes | yes | yes | yes | yes | yes | no | yes | yes | +| preview the query plan before execution | yes | yes | yes | yes | yes | yes | yes | no | yes | +| read partitioned data | yes | yes | yes | yes | yes | yes | yes | yes | yes | diff --git a/docs/reference/online-stores/couchbase.md b/docs/reference/online-stores/couchbase.md index ff8822d85d9..2878deb97ee 100644 --- a/docs/reference/online-stores/couchbase.md +++ b/docs/reference/online-stores/couchbase.md @@ -38,7 +38,7 @@ project: my_feature_repo registry: data/registry.db provider: local online_store: - type: couchbase + type: couchbase.online connection_string: couchbase://127.0.0.1 # Couchbase connection string, copied from 'Connect' page in Couchbase Capella console user: Administrator # Couchbase username from access credentials password: password # Couchbase password from access credentials diff --git a/docs/reference/online-stores/milvus.md b/docs/reference/online-stores/milvus.md new file mode 100644 index 00000000000..014c7bd68a5 --- /dev/null +++ b/docs/reference/online-stores/milvus.md @@ -0,0 +1,65 @@ +# Redis online store + +## Description + +The [Milvus](https://milvus.io/) online store provides support for materializing feature values into Milvus. + +* The data model used to store feature values in Milvus is described in more detail [here](../../specs/online\_store\_format.md). + +## Getting started +In order to use this online store, you'll need to install the Milvus extra (along with the dependency needed for the offline store of choice). E.g. + +`pip install 'feast[milvus]'` + +You can get started by using any of the other templates (e.g. `feast init -t gcp` or `feast init -t snowflake` or `feast init -t aws`), and then swapping in Redis as the online store as seen below in the examples. + +## Examples + +Connecting to a local MilvusDB instance: + +{% code title="feature_store.yaml" %} +```yaml +project: my_feature_repo +registry: data/registry.db +provider: local +online_store: + type: milvus + path: "data/online_store.db" + connection_string: "localhost:6379" + embedding_dim: 128 + index_type: "FLAT" + metric_type: "COSINE" + username: "username" + password: "password" +``` +{% endcode %} + + +The full set of configuration options is available in [MilvusOnlineStoreConfig](https://rtd.feast.dev/en/latest/#feast.infra.online_stores.milvus.MilvusOnlineStoreConfig). + +## Functionality Matrix + +The set of functionality supported by online stores is described in detail [here](overview.md#functionality). +Below is a matrix indicating which functionality is supported by the Milvus online store. + +| | Milvus | +|:----------------------------------------------------------|:-------| +| write feature values to the online store | yes | +| read feature values from the online store | yes | +| update infrastructure (e.g. tables) in the online store | yes | +| teardown infrastructure (e.g. tables) in the online store | yes | +| generate a plan of infrastructure changes | no | +| support for on-demand transforms | yes | +| readable by Python SDK | yes | +| readable by Java | no | +| readable by Go | no | +| support for entityless feature views | yes | +| support for concurrent writing to the same key | yes | +| support for ttl (time to live) at retrieval | yes | +| support for deleting expired data | yes | +| collocated by feature view | no | +| collocated by feature service | no | +| collocated by entity key | no | +| vector similarity search | yes | + +To compare this set of functionality against other online stores, please see the full [functionality matrix](overview.md#functionality-matrix). diff --git a/docs/reference/online-stores/overview.md b/docs/reference/online-stores/overview.md index 04d24447058..b54329ad613 100644 --- a/docs/reference/online-stores/overview.md +++ b/docs/reference/online-stores/overview.md @@ -34,21 +34,21 @@ Details for each specific online store, such as how to configure it in a `featur Below is a matrix indicating which online stores support what functionality. -| | Sqlite | Redis | DynamoDB | Snowflake | Datastore | Postgres | Hbase | [[Cassandra](https://cassandra.apache.org/_/index.html) / [Astra DB](https://www.datastax.com/products/datastax-astra?utm_source=feast)] | [IKV](https://inlined.io) | -| :-------------------------------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | -| write feature values to the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| read feature values from the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| update infrastructure (e.g. tables) in the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| teardown infrastructure (e.g. tables) in the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| generate a plan of infrastructure changes | yes | no | no | no | no | no | no | yes | no | -| support for on-demand transforms | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| readable by Python SDK | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| readable by Java | no | yes | no | no | no | no | no | no | no | -| readable by Go | yes | yes | no | no | no | no | no | no | no | -| support for entityless feature views | yes | yes | yes | yes | yes | yes | yes | yes | yes | -| support for concurrent writing to the same key | no | yes | no | no | no | no | no | no | yes | -| support for ttl (time to live) at retrieval | no | yes | no | no | no | no | no | no | no | -| support for deleting expired data | no | yes | no | no | no | no | no | no | no | -| collocated by feature view | yes | no | yes | yes | yes | yes | yes | yes | no | -| collocated by feature service | no | no | no | no | no | no | no | no | no | -| collocated by entity key | no | yes | no | no | no | no | no | no | yes | +| | Sqlite | Redis | DynamoDB | Snowflake | Datastore | Postgres | Hbase | [[Cassandra](https://cassandra.apache.org/_/index.html) / [Astra DB](https://www.datastax.com/products/datastax-astra?utm_source=feast)] | [IKV](https://inlined.io) | Milvus | +| :-------------------------------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- |:-------| +| write feature values to the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| read feature values from the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| update infrastructure (e.g. tables) in the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| teardown infrastructure (e.g. tables) in the online store | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| generate a plan of infrastructure changes | yes | no | no | no | no | no | no | yes | no | no | +| support for on-demand transforms | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| readable by Python SDK | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | +| readable by Java | no | yes | no | no | no | no | no | no | no | no | +| readable by Go | yes | yes | no | no | no | no | no | no | no | no | +| support for entityless feature views | yes | yes | yes | yes | yes | yes | yes | yes | yes | no | +| support for concurrent writing to the same key | no | yes | no | no | no | no | no | no | yes | no | +| support for ttl (time to live) at retrieval | no | yes | no | no | no | no | no | no | no | no | +| support for deleting expired data | no | yes | no | no | no | no | no | no | no | no | +| collocated by feature view | yes | no | yes | yes | yes | yes | yes | yes | no | no | +| collocated by feature service | no | no | no | no | no | no | no | no | no | no | +| collocated by entity key | no | yes | no | no | no | no | no | no | yes | no | diff --git a/docs/reference/registries/remote.md b/docs/reference/registries/remote.md new file mode 100644 index 00000000000..3651aeb71ea --- /dev/null +++ b/docs/reference/registries/remote.md @@ -0,0 +1,28 @@ +# Remote Registry + +## Description + +The Remote Registry is a gRPC client for the registry that implements the `RemoteRegistry` class using the existing `BaseRegistry` interface. + +## How to configure the client + +User needs to create a client side `feature_store.yaml` file, set the `registry_type` to `remote` and provide the server connection configuration. +The `path` parameter is a URL with a port (default is 6570) used by the client to connect with the Remote Registry server. + +{% code title="feature_store.yaml" %} +```yaml +registry: + registry_type: remote + path: http://localhost:6570 +``` +{% endcode %} + +The optional `cert` parameter can be configured as well, it should point to the public certificate path when the Registry Server starts in SSL mode. This may be needed if the Registry Server is started with a self-signed certificate, typically this file ends with *.crt, *.cer, or *.pem. +More info about the `cert` parameter can be found in [feast-client-connecting-to-remote-registry-sever-started-in-tls-mode](../../how-to-guides/starting-feast-servers-tls-mode.md#feast-client-connecting-to-remote-registry-sever-started-in-tls-mode) + +## How to configure the server + +Please see the detail how to configure registry server [registry-server.md](../feature-servers/registry-server.md) + +## How to configure Authentication and Authorization +Please refer the [page](./../../../docs/getting-started/concepts/permission.md) for more details on how to configure authentication and authorization. diff --git a/docs/roadmap.md b/docs/roadmap.md index ff6549a3cb1..cb55873c3fa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,6 +4,9 @@ The list below contains the functionality that contributors are planning to deve * We welcome contribution to all items in the roadmap! +* **Natural Language Processing** + * [x] Vector Search (Alpha release. See [RFC](https://docs.google.com/document/d/18IWzLEA9i2lDWnbfbwXnMCg3StlqaLVI-uRpQjr_Vos/edit#heading=h.9gaqqtox9jg6)) + * [ ] [Enhanced Feature Server and SDK for native support for NLP](https://github.com/feast-dev/feast/issues/4964) * **Data Sources** * [x] [Snowflake source](https://docs.feast.dev/reference/data-sources/snowflake) * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) @@ -13,6 +16,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/data-sources/postgres) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/data-sources/spark) + * [x] [Couchbase (contrib plugin)](https://docs.feast.dev/reference/data-sources/couchbase) * [x] Kafka / Kinesis sources (via [push support into the online store](https://docs.feast.dev/reference/data-sources/push)) * **Offline Stores** * [x] [Snowflake](https://docs.feast.dev/reference/offline-stores/snowflake) @@ -23,6 +27,7 @@ The list below contains the functionality that contributors are planning to deve * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/offline-stores/postgres) * [x] [Trino (contrib plugin)](https://github.com/Shopify/feast-trino) * [x] [Spark (contrib plugin)](https://docs.feast.dev/reference/offline-stores/spark) + * [x] [Couchbase (contrib plugin)](https://docs.feast.dev/reference/offline-stores/couchbase) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/customizing-feast/adding-a-new-offline-store) * **Online Stores** @@ -37,12 +42,14 @@ The list below contains the functionality that contributors are planning to deve * [x] [Azure Cache for Redis (community plugin)](https://github.com/Azure/feast-azure) * [x] [Postgres (contrib plugin)](https://docs.feast.dev/reference/online-stores/postgres) * [x] [Cassandra / AstraDB (contrib plugin)](https://docs.feast.dev/reference/online-stores/cassandra) + * [x] [ScyllaDB (contrib plugin)](https://docs.feast.dev/reference/online-stores/scylladb) + * [x] [Couchbase (contrib plugin)](https://docs.feast.dev/reference/online-stores/couchbase) * [x] [Custom online store support](https://docs.feast.dev/how-to-guides/customizing-feast/adding-support-for-a-new-online-store) * **Feature Engineering** - * [x] On-demand Transformations (Beta release. See [RFC](https://docs.google.com/document/d/1lgfIw0Drc65LpaxbUu49RCeJgMew547meSJttnUqz7c/edit#)) + * [x] On-demand Transformations (On Read) (Beta release. See [RFC](https://docs.google.com/document/d/1lgfIw0Drc65LpaxbUu49RCeJgMew547meSJttnUqz7c/edit#)) * [x] Streaming Transformations (Alpha release. See [RFC](https://docs.google.com/document/d/1UzEyETHUaGpn0ap4G82DHluiCj7zEbrQLkJJkKSv4e8/edit)) * [ ] Batch transformation (In progress. See [RFC](https://docs.google.com/document/d/1964OkzuBljifDvkV-0fakp2uaijnVzdwWNGdz7Vz50A/edit)) - * [ ] Persistent On-demand Transformations (Beta release. See [GitHub Issue](https://github.com/feast-dev/feast/issues/4376)) + * [x] On-demand Transformations (On Write) (Beta release. See [GitHub Issue](https://github.com/feast-dev/feast/issues/4376)) * **Streaming** * [x] [Custom streaming ingestion job support](https://docs.feast.dev/how-to-guides/customizing-feast/creating-a-custom-provider) * [x] [Push based streaming data ingestion to online store](https://docs.feast.dev/reference/data-sources/push) @@ -65,5 +72,3 @@ The list below contains the functionality that contributors are planning to deve * [x] DataHub integration (see [DataHub Feast docs](https://datahubproject.io/docs/generated/ingestion/sources/feast/)) * [x] Feast Web UI (Beta release. See [docs](https://docs.feast.dev/reference/alpha-web-ui)) * [ ] Feast Lineage Explorer -* **Natural Language Processing** - * [x] Vector Search (Alpha release. See [RFC](https://docs.google.com/document/d/18IWzLEA9i2lDWnbfbwXnMCg3StlqaLVI-uRpQjr_Vos/edit#heading=h.9gaqqtox9jg6)) diff --git a/docs/tutorials/validating-historical-features.md b/docs/tutorials/validating-historical-features.md index 03baccfbc9e..1984adcdcf9 100644 --- a/docs/tutorials/validating-historical-features.md +++ b/docs/tutorials/validating-historical-features.md @@ -173,7 +173,7 @@ def on_demand_stats(inp: pd.DataFrame) -> pd.DataFrame: return out ``` -*Read more about on demand feature views [here](https://docs.feast.dev/reference/alpha-on-demand-feature-view)* +*Read more about on demand feature views [here](../reference/beta-on-demand-feature-view.md)* ```python diff --git a/examples/README.md b/examples/README.md index e796b5000e3..6dac867be43 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,18 +1,22 @@ # Feast Examples -1. **[Quickstart Example](https://github.com/feast-dev/feast/tree/master/examples/quickstart)**: This is a step-by-step guide for getting started with Feast. - -2. **[Java Demo](https://github.com/feast-dev/feast/tree/master/examples/java-demo)**: Demonstrates how to use Feast with Java feature server and deployed with Kubernetes. - -3. **[Python Helm Demo](https://github.com/feast-dev/feast/tree/master/examples/python-helm-demo)**: Demonstrates Feast with Kubernetes using Helm charts and Python feature server. - -4. **[RBAC Local](https://github.com/feast-dev/feast/tree/master/examples/rbac-local)**: Demonstrates using notebooks how configure and test Role-Based Access Control (RBAC) for securing access in Feast using OIDC authorization type with in a local environment. - -5. **[RBAC Remote](https://github.com/feast-dev/feast/tree/master/examples/rbac-local)**: Demonstrates how to configure and test Role-Based Access Control (RBAC) for securing access in Feast using Kubernetes or OIDC Authentication type with in Kubernetes environment. - -6. **[Remote Offline Store](https://github.com/feast-dev/feast/tree/master/examples/remote-offline-store)**: Demonstrates how to set up and use remote offline server. - -7. **[Podman/Podman Compose_local](https://github.com/feast-dev/feast/tree/master/examples/podman_local)**: Demonstrates how to deploy Feast remote server components using Podman Compose locally. - -8. **[RHOAI Feast Demo](https://github.com/feast-dev/feast/tree/master/examples/rhoai-quickstart)**: Showcases Feast's core functionality using a Jupyter notebook, including fetching online feature data from a remote server and retrieving metadata from a remote registry. - +The following examples illustrate various **Feast** use cases to enhance understanding of its functionality. + +1. **[Quickstart Example](quickstart)**: This is a step-by-step guide for getting started with Feast. +1. **[Java Demo](java-demo)**: Demonstrates how to use Feast with Java feature server and deploy it on Kubernetes. +1. **[Kind Quickstart](kind-quickstart)**: Demonstrates how to install and use Feast on Kind with the Helm chart. +1. **[Credit Risk End-to-End](credit-risk-end-to-end)**: Demonstrates how to use Feast with Java feature server and deploy it on Kubernetes. +1. **[Python Helm Demo](python-helm-demo)**: Demonstrates Feast with Kubernetes using Helm charts and Python feature server. +1. **[RBAC Local](rbac-local)**: Shows how to configure and test Role-Based Access Control (RBAC) for securing access in Feast using OIDC authorization in a local environment. +1. **[RBAC Remote](rbac-remote)**: Demonstrates how to configure and test Role-Based Access Control (RBAC) for securing access in Feast using Kubernetes or OIDC Authentication in a Kubernetes environment. +1. **[Remote Offline Store](remote-offline-store)**: Demonstrates how to set up and use a remote offline store. +1. **[Podman/Podman Compose Local](podman_local)**: Demonstrates how to deploy Feast remote server components using Podman Compose locally. +1. **[RHOAI Feast Demo](rhoai-quickstart)**: Showcases Feast's core functionality using a Jupyter notebook, including fetching online feature data from a remote server and retrieving metadata from a remote registry. + +# Feast Operator Examples + +The examples below showcase how to deploy and manage **Feast on Kubernetes** using the **Feast Go Operator**. + +1. **[Operator Quickstart](operator-quickstart)**: Demonstrates how to install and use Feast on Kubernetes with the Feast Go Operator. +1. **[Operator Quickstart with Postgres in TLS](operator-postgres-tls-demo)**: Demonstrates installing and configuring Feast with PostgreSQL in TLS mode on Kubernetes using the Feast Go Operator, with an emphasis on volumes and VolumeMounts support. +1. **[Operator RBAC with Kubernetes](operator-rbac)**: Demonstrates the Feast RBAC example on Kubernetes using the Feast Operator. diff --git a/examples/credit-risk-end-to-end/01_Credit_Risk_Data_Prep.ipynb b/examples/credit-risk-end-to-end/01_Credit_Risk_Data_Prep.ipynb new file mode 100644 index 00000000000..a345ec8ca46 --- /dev/null +++ b/examples/credit-risk-end-to-end/01_Credit_Risk_Data_Prep.ipynb @@ -0,0 +1,757 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a52c80c4-1ea2-4d1e-b582-fac51081e76d", + "metadata": {}, + "source": [ + "
" + ] + }, + { + "cell_type": "markdown", + "id": "576a8e30-fe4c-4eda-bc56-9edd7fde3385", + "metadata": {}, + "source": [ + "# Credit Risk Data Preparation" + ] + }, + { + "cell_type": "markdown", + "id": "1f3fbd5a-1587-4b4e-9263-a57490657337", + "metadata": {}, + "source": [ + "Predicting credit risk is an important task for financial institutions. If a bank can accurately determine the probability that a borrower will pay back a future loan, then they can make better decisions on loan terms and approvals. Getting credit risk right is critical to offering good financial services, and getting credit risk wrong could mean going out of business.\n", + "\n", + "AI models have played a central role in modern credit risk assessment systems. In this example, we develop a credit risk model to predict whether a future loan will be good or bad, given some context data (presumably supplied from the loan application). We use the modeling process to demonstrate how Feast can be used to facilitate the serving of data for training and inference use-cases.\n", + "\n", + "In this notebook, we prepare the data." + ] + }, + { + "cell_type": "markdown", + "id": "4d05715f-ddb8-42de-8f0c-212dcbad9e0e", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "markdown", + "id": "6fba29f9-db1f-4ceb-b066-5b2df2c95d33", + "metadata": {}, + "source": [ + "*The following code assumes that you have read the example README.md file, and that you have setup an environment where the code can be run. Please make sure you have addressed the prerequisite needs.*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8a897b19-6f82-4631-ae51-8a23182ff267", + "metadata": {}, + "outputs": [], + "source": [ + "# Import Python libraries\n", + "import os\n", + "import warnings\n", + "import datetime as dt\n", + "import pandas as pd\n", + "import numpy as np\n", + "from sklearn.datasets import fetch_openml" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b944ed48-54b3-43fa-8373-ce788d7e71af", + "metadata": {}, + "outputs": [], + "source": [ + "# suppress warning messages for example flow (don't run if you want to see warnings)\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "70788c73-144f-4ecf-b370-c5669c538d93", + "metadata": {}, + "outputs": [], + "source": [ + "# Seed for reproducibility\n", + "SEED = 142" + ] + }, + { + "cell_type": "markdown", + "id": "cfb4dfd0-f583-4aa0-bd39-3ff9fbb80db0", + "metadata": {}, + "source": [ + "### Pull the Data" + ] + }, + { + "cell_type": "markdown", + "id": "3c206dfc-d551-4002-ae63-ccbb981768fa", + "metadata": {}, + "source": [ + "The data we will use to train the model is from the [OpenML](https://www.openml.org/) dataset [credit-g](https://www.openml.org/search?type=data&sort=runs&status=active&id=31), obtained from a 1994 German study. More details on the data can be found in the `DESC` attribute and `details` map (see below)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "31a9e964-bdb3-4ae4-b2b4-64bbe0ab93a3", + "metadata": {}, + "outputs": [], + "source": [ + "data = fetch_openml(name=\"credit-g\", version=1, parser='auto')" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "58dbf7c2-f40b-4965-baac-6903a27ef622", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "**Author**: Dr. Hans Hofmann \n", + "**Source**: [UCI](https://archive.ics.uci.edu/ml/datasets/statlog+(german+credit+data)) - 1994 \n", + "**Please cite**: [UCI](https://archive.ics.uci.edu/ml/citation_policy.html)\n", + "\n", + "**German Credit dataset** \n", + "This dataset classifies people described by a set of attributes as good or bad credit risks.\n", + "\n", + "This dataset comes with a cost matrix: \n", + "``` \n", + "Good Bad (predicted) \n", + "Good 0 1 (actual) \n", + "Bad 5 0 \n", + "```\n", + "\n", + "It is worse to class a customer as good when they are bad (5), than it is to class a customer as bad when they are good (1). \n", + "\n", + "### Attribute description \n", + "\n", + "1. Status of existing checking account, in Deutsche Mark. \n", + "2. Duration in months \n", + "3. Credit history (credits taken, paid back duly, delays, critical accounts) \n", + "4. Purpose of the credit (car, television,...) \n", + "5. Credit amount \n", + "6. Status of savings account/bonds, in Deutsche Mark. \n", + "7. Present employment, in number of years. \n", + "8. Installment rate in percentage of disposable income \n", + "9. Personal status (married, single,...) and sex \n", + "10. Other debtors / guarantors \n", + "11. Present residence since X years \n", + "12. Property (e.g. real estate) \n", + "13. Age in years \n", + "14. Other installment plans (banks, stores) \n", + "15. Housing (rent, own,...) \n", + "16. Number of existing credits at this bank \n", + "17. Job \n", + "18. Number of people being liable to provide maintenance for \n", + "19. Telephone (yes,no) \n", + "20. Foreign worker (yes,no)\n", + "\n", + "Downloaded from openml.org.\n" + ] + } + ], + "source": [ + "print(data.DESCR)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "53de57ec-0fb6-4b51-9c27-696b059a1847", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Original data url: https://archive.ics.uci.edu/ml/datasets/statlog+(german+credit+data)\n", + "Paper url: https://dl.acm.org/doi/abs/10.1145/967900.968104\n" + ] + } + ], + "source": [ + "print(\"Original data url: \".ljust(20), data.details[\"original_data_url\"])\n", + "print(\"Paper url: \".ljust(20), data.details[\"paper_url\"])" + ] + }, + { + "cell_type": "markdown", + "id": "6b2c2514-484e-46cb-aedc-89a301266f44", + "metadata": {}, + "source": [ + "### High-Level Data Inspection" + ] + }, + { + "cell_type": "markdown", + "id": "a76af306-caba-403d-a9cb-b5de12573075", + "metadata": {}, + "source": [ + "Let's inspect the data to see high level details like data types and size. We also want to make sure there are no glaring issues (like a large number of null values)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "20fb82c4-ed8d-42f8-b386-c7ebdc9bf786", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RangeIndex: 1000 entries, 0 to 999\n", + "Data columns (total 21 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 checking_status 1000 non-null category\n", + " 1 duration 1000 non-null int64 \n", + " 2 credit_history 1000 non-null category\n", + " 3 purpose 1000 non-null category\n", + " 4 credit_amount 1000 non-null int64 \n", + " 5 savings_status 1000 non-null category\n", + " 6 employment 1000 non-null category\n", + " 7 installment_commitment 1000 non-null int64 \n", + " 8 personal_status 1000 non-null category\n", + " 9 other_parties 1000 non-null category\n", + " 10 residence_since 1000 non-null int64 \n", + " 11 property_magnitude 1000 non-null category\n", + " 12 age 1000 non-null int64 \n", + " 13 other_payment_plans 1000 non-null category\n", + " 14 housing 1000 non-null category\n", + " 15 existing_credits 1000 non-null int64 \n", + " 16 job 1000 non-null category\n", + " 17 num_dependents 1000 non-null int64 \n", + " 18 own_telephone 1000 non-null category\n", + " 19 foreign_worker 1000 non-null category\n", + " 20 class 1000 non-null category\n", + "dtypes: category(14), int64(7)\n", + "memory usage: 71.0 KB\n" + ] + } + ], + "source": [ + "df = data.frame\n", + "df.info()" + ] + }, + { + "cell_type": "markdown", + "id": "a384932a-40df-45f6-bfbc-a9cf6c708f1b", + "metadata": {}, + "source": [ + "We see that there are 21 columns, each with 1000 non-null values. The first 20 columns are contextual fields with `Dtype` of `category` or `int64`, while the last field is actually the target variable, `class`, which we wish to predict. \n", + "\n", + "From the description (above), the `class` tells us whether a loan to a customer was \"good\" or \"bad\". We are anticipating that patterns in the contextual data, as well as their relationship to the class outcomes, can give insight into loan classification. In the following notebooks, we will build a loan classification model that seeks to encode these patterns and relationships in its weights, such that given a new loan application (context data), the model can predict whether the loan (if approved) will be good or bad in the future." + ] + }, + { + "cell_type": "markdown", + "id": "a451c9a3-0390-4d5a-b687-c59f52445eb1", + "metadata": {}, + "source": [ + "### Data Preparation For Demonstrating Feast" + ] + }, + { + "cell_type": "markdown", + "id": "dc4e7653-b118-44c3-ade3-f1b217b112fc", + "metadata": {}, + "source": [ + "At this point, it's important to bring up that Feast was developed primarily to work with production data. Feast requires datasets to have entities (in our case, IDs) and timestamps, which it uses in joins. Feast can support joining data on multiple entities (like primary keys in SQL), as well as \"created\" timestamps and \"event\" timestamps. However, in this example, we'll keep things more simple.\n", + "\n", + "In a real loan application scenario, the application fields (in a database) would be associated with a timestamp, while the actual loan outcome (label) would be determined much later and recorded separately with a different timestamp.\n", + "\n", + "In order to demonstrate Feast capabilities, such as point-in-time joins, we will mock IDs and timestamps for this data. For IDs, we will use the original dataframe index values. For the timestamps, we will generate random values between \"Tue Sep 24 12:00:00 2023\" and \"Wed Oct 9 12:00:00 2023\"." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9d6ec4f6-9410-4858-a440-45dccaa0896b", + "metadata": {}, + "outputs": [], + "source": [ + "# Make index into \"ID\" column\n", + "df = df.reset_index(names=[\"ID\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "055f2cb7-3abf-4d01-be60-e4c7b8ad1988", + "metadata": {}, + "outputs": [], + "source": [ + "# Add mock timestamps\n", + "time_format = \"%a %b %d %H:%M:%S %Y\"\n", + "date = dt.datetime.strptime(\"Wed Oct 9 12:00:00 2023\", time_format)\n", + "end = int(date.timestamp())\n", + "start = int((date - dt.timedelta(days=15)).timestamp()) # 'Tue Sep 24 12:00:00 2023'\n", + "\n", + "def make_tstamp(date):\n", + " dtime = dt.datetime.fromtimestamp(date).ctime()\n", + " return dtime\n", + " \n", + "# (seed set for reproducibility)\n", + "np.random.seed(SEED)\n", + "df[\"application_timestamp\"] = pd.to_datetime([\n", + " make_tstamp(d) for d in np.random.randint(start, end, len(df))\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "f7800ea9-de9a-4aab-9d77-c4276e7db5f9", + "metadata": {}, + "source": [ + "Verify that the newly created \"ID\" and \"application_timestamp\" fields were added to the data as expected." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "9516fc5c-7c25-4e60-acba-7400ab6bab42", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
012
ID012
checking_status<00<=X<200no checking
duration64812
credit_historycritical/other existing creditexisting paidcritical/other existing credit
purposeradio/tvradio/tveducation
credit_amount116959512096
savings_statusno known savings<100<100
employment>=71<=X<44<=X<7
installment_commitment422
personal_statusmale singlefemale div/dep/marmale single
other_partiesnonenonenone
residence_since423
property_magnitudereal estatereal estatereal estate
age672249
other_payment_plansnonenonenone
housingownownown
existing_credits211
jobskilledskilledunskilled resident
num_dependents112
own_telephoneyesnonenone
foreign_workeryesyesyes
classgoodbadgood
application_timestamp2023-10-04 17:50:132023-09-28 18:10:132023-10-03 23:06:03
\n", + "
" + ], + "text/plain": [ + " 0 1 \\\n", + "ID 0 1 \n", + "checking_status <0 0<=X<200 \n", + "duration 6 48 \n", + "credit_history critical/other existing credit existing paid \n", + "purpose radio/tv radio/tv \n", + "credit_amount 1169 5951 \n", + "savings_status no known savings <100 \n", + "employment >=7 1<=X<4 \n", + "installment_commitment 4 2 \n", + "personal_status male single female div/dep/mar \n", + "other_parties none none \n", + "residence_since 4 2 \n", + "property_magnitude real estate real estate \n", + "age 67 22 \n", + "other_payment_plans none none \n", + "housing own own \n", + "existing_credits 2 1 \n", + "job skilled skilled \n", + "num_dependents 1 1 \n", + "own_telephone yes none \n", + "foreign_worker yes yes \n", + "class good bad \n", + "application_timestamp 2023-10-04 17:50:13 2023-09-28 18:10:13 \n", + "\n", + " 2 \n", + "ID 2 \n", + "checking_status no checking \n", + "duration 12 \n", + "credit_history critical/other existing credit \n", + "purpose education \n", + "credit_amount 2096 \n", + "savings_status <100 \n", + "employment 4<=X<7 \n", + "installment_commitment 2 \n", + "personal_status male single \n", + "other_parties none \n", + "residence_since 3 \n", + "property_magnitude real estate \n", + "age 49 \n", + "other_payment_plans none \n", + "housing own \n", + "existing_credits 1 \n", + "job unskilled resident \n", + "num_dependents 2 \n", + "own_telephone none \n", + "foreign_worker yes \n", + "class good \n", + "application_timestamp 2023-10-03 23:06:03 " + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Check data (first few records, transposed for readability)\n", + "df.head(3).T" + ] + }, + { + "cell_type": "markdown", + "id": "72b2105a-b459-4715-aa53-6fe69fc4a210", + "metadata": {}, + "source": [ + "We'll also generate counterpart IDs and timestamps on the label data. In a real-life scenario, the label data would come separate and later relative to the loan application data. To mimic this, let's create a labels dataset with an \"outcome_timestamp\" column with a variable lag from the application timestamp of 30 to 90 days." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e214478b-ed9b-4354-ba6f-4117813c56c3", + "metadata": {}, + "outputs": [], + "source": [ + "# Add (lagged) label timestamps (30 to 90 days)\n", + "def lag_delta(data, seed):\n", + " np.random.seed(seed)\n", + " delta_days = np.random.randint(30, 90, len(data))\n", + " delta_hours = np.random.randint(0, 24, len(data))\n", + " delta = np.array([dt.timedelta(days=int(delta_days[i]), hours=int(delta_hours[i])) for i in range(len(data))])\n", + " return delta\n", + "\n", + "labels = df[[\"ID\", \"class\"]]\n", + "labels[\"outcome_timestamp\"] = pd.to_datetime(df.application_timestamp + lag_delta(df, SEED))" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "356a7225-db20-4c15-87a3-4a0eb3127475", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IDclassoutcome_timestamp
00good2023-11-24 22:50:13
11bad2023-11-03 12:10:13
22good2023-11-30 22:06:03
\n", + "
" + ], + "text/plain": [ + " ID class outcome_timestamp\n", + "0 0 good 2023-11-24 22:50:13\n", + "1 1 bad 2023-11-03 12:10:13\n", + "2 2 good 2023-11-30 22:06:03" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Check labels\n", + "labels.head(3)" + ] + }, + { + "cell_type": "markdown", + "id": "4a29f754-f758-402b-ac42-2dcfcee3b7fc", + "metadata": {}, + "source": [ + "You can verify that the `outcome timestamp` has a difference of 30 to 90 days from the \"application_timestamp\" (above)." + ] + }, + { + "cell_type": "markdown", + "id": "e720ce24-e092-4fcd-be3e-68bb18f4d2a7", + "metadata": {}, + "source": [ + "### Save Data" + ] + }, + { + "cell_type": "markdown", + "id": "5cae0578-8431-46c7-8d64-e52146f47d46", + "metadata": {}, + "source": [ + "Now that we have our data prepared, let's save it to local parquet files in the `data` directory (parquet is one of the file formats supported by Feast).\n", + "\n", + "One more step we will add is splitting the context data column-wise and saving it in two files. This step is contrived--we don't usually split data when we don't need to--but it will allow us to demonstrate later how Feast can easily join datasets (a common need in Data Science projects)." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "cebef56c-1f54-4d31-a545-75d708d38579", + "metadata": {}, + "outputs": [], + "source": [ + "# Create the data directory if it doesn't exist\n", + "os.makedirs(\"Feature_Store/data\", exist_ok=True)\n", + "\n", + "# Split columns and save context data\n", + "a_cols = [\n", + " 'ID', 'checking_status', 'duration', 'credit_history', 'purpose',\n", + " 'credit_amount', 'savings_status', 'employment', 'application_timestamp',\n", + " 'installment_commitment', 'personal_status', 'other_parties',\n", + "]\n", + "b_cols = [\n", + " 'ID', 'residence_since', 'property_magnitude', 'age', 'other_payment_plans',\n", + " 'housing', 'existing_credits', 'job', 'num_dependents', 'own_telephone',\n", + " 'foreign_worker', 'application_timestamp'\n", + "]\n", + "\n", + "df[a_cols].to_parquet(\"Feature_Store/data/data_a.parquet\", engine=\"pyarrow\")\n", + "df[b_cols].to_parquet(\"Feature_Store/data/data_b.parquet\", engine=\"pyarrow\")\n", + "\n", + "# Save label data\n", + "labels.to_parquet(\"Feature_Store/data/labels.parquet\", engine=\"pyarrow\")" + ] + }, + { + "cell_type": "markdown", + "id": "d8d5de9f-bd27-4e95-802c-b121743dd1b0", + "metadata": {}, + "source": [ + "We have saved the following files to the `Feature_Store/data` directory: \n", + "- `data_a.parquet` (training data, a columns)\n", + "- `data_b.parquet` (training data, b columns)\n", + "- `labels.parquet` (label outcomes)" + ] + }, + { + "cell_type": "markdown", + "id": "af6355dc-ff5b-4b3f-b0bd-3c4020ef67e8", + "metadata": {}, + "source": [ + "With the feature data prepared, we are ready to setup and deploy the feature store. \n", + "\n", + "Continue with the [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb) notebook." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/credit-risk-end-to-end/02_Deploying_the_Feature_Store.ipynb b/examples/credit-risk-end-to-end/02_Deploying_the_Feature_Store.ipynb new file mode 100644 index 00000000000..f736cdaed93 --- /dev/null +++ b/examples/credit-risk-end-to-end/02_Deploying_the_Feature_Store.ipynb @@ -0,0 +1,801 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "08d9e060-d455-43e2-b1ec-51e2a53e3169", + "metadata": {}, + "source": [ + "
" + ] + }, + { + "cell_type": "markdown", + "id": "93095241-3886-44a2-83b1-2a9537c21bc8", + "metadata": {}, + "source": [ + "# Deploying the Feature Store" + ] + }, + { + "cell_type": "markdown", + "id": "465783da-18eb-4945-98e7-bb1058a7af1b", + "metadata": {}, + "source": [ + "### Introduction" + ] + }, + { + "cell_type": "markdown", + "id": "11961d1b-72db-48dc-a07d-dcea9ba223b4", + "metadata": {}, + "source": [ + "Feast enables AI/ML teams to serve (and consume) features via feature stores. In this notebook, we will configure the feature stores and feature definitions, and deploy a Feast feature store server. We will also materialize (move) data from the offline store to the online store.\n", + "\n", + "In Feast, offline stores support pulling large amounts of data for model training using tools like Redshift, Snowflake, Bigquery, and Spark. In contrast, the focus of Feast online stores is feature serving in support of model inference, using tools like Redis, Snowflake, PostgreSQL, and SQLite.\n", + "\n", + "In this notebook, we will setup a file-based (Dask) offline store and SQLite online store. The online store will be made available through the Feast server." + ] + }, + { + "cell_type": "markdown", + "id": "dfed8ccf-0d7d-46a1-82f0-5765f8796088", + "metadata": {}, + "source": [ + "This notebook assumes that you have prepared the data by running the notebook [01_Credit_Risk_Data_Prep.ipynb](01_Credit_Risk_Data_Prep.ipynb). " + ] + }, + { + "cell_type": "markdown", + "id": "e66b7a08-5d15-4804-a82a-8bc571777496", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "markdown", + "id": "1c1e87a4-900b-48f3-a400-ce6608046ce3", + "metadata": {}, + "source": [ + "*The following code assumes that you have read the example README.md file, and that you have setup an environment where the code can be run. Please make sure you have addressed the prerequisite needs.*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8bd21689-4a8e-4b0c-937d-0911df9db1d3", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import re\n", + "import sys\n", + "import time\n", + "import signal\n", + "import sqlite3\n", + "import subprocess\n", + "import datetime as dt\n", + "from feast import FeatureStore" + ] + }, + { + "cell_type": "markdown", + "id": "471db4b0-ea93-47a1-9d55-a80e4d2bdc1e", + "metadata": {}, + "source": [ + "### Feast Feature Store Configuration" + ] + }, + { + "cell_type": "markdown", + "id": "0a307490-4121-4bf3-a5c4-77a8885a4f6a", + "metadata": {}, + "source": [ + "For model training, we usually don't need (or want) a constantly running feature server. All we need is the ability to efficiently query and pull all of the training data at training time. In contrast, during model serving we need servers that are always ready to supply feature records in response to application requests. \n", + "\n", + "This training-serving dichotomy is reflected in Feast using \"offline\" and \"online\" stores. Offline stores are configured to work with database technologies typically used for training, while online stores are configured to use storage and streaming technologies that are popular for feature serving.\n", + "\n", + "We need to create a `feature_store.yaml` config file to tell feast the structure we want in our offline and online feature stores. Below, we write the configuration for a local \"Dask\" offline store and local SQLite online store. We give the feature store a project name of `loan_applications`, and provider `local`. The registry is where the feature store will keep track of feature definitions and online store updates; we choose a file location in this case.\n", + "\n", + "See the [feature_store.yaml](https://docs.feast.dev/reference/feature-repository/feature-store-yaml) documentation for further details. " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b3757221-2037-49eb-867f-b9529fec06e2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing Feature_Store/feature_store.yaml\n" + ] + } + ], + "source": [ + "%%writefile Feature_Store/feature_store.yaml\n", + "\n", + "project: loan_applications\n", + "registry: data/registry.db\n", + "provider: local\n", + "offline_store:\n", + " type: dask\n", + "online_store:\n", + " type: sqlite\n", + " path: data/online_store.db\n", + "entity_key_serialization_version: 2" + ] + }, + { + "cell_type": "markdown", + "id": "180038f3-e5ce-4cce-bdf0-118eee7a822d", + "metadata": {}, + "source": [ + "### Feature Definitions" + ] + }, + { + "cell_type": "markdown", + "id": "dd44b206-1f5c-4f55-bbab-41ba2d3f5202", + "metadata": {}, + "source": [ + "We also need to create feature definitions and other feature constructs in a python file, which we name `feature_definitions.py`. For our purposes, we define the following:\n", + "\n", + "- Data Source: connections to data storage or data-producing endpoints\n", + "- Entity: primary key fields which can be used for joining data\n", + "- FeatureView: collections of features from a data source\n", + "\n", + "For more information on these, see the [Concepts](https://docs.feast.dev/getting-started/concepts) section of the Feast documentation." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d3e8fd80-0bee-463c-b3fb-bd0d1ee83a9c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing Feature_Store/feature_definitions.py\n" + ] + } + ], + "source": [ + "%%writefile Feature_Store/feature_definitions.py\n", + "\n", + "# Imports\n", + "import os\n", + "from pathlib import Path\n", + "from feast import (\n", + " FileSource,\n", + " Entity,\n", + " FeatureView,\n", + " Field,\n", + " FeatureService\n", + ")\n", + "from feast.types import Float32, String\n", + "from feast.data_format import ParquetFormat\n", + "\n", + "CURRENT_DIR = os.path.abspath(os.curdir)\n", + "\n", + "# Data Sources\n", + "# A data source tells Feast where the data lives\n", + "data_a = FileSource(\n", + " file_format=ParquetFormat(),\n", + " path=Path(CURRENT_DIR,\"data/data_a.parquet\").as_uri()\n", + ")\n", + "data_b = FileSource(\n", + " file_format=ParquetFormat(),\n", + " path=Path(CURRENT_DIR,\"data/data_b.parquet\").as_uri()\n", + ")\n", + "\n", + "# Entity\n", + "# An entity tells Feast the column it can use to join tables\n", + "loan_id = Entity(\n", + " name = \"loan_id\",\n", + " join_keys = [\"ID\"]\n", + ")\n", + "\n", + "# Feature views\n", + "# A feature view is how Feast groups features\n", + "features_a = FeatureView(\n", + " name=\"data_a\",\n", + " entities=[loan_id],\n", + " schema=[\n", + " Field(name=\"checking_status\", dtype=String),\n", + " Field(name=\"duration\", dtype=Float32),\n", + " Field(name=\"credit_history\", dtype=String),\n", + " Field(name=\"purpose\", dtype=String),\n", + " Field(name=\"credit_amount\", dtype=Float32),\n", + " Field(name=\"savings_status\", dtype=String),\n", + " Field(name=\"employment\", dtype=String),\n", + " Field(name=\"installment_commitment\", dtype=Float32),\n", + " Field(name=\"personal_status\", dtype=String),\n", + " Field(name=\"other_parties\", dtype=String),\n", + " ],\n", + " source=data_a\n", + ")\n", + "features_b = FeatureView(\n", + " name=\"data_b\",\n", + " entities=[loan_id],\n", + " schema=[\n", + " Field(name=\"residence_since\", dtype=Float32),\n", + " Field(name=\"property_magnitude\", dtype=String),\n", + " Field(name=\"age\", dtype=Float32),\n", + " Field(name=\"other_payment_plans\", dtype=String),\n", + " Field(name=\"housing\", dtype=String),\n", + " Field(name=\"existing_credits\", dtype=Float32),\n", + " Field(name=\"job\", dtype=String),\n", + " Field(name=\"num_dependents\", dtype=Float32),\n", + " Field(name=\"own_telephone\", dtype=String),\n", + " Field(name=\"foreign_worker\", dtype=String),\n", + " ],\n", + " source=data_b\n", + ")\n", + "\n", + "# Feature Service\n", + "# a feature service in Feast represents a logical group of features\n", + "loan_fs = FeatureService(\n", + " name=\"loan_fs\",\n", + " features=[features_a, features_b]\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b47c1b5-849e-43f3-8043-60466aaed69f", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "be9723eb-8fa0-4338-b50c-f9f1ff6bb13a", + "metadata": {}, + "source": [ + "### Applying the Configuration and Definitions" + ] + }, + { + "cell_type": "markdown", + "id": "c796d45f-28c0-4875-bbb1-71e5a15dcb96", + "metadata": {}, + "source": [ + "Now that we have our feature store configuration (`feature_store.yaml`) and feature definitions (`feature_definitions.py`), we are ready to \"apply\" them. The `feast apply` command creates a registry file (`Feature_Store/data/registry.db`) and sets up data connections; in this case, it creates a SQLite database (`Feature_Store/data/online_store.db`)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "394467f3-4ced-492a-9379-105aea9d4a6d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "10/27/2024 02:19:03 PM root WARNING: Cannot use sqlite_vec for vector search\n", + "10/27/2024 02:19:03 PM root WARNING: Cannot use sqlite_vec for vector search\n", + "10/27/2024 02:19:03 PM root WARNING: Cannot use sqlite_vec for vector search\n", + "10/27/2024 02:19:03 PM root WARNING: Cannot use sqlite_vec for vector search\n", + "Created entity \u001b[1m\u001b[32mloan_id\u001b[0m\n", + "Created feature view \u001b[1m\u001b[32mdata_a\u001b[0m\n", + "Created feature view \u001b[1m\u001b[32mdata_b\u001b[0m\n", + "Created feature service \u001b[1m\u001b[32mloan_fs\u001b[0m\n", + "\n", + "10/27/2024 02:19:03 PM root WARNING: Cannot use sqlite_vec for vector search\n", + "10/27/2024 02:19:03 PM root WARNING: Cannot use sqlite_vec for vector search\n", + "Created sqlite table \u001b[1m\u001b[32mloan_applications_data_a\u001b[0m\n", + "Created sqlite table \u001b[1m\u001b[32mloan_applications_data_b\u001b[0m\n", + "\n" + ] + } + ], + "source": [ + "# Run 'feast apply' in the Feature_Store directory\n", + "!feast --chdir ./Feature_Store apply" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e32f40eb-a31a-4877-8f40-2d8515302f39", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "total 232\n", + "-rw-r--r-- 1 501 20 33K Oct 27 14:17 data_a.parquet\n", + "-rw-r--r-- 1 501 20 27K Oct 27 14:17 data_b.parquet\n", + "-rw-r--r-- 1 501 20 17K Oct 27 14:17 labels.parquet\n", + "-rw-r--r-- 1 501 20 28K Oct 27 14:19 online_store.db\n", + "-rw-r--r-- 1 501 20 2.8K Oct 27 14:19 registry.db\n" + ] + } + ], + "source": [ + "# List the Feature_Store/data/ directory to see newly created files\n", + "!ls -nlh Feature_Store/data/" + ] + }, + { + "cell_type": "markdown", + "id": "31014885-ce6a-4007-8bdb-d74d3b44781b", + "metadata": {}, + "source": [ + "Note that while `feast apply` set up the `sqlite` online database, `online_store.db`, no data has been added to the online database as of yet. We can verify this by connecting with the `sqlite3` library." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "107ca856-af06-40c4-8339-70daf59cdf37", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Online Store Tables: [('loan_applications_data_a',), ('loan_applications_data_b',)]\n", + "loan_applications_data_a data: []\n", + "loan_applications_data_b data: []\n" + ] + } + ], + "source": [ + "# Connect to sqlite database\n", + "conn = sqlite3.connect(\"Feature_Store/data/online_store.db\")\n", + "cursor = conn.cursor()\n", + "# Query table data (3 tables)\n", + "print(\n", + " \"Online Store Tables: \",\n", + " cursor.execute(\"SELECT name FROM sqlite_master WHERE type='table';\").fetchall()\n", + ")\n", + "print(\n", + " \"loan_applications_data_a data: \",\n", + " cursor.execute(\"SELECT * FROM loan_applications_data_a\").fetchall()\n", + ")\n", + "print(\n", + " \"loan_applications_data_b data: \",\n", + " cursor.execute(\"SELECT * FROM loan_applications_data_b\").fetchall()\n", + ")\n", + "conn.close()" + ] + }, + { + "cell_type": "markdown", + "id": "03b927ee-7913-4a8a-b17b-9bee361d8d94", + "metadata": {}, + "source": [ + "Since we have used `feast apply` to create the registry, we can now use the Feast Python SDK to interact with our new feature store. To see other possible commands see the [Feast Python SDK documentation](https://rtd.feast.dev/en/master/)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c764a60a-b911-41a8-ba8f-7ef0a0bc7257", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "RepoConfig(project='loan_applications', provider='local', registry_config='data/registry.db', online_config={'type': 'sqlite', 'path': 'data/online_store.db'}, offline_config={'type': 'dask'}, batch_engine_config='local', feature_server=None, flags=None, repo_path=PosixPath('Feature_Store'), entity_key_serialization_version=2, coerce_tz_aware=True)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Get feature store config\n", + "store = FeatureStore(repo_path=\"./Feature_Store\")\n", + "store.config" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "fc572976-6ce9-44f6-8b67-28ee6157e29c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Feature view: data_a | Features: [checking_status-String, duration-Float32, credit_history-String, purpose-String, credit_amount-Float32, savings_status-String, employment-String, installment_commitment-Float32, personal_status-String, other_parties-String]\n", + "Feature view: data_b | Features: [residence_since-Float32, property_magnitude-String, age-Float32, other_payment_plans-String, housing-String, existing_credits-Float32, job-String, num_dependents-Float32, own_telephone-String, foreign_worker-String]\n" + ] + } + ], + "source": [ + "# List feature views\n", + "feature_views = store.list_batch_feature_views()\n", + "for fv in feature_views:\n", + " print(f\"Feature view: {fv.name} | Features: {fv.features}\")" + ] + }, + { + "cell_type": "markdown", + "id": "027edcfe-58d7-4dcb-92e2-5a5514c0f1f0", + "metadata": {}, + "source": [ + "### Deploying the Feature Store Servers" + ] + }, + { + "cell_type": "markdown", + "id": "c9aab68d-395f-421e-ba11-ad8c4acc9d6f", + "metadata": {}, + "source": [ + "If you wish to share a feature store with your team, Feast provides feature servers. To spin up an offline feature server process, we can use the `feast serve_offline` command, while to spin up a Feast online feature server, we use the `feast serve` command.\n", + "\n", + "Let's spin up an offline and an online server that we can use in the subsequent notebooks to get features during model training and model serving. We will run both servers as background processes, that we can communicate with in the other notebooks.\n", + "\n", + "First, we write a helper function to extract the first few printed log lines (so we can print it in the notebook cell output)." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "568f81b8-df34-4b06-8a3f-1a6bdc2e6cff", + "metadata": {}, + "outputs": [], + "source": [ + "# TimeoutError class\n", + "class TimeoutError(Exception):\n", + " pass\n", + "\n", + "# TimeoutError raise function\n", + "def timeout():\n", + " raise TimeoutError(\"timeout\")\n", + "\n", + "# Get first few log lines function\n", + "def print_first_proc_lines(proc, wait):\n", + " '''Given a process, `proc`, read and print output lines until they stop \n", + " comming (waiting up to `wait` seconds for new lines to appear)'''\n", + " lines = \"\"\n", + " while True:\n", + " signal.signal(signal.SIGALRM, timeout)\n", + " signal.alarm(wait)\n", + " try:\n", + " lines += proc.stderr.readline()\n", + " except:\n", + " break\n", + " if lines:\n", + " print(lines, file=sys.stderr)" + ] + }, + { + "cell_type": "markdown", + "id": "88d25a87-241a-46c6-9ca7-d035959c5f74", + "metadata": {}, + "source": [ + "Launch the offline server with the command `feast --chdir ./Feature_Store serve_offline`." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ce965dd4-652b-4c36-a064-fd0fd97d3ef7", + "metadata": {}, + "outputs": [], + "source": [ + "# Feast offline server process\n", + "offline_server_proc = subprocess.Popen(\n", + " \"feast --chdir ./Feature_Store serve_offline 2>&2 & echo $! > server_proc.txt\",\n", + " shell=True,\n", + " text=True,\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.PIPE,\n", + " bufsize=0\n", + ")\n", + "print_first_proc_lines(offline_server_proc, 2)" + ] + }, + { + "cell_type": "markdown", + "id": "59958d64-8e68-45ff-9549-556cbf46908c", + "metadata": {}, + "source": [ + "The tail end of the command above, `2>&2 & echo $! > server_proc.txt`, captures log messages (in the offline case there are none), and writes the process PID to the file `server_proc.txt` (we will use this in the cleanup notebook, [05_Credit_Risk_Cleanup.ipynb](05_Credit_Risk_Cleanup.ipynb))." + ] + }, + { + "cell_type": "markdown", + "id": "cfed4334-9e62-4f3f-be96-3f7db2f06ada", + "metadata": {}, + "source": [ + "Next, launch the online server with the command `feast --chdir ./Feature_Store serve`." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a581fbe2-13ba-433e-8e76-dc82cc22af74", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/ddowler/Code/Feast/feast/examples/credit-risk-end-to-end/venv-py3.11/lib/python3.11/site-packages/uvicorn/workers.py:16: DeprecationWarning: The `uvicorn.workers` module is deprecated. Please use `uvicorn-worker` package instead.\n", + "For more details, see https://github.com/Kludex/uvicorn-worker.\n", + " warnings.warn(\n", + "[2024-10-27 14:19:07 -0600] [44621] [INFO] Starting gunicorn 23.0.0\n", + "[2024-10-27 14:19:07 -0600] [44621] [INFO] Listening at: http://127.0.0.1:6566 (44621)\n", + "[2024-10-27 14:19:07 -0600] [44621] [INFO] Using worker: uvicorn.workers.UvicornWorker\n", + "[2024-10-27 14:19:07 -0600] [44623] [INFO] Booting worker with pid: 44623\n", + "[2024-10-27 14:19:07 -0600] [44623] [INFO] Started server process [44623]\n", + "[2024-10-27 14:19:07 -0600] [44623] [INFO] Waiting for application startup.\n", + "[2024-10-27 14:19:07 -0600] [44623] [INFO] Application startup complete.\n", + "\n" + ] + } + ], + "source": [ + "# Feast online server (master and worker) processes\n", + "online_server_proc = subprocess.Popen(\n", + " \"feast --chdir ./Feature_Store serve 2>&2 & echo $! >> server_proc.txt\",\n", + " shell=True,\n", + " text=True,\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.PIPE,\n", + " bufsize=0\n", + ")\n", + "print_first_proc_lines(online_server_proc, 3)" + ] + }, + { + "cell_type": "markdown", + "id": "0e778173-f58a-4074-b63f-107e1f39577b", + "metadata": {}, + "source": [ + "Note that the output helpfully let's us know that the online server is \"Listening at: http://127.0.0.1:6566\" (the default host:port).\n", + "\n", + "List the running processes to verify they are up." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "9b1a224d-884d-45c5-9711-2e2eb4351710", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 501 44594 1 0 2:19PM ?? 0:03.66 **/python **/feast --chdir ./Feature_Store serve_offline\n", + " 501 44621 1 0 2:19PM ?? 0:03.58 **/python **/feast --chdir ./Feature_Store serve\n", + " 501 44623 44621 0 2:19PM ?? 0:00.03 **/python **/feast --chdir ./Feature_Store serve\n", + " 501 44662 44542 0 2:19PM ?? 0:00.01 /bin/zsh -c ps -ef | grep **/feast | grep serve\n" + ] + } + ], + "source": [ + "# List running Feast processes (paths redacted)\n", + "running_procs = !ps -ef | grep feast | grep serve\n", + "\n", + "for line in running_procs:\n", + " redacted = re.sub(r'/*[^\\s]*(?P(python )|(feast ))', r'**/\\g', line)\n", + " print(redacted)" + ] + }, + { + "cell_type": "markdown", + "id": "fd52eeb4-948c-472b-9111-8549fda955a1", + "metadata": {}, + "source": [ + "Note that there are two process for the online server (master and worker)." + ] + }, + { + "cell_type": "markdown", + "id": "8258e7a8-5f6e-4737-93ee-63591518b169", + "metadata": {}, + "source": [ + "### Materialize Features to the Online Store" + ] + }, + { + "cell_type": "markdown", + "id": "21b354ab-ec22-476d-8fd9-6ffe0f3fbacb", + "metadata": {}, + "source": [ + "At this point, there is no data in the online store yet. Let's use the SDK feature store object (that we created above) to \"materialize\" data; this is Feast lingo for moving/updating data from the offline store to the online store." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "ff6146df-03a7-4ac2-a665-ee5f440c3605", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:root:_list_feature_views will make breaking changes. Please use _list_batch_feature_views instead. _list_feature_views will behave like _list_all_feature_views in the future.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Materializing \u001b[1m\u001b[32m2\u001b[0m feature views from \u001b[1m\u001b[32m2023-09-24 12:00:00-06:00\u001b[0m to \u001b[1m\u001b[32m2024-01-07 12:00:00-07:00\u001b[0m into the \u001b[1m\u001b[32msqlite\u001b[0m online store.\n", + "\n", + "\u001b[1m\u001b[32mdata_a\u001b[0m:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " 0%| | 0/1000 [00:00=7\",\"4<=X<7\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"0<=X<200\",\"no checking\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"existing paid\",\"critical/other existing credit\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"female div/dep/mar\",\"male mar/wid\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[12579.0,2463.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"none\",\"none\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"used car\",\"new car\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[24.0,24.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[4.0,4.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"yes\",\"yes\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[2.0,3.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[1.0,1.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[44.0,27.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"none\",\"none\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"for free\",\"own\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[1.0,2.0],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"high qualif/self emp/mgmt\",\"skilled\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"no known property\",\"life insurance\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]},{\"values\":[\"yes\",\"yes\"],\"statuses\":[\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"2023-09-25T01:03:47Z\",\"2023-09-29T03:17:24Z\"]}]}']" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "response" + ] + }, + { + "cell_type": "markdown", + "id": "01d20196-1d42-486d-a0bd-97193c953785", + "metadata": {}, + "source": [ + "The `curl` command gave us a quick validation. In the [04_Credit_Risk_Model_Serving.ipynb](04_Credit_Risk_Model_Serving.ipynb) notebook, we'll use the Python `requests` library to handle the query better." + ] + }, + { + "cell_type": "markdown", + "id": "d74a5117-dd34-4dde-93a8-ea6e8c4c545a", + "metadata": {}, + "source": [ + "Now that the feature stores and their respective servers have been configured and deployed, we can proceed to train an AI model in [03_Credit_Risk_Model_Training.ipynb](03_Credit_Risk_Model_Training.ipynb)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/credit-risk-end-to-end/03_Credit_Risk_Model_Training.ipynb b/examples/credit-risk-end-to-end/03_Credit_Risk_Model_Training.ipynb new file mode 100644 index 00000000000..ca0d0e29d95 --- /dev/null +++ b/examples/credit-risk-end-to-end/03_Credit_Risk_Model_Training.ipynb @@ -0,0 +1,1541 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "54f2ab19-68e1-4725-b6e7-efd8eedebe1a", + "metadata": {}, + "source": [ + "
" + ] + }, + { + "cell_type": "markdown", + "id": "69a40de4-65cf-4b45-b321-2b7ce571f8cb", + "metadata": {}, + "source": [ + "# Credit Risk Model Training" + ] + }, + { + "cell_type": "markdown", + "id": "fe641d83-1e28-4f7f-895c-8ca038f6cc53", + "metadata": {}, + "source": [ + "### Introduction" + ] + }, + { + "cell_type": "markdown", + "id": "8f04f635-401b-47b6-b807-df61d42ec752", + "metadata": {}, + "source": [ + "AI models have played a central role in modern credit risk assessment systems. In this example, we develop a credit risk model to predict whether a future loan will be good or bad, given some context data (presumably supplied from the loan application process). We use the modeling process to demonstrate how Feast can be used to facilitate the serving of data for training and inference use-cases.\n", + "\n", + "In this notebook, we train our AI model. We will use the popular scikit-learn library (sklearn) to train a RandomForestClassifier, as this is a relatively easy choice for a baseline model." + ] + }, + { + "cell_type": "markdown", + "id": "a96bf1aa-c450-4201-83a4-e25b08bdd12d", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "markdown", + "id": "a47b33bc-bc06-4de0-8f3a-beea8179035c", + "metadata": {}, + "source": [ + "*The following code assumes that you have read the example README.md file, and that you have setup an environment where the code can be run. Please make sure you have addressed the prerequisite needs.*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c66a3dab-fdbf-40be-8227-6180dc314a84", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import warnings\n", + "import datetime\n", + "import feast\n", + "import joblib\n", + "import pandas as pd\n", + "import seaborn as sns\n", + "\n", + "from feast import FeatureStore, RepoConfig\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.preprocessing import OrdinalEncoder\n", + "from sklearn.compose import ColumnTransformer\n", + "from sklearn.pipeline import Pipeline\n", + "from sklearn.ensemble import RandomForestClassifier\n", + "from sklearn.metrics import classification_report" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "2a841445-fa47-4826-a874-28ac0e4ea57f", + "metadata": {}, + "outputs": [], + "source": [ + "# Ignore warnings\n", + "warnings.filterwarnings(action=\"ignore\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "23579727-7797-4101-a70d-b0d4c24b0fdf", + "metadata": {}, + "outputs": [], + "source": [ + "# Random seed\n", + "SEED = 142" + ] + }, + { + "cell_type": "markdown", + "id": "fc5be519-7733-449b-8dc3-411e86371315", + "metadata": {}, + "source": [ + "This notebook assumes that you have already done the following:\n", + "\n", + "1. Run the [01_Credit_Risk_Data_Prep.ipynb](01_Credit_Risk_Data_Prep.ipynb) notebook to prepare the data.\n", + "2. Run the [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb) notebook to configure the feature stores and launch the feature store servers.\n", + "\n", + "If you have not completed the above steps, please go back and do so before continuing. This notebook relies on the data prepared by 1, and it uses the Feast offline server stood up by 2." + ] + }, + { + "cell_type": "markdown", + "id": "1ca99047-e508-4b1f-9f4c-f11e38587d70", + "metadata": {}, + "source": [ + "### Load Label (Outcome) Data" + ] + }, + { + "cell_type": "markdown", + "id": "89b49268-b7a5-4abc-8d82-1cdbf9bb4473", + "metadata": {}, + "source": [ + "From our previous data exploration, remember that the label data represents whether the loan was classed as \"good\" (1) or \"bad\" (0). Let's pull the labels for training, as we will use them as our \"entity dataframe\" when pulling features.\n", + "\n", + "This is also a good time to remember that the label timestamps are lagged by 30-90 days from the context data records." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6a227a12-7b3e-462a-8f6e-38a7690df1c4", + "metadata": {}, + "outputs": [], + "source": [ + "labels = pd.read_parquet(\"Feature_Store/data/labels.parquet\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "31a39cad-0a85-4d98-ad95-008c81bb6fe0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IDclassoutcome_timestamp
00good2023-11-24 22:50:13
11bad2023-11-03 12:10:13
22good2023-11-30 22:06:03
33good2023-11-17 07:37:19
44bad2023-12-01 05:01:48
\n", + "
" + ], + "text/plain": [ + " ID class outcome_timestamp\n", + "0 0 good 2023-11-24 22:50:13\n", + "1 1 bad 2023-11-03 12:10:13\n", + "2 2 good 2023-11-30 22:06:03\n", + "3 3 good 2023-11-17 07:37:19\n", + "4 4 bad 2023-12-01 05:01:48" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "labels.head()" + ] + }, + { + "cell_type": "markdown", + "id": "857f29fd-46d3-444b-b24f-eaccd82ab7d3", + "metadata": {}, + "source": [ + "### Pull Feature Data from Feast Offline Store" + ] + }, + { + "cell_type": "markdown", + "id": "07c13b69-3d26-484c-97cd-97734cc812bd", + "metadata": {}, + "source": [ + "In order to pull feature data from the offline store, we create a FeatureStore object that connects to the offline server (continuously running in the previous notebook)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "9e9828f8-f210-4586-ac36-3f7e17f4f1e8", + "metadata": {}, + "outputs": [], + "source": [ + "# Create FeatureStore object\n", + "# (connects to the offline server deployed in 02_Deploying_the_Feature_Store.ipynb) \n", + "store = FeatureStore(config=RepoConfig(\n", + " project=\"loan_applications\",\n", + " provider=\"local\",\n", + " registry=\"Feature_Store/data/registry.db\",\n", + " offline_store={\n", + " \"type\": \"remote\",\n", + " \"host\": \"localhost\",\n", + " \"port\": 8815\n", + " },\n", + " entity_key_serialization_version=2\n", + "))" + ] + }, + { + "cell_type": "markdown", + "id": "c007e7ca-40c1-4850-abed-73b6171ad08d", + "metadata": {}, + "source": [ + "Now, we can retrieve feature data by supplying our entity dataframe and feature specifications to the `get_historical_features` function. Note that this function performs a fuzzy lookback (\"point-in-time\") join, matching the lagged outcome timestamp to the closest application timestamp (per ID) in the context data; it also joins the \"a\" and \"b\" features that we had previously split into two tables.\n", + "\n", + "To keep this example simple, we will limit our feature set to the numerical features plus two categorical features." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "dd2e3cb5-c865-48f4-80b6-8a14a1ff09ab", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:root:_list_feature_views will make breaking changes. Please use _list_batch_feature_views instead. _list_feature_views will behave like _list_all_feature_views in the future.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using outcome_timestamp as the event timestamp. To specify a column explicitly, please name it event_timestamp.\n" + ] + } + ], + "source": [ + "# Get feature data\n", + "# (Joins a and b data, and selects records with the right timestamps)\n", + "df = store.get_historical_features(\n", + " entity_df=labels,\n", + " features=[\n", + " \"data_a:duration\",\n", + " \"data_a:credit_amount\",\n", + " \"data_a:installment_commitment\",\n", + " \"data_a:checking_status\",\n", + " \"data_b:residence_since\",\n", + " \"data_b:age\",\n", + " \"data_b:existing_credits\",\n", + " \"data_b:num_dependents\",\n", + " \"data_b:housing\"\n", + " ]\n", + ").to_df()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c72f6cb1-bbbf-4512-98cd-0abe5ff0c24b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RangeIndex: 1000 entries, 0 to 999\n", + "Data columns (total 12 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 ID 1000 non-null int64 \n", + " 1 class 1000 non-null category \n", + " 2 outcome_timestamp 1000 non-null datetime64[ns, UTC]\n", + " 3 duration 1000 non-null int64 \n", + " 4 credit_amount 1000 non-null int64 \n", + " 5 installment_commitment 1000 non-null int64 \n", + " 6 checking_status 1000 non-null category \n", + " 7 residence_since 1000 non-null int64 \n", + " 8 age 1000 non-null int64 \n", + " 9 existing_credits 1000 non-null int64 \n", + " 10 num_dependents 1000 non-null int64 \n", + " 11 housing 1000 non-null category \n", + "dtypes: category(3), datetime64[ns, UTC](1), int64(8)\n", + "memory usage: 73.8 KB\n" + ] + } + ], + "source": [ + "# Check the data info\n", + "df.info()" + ] + }, + { + "cell_type": "markdown", + "id": "110ea48c-0a5a-4642-aaba-a9eeb4a7da48", + "metadata": {}, + "source": [ + "### Split the Data" + ] + }, + { + "cell_type": "markdown", + "id": "f6669dce-a8b0-4d80-9a15-70b7dfd2d718", + "metadata": {}, + "source": [ + "Next, we split the data into a `train` and `validate` set, which we will use to train and then validate a model. The validation set will allow us to more accurately assess the model's performance on data that it has not seen during the training phase." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "036b0a54-48e4-4414-bb8c-0c30b6ab7469", + "metadata": {}, + "outputs": [], + "source": [ + "# Split data into train and validate datasets\n", + "train, validate = train_test_split(df, test_size=0.2, random_state=SEED)" + ] + }, + { + "cell_type": "markdown", + "id": "4b65cbf7-5981-4f51-97aa-a3ff7027f2f3", + "metadata": {}, + "source": [ + "### Exploratory Data Analysis" + ] + }, + { + "cell_type": "markdown", + "id": "e516ded8-10ad-4274-a736-f288290b5883", + "metadata": {}, + "source": [ + "Before building a model, a data scientist needs to gain understanding of the data to make sure it meets important statistical assumptions, and to identify potential opportunities and issues. As the purpose of this particular example is to show working with Feast, we will take the view of a data scientist looking to build a quick baseline model to establish some low-end metrics.\n", + "\n", + "Note that this data set is very \"clean\", as it has already been prepared. In real-life, production credit risk data can be much more complex, and have many issues that need to be understood and addressed before modeling." + ] + }, + { + "cell_type": "markdown", + "id": "553986a0-c804-4ab4-a4b9-48b16c72fd4f", + "metadata": {}, + "source": [ + "Let's look at counts for the target variable `class`, which tells us whether a (historical) loan was good or bad. We can see that there were many more good loans than bad, making the dataset imbalanced." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "607bd29b-eaf4-41a6-aaca-a8eaaf37e2d2", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjsAAAHHCAYAAABZbpmkAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAysElEQVR4nO3deVxV5d7///dmRmWDoIKWqJWKOHZw2k1qkWRkeWvllKlHG8FKy2PcOWLedqwcQ6tTqWWm2WBq5kRZHcVSTFNT1MqwFCgVtnoUBNbvj37sb/ugpQhsvHw9H4/1yHVd11rrc+3d1jdr2Ngsy7IEAABgKC9PFwAAAFCRCDsAAMBohB0AAGA0wg4AADAaYQcAABiNsAMAAIxG2AEAAEYj7AAAAKMRdgAAgNEIO8AlyGazafz48Z4u45LD6wZcngg7QDmYN2+ebDab21KnTh116dJFn3zyiafLq3T5+fmaNWuWbrjhBtWsWVN+fn6qV6+e7rzzTr3zzjsqKirydInndODAAdlsNr3wwgueLuWCOZ1OTZgwQa1bt1aNGjUUGBioFi1aaNSoUTp06JCny5MkrVy5ksCJSufj6QIAkyQnJ6tRo0ayLEvZ2dmaN2+ebr/9di1fvlx33HGHp8urFL/++qu6deum9PR0xcXFafTo0QoNDVVWVpbWrVunfv36af/+/RozZoynSzXKDz/8oNjYWGVmZuqee+7Rgw8+KD8/P3377bd6/fXX9eGHH2rv3r2eLlMrV65USkoKgQeVirADlKNu3bqpbdu2rvUhQ4YoPDxc77zzzmUTdgYMGKBvvvlG77//vnr27OnWl5SUpC1btigjI8ND1ZmpsLBQPXv2VHZ2ttavX68bbrjBrX/SpEn65z//6aHqAM/jMhZQgUJCQhQYGCgfH/efK1544QVdd911CgsLU2BgoGJiYvTee++V2j4/P1/Dhw9X7dq1FRQUpDvvvFM///zzXx43OztbPj4+mjBhQqm+jIwM2Ww2vfTSS5KkM2fOaMKECWrcuLECAgIUFhamG264QWvXrr3g+aalpWn16tV68MEHSwWdEm3btlX//v3d2nJyclzBMCAgQK1bt9b8+fNLbXvy5Ek9+eSTql+/vvz9/dW0aVO98MILsizLbVxZX7cLcb41n+97bbPZlJiYqKVLl6pFixby9/dX8+bNtWrVqr+s5f3339f27dv1zDPPlAo6kmS32zVp0iS3tiVLligmJkaBgYGqVauW7rvvPv3yyy9uYzp37qzOnTuX2t+gQYPUsGFD1/ofL/29+uqruvrqq+Xv76927dpp8+bNbtulpKS45luyABWNMztAOcrLy9Nvv/0my7KUk5OjWbNm6cSJE7rvvvvcxs2YMUN33nmn+vfvr4KCAi1atEj33HOPVqxYofj4eNe4oUOHasGCBerXr5+uu+46ffrpp2795xIeHq5OnTrp3Xff1bhx49z6Fi9eLG9vb91zzz2SpPHjx2vy5MkaOnSo2rdvL6fTqS1btmjr1q269dZbL2j+y5cvl6RS8/0zp06dUufOnbV//34lJiaqUaNGWrJkiQYNGqTc3Fw9/vjjkiTLsnTnnXfqs88+05AhQ9SmTRutXr1aI0eO1C+//KJp06a59lnW1628a5bO/72WpH//+9/64IMP9OijjyooKEgzZ85Ur169lJmZqbCwsHPWs2zZMkm/n1U7H/PmzdPgwYPVrl07TZ48WdnZ2ZoxY4Y2bNigb775RiEhIRf+okhauHChjh8/roceekg2m01TpkxRz5499cMPP8jX11cPPfSQDh06pLVr1+qtt94q0zGAMrEAXLS5c+dakkot/v7+1rx580qN/89//uO2XlBQYLVo0cK6+eabXW3btm2zJFmPPvqo29h+/fpZkqxx48b9aU2vvPKKJcnasWOHW3t0dLTbcVq3bm3Fx8ef71T/1P/8z/9Ykqzc3Fy39lOnTlm//vqrazl27Jirb/r06ZYka8GCBa62goICy+FwWDVq1LCcTqdlWZa1dOlSS5L17LPPuu377rvvtmw2m7V//37Lsi7+dfvxxx8tSdbzzz9/zjHnW7Nlnd97bVmWJcny8/NzzcOyLGv79u2WJGvWrFl/WvO1115rBQcH/+mYPx6/Tp06VosWLaxTp0652lesWGFJssaOHetq69Spk9WpU6dS+xg4cKDVoEED13rJaxYWFmYdPXrU1f7RRx9Zkqzly5e72hISEiz+6UFl4zIWUI5SUlK0du1arV27VgsWLFCXLl00dOhQffDBB27jAgMDXX8+duyY8vLydOONN2rr1q2u9pUrV0qSHnvsMbdtn3jiifOqpWfPnvLx8dHixYtdbTt37tR3332n3r17u9pCQkK0a9cu7du377zneS5Op1OSVKNGDbf2l19+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xIgRysjI0DvvvKNZs2bp8ccfV2RkpPz8/DRr1iz98MMPWrZsmSZOnOi27cMPP6x9+/Zp5MiRysjI0MKFC91ukgTgLigoSAMHDtTIkSP12WefadeuXRoyZIi8vLxks9nUuHFj3XXXXXrggQf073//W9u3b9d9992nK664QnfddZck6cknn1RqaqomTpyovXv3av78+XrppZf01FNPSZKaNm2q2267TQ899JC++uorpaena+jQoQoMDPTk1FFVePqmIaCyderUyXr00Uethx9+2LLb7VbNmjWt//3f/3XdsLxw4UKrYcOGlr+/v+VwOKxly5ZZkqxvvvnGtY/ly5db11xzjeXv72/deOON1htvvMENysCfcDqdVr9+/axq1apZERER1tSpU6327dtbTz/9tGVZlnX06FFrwIABVnBwsBUYGGjFxcVZe/fuddvHe++9Z0VHR1u+vr5WZGSk9fzzz7v1Hz582IqPj7f8/f2tyMhI680337QaNGjADcqwbJb1/3+5CAAAleTkyZO64oor9OKLL2rIkCGeLgeG8/F0AQAA833zzTfas2eP2rdvr7y8PCUnJ0uS6zIVUJEIOwCASvHCCy8oIyNDfn5+iomJ0ZdffqlatWp5uixcBriMBQAAjMbTWAAAwGiEHQAAYDTCDgAAMBphBwAAGI2wA+CSdeDAAdlsNm3bts3TpQCowgg7AADAaIQdAABgNMIOgCqvuLhYU6ZM0TXXXCN/f39FRkZq0qRJpcYVFRVpyJAhatSokQIDA9W0aVPNmDHDbcz69evVvn17Va9eXSEhIbr++uv1008/SZK2b9+uLl26KCgoSHa7XTExMdqyZUulzBFAxeEblAFUeUlJSfrXv/6ladOm6YYbbtDhw4e1Z8+eUuOKi4t15ZVXasmSJQoLC9PGjRv14IMPqm7durr33ntVWFioHj166IEHHtA777yjgoICff3117LZbJKk/v3769prr9WcOXPk7e2tbdu2ydfXt7KnC6Cc8Q3KAKq048ePq3bt2nrppZc0dOhQt74DBw6oUaNG+uabb9SmTZuzbp+YmKisrCy99957Onr0qMLCwrR+/Xp16tSp1Fi73a5Zs2Zp4MCBFTEVAB7CZSwAVdru3buVn5+vW2655bzGp6SkKCYmRrVr11aNGjX06quvKjMzU5IUGhqqQYMGKS4uTt27d9eMGTN0+PBh17YjRozQ0KFDFRsbq+eee07ff/99hcwJQOUi7ACo0gIDA8977KJFi/TUU09pyJAhWrNmjbZt26bBgweroKDANWbu3LlKS0vTddddp8WLF6tJkybatGmTJGn8+PHatWuX4uPj9emnnyo6Oloffvhhuc8JQOXiMhaAKu306dMKDQ3VzJkz//Iy1rBhw/Tdd98pNTXVNSY2Nla//fbbOb+Lx+FwqF27dpo5c2apvr59++rkyZNatmxZuc4JQOXizA6AKi0gIECjRo3SP/7xD7355pv6/vvvtWnTJr3++uulxjZu3FhbtmzR6tWrtXfvXo0ZM0abN2929f/4449KSkpSWlqafvrpJ61Zs0b79u1Ts2bNdOrUKSUmJmr9+vX66aeftGHDBm3evFnNmjWrzOkCqAA8jQWgyhszZox8fHw0duxYHTp0SHXr1tXDDz9catxDDz2kb775Rr1795bNZlPfvn316KOP6pNPPpEkVatWTXv27NH8+fN15MgR1a1bVwkJCXrooYdUWFioI0eO6P7771d2drZq1aqlnj17asKECZU9XQDljMtYAADAaFzGAgAARiPsAAAAoxF2AACA0Qg7AADAaIQdAABgNMIOAAAwGmEHAAAYjbADAACMRtgBAABGI+wAAACjEXYAAIDRCDsAAMBo/x9dWkm/NZ32CAAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# plot the target variable \"class\"\n", + "p = sns.histplot(train[\"class\"], ec=\"w\", lw=4)\n", + "_ = p.set_title(\"Bad vs. Good Loan Count\")\n", + "_ = p.spines[\"top\"].set_visible(False)\n", + "_ = p.spines[\"right\"].set_visible(False)" + ] + }, + { + "cell_type": "markdown", + "id": "c6a697a5-5709-4a69-b644-62779b4f8bc5", + "metadata": {}, + "source": [ + "Now, view the first few records of the context data." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "79424785-129d-4007-84a5-041b6d38457d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IDclassoutcome_timestampdurationcredit_amountinstallment_commitmentchecking_statusresidence_sinceageexisting_creditsnum_dependentshousing
18473good2023-12-16 03:29:12+00:00612384no checking43612own
764894good2023-11-15 23:19:35+00:001811694no checking32921own
504318good2023-11-23 13:03:53+00:00127014no checking23221own
454340good2023-12-26 17:59:37+00:0024574320<=X<20042421for free
453605good2023-12-18 11:27:02+00:002428284<042211own
\n", + "
" + ], + "text/plain": [ + " ID class outcome_timestamp duration credit_amount \\\n", + "18 473 good 2023-12-16 03:29:12+00:00 6 1238 \n", + "764 894 good 2023-11-15 23:19:35+00:00 18 1169 \n", + "504 318 good 2023-11-23 13:03:53+00:00 12 701 \n", + "454 340 good 2023-12-26 17:59:37+00:00 24 5743 \n", + "453 605 good 2023-12-18 11:27:02+00:00 24 2828 \n", + "\n", + " installment_commitment checking_status residence_since age \\\n", + "18 4 no checking 4 36 \n", + "764 4 no checking 3 29 \n", + "504 4 no checking 2 32 \n", + "454 2 0<=X<200 4 24 \n", + "453 4 <0 4 22 \n", + "\n", + " existing_credits num_dependents housing \n", + "18 1 2 own \n", + "764 2 1 own \n", + "504 2 1 own \n", + "454 2 1 for free \n", + "453 1 1 own " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# View first records in training data\n", + "train.head()" + ] + }, + { + "cell_type": "markdown", + "id": "fd52f5bc-aa0f-48db-b356-c52aa7ce3724", + "metadata": {}, + "source": [ + "### Feature Engineering" + ] + }, + { + "cell_type": "markdown", + "id": "3e5b5c02-ad4d-400e-bdac-bfdf2799f575", + "metadata": {}, + "source": [ + "Once data columns have been prepared so that they can be used to train an AI model, it is common to refer to them as \"features\". The process of preparing features is referred to as \"feature engineering\". \n", + "\n", + "Below, we will train a random forest model. Random forests are relatively robust to non-standardized, non-normalized data, making it easier for us to getting started. As such, the numerical columns are ready for a simple baseline training. \n", + "\n", + "We have pulled two categorical columns, wich we will need to engineer into numerical features." + ] + }, + { + "cell_type": "markdown", + "id": "45a6fb27-140c-4f5a-b464-1f5e5d81d086", + "metadata": {}, + "source": [ + "The `checking_status` column tells us roughly how much money the applicant has in their checking account, while the `housing` column shows the applicant's housing status. We presume that more money in checking correlates inversely with credit risk, while owing vs. renting, vs. living for free correlates directly with credit risk. Hence, converting these to ordinal features makes sense. Of course, in a real study we would want to quantitatively verify these presumptions." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "9e374096-b02d-4cbb-8fca-dcc451c90c50", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "checking_status\n", + "no checking 0.39375\n", + "0<=X<200 0.27500\n", + "<0 0.26125\n", + ">=200 0.07000\n", + "Name: proportion, dtype: float64" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Inspect the `checking_status` column distibution\n", + "train.checking_status.value_counts(normalize=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "0144b525-244b-4526-8e4b-d393cb174d06", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "housing\n", + "own 0.7225\n", + "rent 0.1675\n", + "for free 0.1100\n", + "Name: proportion, dtype: float64" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Inspect the `housing` column distribution\n", + "train.housing.value_counts(normalize=True)" + ] + }, + { + "cell_type": "markdown", + "id": "2cb340b4-7d21-4810-8be2-1633da2e4396", + "metadata": {}, + "source": [ + "We define a tranformer that can be used to convert `checking_status` and `housing` to ordinal variables. The transformer will also drop the non-feature columns (`class`, `ID`, and `application_timestamp`) from the feature data." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "27796e23-c12e-4e51-8fb4-090b26aff2ef", + "metadata": {}, + "outputs": [], + "source": [ + "# Feature lists\n", + "cat_features = [\"checking_status\", \"housing\"]\n", + "num_features = [\n", + " \"duration\", \"credit_amount\", \"installment_commitment\",\n", + " \"residence_since\", \"age\", \"existing_credits\", \"num_dependents\"\n", + "]\n", + "\n", + "# Ordinal encoder for cat_features\n", + "# (We use a ColumnTransformer to passthrough numerical feature columns)\n", + "col_transform = ColumnTransformer([\n", + " (\"cat_features\", OrdinalEncoder(), cat_features),\n", + " (\"num_features\", \"passthrough\", num_features),\n", + " ],\n", + " remainder=\"drop\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "318429b9-e008-4cc7-8108-779934f9ac2f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
checking_statushousingdurationcredit_amountinstallment_commitmentresidence_sinceageexisting_creditsnum_dependents
183.01.06.01238.04.04.036.01.02.0
7643.01.018.01169.04.03.029.02.01.0
5043.01.012.0701.04.02.032.02.01.0
4540.00.024.05743.02.04.024.02.01.0
4531.01.024.02828.04.04.022.01.01.0
\n", + "
" + ], + "text/plain": [ + " checking_status housing duration credit_amount \\\n", + "18 3.0 1.0 6.0 1238.0 \n", + "764 3.0 1.0 18.0 1169.0 \n", + "504 3.0 1.0 12.0 701.0 \n", + "454 0.0 0.0 24.0 5743.0 \n", + "453 1.0 1.0 24.0 2828.0 \n", + "\n", + " installment_commitment residence_since age existing_credits \\\n", + "18 4.0 4.0 36.0 1.0 \n", + "764 4.0 3.0 29.0 2.0 \n", + "504 4.0 2.0 32.0 2.0 \n", + "454 2.0 4.0 24.0 2.0 \n", + "453 4.0 4.0 22.0 1.0 \n", + "\n", + " num_dependents \n", + "18 2.0 \n", + "764 1.0 \n", + "504 1.0 \n", + "454 1.0 \n", + "453 1.0 " + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Check the tranform outputs features as expected\n", + "# (Note: transform output is an array, so we convert it\n", + "# back to dataframe for inspection)\n", + "pd.DataFrame(\n", + " index=train.index,\n", + " columns=cat_features + num_features,\n", + " data= col_transform.fit_transform(train)\n", + ").head()" + ] + }, + { + "cell_type": "markdown", + "id": "a3785c93-8830-4fa2-bb9d-31b6e8fecb01", + "metadata": {}, + "source": [ + "Finally, let's separate out the labels, and engineer them from categorical (\"good\" | \"bad\") to float (1.0 | 0.0). We do this for both the training and validation data." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "30ebff90-a193-43a2-86fb-cf09e7d03777", + "metadata": {}, + "outputs": [], + "source": [ + "# Make \"class\" target variable numeric\n", + "train_y = (train[\"class\"] == \"good\").astype(float)\n", + "validate_y = (validate[\"class\"] == \"good\").astype(float)" + ] + }, + { + "cell_type": "markdown", + "id": "b052f6b2-2a34-441d-8a5f-2aad4e4db022", + "metadata": {}, + "source": [ + "### Train the Model" + ] + }, + { + "cell_type": "markdown", + "id": "c4f14590-31f4-4680-b1a1-75755a78513e", + "metadata": {}, + "source": [ + "Now that the features are prepared, we can train (fit) our baseline model on the feature data." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0ff48f34-dbb6-4221-aefc-3c9b3f9da3e3", + "metadata": {}, + "outputs": [], + "source": [ + "# Specify the model\n", + "rf_model = RandomForestClassifier(\n", + " n_estimators=400,\n", + " criterion=\"entropy\",\n", + " max_depth=4,\n", + " min_samples_leaf=10,\n", + " class_weight={0:5, 1:1},\n", + " random_state=SEED\n", + ")\n", + "\n", + "# Package transform and model in pipeline\n", + "model = Pipeline([(\"transform\", col_transform), (\"rf_model\", rf_model)])" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "1d6ef38a-23b0-4056-a108-960495521164", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Pipeline(steps=[('transform',\n",
+       "                 ColumnTransformer(transformers=[('cat_features',\n",
+       "                                                  OrdinalEncoder(),\n",
+       "                                                  ['checking_status',\n",
+       "                                                   'housing']),\n",
+       "                                                 ('num_features', 'passthrough',\n",
+       "                                                  ['duration', 'credit_amount',\n",
+       "                                                   'installment_commitment',\n",
+       "                                                   'residence_since', 'age',\n",
+       "                                                   'existing_credits',\n",
+       "                                                   'num_dependents'])])),\n",
+       "                ('rf_model',\n",
+       "                 RandomForestClassifier(class_weight={0: 5, 1: 1},\n",
+       "                                        criterion='entropy', max_depth=4,\n",
+       "                                        min_samples_leaf=10, n_estimators=400,\n",
+       "                                        random_state=142))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "Pipeline(steps=[('transform',\n", + " ColumnTransformer(transformers=[('cat_features',\n", + " OrdinalEncoder(),\n", + " ['checking_status',\n", + " 'housing']),\n", + " ('num_features', 'passthrough',\n", + " ['duration', 'credit_amount',\n", + " 'installment_commitment',\n", + " 'residence_since', 'age',\n", + " 'existing_credits',\n", + " 'num_dependents'])])),\n", + " ('rf_model',\n", + " RandomForestClassifier(class_weight={0: 5, 1: 1},\n", + " criterion='entropy', max_depth=4,\n", + " min_samples_leaf=10, n_estimators=400,\n", + " random_state=142))])" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Fit the model\n", + "model.fit(train, train_y)" + ] + }, + { + "cell_type": "markdown", + "id": "73c45c39-9d8e-4f76-aca5-9f0c1568d263", + "metadata": {}, + "source": [ + "### Evaluate the Model" + ] + }, + { + "cell_type": "markdown", + "id": "ef58d432-80ba-428f-b59f-621a9e53b331", + "metadata": {}, + "source": [ + "Let's evaluate our baseline model performance. With credit risk, recall is going to be an important measure to look at. We compare the performance on the training data, with the performance on the validation data through a classification report." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "8c5472f6-2ddc-437d-8102-4d5bd2c9f39c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " precision recall f1-score support\n", + "\n", + " 0.0 0.42 0.92 0.58 232\n", + " 1.0 0.94 0.49 0.64 568\n", + "\n", + " accuracy 0.61 800\n", + " macro avg 0.68 0.70 0.61 800\n", + "weighted avg 0.79 0.61 0.63 800\n", + "\n" + ] + } + ], + "source": [ + "# Evaluate training set performance\n", + "train_preds = model.predict(train)\n", + "print(classification_report(train_y, train_preds))" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "c296bbd3-603e-4615-abbe-2689ebcf5d8c", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " precision recall f1-score support\n", + "\n", + " 0.0 0.46 0.87 0.61 68\n", + " 1.0 0.88 0.48 0.62 132\n", + "\n", + " accuracy 0.61 200\n", + " macro avg 0.67 0.68 0.61 200\n", + "weighted avg 0.74 0.61 0.62 200\n", + "\n" + ] + } + ], + "source": [ + "# Evaluate validation data performance\n", + "print(classification_report(validate_y, model.predict(validate)))" + ] + }, + { + "cell_type": "markdown", + "id": "d57ffbdc-f0b3-4fb6-9575-5acd983082cf", + "metadata": {}, + "source": [ + "The recall on the validation set for bad loans (0 class) is 0.87, meaning that the model correctly identified close to 90% of the bad loans. However, the precision of 0.46 tells us that the model is also classifying many loans that were actually good as bad. Precision and recall are technical metrics. In order to truly assess the models value, we would need feedback from the business side on the impact of misclassifications (for both good and bad loans).\n", + "\n", + "The difference in performance on the training vs. validation data, tells us that the model is slightly overfitting the data. Remember that this is just a quick baseline model. To improve further, we could do things like:\n", + "- gather more data\n", + "- engineer features\n", + "- experiment with hyperparameter settings\n", + "- experiment with other model types\n", + "\n", + "In fact, this is just a start. Creating AI models that meet business needs often requires a lot of guided experimentation." + ] + }, + { + "cell_type": "markdown", + "id": "0378d21a-d6db-42f9-851a-ce71f68c6802", + "metadata": {}, + "source": [ + "### Save the Model" + ] + }, + { + "cell_type": "markdown", + "id": "4450a328-f00c-4579-8e08-b2ebe5046961", + "metadata": {}, + "source": [ + "The last thing we do is save our trained model, so that we can pick it up later in the serving environment." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "da7a7906-d54f-4f2d-9803-6c82c86b28ad", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['rf_model.pkl']" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Save the model to a pickle file\n", + "joblib.dump(model, \"rf_model.pkl\")" + ] + }, + { + "cell_type": "markdown", + "id": "299588b8-ab67-4155-97a9-770e8e4a7476", + "metadata": {}, + "source": [ + "In the next notebook, [04_Credit_Risk_Model_Serving.ipynb](04_Credit_Risk_Model_Serving.ipynb), we will load the trained model and request predictions, with input features provided by the Feast online feature server." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/credit-risk-end-to-end/04_Credit_Risk_Model_Serving.ipynb b/examples/credit-risk-end-to-end/04_Credit_Risk_Model_Serving.ipynb new file mode 100644 index 00000000000..f263dd6cd7b --- /dev/null +++ b/examples/credit-risk-end-to-end/04_Credit_Risk_Model_Serving.ipynb @@ -0,0 +1,697 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9c870dcb-c66d-454d-a3fa-5f9a723bf8af", + "metadata": {}, + "source": [ + "
" + ] + }, + { + "cell_type": "markdown", + "id": "339ab741-ac90-4763-9971-3b274f6a90b4", + "metadata": {}, + "source": [ + "# Credit Risk Model Serving" + ] + }, + { + "cell_type": "markdown", + "id": "31d29794-4c33-4bc1-9bb4-e238c59f882d", + "metadata": {}, + "source": [ + "### Introduction" + ] + }, + { + "cell_type": "markdown", + "id": "d6553fe7-5427-4ecc-b638-615b47acf1a8", + "metadata": {}, + "source": [ + "Model serving is an exciting part of AI/ML. All of our previous work was building to this phase where we can actually serve loan predictions. \n", + "\n", + "So what role does Feast play in model serving? We've already seen that Feast can \"materialize\" data from the training offline store to the serving online store. This comes in handy because many models need contextual features at inference time. \n", + "\n", + "With this example, we can imagine a scenario something like this:\n", + "1. A bank customer submits a loan application on a website. \n", + "2. The website backend requests features, supplying the customer's ID as input.\n", + "3. The backend retrieves feature data for the ID in question.\n", + "4. The backend submits the feature data to the model to obtain a prediction.\n", + "5. The backend uses the prediction to make a decision.\n", + "6. The response is recorded and made available to the user.\n", + "\n", + "With online requests like this, time and resource usage often matter a lot. Feast facilitates quickly retrieving the correct feature data.\n", + "\n", + "In real-life, some of the contextual feature data points could be requested from the user, while others are retrieved from data sources. While outside the scope of this example, Feast does facilitate retrieving request data, and joining it with feature data. (See [Request Source](https://rtd.feast.dev/en/master/#request-source)).\n", + "\n", + "In this notebook, we request feature data from the online store for a small batch of users. We then get outcome predictions from our trained model. This notebook is a continuation of the work done in the previous notebooks; it comes as the step after [03_Credit_Risk_Model_Training.ipynb](03_Credit_Risk_Model_Training.ipynb)." + ] + }, + { + "cell_type": "markdown", + "id": "53818109-c357-435f-8a8b-2a62982fa9a8", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "markdown", + "id": "92b5ab1b-186d-4b76-aac7-9b5110f8673e", + "metadata": {}, + "source": [ + "*The following code assumes that you have read the example README.md file, and that you have setup an environment where the code can be run. Please make sure you have addressed the prerequisite needs.*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "378189ed-e967-4b2b-b591-aab980a685b3", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import os\n", + "import joblib\n", + "import json\n", + "import requests\n", + "import warnings\n", + "import pandas as pd\n", + "\n", + "from feast import FeatureStore" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ea90edb2-16f0-4d40-a280-4e6ea79ea5be", + "metadata": {}, + "outputs": [], + "source": [ + "# ingnore warnings\n", + "warnings.filterwarnings(action=\"ignore\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "55f8ed91-7c13-44f7-a294-b6cacd43f8db", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the model\n", + "model = joblib.load(\"rf_model.pkl\")" + ] + }, + { + "cell_type": "markdown", + "id": "3093e1b6-66d9-4936-b197-d853631914db", + "metadata": {}, + "source": [ + "### Query Feast Online Server for Feature Data" + ] + }, + { + "cell_type": "markdown", + "id": "2b5bbc4a-e2d3-4b7b-8309-434ff3b3e2cf", + "metadata": {}, + "source": [ + "Here, we show two different ways to retrieve data from the online feature server. The first is using the Python `requests` library, and the second is using the Feast Python SDK.\n", + "\n", + "We can use the Python requests library to request feature data from the online feature server (that we deployed in notebook [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb)). The request takes the form of an HTTP POST command sent to the server endpoint (`url`). We request the data we need by supplying the entity and feature information in the data payload. We also need to specify an `application/json` content type in the request header." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c6fd4f1a-917b-4a98-9bf6-101b4a074b64", + "metadata": {}, + "outputs": [], + "source": [ + "# ID examples\n", + "ids = [18, 764, 504, 454, 453, 0, 1, 2, 3, 4, 5, 6, 7, 8]\n", + "\n", + "# Submit get_online_features request to Feast online store server\n", + "response = requests.post(\n", + " url=\"http://localhost:6566/get-online-features\",\n", + " headers = {'Content-Type': 'application/json'},\n", + " data=json.dumps({\n", + " \"entities\": {\"ID\": ids},\n", + " \"features\": [\n", + " \"data_a:duration\",\n", + " \"data_a:credit_amount\",\n", + " \"data_a:installment_commitment\",\n", + " \"data_a:checking_status\",\n", + " \"data_b:residence_since\",\n", + " \"data_b:age\",\n", + " \"data_b:existing_credits\",\n", + " \"data_b:num_dependents\",\n", + " \"data_b:housing\"\n", + " ]\n", + " })\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8e616a52-c18c-44a9-9e63-3aba071d7e79", + "metadata": {}, + "source": [ + "The response is returned as JSON, with feature values for each of the IDs." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cf8948b7-4ed7-4c45-8acf-462331d9e4d2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"metadata\":{\"feature_names\":[\"ID\",\"checking_status\",\"duration\",\"installment_commitment\",\"credit_amount\",\"residence_since\",\"num_dependents\",\"age\",\"housing\",\"existing_credits\"]},\"results\":[{\"values\":[18,764,504,454,453,0,1,2,3,4,5,6,7,8],\"statuses\":[\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\"],\"event_timestamps\":[\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\",\"1970-01-01T00:00:00Z\"]},{\"values\":[\"0<=X<200\",\"no checking\",\"<0\",\"<0\",\"no checking\",\"<0\",\"0<=X<200\",\"no checking\",\"<0\",\"<0\",\"no checking\",\"no checking\",\"0<=X<200\",\"no checking\"],\"statuses\":[\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",\"PRESENT\",'" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Show first 1000 characters of response\n", + "response.text[:1000]" + ] + }, + { + "cell_type": "markdown", + "id": "c719f702-578a-4f35-b8ff-e41707cda23e", + "metadata": {}, + "source": [ + "As the response data comes in JSON format, there is a little formatting required to organize the data into a dataframe with one record per row (and features as columns)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "b992063d-8d83-4bf7-8153-f690b0410359", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IDchecking_statusdurationinstallment_commitmentcredit_amountresidence_sincenum_dependentsagehousingexisting_credits
0180<=X<20024.04.012579.02.01.044.0for free1.0
1764no checking24.04.02463.03.01.027.0own2.0
2504<024.04.01207.04.01.024.0rent1.0
\n", + "
" + ], + "text/plain": [ + " ID checking_status duration installment_commitment credit_amount \\\n", + "0 18 0<=X<200 24.0 4.0 12579.0 \n", + "1 764 no checking 24.0 4.0 2463.0 \n", + "2 504 <0 24.0 4.0 1207.0 \n", + "\n", + " residence_since num_dependents age housing existing_credits \n", + "0 2.0 1.0 44.0 for free 1.0 \n", + "1 3.0 1.0 27.0 own 2.0 \n", + "2 4.0 1.0 24.0 rent 1.0 " + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Inspect the response\n", + "resp_data = json.loads(response.text)\n", + "\n", + "# Transform JSON into dataframe\n", + "records = pd.DataFrame(\n", + " columns=resp_data[\"metadata\"][\"feature_names\"], \n", + " data=[[r[\"values\"][i] for r in resp_data[\"results\"]] for i in range(len(ids))]\n", + ")\n", + "records.head(3)" + ] + }, + { + "cell_type": "markdown", + "id": "6db9b8ac-146e-40d3-b35a-cf4f4b6bbc8a", + "metadata": {}, + "source": [ + "Now, let's see how we can do the same with the Feast Python SDK. Note that we instantiate our `FeatureStore` object with the configuration that we set up in [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb), by pointing to the `./Feature_Store` directory." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "765dc62b-e1e7-45fe-88b4-cc0235519ff8", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:root:_list_feature_views will make breaking changes. Please use _list_batch_feature_views instead. _list_feature_views will behave like _list_all_feature_views in the future.\n", + "WARNING:root:Cannot use sqlite_vec for vector search\n" + ] + } + ], + "source": [ + "# Instantiate FeatureStore object\n", + "store = FeatureStore(repo_path=\"./Feature_Store\")\n", + "\n", + "# Retrieve features\n", + "records = store.get_online_features(\n", + " entity_rows=[{\"ID\":v} for v in ids],\n", + " features=[\n", + " \"data_a:duration\",\n", + " \"data_a:credit_amount\",\n", + " \"data_a:installment_commitment\",\n", + " \"data_a:checking_status\",\n", + " \"data_b:residence_since\",\n", + " \"data_b:age\",\n", + " \"data_b:existing_credits\",\n", + " \"data_b:num_dependents\",\n", + " \"data_b:housing\" \n", + " ]\n", + ").to_df()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1d214e55-df0b-460d-936c-8951f7365a93", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IDcredit_amountinstallment_commitmentchecking_statusdurationnum_dependentshousingageresidence_sinceexisting_credits
01812579.04.00<=X<20024.01.0for free44.02.01.0
17642463.04.0no checking24.01.0own27.03.02.0
25041207.04.0<024.01.0rent24.04.01.0
\n", + "
" + ], + "text/plain": [ + " ID credit_amount installment_commitment checking_status duration \\\n", + "0 18 12579.0 4.0 0<=X<200 24.0 \n", + "1 764 2463.0 4.0 no checking 24.0 \n", + "2 504 1207.0 4.0 <0 24.0 \n", + "\n", + " num_dependents housing age residence_since existing_credits \n", + "0 1.0 for free 44.0 2.0 1.0 \n", + "1 1.0 own 27.0 3.0 2.0 \n", + "2 1.0 rent 24.0 4.0 1.0 " + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "records.head(3)" + ] + }, + { + "cell_type": "markdown", + "id": "fd828758-6c57-4f9e-bbda-3983b6579da2", + "metadata": {}, + "source": [ + "### Get Predictions from the Model" + ] + }, + { + "cell_type": "markdown", + "id": "f446d7ec-0dae-409a-82a2-c0d7016c2001", + "metadata": {}, + "source": [ + "Now we can request predictions from our trained model. \n", + "\n", + "For convenience, we output the predictions along with the implied loan designations. Remember that these are predictions on loan outcomes, given context data from the loan application process. Since we have access to the actual `class` outcomes, we display those as well to see how the model did.|" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "70203f7b-f1e5-46ba-8623-f10bf3a5abf8", + "metadata": {}, + "outputs": [], + "source": [ + "# Get predictions from the model\n", + "preds = model.predict(records)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "27001dde-8bdb-4de1-8c33-a76f030748e0", + "metadata": {}, + "outputs": [], + "source": [ + "# Load labels\n", + "labels = pd.read_parquet(\"Feature_Store/data/labels.parquet\")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ddc958e8-8ff8-49b1-ac10-fc965f3bf21c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IDPredictionLoan_DesignationTrue_Value
18180.0badbad
7647641.0goodgood
5045040.0badbad
4544540.0badbad
4534531.0goodgood
001.0goodgood
110.0badbad
221.0goodgood
330.0badgood
440.0badbad
551.0goodgood
661.0goodgood
770.0badgood
881.0goodgood
\n", + "
" + ], + "text/plain": [ + " ID Prediction Loan_Designation True_Value\n", + "18 18 0.0 bad bad\n", + "764 764 1.0 good good\n", + "504 504 0.0 bad bad\n", + "454 454 0.0 bad bad\n", + "453 453 1.0 good good\n", + "0 0 1.0 good good\n", + "1 1 0.0 bad bad\n", + "2 2 1.0 good good\n", + "3 3 0.0 bad good\n", + "4 4 0.0 bad bad\n", + "5 5 1.0 good good\n", + "6 6 1.0 good good\n", + "7 7 0.0 bad good\n", + "8 8 1.0 good good" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Show preds\n", + "pd.DataFrame({\n", + " \"ID\": ids,\n", + " \"Prediction\": preds,\n", + " \"Loan_Designation\": [\"bad\" if i==0.0 else \"good\" for i in preds],\n", + " \"True_Value\": labels.loc[ids, \"class\"]\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "87cd592a-61fc-4553-b84a-941d1785910d", + "metadata": {}, + "source": [ + "It's important to remember that the model's predictions are like educated guesses based on learned patterns. The model will get some predictions right, and other wrong. With the example records above, it looks like the model did pretty good! An AI/ML team's task is generally to make the model's predictions as useful as possible in helping the organization make decisions (for example, on loan approvals).\n", + "\n", + "In this case, we have a baseline model. While not ready for production, this model has set a low bar by which other models can be measured. Teams can also use a model like this to help with early testing, and with proving out things like pipelines and infrastructure before more sophisticated models are available.\n", + "\n", + "We have used Feast to query the feature data in support of model serving. The next notebook, [05_Credit_Risk_Cleanup.ipynb](05_Credit_Risk_Cleanup.ipynb), cleans up resources created in this and previous notebooks." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/credit-risk-end-to-end/05_Credit_Risk_Cleanup.ipynb b/examples/credit-risk-end-to-end/05_Credit_Risk_Cleanup.ipynb new file mode 100644 index 00000000000..846748dc425 --- /dev/null +++ b/examples/credit-risk-end-to-end/05_Credit_Risk_Cleanup.ipynb @@ -0,0 +1,296 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cf46ec61-7914-4677-b12b-a9e478e88d3f", + "metadata": {}, + "source": [ + "# Credit Risk Cleanup" + ] + }, + { + "cell_type": "markdown", + "id": "6ae8aaec-e01d-48d3-b768-98661ad1ec85", + "metadata": {}, + "source": [ + "Run this notebook if you are done experimenting with this demo, or if you wish to start again with a clean slate.\n", + "\n", + "**RUNNING THE FOLLOWING CODE WILL REMOVE FILES AND PROCESSES CREATED BY THE PREVIOUS EXAMPLE NOTEBOOKS.**\n", + "\n", + "The notebook progresses in reverse order of how the files and processes were added. (The reverse order makes it possible to partially revert changes by running cells up to a certain point.)" + ] + }, + { + "cell_type": "markdown", + "id": "6feaa771-4226-459f-b6dd-214024cb5c7c", + "metadata": {}, + "source": [ + "#### Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "20a39e94-920d-4108-aa6b-1e29d2224f71", + "metadata": {}, + "outputs": [], + "source": [ + "# Imports\n", + "import os\n", + "import time\n", + "import psutil" + ] + }, + { + "cell_type": "markdown", + "id": "3f124260-a8b2-475d-9103-8d336c543fce", + "metadata": {}, + "source": [ + "#### Remove Trained Model File" + ] + }, + { + "cell_type": "markdown", + "id": "f7a05a2b-9a26-4722-a526-84da99fc0b29", + "metadata": {}, + "source": [ + "This removes the model that was created and saved in [03_Credit_Risk_Model_Training.ipynb](03_Credit_Risk_Model_Training.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a6b21063-ea43-4329-be0c-c1644c705db2", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove the model file that was saved in model training.\n", + "model_path = \"./rf_model.pkl\"\n", + "os.remove(model_path)" + ] + }, + { + "cell_type": "markdown", + "id": "ed97c24a-8f25-4e77-9037-f9cf4ad68dfa", + "metadata": {}, + "source": [ + "#### Shutdown Servers" + ] + }, + { + "cell_type": "markdown", + "id": "2f825d10-c13d-4701-b102-e15ad1c0bd3b", + "metadata": {}, + "source": [ + "Shut down the servers that were launched in [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb); also remove the `server_proc.txt` that held the process PIDs." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "66db4d46-a895-4041-ad87-ab0a77f13211", + "metadata": {}, + "outputs": [], + "source": [ + "# Load server process objects\n", + "server_pids = open(\"server_proc.txt\").readlines()\n", + "offline_server_proc = psutil.Process(int(server_pids[0].strip()))\n", + "online_server_proc = psutil.Process(int(server_pids[1].strip()))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "001fd472-2e28-499e-9eac-0a16ad8187a0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Online server : psutil.Process(pid=44621, name='python3.11', status='running', started='14:19:05')\n", + "Online server is running: True\n", + "\n", + "Offline server PID: psutil.Process(pid=44594, name='python3.11', status='running', started='14:19:03')\n", + "Offline server is running: True\n" + ] + } + ], + "source": [ + "# Verify if servers are running\n", + "def verify_servers():\n", + " # online server\n", + " print(f\"Online server : {online_server_proc}\")\n", + " print(f\"Online server is running: {online_server_proc.is_running()}\", end='\\n\\n')\n", + " # offline server\n", + " print(f\"Offline server PID: {offline_server_proc}\")\n", + " print(f\"Offline server is running: {offline_server_proc.is_running()}\")\n", + " \n", + "verify_servers()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "68376350-790a-4e7e-9325-c7de4d22e54b", + "metadata": {}, + "outputs": [], + "source": [ + "# Terminate offline server\n", + "offline_server_proc.terminate()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "446b6bf9-aef2-4873-b477-8bf595a8eabf", + "metadata": {}, + "outputs": [], + "source": [ + "# Terminate online server (master and worker)\n", + "for child in online_server_proc.children(recursive=True):\n", + " child.terminate()\n", + "online_server_proc.terminate()\n", + "time.sleep(2)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "774827f6-4dcd-495b-b5c5-186b97148619", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Online server : psutil.Process(pid=44621, name='python3.11', status='terminated', started='14:19:05')\n", + "Online server is running: False\n", + "\n", + "Offline server PID: psutil.Process(pid=44594, name='python3.11', status='terminated', started='14:19:03')\n", + "Offline server is running: False\n" + ] + } + ], + "source": [ + "# Verify termination\n", + "verify_servers()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f8a155e4-23b3-4fb3-b868-02ba2e0a4a31", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove server_proc.txt (file for keeping track of pids)\n", + "os.remove(\"server_proc.txt\")" + ] + }, + { + "cell_type": "markdown", + "id": "ed7d6f25-d255-4986-9cf2-9876f6c558cc", + "metadata": {}, + "source": [ + "#### Remove Feast Applied Configuration Files" + ] + }, + { + "cell_type": "markdown", + "id": "d73efe15-a1d9-459b-8142-835dc2bf1c9f", + "metadata": {}, + "source": [ + "Remove the registry and online store (SQLite) files created on`feast apply` created in [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "0f13a4ac-d2ad-462b-b65e-4266b7cb4922", + "metadata": {}, + "outputs": [], + "source": [ + "os.remove(\"Feature_Store/data/online_store.db\")\n", + "os.remove(\"Feature_Store/data/registry.db\")" + ] + }, + { + "cell_type": "markdown", + "id": "eb0494cd-0143-4f5f-b7d6-9675e1403d9f", + "metadata": {}, + "source": [ + "#### Remove Feast Configuration Files" + ] + }, + { + "cell_type": "markdown", + "id": "86c33ac7-9e1f-4798-9f14-77773a1c13bd", + "metadata": {}, + "source": [ + "Remove the configution and feature definition files created in [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a747043f-05fe-4b44-979d-9b30565074ee", + "metadata": {}, + "outputs": [], + "source": [ + "os.remove(\"Feature_Store/feature_store.yaml\")\n", + "os.remove(\"Feature_Store/feature_definitions.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "81975a0f-7fd6-4ed3-91cf-812946df4713", + "metadata": {}, + "source": [ + "#### Remove Data Files" + ] + }, + { + "cell_type": "markdown", + "id": "8182dc1e-d5c1-4739-b7c7-0620e93c5b64", + "metadata": {}, + "source": [ + "Remove the data files created in [01_Credit_Risk_Data_Prep.ipynb](01_Credit_Risk_Data_Prep.ipynb)." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "4ddb4fb2-fea1-4b70-8978-732af9a1cd3f", + "metadata": {}, + "outputs": [], + "source": [ + "for f in [\"data_a.parquet\", \"data_b.parquet\", \"labels.parquet\"]:\n", + " os.remove(f\"Feature_Store/data/{f}\")\n", + "os.rmdir(\"Feature_Store/data\")\n", + "os.rmdir(\"Feature_Store\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/credit-risk-end-to-end/README.md b/examples/credit-risk-end-to-end/README.md new file mode 100644 index 00000000000..5f59c750784 --- /dev/null +++ b/examples/credit-risk-end-to-end/README.md @@ -0,0 +1,39 @@ + +![Feast_Logo](https://raw.githubusercontent.com/feast-dev/feast/master/docs/assets/feast_logo.png) + +# Feast Credit Risk Classification End-to-End Example + +This example starts with an [OpenML](https://openml.org) credit risk dataset, and walks through the steps of preparing the data, setting up feature store resources, and serving features; this is all done inside the paradigm of an ML workflow, with the goal of helping users understand how Feast fits in the progression from data preparation, to model training and model serving. + +The example is organized in five notebooks: +1. [01_Credit_Risk_Data_Prep.ipynb](01_Credit_Risk_Data_Prep.ipynb) +2. [02_Deploying_the_Feature_Store.ipynb](02_Deploying_the_Feature_Store.ipynb) +3. [03_Credit_Risk_Model_Training.ipynb](03_Credit_Risk_Model_Training.ipynb) +4. [04_Credit_Risk_Model_Serving.ipynb](04_Credit_Risk_Model_Serving.ipynb) +5. [05_Credit_Risk_Cleanup.ipynb](05_Credit_Risk_Cleanup.ipynb) + +Run the notebooks in order to progress through the example. See below for prerequisite setup steps. + +### Preparing your Environment +To run the example, install the Python dependencies. You may wish to do so inside a virtual environment. Open a command terminal, and run the following: + +``` +# create venv-example virtual environment +python -m venv venv-example +# activate environment +source venv-example/bin/activate +``` + +Install the Python dependencies: +``` +pip install -r requirements.txt +``` + +Note that this example was tested with Python 3.11, but it should also work with other similar versions. + +### Running the Notebooks +Once you have installed the Python dependencies, you can run the example notebooks. To run the notebooks locally, execute the following command in a terminal window: + +```jupyter notebook``` + +You should see a browser window open a page where you can navigate to the example notebook (.ipynb) files and open them. diff --git a/examples/credit-risk-end-to-end/requirements.txt b/examples/credit-risk-end-to-end/requirements.txt new file mode 100644 index 00000000000..8b9b1313e78 --- /dev/null +++ b/examples/credit-risk-end-to-end/requirements.txt @@ -0,0 +1,6 @@ +feast +jupyter==1.1.1 +scikit-learn==1.5.2 +pandas==2.2.3 +matplotlib==3.9.2 +seaborn==0.13.2 \ No newline at end of file diff --git a/examples/operator-postgres-tls-demo/.gitignore b/examples/operator-postgres-tls-demo/.gitignore new file mode 100644 index 00000000000..6eb45f3fbca --- /dev/null +++ b/examples/operator-postgres-tls-demo/.gitignore @@ -0,0 +1,4 @@ +postgres-tls-certs +values.yaml +.ipynb_checkpoints +*.tar.gz \ No newline at end of file diff --git a/examples/operator-postgres-tls-demo/01-Install-postgres-tls-using-helm.ipynb b/examples/operator-postgres-tls-demo/01-Install-postgres-tls-using-helm.ipynb new file mode 100644 index 00000000000..d385f3d8de1 --- /dev/null +++ b/examples/operator-postgres-tls-demo/01-Install-postgres-tls-using-helm.ipynb @@ -0,0 +1,557 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f16967ef", + "metadata": {}, + "source": [ + "# Deploy PostgreSQL with Helm in TLS Mode" + ] + }, + { + "cell_type": "markdown", + "id": "1247e2e7-706c-44a3-a45c-fba638e50f31", + "metadata": {}, + "source": [ + "### NOTE: This PostgreSQL setup guide is intended to demonstrate the capabilities of the Feast operator in configuring Feast with PostgreSQL in TLS mode. For ongoing assistance with Postgres setup, we recommend consulting the official Helm PostgreSQL documentation." + ] + }, + { + "cell_type": "markdown", + "id": "cce2278a", + "metadata": {}, + "source": [ + "## Step 1: Install Prerequisites" + ] + }, + { + "cell_type": "markdown", + "id": "3e4102d8", + "metadata": {}, + "source": [ + "Before starting, ensure you have the following installed:\n", + "- `kubectl` (Kubernetes CLI)\n", + "- `helm` (Helm CLI)\n", + "- A Kubernetes cluster (e.g., Minikube, GKE, EKS, or AKS)" + ] + }, + { + "cell_type": "markdown", + "id": "44b611ba-097e-4777-b77b-739116e7e4d6", + "metadata": {}, + "source": [ + "**Note:** When deploying PostgreSQL and Feast on a Kubernetes cluster, it's important to ensure that your cluster has sufficient resources to support both applications." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e2b40efc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Client Version: v1.31.2\n", + "Kustomize Version: v5.4.2\n", + "version.BuildInfo{Version:\"v3.17.0\", GitCommit:\"301108edc7ac2a8ba79e4ebf5701b0b6ce6a31e4\", GitTreeState:\"clean\", GoVersion:\"go1.23.4\"}\n" + ] + } + ], + "source": [ + "# Verify kubectl and helm are installed\n", + "!kubectl version --client\n", + "!helm version" + ] + }, + { + "cell_type": "markdown", + "id": "4b72fabe", + "metadata": {}, + "source": [ + "## Step 2: Add the Bitnami Helm Repository" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "f439691e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\"bitnami\" already exists with the same configuration, skipping\n", + "Hang tight while we grab the latest from your chart repositories...\n", + "...Successfully got an update from the \"bitnami\" chart repository\n", + "Update Complete. ⎈Happy Helming!⎈\n" + ] + } + ], + "source": [ + "# Add the Bitnami Helm repository\n", + "!helm repo add bitnami https://charts.bitnami.com/bitnami\n", + "!helm repo update" + ] + }, + { + "cell_type": "markdown", + "id": "6f51e5c8-41ba-417e-a2fc-78cf5951d9dc", + "metadata": {}, + "source": [ + "## Step 3: create kubernetes feast namespace" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "d114872a-7a43-4eca-8748-6dc7346dc176", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace/feast created\n", + "Context \"kind-kind\" modified.\n" + ] + } + ], + "source": [ + "!kubectl create ns feast\n", + "!kubectl config set-context --current --namespace feast" + ] + }, + { + "cell_type": "markdown", + "id": "41f4e8db", + "metadata": {}, + "source": [ + "## Step 4: Generate Self Signed TLS Certificates" + ] + }, + { + "cell_type": "markdown", + "id": "c34957e4-dd7f-49c1-986c-eefe74dd7e22", + "metadata": {}, + "source": [ + "**Note**: \n", + "- Self signed certificates are used only for demo purpose, consider using a managed certificate service (e.g., Let's Encrypt) instead of self-signed certificates.\n", + "- \"Replace the `CN` values in the certificate generation step with your actual domain names.\"," + ] + }, + { + "cell_type": "markdown", + "id": "500f9010-6329-4868-83d5-9c063d5890f5", + "metadata": {}, + "source": [ + "Delete the directory of existing certificates if you running this demo not first time." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bdc71e19-0fcc-4a1f-ba94-8b5e427e45d9", + "metadata": {}, + "outputs": [], + "source": [ + "# Delete certificates directory if you are running this example not first time.\n", + "!rm -rf postgres-tls-certs" + ] + }, + { + "cell_type": "markdown", + "id": "91dc26c9-cfaa-46f5-8252-7ad463264236", + "metadata": {}, + "source": [ + "Generate the certificates by executing below scripts. " + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "8e192410", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "..+.......+.........+...+.....+......+.......+...+.....+......+.+..+......+.+.....+...+.......+...+..+.+.....+.......+........+.......+......+...........+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*.+...+...+........+....+..+...+...+....+...+......+..+..........+..+...+...+...............+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*...+...........+......+..........+..+.+.....+....+......+.....................+...+...+..+...+.......+..+.........+.......+.....+....+........+.+..+.............+......+....................+.........+.+......+.....+.......+........+......................+......+..+...+....+...+...+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n", + "..+...+......+.+.........+...+......+..+.......+.....+.+..+...+.+...+......+.....+.........+......+.+...........+....+..................+...+.........+...+.....+.+.....+...............+.+......+...+............+...+......+......+........+.+.....+.............+..+.+..+.+..............+...+...+....+............+...+.....+......+.+.....+.+...+..+...+...................+...........+....+..+.................................+..........+...........+......+.+...+..+...+.......+.....+.......+...........+.......+...+......+.....+..........+...+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n", + "-----\n", + ".+....+......+..+....+...+.....+......+.+........+..........+.....+............+.+...+..+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*...+.+..............+...............+.+...........+.......+...+..+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*....+...............+............+.....+.+......+........+...+...+.+...+.....+......+.+..............+.+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n", + "............+....+.....+.+...+........+..........+..............+.+..............+.........+.+...+...........+......+......+.......+........+...+.........+.+.....+.+.....+.+........+.+.....................+..+.............+........+......+.+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n", + "-----\n", + "Certificate request self-signature ok\n", + "subject=CN = postgresql.feast.svc.cluster.local\n", + "..+....+...+.....+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*....+.+..+.......+......+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*.+..+.+.....+.+...+..................+.....+...+...................+......+..+...+.+......+..+..........+..+..................+.+..+...+......+.+............+..+....+...........+..........+.....+...+......+.+...+...+..+......+.+...+...+.........+......+.....+..................+.+.....+....+..............+.+..............+.+......+....................+..........+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n", + "++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*.....+.........+...+..+.......+.....+.+..+.+......+....................+......+.............+......+...+..+...+.+..+...+....+.....+...+...+.........+......+.+.....+.+..+..........+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++*..+...+.+...........+....+.....+...................+..+.+..+......+............+..........+.........+...+..+...............+..........+.....+....+............+........+.+........+.+.....+.......+.....++\n", + "-----\n", + "Certificate request self-signature ok\n", + "subject=CN = admin\n" + ] + } + ], + "source": [ + "# Create a directory for certificates\n", + "!mkdir -p postgres-tls-certs\n", + "\n", + "# Generate a CA certificate\n", + "!openssl req -new -x509 -days 365 -nodes -out postgres-tls-certs/ca.crt -keyout postgres-tls-certs/ca.key -subj \"/CN=PostgreSQL CA\"\n", + "\n", + "# Generate a server certificate\n", + "!openssl req -new -nodes -out postgres-tls-certs/server.csr -keyout postgres-tls-certs/server.key -subj \"/CN=postgresql.feast.svc.cluster.local\"\n", + "!openssl x509 -req -in postgres-tls-certs/server.csr -days 365 -CA postgres-tls-certs/ca.crt -CAkey postgres-tls-certs/ca.key -CAcreateserial -out postgres-tls-certs/server.crt\n", + "\n", + "# Generate a client certificate\n", + "!openssl req -new -nodes -out postgres-tls-certs/client.csr -keyout postgres-tls-certs/client.key -subj \"/CN=admin\"\n", + "!openssl x509 -req -in postgres-tls-certs/client.csr -days 365 -CA postgres-tls-certs/ca.crt -CAkey postgres-tls-certs/ca.key -CAcreateserial -out postgres-tls-certs/client.crt" + ] + }, + { + "cell_type": "markdown", + "id": "7e39cb28", + "metadata": {}, + "source": [ + "## Step 5: Create Kubernetes Secrets for Certificates" + ] + }, + { + "cell_type": "markdown", + "id": "a4775780-3734-40ba-ae43-48f1e47b481a", + "metadata": {}, + "source": [ + "In this step, we will create **two Kubernetes secrets** that reference the certificates generated earlier step:\n", + "\n", + "- **`postgresql-server-certs`** \n", + " This secret contains the server certificates and will be used by the PostgreSQL server.\n", + "\n", + "- **`postgresql-client-certs`** \n", + " This secret contains the client certificates and will be used by the PostgreSQL client. In our case it will be feast application." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d728d0d5-2ba6-4d4d-b4be-62fb020530d4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret/postgresql-server-certs created\n", + "secret/postgresql-client-certs created\n" + ] + } + ], + "source": [ + "# Create a secret for the server certificates\n", + "!kubectl create secret generic postgresql-server-certs --from-file=ca.crt=./postgres-tls-certs/ca.crt --from-file=tls.crt=./postgres-tls-certs/server.crt --from-file=tls.key=./postgres-tls-certs/server.key\n", + "\n", + "# Create a secret for the client certificates\n", + "!kubectl create secret generic postgresql-client-certs --from-file=ca.crt=./postgres-tls-certs/ca.crt --from-file=tls.crt=./postgres-tls-certs/client.crt --from-file=tls.key=./postgres-tls-certs/client.key" + ] + }, + { + "cell_type": "markdown", + "id": "67d62692", + "metadata": {}, + "source": [ + "## Step 6: Deploy PostgreSQL with Helm" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e14cae77", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME: postgresql\n", + "LAST DEPLOYED: Tue Feb 25 08:12:21 2025\n", + "NAMESPACE: feast\n", + "STATUS: deployed\n", + "REVISION: 1\n", + "TEST SUITE: None\n", + "NOTES:\n", + "CHART NAME: postgresql\n", + "CHART VERSION: 16.4.9\n", + "APP VERSION: 17.3.0\n", + "\n", + "Did you know there are enterprise versions of the Bitnami catalog? For enhanced secure software supply chain features, unlimited pulls from Docker, LTS support, or application customization, see Bitnami Premium or Tanzu Application Catalog. See https://www.arrow.com/globalecs/na/vendors/bitnami for more information.\n", + "\n", + "** Please be patient while the chart is being deployed **\n", + "\n", + "PostgreSQL can be accessed via port 5432 on the following DNS names from within your cluster:\n", + "\n", + " postgresql.feast.svc.cluster.local - Read/Write connection\n", + "\n", + "To get the password for \"postgres\" run:\n", + "\n", + " export POSTGRES_ADMIN_PASSWORD=$(kubectl get secret --namespace feast postgresql -o jsonpath=\"{.data.postgres-password}\" | base64 -d)\n", + "\n", + "To get the password for \"admin\" run:\n", + "\n", + " export POSTGRES_PASSWORD=$(kubectl get secret --namespace feast postgresql -o jsonpath=\"{.data.password}\" | base64 -d)\n", + "\n", + "To connect to your database run the following command:\n", + "\n", + " kubectl run postgresql-client --rm --tty -i --restart='Never' --namespace feast --image docker.io/bitnami/postgresql:17.3.0-debian-12-r1 --env=\"PGPASSWORD=$POSTGRES_PASSWORD\" \\\n", + " --command -- psql --host postgresql -U admin -d feast -p 5432\n", + "\n", + " > NOTE: If you access the container using bash, make sure that you execute \"/opt/bitnami/scripts/postgresql/entrypoint.sh /bin/bash\" in order to avoid the error \"psql: local user with ID 1001} does not exist\"\n", + "\n", + "To connect to your database from outside the cluster execute the following commands:\n", + "\n", + " kubectl port-forward --namespace feast svc/postgresql 5432:5432 &\n", + " PGPASSWORD=\"$POSTGRES_PASSWORD\" psql --host 127.0.0.1 -U admin -d feast -p 5432\n", + "\n", + "WARNING: The configured password will be ignored on new installation in case when previous PostgreSQL release was deleted through the helm command. In that case, old PVC will have an old password, and setting it through helm won't take effect. Deleting persistent volumes (PVs) will solve the issue.\n", + "\n", + "WARNING: There are \"resources\" sections in the chart not set. Using \"resourcesPreset\" is not recommended for production. For production installations, please set the following values according to your workload needs:\n", + " - primary.resources\n", + " - readReplicas.resources\n", + " - volumePermissions.resources\n", + "+info https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/\n" + ] + } + ], + "source": [ + "# Helm values for TLS configuration\n", + "helm_values = \"\"\"\n", + "tls:\n", + " enabled: true\n", + " certificatesSecret: \"postgresql-server-certs\"\n", + " certFilename: \"tls.crt\"\n", + " certKeyFilename: \"tls.key\"\n", + " certCAFilename: \"ca.crt\"\n", + "\n", + "volumePermissions:\n", + " enabled: true\n", + "\n", + "# Set fixed PostgreSQL credentials\n", + "\n", + "global:\n", + " postgresql:\n", + " auth:\n", + " username: admin\n", + " password: password\n", + " database: feast\n", + "\"\"\"\n", + "\n", + "# Write the values to a file\n", + "with open(\"values.yaml\", \"w\") as f:\n", + " f.write(helm_values)\n", + "\n", + "# Install PostgreSQL with Helm\n", + "!helm install postgresql bitnami/postgresql --version 16.4.9 -f values.yaml -n feast " + ] + }, + { + "cell_type": "markdown", + "id": "5be34ace", + "metadata": {}, + "source": [ + "## Step 7: Verify the postgres Deployment" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "132df785-762e-473a-90d2-5fdb66a59a97", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pod/postgresql-0 condition met\n", + "\n", + "NAME READY STATUS RESTARTS AGE\n", + "postgresql-0 1/1 Running 0 14s\n", + "\n", + "Defaulted container \"postgresql\" out of: postgresql, init-chmod-data (init)\n", + "ssl = 'on'\n", + "ssl_ca_file = '/opt/bitnami/postgresql/certs/ca.crt'\n", + "ssl_cert_file = '/opt/bitnami/postgresql/certs/tls.crt'\n", + "#ssl_crl_file = ''\n", + "#ssl_crl_dir = ''\n", + "ssl_key_file = '/opt/bitnami/postgresql/certs/tls.key'\n", + "#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL'\t# allowed SSL ciphers\n", + "#ssl_prefer_server_ciphers = on\n", + "#ssl_ecdh_curve = 'prime256v1'\n", + "#ssl_min_protocol_version = 'TLSv1.2'\n", + "#ssl_max_protocol_version = ''\n", + "#ssl_dh_params_file = ''\n", + "#ssl_passphrase_command = ''\n", + "#ssl_passphrase_command_supports_reload = off\n", + "\n", + "Defaulted container \"postgresql\" out of: postgresql, init-chmod-data (init)\n", + " List of databases\n", + " Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges \n", + "-----------+----------+----------+-----------------+-------------+-------------+--------+-----------+-----------------------\n", + " feast | admin | UTF8 | libc | en_US.UTF-8 | en_US.UTF-8 | | | =Tc/admin +\n", + " | | | | | | | | admin=CTc/admin\n", + " postgres | postgres | UTF8 | libc | en_US.UTF-8 | en_US.UTF-8 | | | \n", + " template0 | postgres | UTF8 | libc | en_US.UTF-8 | en_US.UTF-8 | | | =c/postgres +\n", + " | | | | | | | | postgres=CTc/postgres\n", + " template1 | postgres | UTF8 | libc | en_US.UTF-8 | en_US.UTF-8 | | | =c/postgres +\n", + " | | | | | | | | postgres=CTc/postgres\n", + "(4 rows)\n", + "\n" + ] + } + ], + "source": [ + "# Wait for the status of the PostgreSQL pod to be in Ready status.\n", + "!kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=postgresql --timeout=60s\n", + "\n", + "# Insert an empty line in the output for verbocity.\n", + "print()\n", + "\n", + "# display the pod status.\n", + "!kubectl get pods -l app.kubernetes.io/name=postgresql\n", + "\n", + "# Insert an empty line in the output for verbocity.\n", + "print()\n", + "\n", + "# check if the ssl is on and the path to certificates is configured.\n", + "!kubectl exec postgresql-0 -- cat /opt/bitnami/postgresql/conf/postgresql.conf | grep ssl\n", + "\n", + "# Insert an empty line in the output for verbocity.\n", + "print()\n", + "\n", + "# Connect to PostgreSQL using TLS (non-interactive mode)\n", + "!kubectl exec postgresql-0 -- env PGPASSWORD=password psql -U admin -d feast -c '\\l'\n" + ] + }, + { + "cell_type": "markdown", + "id": "c921423a-81df-456e-9cca-f689070c44d2", + "metadata": {}, + "source": [ + "## Step 8: Port forwarding in the terminal for the connection testing using python" + ] + }, + { + "cell_type": "markdown", + "id": "d6a26bb4-e0e7-419e-9c91-f0d63db127bc", + "metadata": {}, + "source": [ + "**Note:** If you do not intend to test the PostgreSQL connection from outside the Kubernetes cluster, you can skip the remaining steps." + ] + }, + { + "cell_type": "markdown", + "id": "6fcad5e1-66d2-4353-aba7-3549ef21bc9f", + "metadata": {}, + "source": [ + "**Note:**\n", + "To test a connection to a PostgreSQL database outside of your Kubernetes cluster, you'll need to execute the following command in your system's terminal window. This is necessary because Jupyter Notebook does not support running commands in a separate thread." + ] + }, + { + "cell_type": "markdown", + "id": "88a4a7c1-51c4-4c5a-9472-5cace1c47a1c", + "metadata": {}, + "source": [ + "kubectl port-forward svc/postgresql 5432:5432" + ] + }, + { + "cell_type": "markdown", + "id": "a8777ca3-bf59-4f23-b7d0-60ae8c92d5a5", + "metadata": {}, + "source": [ + "## Step 9: Check the connection using Python sql alchemy" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5a523f9f-784f-493b-b69d-5a3cb1a830af", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "postgresql+psycopg://admin:password@localhost:5432/feast?sslmode=verify-ca&sslrootcert=postgres-tls-certs/ca.crt&sslcert=postgres-tls-certs/client.crt&sslkey=postgres-tls-certs/client.key\n", + "Connected successfully!\n" + ] + } + ], + "source": [ + "# Define database connection parameters\n", + "DB_USER = \"admin\"\n", + "DB_PASSWORD = \"password\"\n", + "DB_HOST = \"localhost\"\n", + "DB_PORT = \"5432\"\n", + "DB_NAME = \"feast\"\n", + "\n", + "# TLS Certificate Paths\n", + "SSL_CERT = \"postgres-tls-certs/client.crt\"\n", + "SSL_KEY = \"postgres-tls-certs/client.key\"\n", + "SSL_ROOT_CERT = \"postgres-tls-certs/ca.crt\"\n", + "\n", + "import os\n", + "os.environ[\"FEAST_CA_CERT_FILE_PATH\"] = \"postgres-tls-certs/ca.crt\"\n", + "\n", + "from sqlalchemy import create_engine\n", + "# Create SQLAlchemy connection string\n", + "DATABASE_URL = (\n", + " f\"postgresql+psycopg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?\"\n", + " f\"sslmode=verify-ca&sslrootcert={SSL_ROOT_CERT}&sslcert={SSL_CERT}&sslkey={SSL_KEY}\"\n", + ")\n", + "\n", + "print(DATABASE_URL)\n", + "\n", + "# Create SQLAlchemy engine\n", + "engine = create_engine(DATABASE_URL)\n", + "\n", + "# Test connection\n", + "try:\n", + " with engine.connect() as connection:\n", + " print(\"Connected successfully!\")\n", + "except Exception as e:\n", + " print(\"Connection failed: Make sure that port forwarding step is done in the terminal.\", e)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7503e47e-12f1-44dd-8a50-786d744bbf4c", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/operator-postgres-tls-demo/02-Install-feast.ipynb b/examples/operator-postgres-tls-demo/02-Install-feast.ipynb new file mode 100644 index 00000000000..16948b3610c --- /dev/null +++ b/examples/operator-postgres-tls-demo/02-Install-feast.ipynb @@ -0,0 +1,458 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Install Feast on Kubernetes with the Feast Operator\n", + "## Objective\n", + "\n", + "Provide a reference implementation of a runbook to deploy a Feast environment on a Kubernetes cluster using [Kind](https://kind.sigs.k8s.io/docs/user/quick-start) and the [Feast Operator](../../infra/feast-operator/)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "* Kubernetes Cluster\n", + "* [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) Kubernetes CLI tool." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install the Feast Operator" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace/feast-operator-system created\n", + "customresourcedefinition.apiextensions.k8s.io/featurestores.feast.dev created\n", + "serviceaccount/feast-operator-controller-manager created\n", + "role.rbac.authorization.k8s.io/feast-operator-leader-election-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-featurestore-editor-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-featurestore-viewer-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-manager-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-metrics-auth-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-metrics-reader created\n", + "rolebinding.rbac.authorization.k8s.io/feast-operator-leader-election-rolebinding created\n", + "clusterrolebinding.rbac.authorization.k8s.io/feast-operator-manager-rolebinding created\n", + "clusterrolebinding.rbac.authorization.k8s.io/feast-operator-metrics-auth-rolebinding created\n", + "service/feast-operator-controller-manager-metrics-service created\n", + "deployment.apps/feast-operator-controller-manager created\n", + "deployment.apps/feast-operator-controller-manager condition met\n" + ] + } + ], + "source": [ + "## Use this install command from a release branch (e.g. 'v0.46-branch')\n", + "!kubectl apply -f ../../infra/feast-operator/dist/install.yaml\n", + "\n", + "## OR, for the latest code/builds, use one the following commands from the 'master' branch\n", + "# !make -C ../../infra/feast-operator install deploy IMG=quay.io/feastdev-ci/feast-operator:develop FS_IMG=quay.io/feastdev-ci/feature-server:develop\n", + "# !make -C ../../infra/feast-operator install deploy IMG=quay.io/feastdev-ci/feast-operator:$(git rev-parse HEAD) FS_IMG=quay.io/feastdev-ci/feature-server:$(git rev-parse HEAD)\n", + "\n", + "!kubectl wait --for=condition=available --timeout=5m deployment/feast-operator-controller-manager -n feast-operator-system" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install the Feast services via FeatureStore CR\n", + "Next, we'll use the running Feast Operator to install the feast services. Before doing that it is important to understand basic understanding of operator support of Volumes and volumeMounts and how to mount TLS certificates." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Mounting TLS Certificates with Volumes in Feast Operator \n", + "\n", + "The Feast operator supports **volumes** and **volumeMounts**, allowing you to mount TLS certificates onto a pod. This approach provides flexibility in how you mount these files, supporting different Kubernetes resources such as **Secrets, ConfigMaps,** and **Persistent Volumes (PVs).** \n", + "\n", + "#### Example: Mounting Certificates Using Kubernetes Secrets \n", + "\n", + "In this example, we demonstrate how to mount TLS certificates using **Kubernetes Secrets** that were created in a previous notebook. \n", + "\n", + "#### PostgreSQL Connection Parameters \n", + "\n", + "When connecting to PostgreSQL with TLS, some important parameters in the connection URL are: \n", + "\n", + "- **`sslrootcert`** – Specifies the path to the **CA certificate** file used to validate trusted certificates. \n", + "- **`sslcert`** – Provides the client certificate for **mutual TLS (mTLS) encryption**. \n", + "- **`sslkey`** – Specifies the private key for the client certificate. \n", + "\n", + "If mutual TLS authentication is not required, you can **omit** the `sslcert` and `sslkey` parameters. However, the `sslrootcert` parameter is still necessary for validating server certificates. \n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " Note: Please deploy either option 1 or 2 only. Don't deploy both of them at the same time to avoid conflicts in the lateral steps. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Option 1: Directly Setting the CA Certificate Path** \n", + "\n", + "In this approach, we specify the CA certificate path directly in the Feast PostgreSQL URL using the `sslrootcert` parameter. \n", + "\n", + "You can refer to the `v1alpha1_featurestore_postgres_db_volumes_tls.yaml` file for the complete configuration details. " + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret/postgres-secret created\n", + "secret/feast-data-stores created\n", + "featurestore.feast.dev/sample-db-ssl created\n" + ] + } + ], + "source": [ + "!kubectl apply -f ../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_db_volumes_tls.yaml --namespace=feast" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Option 2: Using an Environment Variable for the CA Certificate** \n", + "\n", + "In this approach, you define the CA certificate path as an environment variable. You can refer to the `v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml` file for the complete configuration details. \n", + "\n", + "```bash\n", + "FEAST_CA_CERT_FILE_PATH=\n" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret/postgres-secret created\n", + "secret/feast-data-stores created\n", + "featurestore.feast.dev/sample-db-ssl created\n" + ] + } + ], + "source": [ + "!kubectl apply -f ../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml --namespace=feast" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Validate the running FeatureStore deployment\n", + "Validate the deployment status." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "deployment.apps/feast-sample-db-ssl condition met\n", + "NAME READY STATUS RESTARTS AGE\n", + "pod/feast-sample-db-ssl-86b47d54-hclb9 1/1 Running 0 27s\n", + "pod/postgresql-0 1/1 Running 0 13h\n", + "\n", + "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE\n", + "service/feast-sample-db-ssl-online ClusterIP 10.96.61.65 80/TCP 27s\n", + "service/postgresql ClusterIP 10.96.228.3 5432/TCP 13h\n", + "service/postgresql-hl ClusterIP None 5432/TCP 13h\n", + "\n", + "NAME READY UP-TO-DATE AVAILABLE AGE\n", + "deployment.apps/feast-sample-db-ssl 1/1 1 1 27s\n", + "\n", + "NAME DESIRED CURRENT READY AGE\n", + "replicaset.apps/feast-sample-db-ssl-86b47d54 1 1 1 27s\n", + "\n", + "NAME READY AGE\n", + "statefulset.apps/postgresql 1/1 13h\n" + ] + } + ], + "source": [ + "!kubectl wait --for=condition=available --timeout=8m deployment/feast-sample-db-ssl -n feast\n", + "!kubectl get all" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Validate that the FeatureStore CR is in a `Ready` state." + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME STATUS AGE\n", + "sample-db-ssl Ready 33s\n" + ] + } + ], + "source": [ + "!kubectl get feast" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the DB includes the expected tables." + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defaulted container \"postgresql\" out of: postgresql, init-chmod-data (init)\n", + " List of relations\n", + " Schema | Name | Type | Owner \n", + "--------+------------------------------------------------------+-------+-------\n", + " public | data_sources | table | admin\n", + " public | entities | table | admin\n", + " public | feast_metadata | table | admin\n", + " public | feature_services | table | admin\n", + " public | feature_views | table | admin\n", + " public | managed_infra | table | admin\n", + " public | on_demand_feature_views | table | admin\n", + " public | permissions | table | admin\n", + " public | postgres_tls_sample_env_ca_driver_hourly_stats | table | admin\n", + " public | postgres_tls_sample_env_ca_driver_hourly_stats_fresh | table | admin\n", + " public | projects | table | admin\n", + " public | saved_datasets | table | admin\n", + " public | stream_feature_views | table | admin\n", + " public | validation_references | table | admin\n", + "(14 rows)\n", + "\n" + ] + } + ], + "source": [ + "!kubectl exec postgresql-0 -- env PGPASSWORD=password psql -U admin -d feast -c '\\dt'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify the client `feature_store.yaml` and create the sample feature store definitions." + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "project: postgres_tls_sample_env_ca\n", + "provider: local\n", + "offline_store:\n", + " host: ${POSTGRES_HOST}\n", + " type: postgres\n", + " port: 5432\n", + " database: ${POSTGRES_DB}\n", + " db_schema: public\n", + " password: ${POSTGRES_PASSWORD}\n", + " sslcert_path: /var/lib/postgresql/certs/tls.crt\n", + " sslkey_path: /var/lib/postgresql/certs/tls.key\n", + " sslmode: verify-full\n", + " sslrootcert_path: system\n", + " user: ${POSTGRES_USER}\n", + "online_store:\n", + " type: postgres\n", + " database: ${POSTGRES_DB}\n", + " db_schema: public\n", + " host: ${POSTGRES_HOST}\n", + " password: ${POSTGRES_PASSWORD}\n", + " port: 5432\n", + " sslcert_path: /var/lib/postgresql/certs/tls.crt\n", + " sslkey_path: /var/lib/postgresql/certs/tls.key\n", + " sslmode: verify-full\n", + " sslrootcert_path: system\n", + " user: ${POSTGRES_USER}\n", + "registry:\n", + " path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=verify-full&sslrootcert=system&sslcert=/var/lib/postgresql/certs/tls.crt&sslkey=/var/lib/postgresql/certs/tls.key\n", + " registry_type: sql\n", + " cache_ttl_seconds: 60\n", + " sqlalchemy_config_kwargs:\n", + " echo: false\n", + " pool_pre_ping: true\n", + "auth:\n", + " type: no_auth\n", + "entity_key_serialization_version: 3\n", + ": MADV_DONTNEED does not work (memset will be used instead)\n", + ": (This is the expected behaviour if you are running under QEMU)\n", + "/opt/app-root/src/sdk/python/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/feast-data/postgres_tls_sample_env_ca/feature_repo/example_repo.py:27: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'driver'.\n", + " driver = Entity(name=\"driver\", join_keys=[\"driver_id\"])\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'driver'.\n", + " entity = cls(\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "Applying changes for project postgres_tls_sample_env_ca\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'driver'.\n", + " entity = cls(\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/src/sdk/python/feast/feature_store.py:579: RuntimeWarning: On demand feature view is an experimental feature. This API is stable, but the functionality does not scale well for offline retrieval\n", + " warnings.warn(\n", + "Deploying infrastructure for driver_hourly_stats\n", + "Deploying infrastructure for driver_hourly_stats_fresh\n", + " Feast apply is completed. You can go to next step.\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-sample-db-ssl -c online -- cat feature_store.yaml\n", + "!kubectl exec deploy/feast-sample-db-ssl -c online -- feast apply\n", + "print(\" Feast apply is completed. You can go to next step.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List the registered feast projects & feature views." + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ": MADV_DONTNEED does not work (memset will be used instead)\n", + ": (This is the expected behaviour if you are running under QEMU)\n", + "/opt/app-root/src/sdk/python/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'driver'.\n", + " entity = cls(\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "NAME DESCRIPTION TAGS OWNER\n", + "postgres_tls_sample {}\n", + "postgres_tls_sample_env_ca A project for driver statistics {}\n", + ": MADV_DONTNEED does not work (memset will be used instead)\n", + ": (This is the expected behaviour if you are running under QEMU)\n", + "/opt/app-root/src/sdk/python/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'driver'.\n", + " entity = cls(\n", + "/opt/app-root/src/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "NAME ENTITIES TYPE\n", + "driver_hourly_stats_fresh {'driver'} FeatureView\n", + "driver_hourly_stats {'driver'} FeatureView\n", + "transformed_conv_rate {'driver'} OnDemandFeatureView\n", + "transformed_conv_rate_fresh {'driver'} OnDemandFeatureView\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-sample-db-ssl -c online -- feast projects list\n", + "!kubectl exec deploy/feast-sample-db-ssl -c online -- feast feature-views list" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, let's verify the feast version." + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ": MADV_DONTNEED does not work (memset will be used instead)\n", + ": (This is the expected behaviour if you are running under QEMU)\n", + "/opt/app-root/src/sdk/python/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "Feast SDK Version: \"0.1.dev1+g6c92447.d20250213\"\n" + ] + } + ], + "source": [ + "!kubectl exec deployment/feast-sample-db-ssl -c online -- feast version" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/operator-postgres-tls-demo/03-Uninstall.ipynb b/examples/operator-postgres-tls-demo/03-Uninstall.ipynb new file mode 100644 index 00000000000..007b8d7bc1a --- /dev/null +++ b/examples/operator-postgres-tls-demo/03-Uninstall.ipynb @@ -0,0 +1,134 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uninstall the Operator and all Feast related objects" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret \"postgres-secret\" deleted\n", + "secret \"feast-data-stores\" deleted\n", + "featurestore.feast.dev \"sample-db-ssl\" deleted\n", + "Error from server (NotFound): error when deleting \"../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml\": secrets \"postgres-secret\" not found\n", + "Error from server (NotFound): error when deleting \"../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml\": secrets \"feast-data-stores\" not found\n", + "Error from server (NotFound): error when deleting \"../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml\": featurestores.feast.dev \"sample-db-ssl\" not found\n" + ] + } + ], + "source": [ + "# If you have choosen the option 1 example earlier.\n", + "!kubectl delete -f ../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_db_volumes_tls.yaml\n", + "\n", + "# If you have choosen the option 2 example earlier.\n", + "!kubectl delete -f ../../infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml\n", + "\n", + "#!kubectl delete -f ../../infra/feast-operator/dist/install.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uninstall the Postgresql using helm" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "release \"postgresql\" uninstalled\n", + "secret \"postgresql-server-certs\" deleted\n", + "secret \"postgresql-client-certs\" deleted\n", + "persistentvolumeclaim \"data-postgresql-0\" deleted\n", + "persistentvolume \"pvc-d0c961d9-7579-4e30-842a-b46812b71f74\" deleted\n" + ] + } + ], + "source": [ + "# Uninstall the Helm release\n", + "!helm uninstall postgresql\n", + "\n", + "# Delete the secrets\n", + "!kubectl delete secret postgresql-server-certs\n", + "!kubectl delete secret postgresql-client-certs\n", + "\n", + "# Remove the certificates directory\n", + "!rm -rf postgres-tls-certs\n", + "\n", + "# Remove PV and PVC for clean up. some times those are not deleted automatically and can cause issues.\n", + "# Delete all PVCs in the default namespace\n", + "!kubectl delete pvc --all\n", + "\n", + "# Delete all PVs\n", + "!kubectl delete pv --all" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ensure everything has been removed, or is in the process of being terminated." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "No resources found in feast namespace.\n" + ] + } + ], + "source": [ + "!kubectl get all" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/operator-postgres-tls-demo/README.md b/examples/operator-postgres-tls-demo/README.md new file mode 100644 index 00000000000..70ae00da6ab --- /dev/null +++ b/examples/operator-postgres-tls-demo/README.md @@ -0,0 +1,50 @@ +# Installing Feast on Kubernetes with PostgreSQL TLS Demo using feast operator + +This example folder contains a series of Jupyter Notebooks that guide you through setting up [Feast](https://feast.dev/) on a Kubernetes cluster. + +In this demo, Feast connects to a PostgreSQL database running in TLS mode, ensuring secure communication between services. Additionally, the example demonstrates how feast application references TLS certificates using Kubernetes volumes and volume mounts. While the focus is on mounting TLS certificates, you can also mount any other resources supported by Kubernetes volumes. + +## Prerequisites + +- A running Kubernetes cluster with sufficient resources. +- [Helm](https://helm.sh/) installed and configured. +- The [Feast Operator](https://docs.feast.dev/) for managing Feast deployments. +- Jupyter Notebook or JupyterLab to run the provided notebooks. +- Basic familiarity with Kubernetes, Helm, and TLS concepts. + +## Notebook Overview + +The following Jupyter Notebooks will walk you through the entire process: + +1. **[01-Install-postgres-tls-using-helm.ipynb](./01-Install-postgres-tls-using-helm.ipynb)** + Installs PostgreSQL in TLS mode using a Helm chart. + +2. **[02-Install-feast.ipynb](02-Install-feast.ipynb)** + Deploys Feast using the Feast Operator. + +3. **[03-Uninstall.ipynb](./03-Uninstall.ipynb)** + Uninstalls Feast, the Feast Operator, and the PostgreSQL deployments set up in this demo. + +## How to Run the Demo + +1. **Clone the Repository** + + ```shell + https://github.com/feast-dev/feast.git + cd examples/operator-postgres-tls-demo + ``` +2. Start Jupyter Notebook or JupyterLab from the repository root: + +```shell +jupyter notebook +``` +3. Execute the Notebooks +Run the notebooks in the order listed above. Each notebook contains step-by-step instructions and code to deploy, test, and eventually clean up the demo components. + + +## Troubleshooting +* **Cluster Resources:** +Verify that your Kubernetes cluster has adequate resources before starting the demo. + +* **Logs & Diagnostics:** +If you encounter issues, check the logs for the PostgreSQL and Feast pods. This can help identify problems related to TLS configurations or resource constraints. \ No newline at end of file diff --git a/examples/operator-quickstart/.gitignore b/examples/operator-quickstart/.gitignore new file mode 100644 index 00000000000..335ec9573de --- /dev/null +++ b/examples/operator-quickstart/.gitignore @@ -0,0 +1 @@ +*.tar.gz diff --git a/examples/operator-quickstart/01-Install.ipynb b/examples/operator-quickstart/01-Install.ipynb new file mode 100644 index 00000000000..7b974a721b3 --- /dev/null +++ b/examples/operator-quickstart/01-Install.ipynb @@ -0,0 +1,401 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Install Feast on Kubernetes with the Feast Operator\n", + "## Objective\n", + "\n", + "Provide a reference implementation of a runbook to deploy a Feast environment on a Kubernetes cluster using [Kind](https://kind.sigs.k8s.io/docs/user/quick-start) and the [Feast Operator](../../infra/feast-operator/)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "* Kubernetes Cluster\n", + "* [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) Kubernetes CLI tool." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install Prerequisites\n", + "\n", + "The following commands install and configure all the prerequisites on a MacOS environment. You can find the\n", + "equivalent instructions on the offical documentation pages:\n", + "* Install the `kubectl` cli.\n", + "* Install Kubernetes and Container runtime (e.g. [Colima](https://github.com/abiosoft/colima)).\n", + " * Alternatively, authenticate to an existing Kubernetes or OpenShift cluster.\n", + " \n", + "```bash\n", + "brew install colima kubectl\n", + "colima start -r containerd -k -m 3 -d 100 -c 2 --cpu-type max -a x86_64\n", + "colima list\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace/feast created\n", + "Context \"colima\" modified.\n" + ] + } + ], + "source": [ + "!kubectl create ns feast\n", + "!kubectl config set-context --current --namespace feast" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Validate the cluster setup:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME STATUS AGE\n", + "feast Active 6s\n" + ] + } + ], + "source": [ + "!kubectl get ns feast" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Deployment Architecture\n", + "The primary objective of this runbook is to guide the deployment of Feast services on a Kubernetes Kind cluster, using the `postgres` template to set up a basic feature store.\n", + "\n", + "In this notebook, we will deploy a distributed topology of Feast services, which includes:\n", + "\n", + "* `Registry Server`: Handles metadata storage for feature definitions.\n", + "* `Online Store Server`: Uses the `Registry Server` to query metadata and is responsible for low-latency serving of features.\n", + "* `Offline Store Server`: Uses the `Registry Server` to query metadata and provides access to batch data for historical feature retrieval.\n", + "\n", + "Each service is backed by a `PostgreSQL` database, which is also deployed within the same Kind cluster." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup Postgresql and Redis\n", + "Apply the included [postgres](postgres.yaml) & [redis](redis.yaml) deployments to run simple databases." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret/postgres-secret created\n", + "deployment.apps/postgres created\n", + "service/postgres created\n", + "deployment.apps/redis created\n", + "service/redis created\n", + "deployment.apps/redis condition met\n", + "deployment.apps/postgres condition met\n" + ] + } + ], + "source": [ + "!kubectl apply -f postgres.yaml -f redis.yaml\n", + "!kubectl wait --for=condition=available --timeout=5m deployment/redis\n", + "!kubectl wait --for=condition=available --timeout=5m deployment/postgres" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME READY STATUS RESTARTS AGE\n", + "pod/postgres-ff8d4cf48-c4znd 1/1 Running 0 2m17s\n", + "pod/redis-b4756b75d-r9nfb 1/1 Running 0 2m15s\n", + "\n", + "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE\n", + "service/postgres ClusterIP 10.43.151.129 5432/TCP 2m17s\n", + "service/redis ClusterIP 10.43.169.233 6379/TCP 2m15s\n", + "\n", + "NAME READY UP-TO-DATE AVAILABLE AGE\n", + "deployment.apps/postgres 1/1 1 1 2m18s\n", + "deployment.apps/redis 1/1 1 1 2m16s\n", + "\n", + "NAME DESIRED CURRENT READY AGE\n", + "replicaset.apps/postgres-ff8d4cf48 1 1 1 2m18s\n", + "replicaset.apps/redis-b4756b75d 1 1 1 2m16s\n" + ] + } + ], + "source": [ + "!kubectl get all" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install the Feast Operator" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace/feast-operator-system created\n", + "customresourcedefinition.apiextensions.k8s.io/featurestores.feast.dev created\n", + "serviceaccount/feast-operator-controller-manager created\n", + "role.rbac.authorization.k8s.io/feast-operator-leader-election-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-featurestore-editor-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-featurestore-viewer-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-manager-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-metrics-auth-role created\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-metrics-reader created\n", + "rolebinding.rbac.authorization.k8s.io/feast-operator-leader-election-rolebinding created\n", + "clusterrolebinding.rbac.authorization.k8s.io/feast-operator-manager-rolebinding created\n", + "clusterrolebinding.rbac.authorization.k8s.io/feast-operator-metrics-auth-rolebinding created\n", + "service/feast-operator-controller-manager-metrics-service created\n", + "deployment.apps/feast-operator-controller-manager created\n", + "deployment.apps/feast-operator-controller-manager condition met\n" + ] + } + ], + "source": [ + "## Use this install command from a release branch (e.g. 'v0.43-branch')\n", + "!kubectl apply -f ../../infra/feast-operator/dist/install.yaml\n", + "\n", + "## OR, for the latest code/builds, use one the following commands from the 'master' branch\n", + "# !make -C ../../infra/feast-operator install deploy IMG=quay.io/feastdev-ci/feast-operator:develop FS_IMG=quay.io/feastdev-ci/feature-server:develop\n", + "# !make -C ../../infra/feast-operator install deploy IMG=quay.io/feastdev-ci/feast-operator:$(git rev-parse HEAD) FS_IMG=quay.io/feastdev-ci/feature-server:$(git rev-parse HEAD)\n", + "\n", + "!kubectl wait --for=condition=available --timeout=5m deployment/feast-operator-controller-manager -n feast-operator-system" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install the Feast services via FeatureStore CR\n", + "Next, we'll use the running Feast Operator to install the feast services. Apply the included [reference deployment](feast.yaml) to install and configure Feast." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret/feast-data-stores created\n", + "featurestore.feast.dev/example created\n" + ] + } + ], + "source": [ + "!kubectl apply -f feast.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Validate the running FeatureStore deployment\n", + "Validate the deployment status." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME READY STATUS RESTARTS AGE\n", + "pod/feast-example-bbdc6cb6-rzkb4 0/1 Init:0/1 0 3s\n", + "pod/postgres-ff8d4cf48-c4znd 1/1 Running 0 4m49s\n", + "pod/redis-b4756b75d-r9nfb 1/1 Running 0 4m47s\n", + "\n", + "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE\n", + "service/feast-example-online ClusterIP 10.43.143.216 80/TCP 4s\n", + "service/postgres ClusterIP 10.43.151.129 5432/TCP 4m49s\n", + "service/redis ClusterIP 10.43.169.233 6379/TCP 4m47s\n", + "\n", + "NAME READY UP-TO-DATE AVAILABLE AGE\n", + "deployment.apps/feast-example 0/1 1 0 5s\n", + "deployment.apps/postgres 1/1 1 1 4m51s\n", + "deployment.apps/redis 1/1 1 1 4m49s\n", + "\n", + "NAME DESIRED CURRENT READY AGE\n", + "replicaset.apps/feast-example-bbdc6cb6 1 1 0 4s\n", + "replicaset.apps/postgres-ff8d4cf48 1 1 1 4m51s\n", + "replicaset.apps/redis-b4756b75d 1 1 1 4m49s\n", + "deployment.apps/feast-example condition met\n" + ] + } + ], + "source": [ + "!kubectl get all\n", + "!kubectl wait --for=condition=available --timeout=8m deployment/feast-example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Validate that the FeatureStore CR is in a `Ready` state." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME STATUS AGE\n", + "example Ready 48m\n" + ] + } + ], + "source": [ + "!kubectl get feast" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the DB includes the expected tables." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " List of relations\n", + " Schema | Name | Type | Owner \n", + "--------+-------------------------+-------+-------\n", + " public | data_sources | table | feast\n", + " public | entities | table | feast\n", + " public | feast_metadata | table | feast\n", + " public | feature_services | table | feast\n", + " public | feature_views | table | feast\n", + " public | managed_infra | table | feast\n", + " public | on_demand_feature_views | table | feast\n", + " public | permissions | table | feast\n", + " public | projects | table | feast\n", + " public | saved_datasets | table | feast\n", + " public | stream_feature_views | table | feast\n", + " public | validation_references | table | feast\n", + "(12 rows)\n", + "\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/postgres -- psql -h localhost -U feast feast -c '\\dt'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, let's verify the feast version." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "Feast SDK Version: \"0.46.0\"\n" + ] + } + ], + "source": [ + "!kubectl exec deployment/feast-example -itc online -- feast version" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/operator-quickstart/02-Demo.ipynb b/examples/operator-quickstart/02-Demo.ipynb new file mode 100644 index 00000000000..536e36f490f --- /dev/null +++ b/examples/operator-quickstart/02-Demo.ipynb @@ -0,0 +1,669 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Run the \"Real-time Credit Scoring\" tutorial" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We'll use the following tutorial as a demonstration.\n", + "\n", + "https://github.com/feast-dev/feast-credit-score-local-tutorial/tree/598a270353d8a83b37535f849a0fa000a07be8b5" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Check the init container to ensure the repo was successfully cloned with git." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Creating feast repository...\n", + "git clone https://github.com/feast-dev/feast-credit-score-local-tutorial /feast-data/credit_scoring_local && cd /feast-data/credit_scoring_local && git checkout 598a270\n", + "Cloning into '/feast-data/credit_scoring_local'...\n", + "Updating files: 100% (25/25), done.\n", + "Note: switching to '598a270'.\n", + "\n", + "You are in 'detached HEAD' state. You can look around, make experimental\n", + "changes and commit them, and you can discard any commits you make in this\n", + "state without impacting any branches by switching back to a branch.\n", + "\n", + "If you want to create a new branch to retain commits you create, you may\n", + "do so (now or later) by using -c with the switch command. Example:\n", + "\n", + " git switch -c \n", + "\n", + "Or undo this operation with:\n", + "\n", + " git switch -\n", + "\n", + "Turn off this advice by setting config variable advice.detachedHead to false\n", + "\n", + "HEAD is now at 598a270 set streamlit version to 1.42.0 (#8)\n", + "Feast repo creation complete\n" + ] + } + ], + "source": [ + "!kubectl logs -f deploy/feast-example -c feast-init" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Verify the client `feature_store.yaml`." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "project: credit_scoring_local\n", + "provider: local\n", + "offline_store:\n", + " type: duckdb\n", + "online_store:\n", + " type: redis\n", + " connection_string: redis.feast.svc.cluster.local:6379\n", + "registry:\n", + " path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres.feast.svc.cluster.local:5432/${POSTGRES_DB}\n", + " registry_type: sql\n", + " cache_ttl_seconds: 60\n", + " sqlalchemy_config_kwargs:\n", + " echo: false\n", + " pool_pre_ping: true\n", + "auth:\n", + " type: no_auth\n", + "entity_key_serialization_version: 3\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- cat feature_store.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Apply the tutorial feature store definitions" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Update the feature store definitions for the tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "No project found in the repository. Using project name credit_scoring_local defined in feature_store.yaml\n", + "Applying changes for project credit_scoring_local\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_store.py:579: RuntimeWarning: On demand feature view is an experimental feature. This API is stable, but the functionality does not scale well for offline retrieval\n", + " warnings.warn(\n", + "Deploying infrastructure for \u001b[1m\u001b[32mzipcode_features\u001b[0m\n", + "Deploying infrastructure for \u001b[1m\u001b[32mcredit_history\u001b[0m\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- feast apply" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Load data from feature views into the online store, beginning from either the previous materialize or materialize-incremental end date, or the beginning of time." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "Materializing \u001b[1m\u001b[32m2\u001b[0m feature views to \u001b[1m\u001b[32m2025-02-20 21:23:35+00:00\u001b[0m into the \u001b[1m\u001b[32mredis\u001b[0m online store.\n", + "\n", + "\u001b[1m\u001b[32mzipcode_features\u001b[0m from \u001b[1m\u001b[32m2015-02-23 21:24:12+00:00\u001b[0m to \u001b[1m\u001b[32m2025-02-20 21:23:35+00:00\u001b[0m:\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "100%|███████████████████████████████████████████████████████| 28844/28844 [00:28<00:00, 1023.99it/s]\n", + "\u001b[1m\u001b[32mcredit_history\u001b[0m from \u001b[1m\u001b[32m2024-11-22 21:24:43+00:00\u001b[0m to \u001b[1m\u001b[32m2025-02-20 21:23:35+00:00\u001b[0m:\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "0it [00:00, ?it/s]\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- bash -c 'feast materialize-incremental $(date -u +\"%Y-%m-%dT%H:%M:%S\")'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Execute feast commands inside the client Pod" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "List the registered feast projects, feature views, & entities." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "NAME DESCRIPTION TAGS OWNER\n", + "credit_scoring_local {}\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "NAME ENTITIES TYPE\n", + "zipcode_features {'zipcode'} FeatureView\n", + "credit_history {'dob_ssn'} FeatureView\n", + "total_debt_calc {'dob_ssn'} OnDemandFeatureView\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "NAME DESCRIPTION TYPE\n", + "dob_ssn Date of birth and last four digits of social security number ValueType.STRING\n", + "zipcode ValueType.INT64\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- feast projects list\n", + "!kubectl exec deploy/feast-example -itc online -- feast feature-views list\n", + "!kubectl exec deploy/feast-example -itc online -- feast entities list" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Train and test the model" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install the required packages." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Collecting streamlit==1.42.0 (from -r ../requirements.txt (line 1))\n", + " Obtaining dependency information for streamlit==1.42.0 from https://files.pythonhosted.org/packages/ad/dc/69068179e09488d0833a970d06e8bf40e35669a7bddb8a3caadc13b7dff4/streamlit-1.42.0-py2.py3-none-any.whl.metadata\n", + " Downloading streamlit-1.42.0-py2.py3-none-any.whl.metadata (8.9 kB)\n", + "Collecting shap (from -r ../requirements.txt (line 2))\n", + " Obtaining dependency information for shap from https://files.pythonhosted.org/packages/06/6a/09e3cb9864118337c0f3c2a0dc5add6b642e9f672665062e186d67ba992d/shap-0.46.0-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading shap-0.46.0-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (24 kB)\n", + "Requirement already satisfied: pandas in /opt/app-root/lib64/python3.11/site-packages (from -r ../requirements.txt (line 3)) (2.2.3)\n", + "Collecting scikit-learn (from -r ../requirements.txt (line 4))\n", + " Obtaining dependency information for scikit-learn from https://files.pythonhosted.org/packages/a8/f3/62fc9a5a659bb58a03cdd7e258956a5824bdc9b4bb3c5d932f55880be569/scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (18 kB)\n", + "Collecting matplotlib (from -r ../requirements.txt (line 5))\n", + " Obtaining dependency information for matplotlib from https://files.pythonhosted.org/packages/b2/7d/2d873209536b9ee17340754118a2a17988bc18981b5b56e6715ee07373ac/matplotlib-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading matplotlib-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (11 kB)\n", + "Collecting altair<6,>=4.0 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for altair<6,>=4.0 from https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl.metadata\n", + " Downloading altair-5.5.0-py3-none-any.whl.metadata (11 kB)\n", + "Collecting blinker<2,>=1.0.0 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for blinker<2,>=1.0.0 from https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl.metadata\n", + " Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB)\n", + "Requirement already satisfied: cachetools<6,>=4.0 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (5.5.1)\n", + "Requirement already satisfied: click<9,>=7.0 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (8.1.8)\n", + "Requirement already satisfied: numpy<3,>=1.23 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (1.26.4)\n", + "Requirement already satisfied: packaging<25,>=20 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (24.2)\n", + "Collecting pillow<12,>=7.1.0 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for pillow<12,>=7.1.0 from https://files.pythonhosted.org/packages/48/a4/fbfe9d5581d7b111b28f1d8c2762dee92e9821bb209af9fa83c940e507a0/pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl.metadata\n", + " Downloading pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl.metadata (9.1 kB)\n", + "Requirement already satisfied: protobuf<6,>=3.20 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (5.29.3)\n", + "Requirement already satisfied: pyarrow>=7.0 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (17.0.0)\n", + "Requirement already satisfied: requests<3,>=2.27 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (2.32.3)\n", + "Requirement already satisfied: rich<14,>=10.14.0 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (13.9.4)\n", + "Requirement already satisfied: tenacity<10,>=8.1.0 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (8.5.0)\n", + "Requirement already satisfied: toml<2,>=0.10.1 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (0.10.2)\n", + "Requirement already satisfied: typing-extensions<5,>=4.4.0 in /opt/app-root/lib64/python3.11/site-packages (from streamlit==1.42.0->-r ../requirements.txt (line 1)) (4.12.2)\n", + "Collecting watchdog<7,>=2.1.5 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for watchdog<7,>=2.1.5 from https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl.metadata\n", + " Downloading watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl.metadata (44 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m44.3/44.3 kB\u001b[0m \u001b[31m13.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting gitpython!=3.1.19,<4,>=3.0.7 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for gitpython!=3.1.19,<4,>=3.0.7 from https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl.metadata\n", + " Downloading GitPython-3.1.44-py3-none-any.whl.metadata (13 kB)\n", + "Collecting pydeck<1,>=0.8.0b4 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for pydeck<1,>=0.8.0b4 from https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl.metadata\n", + " Downloading pydeck-0.9.1-py2.py3-none-any.whl.metadata (4.1 kB)\n", + "Collecting tornado<7,>=6.0.3 (from streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for tornado<7,>=6.0.3 from https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (2.5 kB)\n", + "Collecting scipy (from shap->-r ../requirements.txt (line 2))\n", + " Obtaining dependency information for scipy from https://files.pythonhosted.org/packages/32/ea/564bacc26b676c06a00266a3f25fdfe91a9d9a2532ccea7ce6dd394541bc/scipy-1.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading scipy-1.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (61 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m62.0/62.0 kB\u001b[0m \u001b[31m8.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: tqdm>=4.27.0 in /opt/app-root/lib64/python3.11/site-packages (from shap->-r ../requirements.txt (line 2)) (4.67.1)\n", + "Collecting slicer==0.0.8 (from shap->-r ../requirements.txt (line 2))\n", + " Obtaining dependency information for slicer==0.0.8 from https://files.pythonhosted.org/packages/63/81/9ef641ff4e12cbcca30e54e72fb0951a2ba195d0cda0ba4100e532d929db/slicer-0.0.8-py3-none-any.whl.metadata\n", + " Downloading slicer-0.0.8-py3-none-any.whl.metadata (4.0 kB)\n", + "Collecting numba (from shap->-r ../requirements.txt (line 2))\n", + " Obtaining dependency information for numba from https://files.pythonhosted.org/packages/14/91/18b9f64b34ff318a14d072251480547f89ebfb864b2b7168e5dc5f64f502/numba-0.61.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata\n", + " Downloading numba-0.61.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (2.8 kB)\n", + "Requirement already satisfied: cloudpickle in /opt/app-root/lib64/python3.11/site-packages (from shap->-r ../requirements.txt (line 2)) (3.1.1)\n", + "Requirement already satisfied: python-dateutil>=2.8.2 in /opt/app-root/lib64/python3.11/site-packages (from pandas->-r ../requirements.txt (line 3)) (2.9.0.post0)\n", + "Requirement already satisfied: pytz>=2020.1 in /opt/app-root/lib64/python3.11/site-packages (from pandas->-r ../requirements.txt (line 3)) (2025.1)\n", + "Requirement already satisfied: tzdata>=2022.7 in /opt/app-root/lib64/python3.11/site-packages (from pandas->-r ../requirements.txt (line 3)) (2025.1)\n", + "Collecting joblib>=1.2.0 (from scikit-learn->-r ../requirements.txt (line 4))\n", + " Obtaining dependency information for joblib>=1.2.0 from https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl.metadata\n", + " Downloading joblib-1.4.2-py3-none-any.whl.metadata (5.4 kB)\n", + "Collecting threadpoolctl>=3.1.0 (from scikit-learn->-r ../requirements.txt (line 4))\n", + " Obtaining dependency information for threadpoolctl>=3.1.0 from https://files.pythonhosted.org/packages/4b/2c/ffbf7a134b9ab11a67b0cf0726453cedd9c5043a4fe7a35d1cefa9a1bcfb/threadpoolctl-3.5.0-py3-none-any.whl.metadata\n", + " Downloading threadpoolctl-3.5.0-py3-none-any.whl.metadata (13 kB)\n", + "Collecting contourpy>=1.0.1 (from matplotlib->-r ../requirements.txt (line 5))\n", + " Obtaining dependency information for contourpy>=1.0.1 from https://files.pythonhosted.org/packages/85/fc/7fa5d17daf77306840a4e84668a48ddff09e6bc09ba4e37e85ffc8e4faa3/contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (5.4 kB)\n", + "Collecting cycler>=0.10 (from matplotlib->-r ../requirements.txt (line 5))\n", + " Obtaining dependency information for cycler>=0.10 from https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl.metadata\n", + " Downloading cycler-0.12.1-py3-none-any.whl.metadata (3.8 kB)\n", + "Collecting fonttools>=4.22.0 (from matplotlib->-r ../requirements.txt (line 5))\n", + " Obtaining dependency information for fonttools>=4.22.0 from https://files.pythonhosted.org/packages/28/e9/47c02d5a7027e8ed841ab6a10ca00c93dadd5f16742f1af1fa3f9978adf4/fonttools-4.56.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading fonttools-4.56.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (101 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m101.9/101.9 kB\u001b[0m \u001b[31m9.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hCollecting kiwisolver>=1.3.1 (from matplotlib->-r ../requirements.txt (line 5))\n", + " Obtaining dependency information for kiwisolver>=1.3.1 from https://files.pythonhosted.org/packages/3a/97/5edbed69a9d0caa2e4aa616ae7df8127e10f6586940aa683a496c2c280b9/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (6.2 kB)\n", + "Collecting pyparsing>=2.3.1 (from matplotlib->-r ../requirements.txt (line 5))\n", + " Obtaining dependency information for pyparsing>=2.3.1 from https://files.pythonhosted.org/packages/1c/a7/c8a2d361bf89c0d9577c934ebb7421b25dc84bf3a8e3ac0a40aed9acc547/pyparsing-3.2.1-py3-none-any.whl.metadata\n", + " Downloading pyparsing-3.2.1-py3-none-any.whl.metadata (5.0 kB)\n", + "Requirement already satisfied: jinja2 in /opt/app-root/lib64/python3.11/site-packages (from altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (3.1.5)\n", + "Requirement already satisfied: jsonschema>=3.0 in /opt/app-root/lib64/python3.11/site-packages (from altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (4.23.0)\n", + "Collecting narwhals>=1.14.2 (from altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for narwhals>=1.14.2 from https://files.pythonhosted.org/packages/ed/ea/dc14822a0a75e027562f081eb638417b1b7845e1e01dd85c5b6573ebf1b2/narwhals-1.27.1-py3-none-any.whl.metadata\n", + " Downloading narwhals-1.27.1-py3-none-any.whl.metadata (10 kB)\n", + "Collecting gitdb<5,>=4.0.1 (from gitpython!=3.1.19,<4,>=3.0.7->streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for gitdb<5,>=4.0.1 from https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl.metadata\n", + " Downloading gitdb-4.0.12-py3-none-any.whl.metadata (1.2 kB)\n", + "Requirement already satisfied: six>=1.5 in /opt/app-root/lib64/python3.11/site-packages (from python-dateutil>=2.8.2->pandas->-r ../requirements.txt (line 3)) (1.17.0)\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /opt/app-root/lib64/python3.11/site-packages (from requests<3,>=2.27->streamlit==1.42.0->-r ../requirements.txt (line 1)) (3.4.1)\n", + "Requirement already satisfied: idna<4,>=2.5 in /opt/app-root/lib64/python3.11/site-packages (from requests<3,>=2.27->streamlit==1.42.0->-r ../requirements.txt (line 1)) (3.10)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/app-root/lib64/python3.11/site-packages (from requests<3,>=2.27->streamlit==1.42.0->-r ../requirements.txt (line 1)) (2.3.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /opt/app-root/lib64/python3.11/site-packages (from requests<3,>=2.27->streamlit==1.42.0->-r ../requirements.txt (line 1)) (2025.1.31)\n", + "Requirement already satisfied: markdown-it-py>=2.2.0 in /opt/app-root/lib64/python3.11/site-packages (from rich<14,>=10.14.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (3.0.0)\n", + "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /opt/app-root/lib64/python3.11/site-packages (from rich<14,>=10.14.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (2.19.1)\n", + "Collecting llvmlite<0.45,>=0.44.0dev0 (from numba->shap->-r ../requirements.txt (line 2))\n", + " Obtaining dependency information for llvmlite<0.45,>=0.44.0dev0 from https://files.pythonhosted.org/packages/99/fe/d030f1849ebb1f394bb3f7adad5e729b634fb100515594aca25c354ffc62/llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata\n", + " Downloading llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.8 kB)\n", + "Collecting smmap<6,>=3.0.1 (from gitdb<5,>=4.0.1->gitpython!=3.1.19,<4,>=3.0.7->streamlit==1.42.0->-r ../requirements.txt (line 1))\n", + " Obtaining dependency information for smmap<6,>=3.0.1 from https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl.metadata\n", + " Downloading smmap-5.0.2-py3-none-any.whl.metadata (4.3 kB)\n", + "Requirement already satisfied: MarkupSafe>=2.0 in /opt/app-root/lib64/python3.11/site-packages (from jinja2->altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (3.0.2)\n", + "Requirement already satisfied: attrs>=22.2.0 in /opt/app-root/lib64/python3.11/site-packages (from jsonschema>=3.0->altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (25.1.0)\n", + "Requirement already satisfied: jsonschema-specifications>=2023.03.6 in /opt/app-root/lib64/python3.11/site-packages (from jsonschema>=3.0->altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (2024.10.1)\n", + "Requirement already satisfied: referencing>=0.28.4 in /opt/app-root/lib64/python3.11/site-packages (from jsonschema>=3.0->altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (0.36.2)\n", + "Requirement already satisfied: rpds-py>=0.7.1 in /opt/app-root/lib64/python3.11/site-packages (from jsonschema>=3.0->altair<6,>=4.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (0.22.3)\n", + "Requirement already satisfied: mdurl~=0.1 in /opt/app-root/lib64/python3.11/site-packages (from markdown-it-py>=2.2.0->rich<14,>=10.14.0->streamlit==1.42.0->-r ../requirements.txt (line 1)) (0.1.2)\n", + "Downloading streamlit-1.42.0-py2.py3-none-any.whl (9.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m9.6/9.6 MB\u001b[0m \u001b[31m5.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0mm\n", + "\u001b[?25hDownloading shap-0.46.0-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (540 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m540.2/540.2 kB\u001b[0m \u001b[31m7.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hDownloading slicer-0.0.8-py3-none-any.whl (15 kB)\n", + "Downloading scikit_learn-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (13.5 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m13.5/13.5 MB\u001b[0m \u001b[31m6.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hDownloading matplotlib-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (8.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m8.6/8.6 MB\u001b[0m \u001b[31m3.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hDownloading altair-5.5.0-py3-none-any.whl (731 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m731.2/731.2 kB\u001b[0m \u001b[31m3.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hDownloading blinker-1.9.0-py3-none-any.whl (8.5 kB)\n", + "Downloading contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (326 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m326.2/326.2 kB\u001b[0m \u001b[31m2.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hDownloading cycler-0.12.1-py3-none-any.whl (8.3 kB)\n", + "Downloading fonttools-4.56.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (4.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m4.9/4.9 MB\u001b[0m \u001b[31m3.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hDownloading GitPython-3.1.44-py3-none-any.whl (207 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m207.6/207.6 kB\u001b[0m \u001b[31m15.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hDownloading joblib-1.4.2-py3-none-any.whl (301 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m301.8/301.8 kB\u001b[0m \u001b[31m15.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hDownloading kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.4 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.4/1.4 MB\u001b[0m \u001b[31m6.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hDownloading pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl (4.5 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m4.5/4.5 MB\u001b[0m \u001b[31m4.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0mm\n", + "\u001b[?25hDownloading pydeck-0.9.1-py2.py3-none-any.whl (6.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m6.9/6.9 MB\u001b[0m \u001b[31m4.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0mm\n", + "\u001b[?25hDownloading pyparsing-3.2.1-py3-none-any.whl (107 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m107.7/107.7 kB\u001b[0m \u001b[31m4.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hDownloading scipy-1.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (37.6 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m37.6/37.6 MB\u001b[0m \u001b[31m4.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hDownloading threadpoolctl-3.5.0-py3-none-any.whl (18 kB)\n", + "Downloading tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (437 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m437.2/437.2 kB\u001b[0m \u001b[31m14.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hDownloading watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl (79 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m79.1/79.1 kB\u001b[0m \u001b[31m14.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hDownloading numba-0.61.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (3.8 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.8/3.8 MB\u001b[0m \u001b[31m2.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hDownloading gitdb-4.0.12-py3-none-any.whl (62 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m62.8/62.8 kB\u001b[0m \u001b[31m6.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hDownloading llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (42.4 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m42.4/42.4 MB\u001b[0m \u001b[31m2.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hDownloading narwhals-1.27.1-py3-none-any.whl (308 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m308.8/308.8 kB\u001b[0m \u001b[31m3.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0ma \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hDownloading smmap-5.0.2-py3-none-any.whl (24 kB)\n", + "Installing collected packages: watchdog, tornado, threadpoolctl, smmap, slicer, scipy, pyparsing, pillow, narwhals, llvmlite, kiwisolver, joblib, fonttools, cycler, contourpy, blinker, scikit-learn, pydeck, numba, matplotlib, gitdb, shap, gitpython, altair, streamlit\n", + "Successfully installed altair-5.5.0 blinker-1.9.0 contourpy-1.3.1 cycler-0.12.1 fonttools-4.56.0 gitdb-4.0.12 gitpython-3.1.44 joblib-1.4.2 kiwisolver-1.4.8 llvmlite-0.44.0 matplotlib-3.10.0 narwhals-1.27.1 numba-0.61.0 pillow-11.1.0 pydeck-0.9.1 pyparsing-3.2.1 scikit-learn-1.6.1 scipy-1.15.2 shap-0.46.0 slicer-0.0.8 smmap-5.0.2 streamlit-1.42.0 threadpoolctl-3.5.0 tornado-6.4.2 watchdog-6.0.0\n", + "\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.2.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m25.0.1\u001b[0m\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- bash -c 'pip install -r ../requirements.txt'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Train and test the model." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "Loan rejected!\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- bash -c 'cd ../ && python run.py'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interactive demo (using Streamlit)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In a new terminal, run the following command and leave it active.\n", + "\n", + "```bash\n", + "$ kubectl port-forward deploy/feast-example 8501:8501\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Start the Streamlit application" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Collecting usage statistics. To deactivate, set browser.gatherUsageStats to false.\n", + "\u001b[0m\n", + "\u001b[0m\n", + "\u001b[34m\u001b[1m You can now view your Streamlit app in your browser.\u001b[0m\n", + "\u001b[0m\n", + "\u001b[34m Local URL: \u001b[0m\u001b[1mhttp://localhost:8501\u001b[0m\n", + "\u001b[34m Network URL: \u001b[0m\u001b[1mhttp://10.42.0.8:8501\u001b[0m\n", + "\u001b[34m External URL: \u001b[0m\u001b[1mhttp://23.112.66.217:8501\u001b[0m\n", + "\u001b[0m\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "(1000, 22)\n", + "2025-02-20 21:57:48.314 \n", + "Calling `st.pyplot()` without providing a figure argument has been deprecated\n", + "and will be removed in a later version as it requires the use of Matplotlib's\n", + "global figure object, which is not thread-safe.\n", + "\n", + "To future-proof this code, you should pass in a figure as shown below:\n", + "\n", + "```python\n", + "fig, ax = plt.subplots()\n", + "ax.scatter([1, 2, 3], [1, 2, 3])\n", + "# other plotting actions...\n", + "st.pyplot(fig)\n", + "```\n", + "\n", + "If you have a specific use case that requires this functionality, please let us\n", + "know via [issue on Github](https://github.com/streamlit/streamlit/issues).\n", + "\n", + "2025-02-20 21:57:57.474 \n", + "Calling `st.pyplot()` without providing a figure argument has been deprecated\n", + "and will be removed in a later version as it requires the use of Matplotlib's\n", + "global figure object, which is not thread-safe.\n", + "\n", + "To future-proof this code, you should pass in a figure as shown below:\n", + "\n", + "```python\n", + "fig, ax = plt.subplots()\n", + "ax.scatter([1, 2, 3], [1, 2, 3])\n", + "# other plotting actions...\n", + "st.pyplot(fig)\n", + "```\n", + "\n", + "If you have a specific use case that requires this functionality, please let us\n", + "know via [issue on Github](https://github.com/streamlit/streamlit/issues).\n", + "\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'zipcode'.\n", + " entity = cls(\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'dob_ssn'.\n", + " entity = cls(\n", + "(1000, 22)\n", + "2025-02-20 21:58:34.935 \n", + "Calling `st.pyplot()` without providing a figure argument has been deprecated\n", + "and will be removed in a later version as it requires the use of Matplotlib's\n", + "global figure object, which is not thread-safe.\n", + "\n", + "To future-proof this code, you should pass in a figure as shown below:\n", + "\n", + "```python\n", + "fig, ax = plt.subplots()\n", + "ax.scatter([1, 2, 3], [1, 2, 3])\n", + "# other plotting actions...\n", + "st.pyplot(fig)\n", + "```\n", + "\n", + "If you have a specific use case that requires this functionality, please let us\n", + "know via [issue on Github](https://github.com/streamlit/streamlit/issues).\n", + "\n", + "2025-02-20 21:58:43.709 \n", + "Calling `st.pyplot()` without providing a figure argument has been deprecated\n", + "and will be removed in a later version as it requires the use of Matplotlib's\n", + "global figure object, which is not thread-safe.\n", + "\n", + "To future-proof this code, you should pass in a figure as shown below:\n", + "\n", + "```python\n", + "fig, ax = plt.subplots()\n", + "ax.scatter([1, 2, 3], [1, 2, 3])\n", + "# other plotting actions...\n", + "st.pyplot(fig)\n", + "```\n", + "\n", + "If you have a specific use case that requires this functionality, please let us\n", + "know via [issue on Github](https://github.com/streamlit/streamlit/issues).\n", + "\n" + ] + } + ], + "source": [ + "!kubectl exec deploy/feast-example -itc online -- bash -c 'cd ../ && streamlit run --server.port 8501 streamlit_app.py'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Then navigate to the local URL on which Streamlit is being served.\n", + "\n", + "http://localhost:8501" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/operator-quickstart/03-Uninstall.ipynb b/examples/operator-quickstart/03-Uninstall.ipynb new file mode 100644 index 00000000000..3abd489dd58 --- /dev/null +++ b/examples/operator-quickstart/03-Uninstall.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uninstall the Operator and all Feast related objects" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "secret \"feast-data-stores\" deleted\n", + "featurestore.feast.dev \"example\" deleted\n", + "secret \"postgres-secret\" deleted\n", + "deployment.apps \"postgres\" deleted\n", + "service \"postgres\" deleted\n", + "deployment.apps \"redis\" deleted\n", + "service \"redis\" deleted\n", + "namespace \"feast-operator-system\" deleted\n", + "customresourcedefinition.apiextensions.k8s.io \"featurestores.feast.dev\" deleted\n", + "serviceaccount \"feast-operator-controller-manager\" deleted\n", + "role.rbac.authorization.k8s.io \"feast-operator-leader-election-role\" deleted\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-featurestore-editor-role\" deleted\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-featurestore-viewer-role\" deleted\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-manager-role\" deleted\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-metrics-auth-role\" deleted\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-metrics-reader\" deleted\n", + "rolebinding.rbac.authorization.k8s.io \"feast-operator-leader-election-rolebinding\" deleted\n", + "clusterrolebinding.rbac.authorization.k8s.io \"feast-operator-manager-rolebinding\" deleted\n", + "clusterrolebinding.rbac.authorization.k8s.io \"feast-operator-metrics-auth-rolebinding\" deleted\n", + "service \"feast-operator-controller-manager-metrics-service\" deleted\n", + "deployment.apps \"feast-operator-controller-manager\" deleted\n" + ] + } + ], + "source": [ + "!kubectl delete -f feast.yaml\n", + "!kubectl delete -f postgres.yaml\n", + "!kubectl delete -f redis.yaml\n", + "!kubectl delete -f ../../infra/feast-operator/dist/install.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ensure everything has been removed, or is in the process of being terminated." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "No resources found in feast namespace.\n" + ] + } + ], + "source": [ + "!kubectl get all" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/operator-quickstart/README.md b/examples/operator-quickstart/README.md new file mode 100644 index 00000000000..56ba7173b56 --- /dev/null +++ b/examples/operator-quickstart/README.md @@ -0,0 +1,7 @@ +# Install and run a Feature Store on Kubernetes with the Feast Operator + +The following notebooks will guide you through how to install and use Feast on Kubernetes with the Feast Go Operator. + +* [01-Install.ipynb](./01-Install.ipynb): Install and configure a Feature Store in Kubernetes with the Operator. +* [02-Demo.ipynb](./02-Demo.ipynb): Validate the feature store with demo application. +* [03-Uninstall.ipynb](./03-Uninstall.ipynb): Clear the installed deployments. diff --git a/examples/operator-quickstart/feast.yaml b/examples/operator-quickstart/feast.yaml new file mode 100644 index 00000000000..b665ec5a8bf --- /dev/null +++ b/examples/operator-quickstart/feast.yaml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: Secret +metadata: + name: feast-data-stores + namespace: feast +stringData: + redis: | + connection_string: redis.feast.svc.cluster.local:6379 + sql: | + path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres.feast.svc.cluster.local:5432/${POSTGRES_DB} + cache_ttl_seconds: 60 + sqlalchemy_config_kwargs: + echo: false + pool_pre_ping: true +--- +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: example + namespace: feast +spec: + feastProject: credit_scoring_local + feastProjectDir: + git: + url: https://github.com/feast-dev/feast-credit-score-local-tutorial + ref: 598a270 + services: + offlineStore: + persistence: + file: + type: duckdb + onlineStore: + persistence: + store: + type: redis + secretRef: + name: feast-data-stores + server: + envFrom: + - secretRef: + name: postgres-secret + env: + - name: MPLCONFIGDIR + value: /tmp + resources: + requests: + cpu: 150m + memory: 128Mi + registry: + local: + persistence: + store: + type: sql + secretRef: + name: feast-data-stores diff --git a/examples/operator-quickstart/postgres.yaml b/examples/operator-quickstart/postgres.yaml new file mode 100644 index 00000000000..e37caa16da4 --- /dev/null +++ b/examples/operator-quickstart/postgres.yaml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + namespace: feast +stringData: + POSTGRES_DB: feast + POSTGRES_USER: feast + POSTGRES_PASSWORD: feast +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: feast +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: 'postgres:16-alpine' + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: postgres-secret + volumeMounts: + - mountPath: /var/lib/postgresql + name: postgresdata + volumes: + - name: postgresdata + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: feast + labels: + app: postgres +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + selector: + app: postgres \ No newline at end of file diff --git a/examples/operator-quickstart/redis.yaml b/examples/operator-quickstart/redis.yaml new file mode 100644 index 00000000000..5d70b6bd5d6 --- /dev/null +++ b/examples/operator-quickstart/redis.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: feast +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: 'bitnami/redis:latest' + ports: + - containerPort: 6379 + env: + - name: ALLOW_EMPTY_PASSWORD + value: "yes" +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: feast + labels: + app: redis +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: 6379 + protocol: TCP + selector: + app: redis \ No newline at end of file diff --git a/examples/operator-rbac/03-uninstall.ipynb b/examples/operator-rbac/03-uninstall.ipynb new file mode 100644 index 00000000000..f9c794c03f8 --- /dev/null +++ b/examples/operator-rbac/03-uninstall.ipynb @@ -0,0 +1,175 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "## Uninstall", + "id": "bd1a081f3f7f5752" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### Uninstall the Operator and all Feast related objects##", + "id": "1175f3d6c5ee9bf0" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-05T19:09:52.349677Z", + "start_time": "2025-03-05T19:09:46.308482Z" + } + }, + "cell_type": "code", + "source": [ + "!kubectl delete -f ../../infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml\n", + "!kubectl delete -f ../../infra/feast-operator/dist/install.yaml" + ], + "id": "f4b4c6fa4a1fe0a8", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "featurestore.feast.dev \"sample-kubernetes-auth\" deleted\r\n", + "namespace \"feast-operator-system\" deleted\r\n", + "customresourcedefinition.apiextensions.k8s.io \"featurestores.feast.dev\" deleted\r\n", + "serviceaccount \"feast-operator-controller-manager\" deleted\r\n", + "role.rbac.authorization.k8s.io \"feast-operator-leader-election-role\" deleted\r\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-featurestore-editor-role\" deleted\r\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-featurestore-viewer-role\" deleted\r\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-manager-role\" deleted\r\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-metrics-auth-role\" deleted\r\n", + "clusterrole.rbac.authorization.k8s.io \"feast-operator-metrics-reader\" deleted\r\n", + "rolebinding.rbac.authorization.k8s.io \"feast-operator-leader-election-rolebinding\" deleted\r\n", + "clusterrolebinding.rbac.authorization.k8s.io \"feast-operator-manager-rolebinding\" deleted\r\n", + "clusterrolebinding.rbac.authorization.k8s.io \"feast-operator-metrics-auth-rolebinding\" deleted\r\n", + "service \"feast-operator-controller-manager-metrics-service\" deleted\r\n", + "deployment.apps \"feast-operator-controller-manager\" deleted\r\n" + ] + } + ], + "execution_count": 6 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Uninstall Client Related Objects", + "id": "2a2aa884aeddfb99" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-05T19:09:54.655575Z", + "start_time": "2025-03-05T19:09:53.553918Z" + } + }, + "cell_type": "code", + "source": [ + "!echo \"Deleting RoleBindings...\"\n", + "!kubectl delete rolebinding feast-user-rolebinding -n feast --ignore-not-found\n", + "!kubectl delete rolebinding feast-admin-rolebinding -n feast --ignore-not-found\n", + "\n", + "!echo \"Deleting ServiceAccounts...\"\n", + "!kubectl delete serviceaccount feast-user-sa -n feast --ignore-not-found\n", + "!kubectl delete serviceaccount feast-admin-sa -n feast --ignore-not-found\n", + "!kubectl delete serviceaccount feast-unauthorized-user-sa -n feast --ignore-not-found\n" + ], + "id": "6ce30879d64bbd06", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Deleting RoleBindings...\r\n", + "rolebinding.rbac.authorization.k8s.io \"feast-user-rolebinding\" deleted\r\n", + "rolebinding.rbac.authorization.k8s.io \"feast-admin-rolebinding\" deleted\r\n", + "Deleting ServiceAccounts...\r\n", + "serviceaccount \"feast-user-sa\" deleted\r\n", + "serviceaccount \"feast-admin-sa\" deleted\r\n", + "serviceaccount \"feast-unauthorized-user-sa\" deleted\r\n" + ] + } + ], + "execution_count": 7 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Ensure everything has been removed, or is in the process of being terminated.", + "id": "638421caa8ff849e" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-05T19:09:59.868383Z", + "start_time": "2025-03-05T19:09:59.611048Z" + } + }, + "cell_type": "code", + "source": "!kubectl get all -n feast\n", + "id": "587eb85352a8a353", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "No resources found in feast namespace.\r\n" + ] + } + ], + "execution_count": 8 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-05T19:10:07.846749Z", + "start_time": "2025-03-05T19:10:02.561070Z" + } + }, + "cell_type": "code", + "source": "!kubectl delete namespace feast", + "id": "7a0ce2d9e4a92828", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace \"feast\" deleted\r\n" + ] + } + ], + "execution_count": 9 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "", + "id": "10707783148c5f8d" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/operator-rbac/1-setup-operator-rbac.ipynb b/examples/operator-rbac/1-setup-operator-rbac.ipynb new file mode 100644 index 00000000000..69cc285a01c --- /dev/null +++ b/examples/operator-rbac/1-setup-operator-rbac.ipynb @@ -0,0 +1,760 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Feast Operator with RBAC Configuration\n", + "## Objective\n", + "\n", + "This demo provides a reference implementation of a runbook on how to enable Role-Based Access Control (RBAC) for Feast using the Feast Operator with the Kubernetes authentication type. This serves as useful reference material for a cluster admin / MLOps engineer.\n", + "\n", + "The demo steps include deploying the Feast Operator, creating Feast instances with server components (registry, offline store, online store), and Feast client testing locally. The goal is to ensure secure access control for Feast instances deployed by the Feast Operator.\n", + " \n", + "Please read these reference documents for understanding the Feast RBAC framework.\n", + "- [RBAC Architecture](https://docs.feast.dev/v/master/getting-started/architecture/rbac) \n", + "- [RBAC Permission](https://docs.feast.dev/v/master/getting-started/concepts/permission).\n", + "- [RBAC Authorization Manager](https://docs.feast.dev/v/master/getting-started/components/authz_manager)\n" + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Deployment Architecture\n", + "In this notebook, we will deploy a distributed topology of Feast services, which includes:\n", + "\n", + "* `Registry Server`: Handles metadata storage for feature definitions.\n", + "* `Online Store Server`: Uses the `Registry Server` to query metadata and is responsible for low-latency serving of features.\n", + "* `Offline Store Server`: Uses the `Registry Server` to query metadata and provides access to batch data for historical feature retrieval.\n", + "\n", + "Additionally, we will cover:\n", + "* RBAC Configuration with Kubernetes Authentication for Feast resources." + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Prerequisites\n", + "* Kubernetes Cluster\n", + "* [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) Kubernetes CLI tool." + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Install Prerequisites\n", + "The following commands install and configure all the prerequisites on a MacOS environment. You can find the\n", + "equivalent instructions on the offical documentation pages:\n", + "* Install the `kubectl` cli.\n", + "* Install Kubernetes and Container runtime (e.g. [Colima](https://github.com/abiosoft/colima)).\n", + " * Alternatively, authenticate to an existing Kubernetes or OpenShift cluster.\n", + " \n", + "```bash\n", + "brew install colima kubectl\n", + "colima start -r containerd -k -m 3 -d 100 -c 2 --cpu-type max -a x86_64\n", + "colima list\n", + "```" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:27:31.474254Z", + "start_time": "2025-03-06T18:27:31.012088Z" + } + }, + "cell_type": "code", + "source": [ + "!kubectl create ns feast\n", + "!kubectl config set-context --current --namespace feast" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace/feast created\r\n", + "Context \"kind-kind\" modified.\r\n" + ] + } + ], + "execution_count": 1 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Validate the cluster setup:" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:32:23.198122Z", + "start_time": "2025-03-06T18:32:22.930547Z" + } + }, + "cell_type": "code", + "source": "!kubectl get ns feast", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME STATUS AGE\r\n", + "feast Active 4m52s\r\n" + ] + } + ], + "execution_count": 2 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Feast Admin Steps:\n", + "Feast Admins or MLOps Engineers may require Kubernetes Cluster Admin roles when working with OpenShift or Kubernetes clusters. Below is the list of steps Required to set up Feast RBAC with the Operator by an Admin or MLOps Engineer.\n", + "\n", + "1. **Install the Feast Operator**\n", + "2. **Install the Feast services via FeatureStore CR**\n", + "3. **Configure the RBAC Permissions**\n", + "4. **Perform Feast Apply**\n", + "5. **Setting Service Account and Role Binding**\n", + "\n", + "## Install the Feast Operator" + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:32:40.721042Z", + "start_time": "2025-03-06T18:32:28.484245Z" + } + }, + "cell_type": "code", + "source": [ + "## Use this install command from a stable branch \n", + "!kubectl apply -f ../../infra/feast-operator/dist/install.yaml\n", + "\n", + "## OR, for the latest code/builds, use one the following commands from the 'master' branch\n", + "# !make -C ../../infra/feast-operator install deploy IMG=quay.io/feastdev-ci/feast-operator:develop FS_IMG=quay.io/feastdev-ci/feature-server:develop\n", + "# !make -C ../../infra/feast-operator install deploy IMG=quay.io/feastdev-ci/feast-operator:$(git rev-parse HEAD) FS_IMG=quay.io/feastdev-ci/feature-server:$(git rev-parse HEAD)\n", + "\n", + "!kubectl wait --for=condition=available --timeout=5m deployment/feast-operator-controller-manager -n feast-operator-system" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "namespace/feast-operator-system created\r\n", + "customresourcedefinition.apiextensions.k8s.io/featurestores.feast.dev created\r\n", + "serviceaccount/feast-operator-controller-manager created\r\n", + "role.rbac.authorization.k8s.io/feast-operator-leader-election-role created\r\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-featurestore-editor-role created\r\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-featurestore-viewer-role created\r\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-manager-role created\r\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-metrics-auth-role created\r\n", + "clusterrole.rbac.authorization.k8s.io/feast-operator-metrics-reader created\r\n", + "rolebinding.rbac.authorization.k8s.io/feast-operator-leader-election-rolebinding created\r\n", + "clusterrolebinding.rbac.authorization.k8s.io/feast-operator-manager-rolebinding created\r\n", + "clusterrolebinding.rbac.authorization.k8s.io/feast-operator-metrics-auth-rolebinding created\r\n", + "service/feast-operator-controller-manager-metrics-service created\r\n", + "deployment.apps/feast-operator-controller-manager created\r\n", + "deployment.apps/feast-operator-controller-manager condition met\r\n" + ] + } + ], + "execution_count": 3 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install the Feast services via FeatureStore CR\n", + "Next, we'll use the running Feast Operator to install the feast services with Server components online, offline, registry with kubernetes Authorization set. Apply the included [reference deployment](../../infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml) to install and configure Feast with kubernetes Authorization ." + ] + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:34:39.847211Z", + "start_time": "2025-03-06T18:34:39.378680Z" + } + }, + "source": [ + "!cat ../../infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml\n", + "!kubectl apply -f ../../infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml -n feast" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "apiVersion: feast.dev/v1alpha1\r\n", + "kind: FeatureStore\r\n", + "metadata:\r\n", + " name: sample-kubernetes-auth\r\n", + "spec:\r\n", + " feastProject: feast_rbac\r\n", + " authz:\r\n", + " kubernetes:\r\n", + " roles:\r\n", + " - feast-writer\r\n", + " - feast-reader\r\n", + " services:\r\n", + " offlineStore:\r\n", + " server: {}\r\n", + " onlineStore:\r\n", + " server: {}\r\n", + " registry:\r\n", + " local:\r\n", + " server: {}\r\n", + " ui: {}\r\n", + "featurestore.feast.dev/sample-kubernetes-auth created\r\n" + ] + } + ], + "execution_count": 4 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Validate the running FeatureStore deployment\n", + "Validate the deployment status." + ] + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:35:05.202176Z", + "start_time": "2025-03-06T18:35:02.498106Z" + } + }, + "source": [ + "!kubectl get all\n", + "!kubectl wait --for=condition=available --timeout=8m deployment/feast-sample-kubernetes-auth" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME READY STATUS RESTARTS AGE\r\n", + "pod/feast-sample-kubernetes-auth-774f6df8df-95nc6 0/4 Running 0 22s\r\n", + "\r\n", + "NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE\r\n", + "service/feast-sample-kubernetes-auth-offline ClusterIP 10.96.38.230 80/TCP 22s\r\n", + "service/feast-sample-kubernetes-auth-online ClusterIP 10.96.140.194 80/TCP 22s\r\n", + "service/feast-sample-kubernetes-auth-registry ClusterIP 10.96.140.31 80/TCP 22s\r\n", + "service/feast-sample-kubernetes-auth-ui ClusterIP 10.96.26.21 80/TCP 22s\r\n", + "\r\n", + "NAME READY UP-TO-DATE AVAILABLE AGE\r\n", + "deployment.apps/feast-sample-kubernetes-auth 0/1 1 0 22s\r\n", + "\r\n", + "NAME DESIRED CURRENT READY AGE\r\n", + "replicaset.apps/feast-sample-kubernetes-auth-774f6df8df 1 1 0 22s\r\n", + "deployment.apps/feast-sample-kubernetes-auth condition met\r\n" + ] + } + ], + "execution_count": 5 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Validate that the FeatureStore CR is in a `Ready` state." + ] + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:35:55.728523Z", + "start_time": "2025-03-06T18:35:55.452894Z" + } + }, + "source": [ + "!kubectl get feast" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "NAME STATUS AGE\r\n", + "sample-kubernetes-auth Ready 76s\r\n" + ] + } + ], + "execution_count": 6 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Configure the RBAC Permissions\n", + "As we have created Kubernetes roles in FeatureStore CR to manage access control for Feast objects, the Python script `permissions_apply.py` will apply these roles to configure permissions. See the detailed code example below with comments." + ] + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:37:17.062072Z", + "start_time": "2025-03-06T18:37:16.930026Z" + } + }, + "cell_type": "code", + "source": [ + "#view the permissions \n", + "!cat permissions_apply.py" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "# Necessary modules for permissions and policies in Feast for RBAC\r\n", + "from feast.feast_object import ALL_RESOURCE_TYPES\r\n", + "from feast.permissions.action import READ, AuthzedAction, ALL_ACTIONS\r\n", + "from feast.permissions.permission import Permission\r\n", + "from feast.permissions.policy import RoleBasedPolicy\r\n", + "\r\n", + "# Define K8s roles same as created with FeatureStore CR\r\n", + "admin_roles = [\"feast-writer\"] # Full access (can create, update, delete ) Feast Resources\r\n", + "user_roles = [\"feast-reader\"] # Read-only access on Feast Resources\r\n", + "\r\n", + "# User permissions (feast_user_permission)\r\n", + "# - Grants read and describing Feast objects access\r\n", + "user_perm = Permission(\r\n", + " name=\"feast_user_permission\",\r\n", + " types=ALL_RESOURCE_TYPES,\r\n", + " policy=RoleBasedPolicy(roles=user_roles),\r\n", + " actions=[AuthzedAction.DESCRIBE] + READ # Read access (READ_ONLINE, READ_OFFLINE) + describe other Feast Resources.\r\n", + ")\r\n", + "\r\n", + "# Admin permissions (feast_admin_permission)\r\n", + "# - Grants full control over all resources\r\n", + "admin_perm = Permission(\r\n", + " name=\"feast_admin_permission\",\r\n", + " types=ALL_RESOURCE_TYPES,\r\n", + " policy=RoleBasedPolicy(roles=admin_roles),\r\n", + " actions=ALL_ACTIONS # Full permissions: CREATE, UPDATE, DELETE, READ, WRITE\r\n", + ")\r\n" + ] + } + ], + "execution_count": 7 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:37:31.662484Z", + "start_time": "2025-03-06T18:37:31.139869Z" + } + }, + "cell_type": "code", + "source": [ + "# Copy the Permissions to the pods under feature_repo directory\n", + "!kubectl cp permissions_apply.py $(kubectl get pods -l 'feast.dev/name=sample-kubernetes-auth' -ojsonpath=\"{.items[*].metadata.name}\"):/feast-data/feast_rbac/feature_repo -c online" + ], + "outputs": [], + "execution_count": 8 + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:37:38.003082Z", + "start_time": "2025-03-06T18:37:37.662378Z" + } + }, + "source": [ + "#view the feature_store.yaml configuration \n", + "!kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- cat feature_store.yaml" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "project: feast_rbac\r\n", + "provider: local\r\n", + "offline_store:\r\n", + " type: dask\r\n", + "online_store:\r\n", + " path: /feast-data/online_store.db\r\n", + " type: sqlite\r\n", + "registry:\r\n", + " path: /feast-data/registry.db\r\n", + " registry_type: file\r\n", + "auth:\r\n", + " type: kubernetes\r\n", + "entity_key_serialization_version: 3\r\n" + ] + } + ], + "execution_count": 9 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Apply the Permissions and Feast Object to Registry" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:37:56.131390Z", + "start_time": "2025-03-06T18:37:45.483916Z" + } + }, + "cell_type": "code", + "source": "!kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- feast apply", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ": MADV_DONTNEED does not work (memset will be used instead)\r\n", + ": (This is the expected behaviour if you are running under QEMU)\r\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\r\n", + " DUMMY_ENTITY = Entity(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_len\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "/feast-data/feast_rbac/feature_repo/example_repo.py:27: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'driver'.\r\n", + " driver = Entity(name=\"driver\", join_keys=[\"driver_id\"])\r\n", + "Applying changes for project feast_rbac\r\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_store.py:579: RuntimeWarning: On demand feature view is an experimental feature. This API is stable, but the functionality does not scale well for offline retrieval\r\n", + " warnings.warn(\r\n", + "Created project \u001B[1m\u001B[32mfeast_rbac\u001B[0m\r\n", + "Created entity \u001B[1m\u001B[32mdriver\u001B[0m\r\n", + "Created feature view \u001B[1m\u001B[32mdriver_hourly_stats\u001B[0m\r\n", + "Created feature view \u001B[1m\u001B[32mdriver_hourly_stats_fresh\u001B[0m\r\n", + "Created on demand feature view \u001B[1m\u001B[32mtransformed_conv_rate\u001B[0m\r\n", + "Created on demand feature view \u001B[1m\u001B[32mtransformed_conv_rate_fresh\u001B[0m\r\n", + "Created feature service \u001B[1m\u001B[32mdriver_activity_v2\u001B[0m\r\n", + "Created feature service \u001B[1m\u001B[32mdriver_activity_v1\u001B[0m\r\n", + "Created feature service \u001B[1m\u001B[32mdriver_activity_v3\u001B[0m\r\n", + "Created permission \u001B[1m\u001B[32mfeast_admin_permission\u001B[0m\r\n", + "Created permission \u001B[1m\u001B[32mfeast_user_permission\u001B[0m\r\n", + "\r\n", + "Created sqlite table \u001B[1m\u001B[32mfeast_rbac_driver_hourly_stats_fresh\u001B[0m\r\n", + "Created sqlite table \u001B[1m\u001B[32mfeast_rbac_driver_hourly_stats\u001B[0m\r\n", + "\r\n" + ] + } + ], + "execution_count": 10 + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "**List the applied permission details permissions on Feast Resources.**" + }, + { + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:38:45.881715Z", + "start_time": "2025-03-06T18:38:04.170364Z" + } + }, + "source": [ + "!kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- feast permissions list-roles\n", + "!kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- feast permissions list\n", + "!kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- feast permissions describe feast_admin_permission\n", + "!kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- feast permissions describe feast_user_permission" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ": MADV_DONTNEED does not work (memset will be used instead)\r\n", + ": (This is the expected behaviour if you are running under QEMU)\r\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\r\n", + " DUMMY_ENTITY = Entity(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_len\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "+--------------+\r\n", + "| ROLE NAME |\r\n", + "+==============+\r\n", + "| feast-reader |\r\n", + "+--------------+\r\n", + "| feast-writer |\r\n", + "+--------------+\r\n", + ": MADV_DONTNEED does not work (memset will be used instead)\r\n", + ": (This is the expected behaviour if you are running under QEMU)\r\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\r\n", + " DUMMY_ENTITY = Entity(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_len\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "NAME TYPES NAME_PATTERNS ACTIONS ROLES REQUIRED_TAGS\r\n", + "feast_admin_permission Project - CREATE feast-writer -\r\n", + " FeatureView DESCRIBE\r\n", + " OnDemandFeatureView UPDATE\r\n", + " BatchFeatureView DELETE\r\n", + " StreamFeatureView READ_ONLINE\r\n", + " Entity READ_OFFLINE\r\n", + " FeatureService WRITE_ONLINE\r\n", + " DataSource WRITE_OFFLINE\r\n", + " ValidationReference\r\n", + " SavedDataset\r\n", + " Permission\r\n", + "feast_user_permission Project - DESCRIBE feast-reader -\r\n", + " FeatureView READ_OFFLINE\r\n", + " OnDemandFeatureView READ_ONLINE\r\n", + " BatchFeatureView\r\n", + " StreamFeatureView\r\n", + " Entity\r\n", + " FeatureService\r\n", + " DataSource\r\n", + " ValidationReference\r\n", + " SavedDataset\r\n", + " Permission\r\n", + ": MADV_DONTNEED does not work (memset will be used instead)\r\n", + ": (This is the expected behaviour if you are running under QEMU)\r\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\r\n", + " DUMMY_ENTITY = Entity(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_len\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "spec:\r\n", + " name: feast_admin_permission\r\n", + " types:\r\n", + " - PROJECT\r\n", + " - FEATURE_VIEW\r\n", + " - ON_DEMAND_FEATURE_VIEW\r\n", + " - BATCH_FEATURE_VIEW\r\n", + " - STREAM_FEATURE_VIEW\r\n", + " - ENTITY\r\n", + " - FEATURE_SERVICE\r\n", + " - DATA_SOURCE\r\n", + " - VALIDATION_REFERENCE\r\n", + " - SAVED_DATASET\r\n", + " - PERMISSION\r\n", + " actions:\r\n", + " - CREATE\r\n", + " - DESCRIBE\r\n", + " - UPDATE\r\n", + " - DELETE\r\n", + " - READ_ONLINE\r\n", + " - READ_OFFLINE\r\n", + " - WRITE_ONLINE\r\n", + " - WRITE_OFFLINE\r\n", + " policy:\r\n", + " roleBasedPolicy:\r\n", + " roles:\r\n", + " - feast-writer\r\n", + "meta:\r\n", + " createdTimestamp: '2025-03-06T18:37:55.742625Z'\r\n", + " lastUpdatedTimestamp: '2025-03-06T18:37:55.742625Z'\r\n", + "\r\n", + ": MADV_DONTNEED does not work (memset will be used instead)\r\n", + ": (This is the expected behaviour if you are running under QEMU)\r\n", + "/opt/app-root/lib64/python3.11/site-packages/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\r\n", + " DUMMY_ENTITY = Entity(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "/opt/app-root/lib64/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_len\" in \"SqliteOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\r\n", + " warnings.warn(\r\n", + "spec:\r\n", + " name: feast_user_permission\r\n", + " types:\r\n", + " - PROJECT\r\n", + " - FEATURE_VIEW\r\n", + " - ON_DEMAND_FEATURE_VIEW\r\n", + " - BATCH_FEATURE_VIEW\r\n", + " - STREAM_FEATURE_VIEW\r\n", + " - ENTITY\r\n", + " - FEATURE_SERVICE\r\n", + " - DATA_SOURCE\r\n", + " - VALIDATION_REFERENCE\r\n", + " - SAVED_DATASET\r\n", + " - PERMISSION\r\n", + " actions:\r\n", + " - DESCRIBE\r\n", + " - READ_OFFLINE\r\n", + " - READ_ONLINE\r\n", + " policy:\r\n", + " roleBasedPolicy:\r\n", + " roles:\r\n", + " - feast-reader\r\n", + "meta:\r\n", + " createdTimestamp: '2025-03-06T18:37:55.743643Z'\r\n", + " lastUpdatedTimestamp: '2025-03-06T18:37:55.743643Z'\r\n", + "\r\n" + ] + } + ], + "execution_count": 11 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Setting Up Service Account and RoleBinding \n", + "The steps below will:\n", + "- Create **three different ServiceAccounts** for Feast.\n", + "- Assign appropriate **RoleBindings** for access control." + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Test Cases\n", + "| User Type | ServiceAccount | RoleBinding Assigned | Expected Behavior in output |\n", + "|----------------|-----------------------------|----------------------|------------------------------------------------------------|\n", + "| **Read-Only** | `feast-user-sa` | `feast-reader` | Can **read** from the feature store, but **cannot write**. |\n", + "| **Unauthorized** | `feast-unauthorized-user-sa` | _None_ | **Access should be denied** in `test.py`. |\n", + "| **Admin** | `feast-admin-sa` | `feast-writer` | Can **read and write** feature store data. |" + ] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### Setup Read-Only Feast User the ServiceAccount and Role Binding (serviceaccount: feast-user-sa, role: feast-reader)" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:42:04.122440Z", + "start_time": "2025-03-06T18:42:03.397214Z" + } + }, + "cell_type": "code", + "source": [ + "# Step 1: Create the ServiceAccount\n", + "!echo \"Creating ServiceAccount: feast-user-sa\"\n", + "!kubectl create serviceaccount feast-user-sa -n feast\n", + "\n", + "# Step 2: Assign RoleBinding (Read-Only Access for Feast)\n", + "!echo \"Assigning Read-Only RoleBinding: feast-user-rolebinding\"\n", + "!kubectl create rolebinding feast-user-rolebinding --role=feast-reader --serviceaccount=feast:feast-user-sa -n feast" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Creating ServiceAccount: feast-user-sa\r\n", + "serviceaccount/feast-user-sa created\r\n", + "Assigning Read-Only RoleBinding: feast-user-rolebinding\r\n", + "rolebinding.rbac.authorization.k8s.io/feast-user-rolebinding created\r\n" + ] + } + ], + "execution_count": 12 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### Setup Unauthorized Feast User (serviceaccount: feast-unauthorized-user-sa, role: None)" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:42:07.992216Z", + "start_time": "2025-03-06T18:42:07.721628Z" + } + }, + "cell_type": "code", + "source": [ + "# Create the ServiceAccount (Without RoleBinding)\n", + "!echo \"Creating Unauthorized ServiceAccount: feast-unauthorized-user-sa\"\n", + "!kubectl create serviceaccount feast-unauthorized-user-sa -n feast\n" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Creating Unauthorized ServiceAccount: feast-unauthorized-user-sa\r\n", + "serviceaccount/feast-unauthorized-user-sa created\r\n" + ] + } + ], + "execution_count": 13 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Setup Test Admin Feast User (serviceaccount: feast-admin-sa, role: feast-writer)" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:42:11.651408Z", + "start_time": "2025-03-06T18:42:11.097231Z" + } + }, + "cell_type": "code", + "source": [ + "# Create the ServiceAccount\n", + "!echo \"Creating ServiceAccount: feast-admin-sa\"\n", + "!kubectl create serviceaccount feast-admin-sa -n feast\n", + "\n", + "# Assign RoleBinding (Admin Access for Feast)\n", + "!echo \"Assigning Admin RoleBinding: feast-admin-rolebinding\"\n", + "!kubectl create rolebinding feast-admin-rolebinding --role=feast-writer --serviceaccount=feast:feast-admin-sa -n feast\n" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Creating ServiceAccount: feast-admin-sa\r\n", + "serviceaccount/feast-admin-sa created\r\n", + "Assigning Admin RoleBinding: feast-admin-rolebinding\r\n", + "rolebinding.rbac.authorization.k8s.io/feast-admin-rolebinding created\r\n" + ] + } + ], + "execution_count": 14 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "[Next Run Client notebook](./2-client.ipynb)" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/operator-rbac/2-client.ipynb b/examples/operator-rbac/2-client.ipynb new file mode 100644 index 00000000000..cf9d57cb5bc --- /dev/null +++ b/examples/operator-rbac/2-client.ipynb @@ -0,0 +1,828 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Feast Client with RBAC\n", + "### Kubernetes RBAC Authorization\n", + "\n", + "## Feast Role-Based Access Control (RBAC) in Kubernetes \n", + "\n", + "Feast **Role-Based Access Control (RBAC)** in Kubernetes supports authentication both **inside a Kubernetes pod** and for **external clients** using the `LOCAL_K8S_TOKEN` environment variable. \n", + "\n", + "\n", + "### Inside a Kubernetes Pod\n", + "Feast automatically retrieves the Kubernetes ServiceAccount token from:\n", + "```\n", + "/var/run/secrets/kubernetes.io/serviceaccount/token\n", + "```\n", + "This means:\n", + "- No manual configuration is needed inside a pod.\n", + "- The token is mounted automatically and used for authentication.\n", + "- Developer just need create the binding with role and service account accordingly.\n", + "- Code Reference: \n", + "[Feast Kubernetes Auth Client Manager (Pod Token Usage)](https://github.com/feast-dev/feast/blob/master/sdk/python/feast/permissions/client/kubernetes_auth_client_manager.py#L15) \n", + "- Using a service account from a pod [Example](https://github.com/feast-dev/feast/blob/master/examples/rbac-remote/client/k8s/)\n", + "\n", + "### Outside a Kubernetes Pod (External Clients & Local Testing)\n", + " \n", + "If running Feast outside of Kubernetes, authentication requires setting the token manually to the environment variable `LOCAL_K8S_TOKEN` :\n", + "```sh\n", + "export LOCAL_K8S_TOKEN=\"your-service-account-token\"\n", + "```\n", + "\n", + "For more details, refer the user guide: [Kubernetes RBAC Authorization](https://docs.feast.dev/master/getting-started/components/authz_manager#kubernetes-rbac-authorization) \n" + ], + "id": "bb0145c9c1f6ebcc" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Test Cases\n", + "| User Type | ServiceAccount | RoleBinding Assigned | Expected Behavior in output |\n", + "|----------------|-----------------------------|----------------------|------------------------------------------------------------|\n", + "| **Read-Only** | `feast-user-sa` | `feast-reader` | Can **read** from the feature store, but **cannot write**. |\n", + "| **Unauthorized** | `feast-unauthorized-user-sa` | _None_ | **Access should be denied** in `test.py`. |\n", + "| **Admin** | `feast-admin-sa` | `feast-writer` | Can **read and write** feature store data. |" + ], + "id": "160681ba4ab3c2c5" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### Feature Store settings", + "id": "6590c081efb1fe3c" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:47:45.151296Z", + "start_time": "2025-03-06T18:47:45.024854Z" + } + }, + "cell_type": "code", + "source": "!cat client/feature_store.yaml", + "id": "fac5f67ff391b5cf", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "project: feast_rbac\r\n", + "provider: local\r\n", + "offline_store:\r\n", + " host: localhost\r\n", + " type: remote\r\n", + " port: 8081\r\n", + "online_store:\r\n", + " path: http://localhost:8082\r\n", + " type: remote\r\n", + "registry:\r\n", + " path: localhost:8083\r\n", + " registry_type: remote\r\n", + "auth:\r\n", + " type: kubernetes\r\n", + "entity_key_serialization_version: 3\r\n" + ] + } + ], + "execution_count": 1 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "**The Operator client feature store ConfigMap** containing the `feature_store.yaml `settings. We can retrieve it and port froward to local as we are testing locally.", + "id": "84f73e09711bff9f" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:46:36.029308Z", + "start_time": "2025-03-06T18:46:35.712532Z" + } + }, + "cell_type": "code", + "source": "!kubectl get configmap feast-sample-kubernetes-auth-client -n feast -o jsonpath='{.data.feature_store\\.yaml}' ", + "id": "456fb4df46f32380", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "project: feast_rbac\r\n", + "provider: local\r\n", + "offline_store:\r\n", + " host: feast-sample-kubernetes-auth-offline.feast.svc.cluster.local\r\n", + " type: remote\r\n", + " port: 80\r\n", + "online_store:\r\n", + " path: http://feast-sample-kubernetes-auth-online.feast.svc.cluster.local:80\r\n", + " type: remote\r\n", + "registry:\r\n", + " path: feast-sample-kubernetes-auth-registry.feast.svc.cluster.local:80\r\n", + " registry_type: remote\r\n", + "auth:\r\n", + " type: kubernetes\r\n", + "entity_key_serialization_version: 3\r\n" + ] + } + ], + "execution_count": 34 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### The function below is executed to support the preparation of client testing.", + "id": "ae61f4dca31f3466" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Run Port Forwarding for All Services for local testing ", + "id": "28636825ae8f676d" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:47:55.237205Z", + "start_time": "2025-03-06T18:47:55.226143Z" + } + }, + "cell_type": "code", + "source": [ + "import subprocess\n", + "\n", + "# Define services and their local ports\n", + "services = {\n", + " \"offline_store\": (\"feast-sample-kubernetes-auth-offline\", 8081),\n", + " \"online_store\": (\"feast-sample-kubernetes-auth-online\", 8082),\n", + " \"registry\": (\"feast-sample-kubernetes-auth-registry\", 8083),\n", + "}\n", + "\n", + "# Start port-forwarding for each service\n", + "port_forward_processes = {}\n", + "for name, (service, local_port) in services.items():\n", + " cmd = f\"kubectl port-forward svc/{service} -n feast {local_port}:80\"\n", + " process = subprocess.Popen(cmd, shell=True)\n", + " port_forward_processes[name] = process\n", + " print(f\"Port forwarding {service} -> localhost:{local_port}\")" + ], + "id": "c014248190863e8a", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Port forwarding feast-sample-kubernetes-auth-offline -> localhost:8081\n", + "Port forwarding feast-sample-kubernetes-auth-online -> localhost:8082\n", + "Port forwarding feast-sample-kubernetes-auth-registry -> localhost:8083\n" + ] + } + ], + "execution_count": 2 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Function to retrieve a Kubernetes service account token and set it as an environment variable", + "id": "c0eccef6379f442c" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T18:48:00.150752Z", + "start_time": "2025-03-06T18:48:00.143370Z" + } + }, + "cell_type": "code", + "source": [ + "import subprocess\n", + "import os\n", + "\n", + "def get_k8s_token(service_account):\n", + " namespace = \"feast\"\n", + "\n", + " if not service_account:\n", + " raise ValueError(\"Service account name is required.\")\n", + "\n", + " result = subprocess.run(\n", + " [\"kubectl\", \"create\", \"token\", service_account, \"-n\", namespace],\n", + " capture_output=True, text=True, check=True\n", + " )\n", + "\n", + " token = result.stdout.strip()\n", + "\n", + " if not token:\n", + " return None # Silently return None if token retrieval fails\n", + "\n", + " os.environ[\"LOCAL_K8S_TOKEN\"] = token\n", + " return \"Token Retrieved: ***** (hidden for security)\"\n" + ], + "id": "70bdbcd7b3fe44", + "outputs": [], + "execution_count": 3 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "**Generating training data**. The following test functions were copied from the `test_workflow.py` template but we added `try` blocks to print only \n", + "the relevant error messages, since we expect to receive errors from the permission enforcement modules." + ], + "id": "8c9e27ec4ed8ca2c" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:16:04.254201Z", + "start_time": "2025-03-06T20:16:04.245605Z" + } + }, + "cell_type": "code", + "source": [ + "from feast import FeatureStore\n", + "from feast.data_source import PushMode\n", + "from datetime import datetime\n", + "import pandas as pd\n", + "\n", + "# Initialize Feature Store\n", + "store = FeatureStore(repo_path=\"client\")\n", + "\n", + "def fetch_historical_features_entity_df(store: FeatureStore, for_batch_scoring: bool):\n", + " \"\"\"Fetch historical features for training or batch scoring.\"\"\"\n", + " try:\n", + " entity_df = pd.DataFrame.from_dict(\n", + " {\n", + " \"driver_id\": [1001, 1002, 1003],\n", + " \"event_timestamp\": [\n", + " datetime(2021, 4, 12, 10, 59, 42),\n", + " datetime(2021, 4, 12, 8, 12, 10),\n", + " datetime(2021, 4, 12, 16, 40, 26),\n", + " ],\n", + " \"label_driver_reported_satisfaction\": [1, 5, 3],\n", + " \"val_to_add\": [1, 2, 3],\n", + " \"val_to_add_2\": [10, 20, 30],\n", + " }\n", + " )\n", + " if for_batch_scoring:\n", + " entity_df[\"event_timestamp\"] = pd.to_datetime(\"now\", utc=True)\n", + "\n", + " training_df = store.get_historical_features(\n", + " entity_df=entity_df,\n", + " features=[\n", + " \"driver_hourly_stats:conv_rate\",\n", + " \"driver_hourly_stats:acc_rate\",\n", + " \"driver_hourly_stats:avg_daily_trips\",\n", + " \"transformed_conv_rate:conv_rate_plus_val1\",\n", + " \"transformed_conv_rate:conv_rate_plus_val2\",\n", + " ],\n", + " ).to_df()\n", + " print(f\"Successfully fetched {'batch scoring' if for_batch_scoring else 'training'} historical features:\\n\", training_df.head())\n", + "\n", + " except PermissionError:\n", + " print(\"\\n*** PERMISSION DENIED *** Cannot fetch historical features.\")\n", + " except Exception as e:\n", + " print(f\"Unexpected error while fetching historical features: {e}\")\n", + "\n", + "def fetch_online_features(store: FeatureStore, source: str = \"\"):\n", + " \"\"\"Fetch online features from the feature store.\"\"\"\n", + " try:\n", + " entity_rows = [\n", + " {\n", + " \"driver_id\": 1001,\n", + " \"val_to_add\": 1000,\n", + " \"val_to_add_2\": 2000,\n", + " },\n", + " {\n", + " \"driver_id\": 1002,\n", + " \"val_to_add\": 1001,\n", + " \"val_to_add_2\": 2002,\n", + " },\n", + " ]\n", + " if source == \"feature_service\":\n", + " features_to_fetch = store.get_feature_service(\"driver_activity_v1\")\n", + " elif source == \"push\":\n", + " features_to_fetch = store.get_feature_service(\"driver_activity_v3\")\n", + " else:\n", + " features_to_fetch = [\n", + " \"driver_hourly_stats:acc_rate\",\n", + " \"transformed_conv_rate:conv_rate_plus_val1\",\n", + " \"transformed_conv_rate:conv_rate_plus_val2\",\n", + " ]\n", + "\n", + " returned_features = store.get_online_features(\n", + " features=features_to_fetch,\n", + " entity_rows=entity_rows,\n", + " ).to_dict()\n", + "\n", + " print(f\"Successfully fetched online features {'via feature service' if source else 'directly'}:\\n\")\n", + " for key, value in sorted(returned_features.items()):\n", + " print(f\"{key} : {value}\")\n", + "\n", + " except PermissionError:\n", + " print(\"\\n*** PERMISSION DENIED *** Cannot fetch online features.\")\n", + " except Exception as e:\n", + " print(f\"Unexpected error while fetching online features: {e}\")\n", + "\n", + "def check_permissions():\n", + " \"\"\"Check user role, test various Feast operations,.\"\"\"\n", + "\n", + " feature_views = []\n", + "\n", + " # Step 1: List feature views\n", + " print(\"\\n--- List feature views ---\")\n", + " try:\n", + " feature_views = store.list_feature_views()\n", + " if not feature_views:\n", + " print(\"No feature views found. You might not have access or they haven't been created.\")\n", + " has_feature_view_access = False\n", + " else:\n", + " print(f\"Successfully listed {len(feature_views)} feature views:\")\n", + " for fv in feature_views:\n", + " print(f\" - {fv.name}\")\n", + "\n", + " except PermissionError:\n", + " print(\"\\n*** PERMISSION DENIED *** Cannot list feature views.\")\n", + " has_feature_view_access = False\n", + " except Exception as e:\n", + " print(f\"Unexpected error listing feature views: {e}\")\n", + " has_feature_view_access = False\n", + "\n", + " # Step 2: Fetch Historical Features\n", + " print(\"\\n--- Fetching Historical Features for Training ---\")\n", + " fetch_historical_features_entity_df(store, for_batch_scoring=False)\n", + "\n", + " print(\"\\n--- Fetching Historical Features for Batch Scoring ---\")\n", + " fetch_historical_features_entity_df(store, for_batch_scoring=True)\n", + "\n", + " # Step 3: Apply Feature Store\n", + " print(\"\\n--- Write to Feature Store ---\")\n", + " try:\n", + " store.apply(feature_views)\n", + " print(\"User has write access to the feature store.\")\n", + " except PermissionError:\n", + " print(\"\\n*** PERMISSION DENIED *** User lacks permission to modify the feature store.\")\n", + " except Exception as e:\n", + " print(f\"Unexpected error testing write access: {e}\")\n", + "\n", + " # Step 4: Fetch Online Features\n", + " print(\"\\n--- Fetching Online Features ---\")\n", + " fetch_online_features(store)\n", + "\n", + " print(\"\\n--- Fetching Online Features via Feature Service ---\")\n", + " fetch_online_features(store, source=\"feature_service\")\n", + "\n", + " print(\"\\n--- Fetching Online Features via Push Source ---\")\n", + " fetch_online_features(store, source=\"push\")\n", + "\n", + " print(\"\\n--- Performing Push Source ---\")\n", + " # Step 5: Simulate Event Push (Streaming Ingestion)\n", + " try:\n", + " event_df = pd.DataFrame.from_dict(\n", + " {\n", + " \"driver_id\": [1001],\n", + " \"event_timestamp\": [datetime.now()],\n", + " \"created\": [datetime.now()],\n", + " \"conv_rate\": [1.0],\n", + " \"acc_rate\": [1.0],\n", + " \"avg_daily_trips\": [1000],\n", + " }\n", + " )\n", + " store.push(\"driver_stats_push_source\", event_df, to=PushMode.ONLINE_AND_OFFLINE)\n", + " print(\"Successfully pushed a test event.\")\n", + " except PermissionError:\n", + " print(\"\\n*** PERMISSION DENIED *** Cannot push event (no write access).\")\n", + " except Exception as e:\n", + " print(f\"Unexpected error while pushing event: {e}\")\n" + ], + "id": "934963c5f6b18930", + "outputs": [], + "execution_count": 51 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "### Test Read-Only Feast User \n", + "**Step 1: Set the Token**" + ], + "id": "84e3f83699b8d83" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:12:44.771268Z", + "start_time": "2025-03-06T20:12:44.691353Z" + } + }, + "cell_type": "code", + "source": "get_k8s_token(\"feast-user-sa\")", + "id": "f1fe8baa02d27d38", + "outputs": [ + { + "data": { + "text/plain": [ + "'Token Retrieved: ***** (hidden for security)'" + ] + }, + "execution_count": 48, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 48 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "**Step 2: Test misc functions from offline, online, materialize_incremental, and others**", + "id": "140c909fa8bcc6ab" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:16:16.680582Z", + "start_time": "2025-03-06T20:16:14.930480Z" + } + }, + "cell_type": "code", + "source": [ + "# Run the permission check function\n", + "check_permissions()\n" + ], + "id": "14b7ad38368db767", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- List feature views ---\n", + "Successfully listed 2 feature views:\n", + " - driver_hourly_stats\n", + " - driver_hourly_stats_fresh\n", + "\n", + "--- Fetching Historical Features for Training ---\n", + "Handling connection for 8081\n", + "Successfully fetched training historical features:\n", + " driver_id event_timestamp label_driver_reported_satisfaction \\\n", + "0 1001 2021-04-12 10:59:42+00:00 1 \n", + "1 1002 2021-04-12 08:12:10+00:00 5 \n", + "2 1003 2021-04-12 16:40:26+00:00 3 \n", + "\n", + " val_to_add val_to_add_2 conv_rate acc_rate avg_daily_trips \\\n", + "0 1 10 0.677818 0.453707 193 \n", + "1 2 20 0.328160 0.900565 929 \n", + "2 3 30 0.787191 0.958963 571 \n", + "\n", + " conv_rate_plus_val1 conv_rate_plus_val2 \n", + "0 1.677818 10.677818 \n", + "1 2.328160 20.328160 \n", + "2 3.787191 30.787191 \n", + "\n", + "--- Fetching Historical Features for Batch Scoring ---\n", + "Handling connection for 8081\n", + "Successfully fetched batch scoring historical features:\n", + " driver_id event_timestamp \\\n", + "0 1001 2025-03-06 20:16:15.556223+00:00 \n", + "1 1002 2025-03-06 20:16:15.556223+00:00 \n", + "2 1003 2025-03-06 20:16:15.556223+00:00 \n", + "\n", + " label_driver_reported_satisfaction val_to_add val_to_add_2 conv_rate \\\n", + "0 1 1 10 0.782836 \n", + "1 5 2 20 0.731948 \n", + "2 3 3 30 0.613211 \n", + "\n", + " acc_rate avg_daily_trips conv_rate_plus_val1 conv_rate_plus_val2 \n", + "0 0.729726 652 1.782836 10.782836 \n", + "1 0.384902 902 2.731948 20.731948 \n", + "2 0.075386 101 3.613211 30.613211 \n", + "\n", + "--- Write to Feature Store ---\n", + "\n", + "*** PERMISSION DENIED *** User lacks permission to modify the feature store.\n", + "\n", + "--- Fetching Online Features ---\n", + "Handling connection for 8082\n", + "Successfully fetched online features directly:\n", + "\n", + "acc_rate : [None, None]\n", + "conv_rate_plus_val1 : [None, None]\n", + "conv_rate_plus_val2 : [None, None]\n", + "driver_id : [1001, 1002]\n", + "\n", + "--- Fetching Online Features via Feature Service ---\n", + "Handling connection for 8082\n", + "Successfully fetched online features via feature service:\n", + "\n", + "conv_rate : [None, None]\n", + "conv_rate_plus_val1 : [None, None]\n", + "conv_rate_plus_val2 : [None, None]\n", + "driver_id : [1001, 1002]\n", + "\n", + "--- Fetching Online Features via Push Source ---\n", + "Handling connection for 8082\n", + "Successfully fetched online features via feature service:\n", + "\n", + "acc_rate : [None, None]\n", + "avg_daily_trips : [None, None]\n", + "conv_rate : [None, None]\n", + "conv_rate_plus_val1 : [None, None]\n", + "conv_rate_plus_val2 : [None, None]\n", + "driver_id : [1001, 1002]\n", + "\n", + "--- Performing Push Source ---\n", + "Unexpected error while pushing event: \n" + ] + } + ], + "execution_count": 53 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "### Test Unauthorized Feast User ", + "id": "e5e63a172da6d6d7" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:16:38.487573Z", + "start_time": "2025-03-06T20:16:38.351889Z" + } + }, + "cell_type": "code", + "source": [ + "# Retrieve and store the token\n", + "get_k8s_token(\"feast-unauthorized-user-sa\")" + ], + "id": "a7b3a6578fcf5c3c", + "outputs": [ + { + "data": { + "text/plain": [ + "'Token Retrieved: ***** (hidden for security)'" + ] + }, + "execution_count": 54, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 54 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:16:41.522132Z", + "start_time": "2025-03-06T20:16:41.254668Z" + } + }, + "cell_type": "code", + "source": "check_permissions()", + "id": "7aea5658325ab008", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- List feature views ---\n", + "No feature views found. You might not have access or they haven't been created.\n", + "\n", + "--- Fetching Historical Features for Training ---\n", + "\n", + "*** PERMISSION DENIED *** Cannot fetch historical features.\n", + "\n", + "--- Fetching Historical Features for Batch Scoring ---\n", + "\n", + "*** PERMISSION DENIED *** Cannot fetch historical features.\n", + "\n", + "--- Write to Feature Store ---\n", + "\n", + "*** PERMISSION DENIED *** User lacks permission to modify the feature store.\n", + "\n", + "--- Fetching Online Features ---\n", + "\n", + "*** PERMISSION DENIED *** Cannot fetch online features.\n", + "\n", + "--- Fetching Online Features via Feature Service ---\n", + "\n", + "*** PERMISSION DENIED *** Cannot fetch online features.\n", + "\n", + "--- Fetching Online Features via Push Source ---\n", + "\n", + "*** PERMISSION DENIED *** Cannot fetch online features.\n", + "\n", + "--- Performing Push Source ---\n", + "Unexpected error while pushing event: Unable to find push source 'driver_stats_push_source'.\n" + ] + } + ], + "execution_count": 55 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "## Test Admin Feast User", + "id": "cb78ced7c37ceb4c" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:17:02.206503Z", + "start_time": "2025-03-06T20:17:02.137409Z" + } + }, + "cell_type": "code", + "source": [ + "# Retrieve and store the token\n", + "get_k8s_token(\"feast-admin-sa\")" + ], + "id": "4f10aae116825619", + "outputs": [ + { + "data": { + "text/plain": [ + "'Token Retrieved: ***** (hidden for security)'" + ] + }, + "execution_count": 56, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 56 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-06T20:17:07.799782Z", + "start_time": "2025-03-06T20:17:05.946696Z" + } + }, + "cell_type": "code", + "source": "check_permissions()", + "id": "7a6133f052b9cfe1", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- List feature views ---\n", + "Successfully listed 2 feature views:\n", + " - driver_hourly_stats\n", + " - driver_hourly_stats_fresh\n", + "\n", + "--- Fetching Historical Features for Training ---\n", + "Handling connection for 8081\n", + "Successfully fetched training historical features:\n", + " driver_id event_timestamp label_driver_reported_satisfaction \\\n", + "0 1001 2021-04-12 10:59:42+00:00 1 \n", + "1 1002 2021-04-12 08:12:10+00:00 5 \n", + "2 1003 2021-04-12 16:40:26+00:00 3 \n", + "\n", + " val_to_add val_to_add_2 conv_rate acc_rate avg_daily_trips \\\n", + "0 1 10 0.677818 0.453707 193 \n", + "1 2 20 0.328160 0.900565 929 \n", + "2 3 30 0.787191 0.958963 571 \n", + "\n", + " conv_rate_plus_val1 conv_rate_plus_val2 \n", + "0 1.677818 10.677818 \n", + "1 2.328160 20.328160 \n", + "2 3.787191 30.787191 \n", + "\n", + "--- Fetching Historical Features for Batch Scoring ---\n", + "Handling connection for 8081\n", + "Successfully fetched batch scoring historical features:\n", + " driver_id event_timestamp \\\n", + "0 1001 2025-03-06 20:17:06.566035+00:00 \n", + "1 1002 2025-03-06 20:17:06.566035+00:00 \n", + "2 1003 2025-03-06 20:17:06.566035+00:00 \n", + "\n", + " label_driver_reported_satisfaction val_to_add val_to_add_2 conv_rate \\\n", + "0 1 1 10 0.782836 \n", + "1 5 2 20 0.731948 \n", + "2 3 3 30 0.613211 \n", + "\n", + " acc_rate avg_daily_trips conv_rate_plus_val1 conv_rate_plus_val2 \n", + "0 0.729726 652 1.782836 10.782836 \n", + "1 0.384902 902 2.731948 20.731948 \n", + "2 0.075386 101 3.613211 30.613211 \n", + "\n", + "--- Write to Feature Store ---\n", + "User has write access to the feature store.\n", + "\n", + "--- Fetching Online Features ---\n", + "Handling connection for 8082\n", + "Successfully fetched online features directly:\n", + "\n", + "acc_rate : [None, None]\n", + "conv_rate_plus_val1 : [None, None]\n", + "conv_rate_plus_val2 : [None, None]\n", + "driver_id : [1001, 1002]\n", + "\n", + "--- Fetching Online Features via Feature Service ---\n", + "Handling connection for 8082\n", + "Successfully fetched online features via feature service:\n", + "\n", + "conv_rate : [None, None]\n", + "conv_rate_plus_val1 : [None, None]\n", + "conv_rate_plus_val2 : [None, None]\n", + "driver_id : [1001, 1002]\n", + "\n", + "--- Fetching Online Features via Push Source ---\n", + "Handling connection for 8082\n", + "Successfully fetched online features via feature service:\n", + "\n", + "acc_rate : [None, None]\n", + "avg_daily_trips : [None, None]\n", + "conv_rate : [None, None]\n", + "conv_rate_plus_val1 : [None, None]\n", + "conv_rate_plus_val2 : [None, None]\n", + "driver_id : [1001, 1002]\n", + "\n", + "--- Performing Push Source ---\n", + "Unexpected error while pushing event: \n" + ] + } + ], + "execution_count": 57 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + " **Note:**\n", + "**Currently, remote materialization not available in Feast when using the Remote Client**\n", + "**Workaround: Consider using running it from pod like**\n", + " \n", + " `kubectl exec deploy/feast-sample-kubernetes-auth -itc online -- bash -c 'feast materialize-incremental $(date -u +\"%Y-%m-%dT%H:%M:%S\")`\n" + ], + "id": "e451c30649630b2f" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Terminate the process", + "id": "e88442b1bae2b327" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2025-03-05T19:09:29.743583Z", + "start_time": "2025-03-05T19:09:29.734671Z" + } + }, + "cell_type": "code", + "source": [ + "for name, process in port_forward_processes.items():\n", + " process.terminate()\n", + " print(f\"Stopped port forwarding for {name}\")" + ], + "id": "2984d62766da122a", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Stopped port forwarding for offline_store\n", + "Stopped port forwarding for online_store\n", + "Stopped port forwarding for registry\n" + ] + } + ], + "execution_count": 25 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "[Next: Uninstall the Operator and all Feast objects](./03-uninstall.ipynb)", + "id": "38c54e92643e0bda" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/operator-rbac/README.md b/examples/operator-rbac/README.md new file mode 100644 index 00000000000..9c0a0461678 --- /dev/null +++ b/examples/operator-rbac/README.md @@ -0,0 +1,6 @@ +# Running the Feast RBAC example on Kubernetes using the Feast Operator. + +1. [1-setup-operator-rbac.ipynb](1-setup-operator-rbac.ipynb) will guide you through how to setup Role-Based Access Control (RBAC) for Feast using the [Feast Operator](../../infra/feast-operator/) and Kubernetes Authentication. This Feast Admin Step requires you to setup the operator and Feast RBAC on K8s. +2. [2-client.ipynb](2-client.ipynb) Validate the RBAC with the client example using different test cases using a service account token locally. +3. [03-uninstall.ipynb](03-uninstall.ipynb) Clear the installed deployments and K8s Objects. + diff --git a/examples/operator-rbac/client/feature_store.yaml b/examples/operator-rbac/client/feature_store.yaml new file mode 100644 index 00000000000..49a4c426363 --- /dev/null +++ b/examples/operator-rbac/client/feature_store.yaml @@ -0,0 +1,15 @@ +project: feast_rbac +provider: local +offline_store: + host: localhost + type: remote + port: 8081 +online_store: + path: http://localhost:8082 + type: remote +registry: + path: localhost:8083 + registry_type: remote +auth: + type: kubernetes +entity_key_serialization_version: 3 diff --git a/examples/operator-rbac/permissions_apply.py b/examples/operator-rbac/permissions_apply.py new file mode 100644 index 00000000000..0d46ad5260a --- /dev/null +++ b/examples/operator-rbac/permissions_apply.py @@ -0,0 +1,27 @@ +# Necessary modules for permissions and policies in Feast for RBAC +from feast.feast_object import ALL_RESOURCE_TYPES +from feast.permissions.action import READ, AuthzedAction, ALL_ACTIONS +from feast.permissions.permission import Permission +from feast.permissions.policy import RoleBasedPolicy + +# Define K8s roles same as created with FeatureStore CR +admin_roles = ["feast-writer"] # Full access (can create, update, delete ) Feast Resources +user_roles = ["feast-reader"] # Read-only access on Feast Resources + +# User permissions (feast_user_permission) +# - Grants read and describing Feast objects access +user_perm = Permission( + name="feast_user_permission", + types=ALL_RESOURCE_TYPES, + policy=RoleBasedPolicy(roles=user_roles), + actions=[AuthzedAction.DESCRIBE] + READ # Read access (READ_ONLINE, READ_OFFLINE) + describe other Feast Resources. +) + +# Admin permissions (feast_admin_permission) +# - Grants full control over all resources +admin_perm = Permission( + name="feast_admin_permission", + types=ALL_RESOURCE_TYPES, + policy=RoleBasedPolicy(roles=admin_roles), + actions=ALL_ACTIONS # Full permissions: CREATE, UPDATE, DELETE, READ, WRITE +) diff --git a/examples/python-helm-demo/README.md b/examples/python-helm-demo/README.md index 90469e746d4..078550ae392 100644 --- a/examples/python-helm-demo/README.md +++ b/examples/python-helm-demo/README.md @@ -3,87 +3,168 @@ For this tutorial, we set up Feast with Redis. -We use the Feast CLI to register and materialize features, and then retrieving via a Feast Python feature server deployed in Kubernetes +We use the Feast CLI to register and materialize features from the current machine, and then retrieving via a +Feast Python feature server deployed in Kubernetes ## First, let's set up a Redis cluster 1. Start minikube (`minikube start`) -2. Use helm to install a default Redis cluster +1. Use helm to install a default Redis cluster ```bash helm repo add bitnami https://charts.bitnami.com/bitnami helm repo update helm install my-redis bitnami/redis ``` ![](redis-screenshot.png) -3. Port forward Redis so we can materialize features to it +1. Port forward Redis so we can materialize features to it ```bash kubectl port-forward --namespace default svc/my-redis-master 6379:6379 ``` -4. Get your Redis password using the command (pasted below for convenience). We'll need this to tell Feast how to communicate with the cluster. +1. Get your Redis password using the command (pasted below for convenience). We'll need this to tell Feast how to communicate with the cluster. ```bash export REDIS_PASSWORD=$(kubectl get secret --namespace default my-redis -o jsonpath="{.data.redis-password}" | base64 --decode) echo $REDIS_PASSWORD ``` +## Then, let's set up a MinIO S3 store +Manifests have been taken from [Deploy Minio in your project](https://ai-on-openshift.io/tools-and-applications/minio/minio/#deploy-minio-in-your-project). + +1. Deploy MinIO instance: + ``` + kubectl apply -f minio-dev.yaml + ``` + +1. Forward the UI port: + ```console + kubectl port-forward svc/minio-service 9090:9090 + ``` +1. Login to (localhost:9090)[http://localhost:9090] as `minio`/`minio123` and create bucket called `feast-demo`. +1. Stop previous port forwarding and forward the API port instead: + ```console + kubectl port-forward svc/minio-service 9000:9000 + ``` + ## Next, we setup a local Feast repo -1. Install Feast with Redis dependencies `pip install "feast[redis]"` -2. Make a bucket in GCS (or S3) -3. The feature repo is already setup here, so you just need to swap in your GCS bucket and Redis credentials. - We need to modify the `feature_store.yaml`, which has two fields for you to replace: +1. Install Feast with Redis dependencies `pip install "feast[redis,aws]"` +1. The feature repo is already setup here, so you just need to swap in your Redis credentials. + We need to modify the `feature_store.yaml`, which has one field for you to replace: + ```console + sed "s/_REDIS_PASSWORD_/${REDIS_PASSWORD}/" feature_repo/feature_store.yaml.template > feature_repo/feature_store.yaml + cat feature_repo/feature_store.yaml + ``` + + Example repo: ```yaml - registry: gs://[YOUR GCS BUCKET]/demo-repo/registry.db + registry: s3://localhost:9000/feast-demo/registry.db project: feast_python_demo - provider: gcp + provider: local online_store: type: redis - # Note: this would normally be using instance URL's to access Redis - connection_string: localhost:6379,password=[YOUR PASSWORD] + connection_string: localhost:6379,password=**** offline_store: type: file entity_key_serialization_version: 2 ``` -4. Run `feast apply` from within the `feature_repo` directory to apply your local features to the remote registry - - Note: you may need to authenticate to gcloud first with `gcloud auth login` -5. Materialize features to the online store: +1. To run `feast apply` from the current machine we need to define the AWS credentials to connect the MinIO S3 store, which +are defined in [minio.env](./minio.env): + ```console + source minio.env + cd feature_repo + feast apply + ``` +1. Let's validate the setup by running some queries + ```console + feast entities list + feast feature-views list + ``` +1. Materialize features to the online store: ```bash + cd feature_repo CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S") feast materialize-incremental $CURRENT_TIME ``` ## Now let's setup the Feast Server -1. Add the gcp-auth addon to mount GCP credentials: - ```bash - minikube addons enable gcp-auth - ``` -2. Add Feast's Python/Go feature server chart repo +1. Add Feast's Python feature server chart repo ```bash helm repo add feast-charts https://feast-helm-charts.storage.googleapis.com helm repo update ``` -3. For this tutorial, because we don't have a direct hosted endpoint into Redis, we need to change `feature_store.yaml` to talk to the Kubernetes Redis service - ```bash - sed -i '' 's/localhost:6379/my-redis-master:6379/g' feature_store.yaml - ``` -4. Install the Feast helm chart: `helm install feast-release feast-charts/feast-feature-server --set feature_store_yaml_base64=$(base64 feature_store.yaml)` - > **Dev instructions**: if you're changing the java logic or chart, you can do - 1. `eval $(minikube docker-env)` - 2. `make build-feature-server-dev` - 3. `helm install feast-release ../../../infra/charts/feast-feature-server --set image.tag=dev --set feature_store_yaml_base64=$(base64 feature_store.yaml)` -5. (Optional): check logs of the server to make sure it’s working +1. For this tutorial, we'll use a predefined configuration where we just needs to inject the Redis service password: + ```console + sed "s/_REDIS_PASSWORD_/$REDIS_PASSWORD/" online_feature_store.yaml.template > online_feature_store.yaml + cat online_feature_store.yaml + ``` + As you see, the connection points to `my-redis-master:6379` instead of `localhost:6379`. + +1. Install the Feast helm chart: + ```console + helm upgrade --install feast-online feast-charts/feast-feature-server \ + --set fullnameOverride=online-server --set feast_mode=online \ + --set feature_store_yaml_base64=$(base64 -i 'online_feature_store.yaml') + ``` +1. Patch the deployment to include MinIO settings: + ```console + kubectl patch deployment online-server --type='json' -p='[ + { + "op": "add", + "path": "/spec/template/spec/containers/0/env/-", + "value": { + "name": "AWS_ACCESS_KEY_ID", + "value": "minio" + } + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/env/-", + "value": { + "name": "AWS_SECRET_ACCESS_KEY", + "value": "minio123" + } + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/env/-", + "value": { + "name": "AWS_DEFAULT_REGION", + "value": "default" + } + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/env/-", + "value": { + "name": "FEAST_S3_ENDPOINT_URL", + "value": "http://minio-service:9000" + } + } + ]' + kubectl wait --for=condition=available deployment/online-server --timeout=2m + ``` +1. (Optional): check logs of the server to make sure it’s working ```bash - kubectl logs svc/feast-release-feast-feature-server + kubectl logs svc/online-server ``` -6. Port forward to expose the grpc endpoint: +1. Port forward to expose the grpc endpoint: ```bash - kubectl port-forward svc/feast-release-feast-feature-server 6566:80 + kubectl port-forward svc/online-server 6566:80 ``` -7. Run test fetches for online features:8. - - First: change back the Redis connection string to allow localhost connections to Redis +1. Run test fetches for online features:8. ```bash - sed -i '' 's/my-redis-master:6379/localhost:6379/g' feature_store.yaml + source minio.env + cd test + python test_python_fetch.py ``` - - Then run the included fetch script, which fetches both via the HTTP endpoint and for comparison, via the Python SDK - ```bash - python test_python_fetch.py + + Output example: + ```console + --- Online features with SDK --- + WARNING:root:_list_feature_views will make breaking changes. Please use _list_batch_feature_views instead. _list_feature_views will behave like _list_all_feature_views in the future. + conv_rate : [0.6799587607383728, 0.9761165976524353] + driver_id : [1001, 1002] + + --- Online features with HTTP endpoint --- + conv_rate : [0.67995876 0.9761166 ] + driver_id : [1001 1002] ``` \ No newline at end of file diff --git a/examples/python-helm-demo/feature_repo/data/driver_stats_with_string.parquet b/examples/python-helm-demo/feature_repo/data/driver_stats_with_string.parquet index 83b8c31aa51a5bc273fdce897fbe8a8473179d92..ae8f17e45d3b840f99f0cee550030a913c50b232 100644 GIT binary patch delta 24528 zcmb5#Wmr^S_&55YLqHk~Z~!R@>70Fk0THAdB$O7Ek`U<`6}vkyFt8OxL@_Z@v9Xm< zY!p$kQO^23C$9fF*YoDQkk49sX72^g-Yf2VP0vd4k88w*!S12f8j>2zEHu_x_-ovi z(%`A`cvm!gBY1AT3seUG)#7#TJ{hIT6eWJxq`~A5)p@DSY`PmTYB)1tVa3!DOrB?L zgFdsmzoXxf>2_3kj487-%YD8%Q)HBPYqXe~LdP}@TQRrZ*tM;(4u6I0{9ws#&g^xb zoc&t?v-(`Br5n?&WKF>sW~WW_2`{Fo`v=dL$=_k(9l&ggi(VYeOwd@+8ph<^JTWAS zSv{vaKIXp$Fns3(R`2}b{~>`XI#h0x!sJgrSd_+W((5=gMaT*XPXYZNgqFSbCK&iQo$v+Oo24>ULsbP)G1k>QL*caM=H(#@m~XNbn7>( zcdpLS_{S6lR>z9{W9Q4<*(%9wIx7@=E5iy2MK+d$m^|y`0wrekyOk$Ym~PuH@- zG2gtjnIbi##lx8V>yfRx|20rKcZfc#CveB(4Vk>}Z);4L)dz=sFk`x<`Pz(Tc8(}5 zvSErI?K@-7C9{r`Y8GdSRp~2mV?PVnf=g%S)IQ{$Lqfa%x@?AuzKf9-d=yE zXp?ns5R)I7E`yvw2s;NQ)}u`rs(jvhT}~B zl;ZxA|23e$*W)a!Cp>*J?>v*YYS67q%xZsu##N@9R95VDX6NZmTW>K%g*V^aW%5T4 zSUmX0p8uvdqYECh0}0g$C!R5R(aU+A%xabMUR`2teXU~GzASpp4s^~mXnn^NxrPt< z$mI7`#`iLt_8+bJ%1lUo-Tj@()77&1&8&XlUG$gfwzTLBZ-AZey+=}#DdIixm0|K5 z<(CWPS)nP{^`Rm&VN|A$3X|8hFK->0F>PKD79x=hif_%b~vzhc?> zk<2ECbFwDPgioIX&6vF1`l~IO)d^uwt(k5^D)sD{opjpffIX7&4tG>Pf9eQD)nW@l{EwM?c+{j>5^CjW*|FDjoEnr4S?Dr6?m ztgd1v@5d3-a%Oey%N!BYEnTBw7PFJ@)jx+RdR*wSfXQF6Yu;jJlkcOO%a{ogavH0c zyi?AxYnjyrQ?_nkx>;1c*~IL8)nc)gDcanhze7x<5GAxYvFpE^Xz)bdK6X6e$`Y@G zOx}#Miw-lZoqO-rGu^)Mha6{i?hT1Q$rL5etU1Hv4?ogI;AD-!a|F%*#G7JMAW%|HKr1TrB&Q$=`V<@cVxaO#HO!7po^|@t^iHdAEc0 zcsy-h^}HEr5==Mux&zY8&R;LS$}vTCYA%XQAwR=Yq|6FU2GcI9F%zEEC}=Txs~?08 zWmX5stk?Ok0cocWKC5@0NjDtH6ir{BVa()PT{}FA+4T1J4-00(Rx`3@@?zpD?U>c7 zi?2B{-C9m7yD~dx^+Xa=Bp9*Dou@72@xKLidH&bH0g>rA_Cms>!#RFT-iQ}R1DVwi zRr^DjZp+4aL@+zY70esY6p7W`jAimq+*glhHswjjCNUGt9Ji)2c`wu6q%*5GuCvJe z@A?r}^K)3eQ}K6W9#eFA6t9pcWET*|dKI$+O^%Bel`#`~Pu&$Vd3!!;&tg_5>c-Du zx(yAgna}LJE9_px6fHbty^P8Cd_H~Ue+~4joLR%_2}eC7H!ykG`M#T&)kZs(RWsc> z?mgJf>|86Qvx_MTc1Yg)UqgJkv^@uyP3P8rI>byUxnf_>1 z$ev;K&bWnv=a?dmQ>!jA`8Pj2X=XOf(a~#RCLl2FCX@HG;=mnd^`Y9Y_n2;zJDndf zJM~mVPnn`8?w5t_tiWHHr|^RHbg#txwuioA2NERjt$WMlotEtAW>!zLH~hqOvz(Oi zh1vOf&EaoM(U#^PKbidTKe>Kpld@^$05jog^fhrYcK*x-%F@hgmy?llOt-Jy8x@$H z`-XQ7W{OgT0j6rKz}KnB(PTE=KX`NqGhs=`pW#fNx3UMHSv}x3&w%N6Joly%vvcY; z^-)ZbX={uHlm9|us}-|pgWVfjWwU7vjTtD{JXx)ruY-ufy@N$?uj8x-ko7J;mqp!{@qbbH;;1bSZ3$%1JfrmMMv6C zCo=h&gC$d$O(WfW)0qivQbE zMw^4#i`K&b?={{|)%Y^iUt`1Y|FxW`cj~hperir3v@fh6i(_5X6JO?>2+Q7&B4xw5R5`@}&TBph!fG5yazh-Bi%W2u0y?R7%X}&wC!&XrZb2tJYMITVR_myZrxxh_{MnCjRsRxS-s^JRvqTP zEQmzG@IAVy zh2~*gXcF!^kHWJrzi6G6DT;M`Nh($uG169;=l_+4wkpF&bAa?#7gFlw%fxHnCg|;3 z#Fc3sqe~NdD11~7#=nh%$F6LunK+kn+BC2x{ydF1AY4du#%9A@Z8IIH%|>SJH!eoI zgG=14ftPh9Sm+u7Pj?U8+G>P(@3XP4vZ{UmP&}7p ze%Fy;Wk2n>-b&{O0 zR>9`yhpGLS2NZguY45E0uD{NELV53RPWAd1Zl13*D^PcLzTkCX08Xt4q7Oqa&^EIj zl;!!54w=lMPE8Rl_^^R4G&i}@!{@Z?Z3opk6;S@Mv2ah$MMmg2oJ{m3@C|Y7h6Hu% zkAb$>6K?On9L%^{O}q3wgjnCRnS6cgsdH{7v?lmsu6+>YS^Oh!`zBh|ZjKQSw@KrC zC%Fu_M4UkvXE`+$b@r)<8}LGULmc+R=+GG-Tlij2#*UzG^wH-4>F&4Yg8aqlQ1VCG zz56<6^h-qj>laeDOaShlccur{kIA6Z8H1h)FOYZnU@AJgh|5(n!@)bH7!@^x;tDuA z6JbtPmPzp6IT z!HnJ=E_tS{WcfE4G7GEepHmHqo>h~=yNE~-TiP=ihP&q-dJk8 zn#@n`BC!!mNn*AYlvB$v;(!x|JygIA<1^%^*h^RXs%yn5>3i%k-^&K$70PLgcLXeU6arQ|Y0&e}BnVjL zDjf8Vu1y!waIY@Gn~CLky!<@%ot#ALXPRLOmx`oEw)i$V0@H)MaPV(71uJ#ZmW5T^ zjV=Q$dtiztwIMVoJOy?yLSU7cDOmL49;JQSLLUoWa6Uq10p{G<$u(Duq?Ab=G;Nv# zw_!*=bQh*0ys!k(ZN6A5%TZIoc+TqCBy8?Cz?~n;xZGMwj}1<84@T?K^F$rWHH^d? zA1^GgO5x^J&Y=ZIr($t{6aMCZp&6kL$gShSs_!Q0Z`wv}pX11hn(2(mF%rf{uI1c1 za%rNmI~udkx}NQu2<^#HXh}{&@9687WC(6d!VT?gG%irV1G93hcs?04pg^v*7f2y11bc3!pmJ9` zxBpJG5IL^F*esUIExPK30m&8g>d{BqvPT~MHJd5->uNGKjDnl40~7e`b!JbK$W5eV1NvI+Uy|h94?pFjsjL8U$Hrs#{K}%EKXc zOAQsOqhSy+m!>APapQjo@00TlW%M||;T#IeFn3H423Zy0uxB|WU6Zge$N^__kJDnk z;9QzldmUecNGX$UcIq_;w`5ZFb`qP)|~xhL}7R3qzy!yS95*Hwxh zg=`_u5A1(CEFKq!Bw@=+J=%Qi64h)ngLK9O%y!ViS+^?`YCa7;5i&6RGzyJhmFde0 zeLA|Pgwk!(5Zp5zUiC_x-Jx$}uHHm7{o)vLsgpkHexR@lB?wpOz2aKF1VC~}2u0Yb zP{ie08b3xGJ`1kU*Uv`e{^>8LZ;^|Xz#O_<-%fQuesVQx26(>b1Er21i33HJP%N53 zDm%oeXu3B{@*h*gh_QG(+CwmZ@0ARis9x(Ahy_;k)q+RiEpk-n)lr&&utzHA@4N`K>hORTud> z#^N@);p^=r7`O-*bH)0FG)zYl&KLC2bowb*`)(Jvrf`Y^F(X#c8y{ zRhqte4)@&gIlU((j2|?K_I+@L%bD}8-J>Qxt|Wo@1)Ix zI?3~t33fb)#UB>~e9$&R#baZtYdA{NhuPs|bbn~ji+-WZt`@`gH}uE^c-G7g4~yQ8mN-n+ou<)xqw*0JP8dq2}^4oWp}pTyBLC zonI#0Lks$~(bqMSa?d>DEMq&lRL9A1t31Z(DSV`OgI(N$YBi*torKfPd5BQmNM$!K z(yw(RalJZ0{@KQIz!3 z1A0BGsQ;o#9%k8i)xCgL&XmKA4n52l%fgl%P23L7!O3Q6JX(@LPGJ+Ud(m=A?uo}? zhfr)$6W~JDK6=+u28Gz?oa+R8l0KD)CufFGmGUyywae3uFF%E}^l&5{j2oZ@SG}m_ zupKtFNn+xqE98+j4XxQdG>{mDorachdJzv(9V1Nko(N}IO*pUj;wUbSK8kj$X4$kPKyYs!EV$@41n>eUVoPeEt8EA$rB^SwUlnjaU^u!=64 zVmT63C0=+SbeWEv`#lspk?74f8O(m30onEC0`)UT$z`Sz&AOqE#~-ASej$!>yN6+% z`vDRiv&E_*^5~(7SQeX$H6}?|dwMfBLt6^wQG?Mq{{SsF&c>~!1yol(5!beK(#`Vm z>`8ERbf^qD)8NJQ3!REgnu-ZQct4_=ln9ZLI?8-;h z(1(I6?ZK23R*c|)9|8mMRID1DNVw??{^fDF5Ee=AAFLr~ z$~cwtM3A5RmUMTNay!4gr^A0F@o?}SdUMMWdB>}Tf@#9tRAzUY`+WE>*%t|L!&#n6 z4dv+Lrzk>CDA@X5-q!vhD3vP6#P|08$KJN=9d^3D>O%x?l>}j6;8#+ zJ!n#6JB6mZA)?m+Ei1<3{>+bD{+a7!u=E;zTyu)^8$63HW}YCPygwr9OQBLLi|lRw z@D^@speMR~$c*$v)lS0X(_d-oBol<{Y7*DS$2jG2;H7JGjvFS>9n)NzF@leptRG~w zX&JZnNjfAY*U|il;dpkcio_rL;$)H*bcTkse6k`ciuh3e zTZX;sWU%u`B!p93i|KL3L>v&`K`pn&;C)vVW*d(~_=y}SD(G;pE>+T{k6(yq^N-uE z8-t@q)1fY23MFTM6z6WF`SU-Mm+gJpG-)J))i;vii+N-|#uIA_6w#8Qh7>P(6rTT0 z-HioU#@3V%8qDEr{)a+$c;Rw|wh(f$Vw^X&3)ih!kM^Cme6oS1 zbQRT~9gWBC@uv;^RBM=|%?9QaHvL72T9JWhCH zqKYC+jYV{~>^OPrte{mk?r1wNPaBNGX!@j|R5(hQfS#435a%-mH~Y@gzF#qPW#Eh8 z(f2u2tiPRxKgvUNv>4pN-;&G1WI=nwaM-~OeOCj~mTrVwPp?pZTMCNuzX@{pt)P?B z^2zFIIZnOwg6P~o&T_^mEWTNQrJ5H6hc|npwcs`l?NWr{`5@YL&>Y)^Glrvib_Tah z*$I0OhjPz@Uee|V`LNsagd#LY;L5f%#Jv2=-HmUczLp_aX4Fn54tJ<{s|w!PFQcCE z`qclM@S->YBbwUbUd$jwH4-3HL_-qt&H3 zkls>D%fwr$rzIEfjyKb=+}ZS@&KA}xIp|+~nR9BgfxAMNtKzX>6va-$nk4qimgI9W zza(J7?sCz|+1!me#?&xQLkO!PU$}jKN_9Rf=qFzln!jUF7cdrw)I$*Q!UF44-BA1W ziJd+8%W82Agkr0spy{>7Hjo#YPJ@1!R;wc)`>!X+hj}?UZ!7K$7vql2dU76 zm?e!Ukp<^UsJySC(gA^9^YR#l0mo_jLp;NhtTp3nS@nRz$ae}Uojll zqdf6&i2=9~?`W`Loa@O2IdJ-6MGBdgW{gTDpvObmu{QLKf$| zaI4_QIw4~HY+?H?0O?V-oMiB1=+?H=A*CYc5UCIp^+(}oQ775Bhv2rFE6Me~6bP@+ zzC*g+!6e#njTAp!=gif#p|h(5k4vWF4Vx8vG^gXaMhN!W8d7GTCDy(ULCLTT*ePa^ z*Nho7F8L81Z>vC>*?THok&O8-ho2wWS2{CCt{9inyIj!m#S22d(RxP0f|Hmm}XG2}v z3WCVflzw9?Ei^BI+@5-_cvBXpt`=%?6SFPhxZD}yF2CsHVRQTq-9m3n&XVz^c=$aw z!XpDCWCaxB+&4e;8XLo|L>EKcE|HI;3s@Eq?n7Ray1*R@{y^oJKC+hKa5G{acO@_$ ze;s(3X7z${EkkgGb!>y|U(s9}LtL)WfYE9bj1saFQ$7v|1P?n2t@Sw#k8im5Z`6;aQtH^SC>3i zhy^trNei}T^Wk7e-Nm5;Kkmg9|K>va)Vjhmb z#A3pN=gl-{4j($c#@H4hi=QqJso&k4>Y^R!WD*2F2QqPa<7B9ZHE{aBT(J55H2Sje z1=&1yg7M%(R23-{$J}UR?v{cIu9PewyLK6zzjuZ*9p=(oJ27k&8;s;J#l@#rR6vsezH(c7u@={RVs$CFp<2d;6o4OOmiz(!FxbUXHOWuY@^ zikCBG4M@PrFb&GRLJU|grqZl>Zi(<@GR}o5LaEUbv$h|g8A?JN5|tdBQ@+e3olsA-gx zT!?i(o)j`C8-ruFQRTT>n%O0e=m@NxULyB89(U@Mwjg=jWmyS+YtQv{3n&g0|kTG<#qU?O(HvJEdoUlZsg!Jr1FpM%8q{&;mE1gzk1%Z1VKNx$6(e zVnqCeTaY<=1e}lNa-SpZ;h=P!YK|(QKmRoS8F`m8eDjs`uF9cs zb1q%{2Hb)9l*8xWRiMsL%?6spt@O@5sgtkuS^t{ItjFDk(ww@+?F=)_`bRCiE4r6J|T2 z=%Y0q9XX#~@$_MO-4Ff6DQK8lOfrJ;SQ|H#V%XX)UUMe*Fx-*r2zJ8y@NdpTT6Aa}iV}*kJmMmSto%juR+v!V_~YzRRfozR3v80VN@qR^k8*l$ zj!2logHro7N_rK8n8ilOjoiXH=k4Jd#Lm&mnrKqCQ@~5V=iIFDv1nNCfz;S^L=C!3 zlg3QJg>4m(kC1|$_H*jEV2i6$)VYCSzEoF&PD>k+_@*w$C-7Pi==TS);Tp34XHIpm^HAZUOJYVp!-i}Bk9YsS&;8%0Q2#gS z&zV|sB#Ie_>RU<3HJpZNdUrVQgMk>^Aw~J4$X-V-~DoM5@`HMwh);Y9xiPVvDQe0HxT3xkPp4Fe>gD=0m!gZxDr zIGvD4{KrBFGv=?Sbx~4q|H4D!J2^mC2K{Ppsn|Fdr=#?-;r&{QnP*Ft!Tc{Ox(=kDk(j5$7MK$Ns8gqQZMY7@q?-#j)G3@aT>%P?(lIN zSi;T^LB|UqtMP?1kX}u54WyxXZz6TLlvCx515~oLlESQFsZGA0F06Y-EoaB#0y40U zEh^7U|3$A($)M}cH98w6OMTBOpszI+UbA)!<}DO@;=PY9l+52!)(Sbim)=N8@gW#q z`JLmnFClNm)7-CgN38fOL-{&a1>zYFsNOLaGCgXrl%L0qdOSe#7Ok}I&`?;cv4*&K z88)9)!Mtv5&S70E^~qWYI=1WMo~$LJUrJM~Nf1)+h+*)ntb{v9mtNSZ*Rttn)Eg9 z($EK#rlW)G_L*E#@mTs9`;yY`{SjR3K1atI_mIj_Us~p;ij2fqwDO!$UEzyq{Nbo` znGE3Hm@PhRPZNli zx6wWePulod8eeObadzM@m8~kF`v|4aEq|$Yfe*ZOc<5Ou!Cm?}g+(G*;CSR{glnl{ z@Q!b^H2Nj2KlYC6NoW0PXH^l>FAsximI~5O-6q-j-n6Lum%uwa7?EGTk(x;!O7`Z% zqNV~>1?-=?I|OkHX~p5;2s$56Gp$2$eri4p_;Qf{EKYwNIUHQA29F1tTttu!zV*o> zcIO6iJQ0qV;abp0+AJ7XFoBE@%R(kWD2DbNf-e_<%qTI6i^zj)+7U7{*hq8wTey}y zTU-}qP{kDp_V{Sx;(kM{)GWpw-3ZJ}PoNl?m(*oZPkK9!)9W4|Zph{3bo9~{fsB|9 z4)5}Sp@{&!@As4claV-AFdet1yI}3$D-<%pnyMbwObyg zdoGawRuP?wTTaJW`uXz03v}6C1-Vkw@a}pQ1*?|AMlB42AXQ9okHfyWbF?-)3>(Mq zC&Qpxf#k~hbgd&7ZZ`t(CC?LDAF|=|b-G}aeH(2HxX;2|K8Q@v!^x>@xC{NA+(GAZ ze3Dowq~H;EX~_c{(l#l;hK2vQ^Af`$;HN5jT!^d`4 zTG<^%TOMv8jmD286(xgoQ$7|v`A%KJDA-Iag{6Kl2JKkJ4QZK2$9<;4YurULdDzMs z`A>(E%3i_GMPgX5P(?+%g{H8uvxPPRDdu~ryZ8n*O^xGLT?v5o>HxGXo(fybX=rC|S#FOC`CMEKE~kdnne_Bg7n!WFM4$FAT3i-^t1?jtlgop{ zw2id9z6hi0-jT)|dzj@-rn_UJsLJOPcStz7m3zFuog3FS3dvz+IC->&B!8cvAJ#$C zex{z5uPnpux+pqftBm}#0K|FuVD0B?EAPiFfTs=2N$X%MQ#DN zVcu0PBhwqs=Oi%)e0+bW1E&-z6g!NCg6%J|yJ?DP_j1rU=sx8)w9+1%GqiE+K5osO zI4J98BJol=R^2he{egXyb*_qL?_(46i%%(#E$%fY=|VJeFXhc0jnccZa9Ga6nqQ7W znqgc^>NoT86$yCaT86dnC(*JuyXg5*1lqe<_Ps8>LaD zkwj@veUUM36lBgDL*Fogq`!Tk`-QRG!^huf$Hr4!Ab&gUuvJ6!iUm~K_m(tAq#*b} zI{pT*T?(bUC}^FqfxZ~};>vwHD(a9!kby3a&1j@K3pJ5j=#4l#Ns#+%y66!H_r0NX zvGxxgEcZmmf;_tX<`BKj)<&jk1!h*=CacRl#F?{2;BJ;RJXuFlBd^oKgPORrY%3kw z9)-l7T5=0EM+myefbW9J^~bqJp{xT7R|zqBOFjY%J>V6;oy1>z(YfKjX;Q`>%Gz;? zM87|C!{XxcPhtYk`Rd)5NCH<**iiL8H`$li!`82uo+zXVtQ$gz#pdIO>vvil{Fy6wJdgez=OJZm9t7rIAn9fbtT7VOh>s2Q)#D7g zPOreJ2@gp*Jqg~w)hT!`55q(v3cXfCj+1H2Tr-rh4CB~t zAN*)v;M%sql&!m`K|L!RttS6iCl<^}mtLWN+Y~S(HWG~y#w7RF95=&@5Lsq}`BgIb zyFi$Wk?&=(yW0(oJ`tqu&N_m4dkWbTf-_^yp>?Jl{_pHz|M(A)=~C{T#(u6Z<~z67 zy^p3W*+KInlQHO_5^U{5@cij#YRW!CaUCX{pWQ-hy}sV z=xfe)T9)&e)|?i{T4B^UyvsU9;VeL7{W^vYwJ1{37de&>*Fw`QC-}`*eV}1N^(wOJ}$46a-BhOB1|Tx<1_I3zc0K@W>g2U8)a-oWG44rob9^`h&2$hlLaL z1lTg#3HLmF@K$m#yjXvJefeWL+_{iW^_J3mD_8vfbC^Cxnm~004+q!TVE^KY7|Zc6 zw(cQUns$|}yItWtToWfICBpN_Qm&~i5K-ZmsBMb^XOjDZB2~Px@lP@5ZYNa0^g9{2 zSEYuAlw!Qf4B_r~Nm8o5Ev0QJ#nCafoc*9ZG}m7j@6+^XWlI1H)NP}V5vRzfJ_VyU zvGiv$4^^vlAWRJw#H41^yN(Z3sA569dzUCsb1!#m&K_>7jUyIo7edEI2M0^4sAP8= zNmPYFLFjghbPg?{ldCe(v@V!blC^1E^E4>Dcu$#E-f+8QuaU_vS1O#INpILrvWiI@ z&JJHoewV$8P7S8i#o;&-GYPK?GqCBc5T{b-)8F1odMoEnxeaq@xj=$m80Mnm=2To= z<&5cGZ1ae=I$c>5L;I%cK^Q%hWe)7whMk66RD5eIJ!4(#(}hJymfOv>i{GXUhwpTv zt`sYRHq!C-IA}ezg5~HC*k*ZPTIer<)5dX7T$Yd4`>u3qt{)bZ2P5vl913fwrM`M@9+b(;@UKDeH=E(?4n=ELe6p@32`)O2?grkCyS0ru-lpl$4|Gow#`o1 z=KY2u&R?PuUnwk4kc4)A1&qoDqv>Qk9*As^EEr(ws9 z5J(BkuueCK{Li@|&?tiBXMLggmu{T!rYem%TZ;75X{eJMBrr7)hEVjUNGS9^r+p1$ zS#XZ6Xnu2$8RQAQ&h3=DCK=BgY&c$P8f2DT=h9B4BX6N8qE@&Gl2?Txq%)oR_69@q z=N4|CoB$ff1W4>o=G+ZhsUlzqMx}@06x;YQ!c!Z@^XtjXQA7oAG_b#`ntDGCMV@f2 z80vBfvhqsEP_x3%4G+oka}g%$UE$(&O0b>n6f+(^0+PY6xd&%+A-=N$?|)lyk$tAz z>w)duKH2fu=K7nY?ufXEt1579*Q8~78cq-F5x-V|1*F@P>DRR-3Dm^_xr7l(2>lmn-1o{@DUkku}-|JX$J`cBPtabH^^_q?|8Y2KWXNUQjDhN^I6Sh^ zM^0rrvbpW#_EH;Bh90ONvWyHreuv$<6NU-ddR`Lw zQzB_E8&(a!&!D|qRB^}cnn3D-KRs3Ms>1%3g(80pYVi_axNV}2Y;+5U*O&qm1qQvpT&iI6>fTu^RUh(-SADQ)U` zdOR_lYaP=k<+I_2-g0lL!(b{ay@V8R#G4owJ(sDt}HAzxI!v= zB!05_|G!Qav=i6Goq;Y&SJc3_Gj^~LKTQ(`ZRf0ieda7%@6n9NajcuIrX!s%NLOPu z^)@AA^Za)tu}2>t*aEi0&>62k-JtsRQnp1}_=D!_CsWXGM<^eg#C3d-63E|;7WkOT zp?$4A8mtE>>H2cPtaJ;~%CkWE;2`38S|ai(o0uMfK<#xR`ZEjBRg^{>*cL{^?vuni zOFCmyzI!k>I#$l&}9@eWz;#rdwCbaNKV%HUV8o-g+ zcn|!2P{9?jjXmj0Y!EwF8(I;6NYFBw*o>2|PK`pQZU{P>4Del7fU-}{_~W;bgf36& zsaw(=El>B5!kpzK-eV5gr>E%L0uuzylOPAP#iZfJ(Rnvd431VsQi><^!=KUG4Yz4p ziV)w}+OH&x5Bm@GH2HS{seijlKUv)Usq`mu{IrZpEw56H=p+?KO~S8(w`sz5X)IG( zOW*IE;XWv`=fCHs5<(A8ftu`9GQaDNm%%zPQ@0^4Gj&W#6VlI=9Ly663 zsxvQA)W#(=Xmk&$?9zn0?{$ja?}DzOS@@D6pyJt4s7;T8%EWM}v~Q;s`-kCc@>(v$ zpd8w~C1m$y2vp?4;dhyZM?VV*XZ&W+zi(&ATQUU|R#Ge;dWSk@iIayY7uu^tY}c>_ zKD@d|G^z--8|*Q=_AV{ZGR5OxX)rrA4N(bh_#%--+iT=#Zp{G-5Zxqew#`*1Rvj}! z6Dec011_+e^K||lns)lSOGVyV3S93&rc<*iS{U?!BtN>*=h1uU)}gs{DI^V2bF9$i zx}U1oN#lK#0~Jov;Dj|c__LBF@?QI)VdP~hyWdPzAFbgwX#w@u1R-zoAUcveks>5) z=-PcPE_IeO^hN|x;*up4RuGAk(-o;LLYG>`Xdw8QBb^Up8v-p{NjNQphs&Nu*dM5b zMu|x*vZ0R&XA|&X%yk;FvWZ69c#)lP5~Uv3g;-P}N=FVujj1~B#g6p zf?{E~$PE{DMxt)2G4_tOL;ig?EHKtZ&bS-&wz3%B>$I^&MGL;NMzne4N%9*TgQhdN zoS9uK?c7|9GC5&7MkQ)-i7Q!3$}kChHb!rHV?Zj~J#b#`5uFvV1b*rvx^f6Gbw~qs z%AxK1TB?-ZMc1x2lS!sBc0b}_=+=?AS&@!lM<*JwEQt({@1$Mq3m%Gr2gvM>A$HF4 z$GvSMAbMIsK9^lEZeBJl=I6T#g-@PvDb^=wIa{SzU)WCb0t^tOVa8&8)-X+GsVCD+ zoIIyWFCU%fKD5+RLG)AvcB`ZFeH7V5NkUSBE!WRV;q5>MUqTAsWL}ZDk39tk zdZ1yoB*jaI~``p~}2{^w^ z*vRdFI|3162PylIDO@ZkQ}$0+Xzntnj7>gh<^Li52UC$&lz`9)r>ScBCQ6@W4K4jeOF|yYlT@}-*xWo?_5CKd zxyqY6amACHapETh7#JXX^$c2g;wU}P%VbmW3pDA5lOS1x&2Xey4=3Y>IH5muoYZM~ z!fb)p`3HgmDL^P4fW*XmbpB8tRsYF^FedFTJ)JX--uH*o)vp`4m-p{;Ue?J7=bxY@ zLoSl1?FOz!wg{ugm!No%IULSwQ>f+`*v2^uDt3Be;Z0-idE^-AcUaTeSu*&|w#gWM zOGZ+;9Guh?aBOG@R^HLV_Cw=H8FKhj`Gp$)R+Gq~5Qo0|3$bgRCuZ&Jp`I_&R1l{G zr?|;bxYA5T%)&cMLY1GLRN1@h|37<9@HE_=1O(hX@a7!*jk`_FLaKSn`MXAw;q5dNc) zibn=NuTburt1mNXGhpKqfSU3uz%ewrGj%%C@P z6ue&OVMEgv(mrB9pN0p(_v$_h_Dn^Kq$BoqMAO@SkEmVa18EqufvR5v`+6M6+t3{E z=NaPLcpq9Q+}+Q8x>-!tRiYSG_1A>VppVN+!#G9bIF1*b3-fbn%~M@FS<{uylVpO0m8lA{UpsM3QC9kDaI-f zT1nP8HQ<0GBU=;&?4>9pjwU_rrmx$@Ddiv^seivwOG+)Z7rJxq_pMyN*^a?q`61Np zr-5mfd|DP4L!XzvrX$e6$Snd4&f-v~*+cHy>OyR8NrB!3O{gA>0OC4m!Q8tp14pP_-O-6juS0D_2M_)PzzB2Z$dMj-2-a*tg^VsO3C> znoid>9y*~(384r`GlC#p6eI}&=|!5-q!$5^r3;7#P^3zWAVskNf~Y9XUZ@MAAZ0}a z)`Fmj;v%}Py6om2E%`abZXi*%FdvwSX8E=v;KcrJ{tx~qX!dd_sq~zq`03ZoHd>f)rBQm>9=fCJEY&7Z zb|rmciI*nvah|rDB0=Y|d_bFDbC7oEuqypQw-c>Dft~i`XE`b<+lYScJU@N&us(i& z4&P}0-OHcLKbMi@T>ctk`6bNX)9){Tid@X#_~*-?Qj)xW0RgN=12JaD6Lw0&TQ21=?|qsXYfS?PkLj_8=1830Q)AxcqbgohqOK zePj;oGlyZmg-M1sVIT_5-~b2gP>MmD?C3m0PMkS|MO5>yk1xZ!`mtP}YJ zC_>kQ{1m8wI?VT?=Ld0M9Nid{fSwCvVP6J1gGz;iNg%|axQ%cKWgC{=;2Y6(gTv_d zqw9l8B7cE?hdCAg7N`eXmd}A!>=j~G1&ol7Ag94AfoAM|4_wg;p&x?gp_j(}69hma zkip;{xCNgLZh{;f(#7((;4bnvm{+6w8U82uO7v>TKSJLDIj|mmA+!SYVfGj~8!TUg z?1D}eXWOCCm@&oi$1@b`mv0>92_OrCkPm>f@WIgYIP3tmfqoDD1v-thd*DgfA%Y%s z_QWBm5zy)~#yaX$dHNXkXXQ4Hq z1+Z{q*3cRBQSdo{33j1lM@NR=kMk2y6LeDOdZ9GvC#VQuW5dV)IF>@deiR;94h83t zN0A-j<=_?ILvZj6JP}%f-T>WaL@TbRBk<&^>~3A+Lqr z_{SqgC<5*%h+qW{I3ZgBFZhEvM1=1F$G|*xa?zy&Rm^VySM0x#On1aLTx1&pg zF99at3o;#Q2+e{rx8aYIfQ98Hlt%E6pk`nl(8irS!K?=TWoRHeDRgO|7u`*;8r^Z^ z`|yjv8TmQ#HSFJjj-m4b=RiF2T3`iaIq>{PV>tBSf(C*i_+<1Kq0hlybiv5Gar?H=%gC>wCdeB>1^9&fCm3T8fx%f6AE0K) zdhk)Wk#*n*IDoDid>xFh36}fd^QN-A(9S;D3ZKhcci% z=r-U?3w#EA9~2|P19Z&aQ2YvGgklW70F!Uv`=NEvOz1dZfB`TGe#ZVe>^8x(LnE;# z27eP+WA*?Y0~!JpFkX#m@S9ry*ECH6+4rpTG_D!9E< z&qtwA<{ ze+^xOJJ|pfKow>O(4T@|3B3;WK(`s*7d{VKzx?@M5#=-r4HUP*8t?))RswYc!Hik)<*Kk@`r3-?dZL|F{VP;x_Kq319(!{I8Z zJv0rP3f+hqJ5a&i5U_x^1(TSaM#i&AIE~#fXft|c_!P_@feSzoeLtAQYzpjJ{`yZD z0|{UUVwRVHJC-)#uojl2kzL^hz$N5n%vM9|kY&Ij3upe%H1&?n#yI#1-^ppD4E&|2g~+_?mD3it>d&;SR)MkA>XPkgR+RGa4y!el$?VoDr@FNIc&ZQk_o>!Z zpXytCu4SrE?P8bWUGeJ+g$!m& zW9)fnmkRO>qmMQTZ3=C9KCo$_OKHX0_k+&))W?(ix6r=4>BLXDK2`P530%q!22KK- z9T?nOBONyJuC5+55m-Mhth$0hD%>n)XX0o^_B!BbE^bscWG=%r8!WU~l}Z}sR9vqx zXsKKrIBca-Q$1|0PF&>I3~reow#jcJv21mRT%1+(y90;qHqKO!*c%-i8QJ*u(+4pJ zhMD4Fsvc37n0P-qfnSa#sX(TLs7MH_nH4U4Eu- z!l5>OdLp7?VRVA~+UJiGjCq2@(a25_9&w@@ujA3E9)-(hTVgGXoTK~t31;itxmsjm z`ct3E5bmcEyke7>De=6zI+hmqV$V6c9*cYEcf`x7u%pow=PUzsa0aq+0D5a(=7t+)DXt`6@#Z*RV|QCv_JJD&W0$n`kI zacuXheNi(#-G*QKn|*dJ{n)cz;MZSxd{fxpc6O$4k}dO62iiU|@1{=O(C1Afu2mrk z{jjdrcfSyg*YAL+{g|U5X{v^iPA)O&Oqcw~a5^X*A<0Q$BkIXyNXO2e^#398qe7;F zoV>rWlC_DV$?Eb1#q3II;i+sjTJO}Rb!5G?9L)~Ns@yu+5XKfWNnIsr^C7pYLs^q` z>^V9PY0qQJLyx%RF%94L5`&CHhpO`FVT-Z_ueTRD@)5XIr^%+Gp92bI)%SPn*R{l3 z=b1Mhk+)rA%$;X!G2rB0_(cAyWf7OknS3jo$0kCQ5Z~wQtlXs!43<4#RUeCNEEN>S zQdXKd?4scwfOX1ecS}xSK>soL6P97*QRg5F;XLOw_-zrVKblmNPt#io`$6JG#m0 zWT!_=(NpPTaeeNmjxl$OGRjR7t7jkHQczw%y7SOE4Y`NaGT~Zvg`dI$D1-PZnf}wmcaFXLPw)Sc0E-%$Gy;$ zbk?Vsn=h-T@wk@utH#H~Zjq+2aaOoc`T@S&CNfcYTxCk&>YJoj$vx_8&aHpw*1V@Q zaW+CV_occ!)3|Qj`Tob+yETO-D|e;u6SOH+&y!j+FF4|4rK>a5>-pB4zWgNZ?892^B#~28vp}}8bAb9v0m$E)MLRitM;Sc)9As zXvO)fgY_FSZvIA$yr3LARUsPmdG5NV)Y8WZQ7N<2e0N++)E}+0w^@0J&tj{DJTdKd z+vnOKhOo!os;C{m@JF_3N7~#EWOq&P^ZAMPIzT$?(y(`h>x=1c&x;8#Z6n(c4m!P% z?;vk-w5XTsJJUWhnK{?4xp*Z)rlmoFkodqMJF>%@`gFvW@9bxP_d&1BwvGs+&d9J8 zdg>+Bd$?y8l!T20UR8*lSY+>tk*ZhQ+OoA*BW}g>9c*jlhDh%mo>)z)SvH*M^(#@f zvwasUvZ^DrTkG+A?wol8{lm;CPj_2udkNPZ?Mn^U2IWc;I?8JHSqClkYrI^X5={~B zYO21lF9h$6R&wgSu~emCxN>h2hz1q?D_Or|0j~b=7)*BXOU33rYzS5tx zr^ZL4u}jgAXs4B#r_nL{Zc{+AOo!d`)NrrfCk+ZsY}s07#VSfHTQ%lmTjKSODmBTO zd}>We^4U9MbH*cgRBUo`sEgFxdftXttojm}1BFcjVP|ec>&YsYn(Cd*ra10D({!$z zmwM$XJArcQDSiaZ{`Cr&%6Lh^dROu-SIVdB9F*xy4p#my8P=0{uK)FZf5PG%jg*?Kki@|QtM|evtex|9gwC#`sn6l@_5=hK;RKhCn&} z91rX3n;l9?@~{GSi-i_UaZsK<Qiax9@lOr^n8T`-}~WiT&eaXFb`i%%AoG z4?y7S$3-YXmb-d}gYsdTrJX=t@s~G0WNk}O<_gahkuhSWCLH{Kd6!1Yzk4G_sXsjy z%3m&LGXHNbXQKS=ivo=Q>P3P7>|fVk{t|z9>RG93;~x+RnmY8R@PXd7l18tlm0mT{HMv{^W_l8sHCbs@~ delta 25529 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 + quay.io/minio/minio:RELEASE.2023-06-19T19-52-50Z + args: + - server + - /data + - --console-address + - :9090 + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + securityContext: {} + schedulerName: default-scheduler + strategy: + type: Recreate + revisionHistoryLimit: 10 + progressDeadlineSeconds: 600 +--- +kind: Service +apiVersion: v1 +metadata: + name: minio-service +spec: + ipFamilies: + - IPv4 + ports: + - name: api + protocol: TCP + port: 9000 + targetPort: 9000 + - name: ui + protocol: TCP + port: 9090 + targetPort: 9090 + internalTrafficPolicy: Cluster + type: ClusterIP + ipFamilyPolicy: SingleStack + sessionAffinity: None + selector: + app: minio \ No newline at end of file diff --git a/examples/python-helm-demo/minio.env b/examples/python-helm-demo/minio.env new file mode 100644 index 00000000000..b19ec5083f5 --- /dev/null +++ b/examples/python-helm-demo/minio.env @@ -0,0 +1,7 @@ +export AWS_ACCESS_KEY_ID=minio +export AWS_DEFAULT_REGION=default +#export AWS_S3_BUCKET=feast-demo +#export AWS_S3_ENDPOINT=http://localhost:9000 +export FEAST_S3_ENDPOINT_URL=http://localhost:9000 +export AWS_SECRET_ACCESS_KEY=minio123 + diff --git a/examples/python-helm-demo/online_feature_store.yaml.template b/examples/python-helm-demo/online_feature_store.yaml.template new file mode 100644 index 00000000000..7acb9582c51 --- /dev/null +++ b/examples/python-helm-demo/online_feature_store.yaml.template @@ -0,0 +1,7 @@ +project: feast_python_demo +provider: local +registry: s3://feast-demo/registry.db +online_store: + type: redis + connection_string: my-redis-master:6379,password=_REDIS_PASSWORD_ +entity_key_serialization_version: 2 \ No newline at end of file diff --git a/examples/python-helm-demo/test/feature_store.yaml b/examples/python-helm-demo/test/feature_store.yaml new file mode 100644 index 00000000000..13e99873ee7 --- /dev/null +++ b/examples/python-helm-demo/test/feature_store.yaml @@ -0,0 +1,7 @@ +registry: s3://feast-demo/registry.db +project: feast_python_demo +provider: local +online_store: + path: http://localhost:6566 + type: remote +entity_key_serialization_version: 2 \ No newline at end of file diff --git a/examples/python-helm-demo/feature_repo/test_python_fetch.py b/examples/python-helm-demo/test/test_python_fetch.py similarity index 73% rename from examples/python-helm-demo/feature_repo/test_python_fetch.py rename to examples/python-helm-demo/test/test_python_fetch.py index f9c7c62f4fd..715912422f3 100644 --- a/examples/python-helm-demo/feature_repo/test_python_fetch.py +++ b/examples/python-helm-demo/test/test_python_fetch.py @@ -1,6 +1,7 @@ from feast import FeatureStore import requests import json +import pandas as pd def run_demo_http(): @@ -14,7 +15,14 @@ def run_demo_http(): r = requests.post( "http://localhost:6566/get-online-features", data=json.dumps(online_request) ) - print(json.dumps(r.json(), indent=4, sort_keys=True)) + + resp_data = json.loads(r.text) + records = pd.DataFrame.from_records( + columns=resp_data["metadata"]["feature_names"], + data=[[r["values"][i] for r in resp_data["results"]] for i in range(len(resp_data["results"]))] + ) + for col in sorted(records.columns): + print(col, " : ", records[col].values) def run_demo_sdk(): diff --git a/examples/quickstart/quickstart.ipynb b/examples/quickstart/quickstart.ipynb index 9082baa48dd..5604cc25540 100644 --- a/examples/quickstart/quickstart.ipynb +++ b/examples/quickstart/quickstart.ipynb @@ -1,1103 +1,1102 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "p5JTeKfCVBZf" - }, - "source": [ - "# Overview\n", - "\n", - "In this tutorial, we'll use Feast to generate training data and power online model inference for a \n", - "ride-sharing driver satisfaction prediction model. Feast solves several common issues in this flow:\n", - "\n", - "1. **Training-serving skew and complex data joins:** Feature values often exist across multiple tables. Joining \n", - " these datasets can be complicated, slow, and error-prone.\n", - " * Feast joins these tables with battle-tested logic that ensures _point-in-time_ correctness so future feature \n", - " values do not leak to models.\n", - "2. **Online feature availability:** At inference time, models often need access to features that aren't readily \n", - " available and need to be precomputed from other data sources.\n", - " * Feast manages deployment to a variety of online stores (e.g. DynamoDB, Redis, Google Cloud Datastore) and \n", - " ensures necessary features are consistently _available_ and _freshly computed_ at inference time.\n", - "3. **Feature and model versioning:** Different teams within an organization are often unable to reuse \n", - " features across projects, resulting in duplicate feature creation logic. Models have data dependencies that need \n", - " to be versioned, for example when running A/B tests on model versions.\n", - " * Feast enables discovery of and collaboration on previously used features and enables versioning of sets of \n", - " features (via _feature services_).\n", - " * _(Experimental)_ Feast enables light-weight feature transformations so users can re-use transformation logic \n", - " across online / offline use cases and across models.\n", - "\n", - "We will:\n", - "1. Deploy a local feature store with a **Parquet file offline store** and **Sqlite online store**.\n", - "2. Build a training dataset using our time series features from our **Parquet files**.\n", - "3. Materialize feature values from the offline store into the online store.\n", - "4. Read the latest features from the online store for inference." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "9_Y997DzvOMI" - }, - "source": [ - "## Step 1: Install Feast\n", - "\n", - "Install Feast (and Pygments for pretty printing) using pip:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "rXNMAAJKQPG5" - }, - "outputs": [], - "source": [ - "%%sh\n", - "pip install feast -U -q\n", - "pip install Pygments -q\n", - "echo \"Please restart your runtime now (Runtime -> Restart runtime). This ensures that the correct dependencies are loaded.\"" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": false, - "id": "sOX_LwjaAhKz" - }, - "source": [ - "**Reminder**: Please restart your runtime after installing Feast (Runtime -> Restart runtime). This ensures that the correct dependencies are loaded.\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "OZetvs5xx4GP" - }, - "source": [ - "## Step 2: Create a feature repository\n", - "\n", - "A feature repository is a directory that contains the configuration of the feature store and individual features. This configuration is written as code (Python/YAML) and it's highly recommended that teams track it centrally using git. See [Feature Repository](https://docs.feast.dev/reference/feature-repository) for a detailed explanation of feature repositories.\n", - "\n", - "The easiest way to create a new feature repository to use the `feast init` command. This creates a scaffolding with initial demo data.\n", - "\n", - "### Demo data scenario \n", - "- We have surveyed some drivers for how satisfied they are with their experience in a ride-sharing app. \n", - "- We want to generate predictions for driver satisfaction for the rest of the users so we can reach out to potentially dissatisfied users." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "IhirSkgUvYau", - "outputId": "664367b9-6a2a-493d-fd78-6495fb459fa2" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Creating a new Feast repository in \u001b[1m\u001b[32m/content/feature_repo\u001b[0m.\n", - "\n" - ] - } - ], - "source": [ - "!feast init feature_repo" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "OdTASZPvyKCe" - }, - "source": [ - "### Step 2a: Inspecting the feature repository\n", - "\n", - "Let's take a look at the demo repo itself. It breaks down into\n", - "\n", - "\n", - "* `data/` contains raw demo parquet data\n", - "* `example_repo.py` contains demo feature definitions\n", - "* `feature_store.yaml` contains a demo setup configuring where data sources are\n", - "* `test_workflow.py` showcases how to run all key Feast commands, including defining, retrieving, and pushing features.\n", - " * You can run this with `python test_workflow.py`.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "9jXuzt4ovzA3", - "outputId": "9e326892-f0cc-4d86-d0b2-f33f822f83a9" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/content/feature_repo\n", - "README.md feature_store.yaml\n", - "__init__.py example_repo.py test_workflow.py\n", - "\n", - "./data:\n", - "driver_stats.parquet\n" - ] - } - ], - "source": [ - "%cd feature_repo/feature_repo\n", - "!ls -R" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "MJk_WNsbeUP6" - }, - "source": [ - "### Step 2b: Inspecting the project configuration\n", - "Let's inspect the setup of the project in `feature_store.yaml`. \n", - "\n", - "The key line defining the overall architecture of the feature store is the **provider**. \n", - "\n", - "The provider value sets default offline and online stores. \n", - "* The offline store provides the compute layer to process historical data (for generating training data & feature \n", - " values for serving). \n", - "* The online store is a low latency store of the latest feature values (for powering real-time inference).\n", - "\n", - "Valid values for `provider` in `feature_store.yaml` are:\n", - "\n", - "* local: use file source with SQLite/Redis\n", - "* gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis\n", - "* aws: use Redshift/Snowflake with DynamoDB/Redis\n", - "\n", - "Note that there are many other offline / online stores Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/roadmap for all supported connectors.\n", - "\n", - "A custom setup can also be made by following [Customizing Feast](https://docs.feast.dev/v/master/how-to-guides/customizing-feast)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "9_YJ--uYdtcP", - "outputId": "af56a8da-9ca2-4dd9-f73c-a60dd3e1613a" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[94mproject\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mfeature_repo\u001b[37m\u001b[39;49;00m\n", - "\u001b[37m# By default, the registry is a file (but can be turned into a more scalable SQL-backed registry)\u001b[39;49;00m\u001b[37m\u001b[39;49;00m\n", - "\u001b[94mregistry\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mdata/registry.db\u001b[37m\u001b[39;49;00m\n", - "\u001b[37m# The provider primarily specifies default offline / online stores & storing the registry in a given cloud\u001b[39;49;00m\u001b[37m\u001b[39;49;00m\n", - "\u001b[94mprovider\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mlocal\u001b[37m\u001b[39;49;00m\n", - "\u001b[94monline_store\u001b[39;49;00m:\u001b[37m\u001b[39;49;00m\n", - "\u001b[37m \u001b[39;49;00m\u001b[94mpath\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mdata/online_store.db\u001b[37m\u001b[39;49;00m\n", - "\u001b[94mentity_key_serialization_version\u001b[39;49;00m:\u001b[37m \u001b[39;49;00m2\u001b[37m\u001b[39;49;00m\n" - ] - } - ], - "source": [ - "!pygmentize feature_store.yaml" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "FnMlk4zshywp" - }, - "source": [ - "### Inspecting the raw data\n", - "\n", - "The raw feature data we have in this demo is stored in a local parquet file. The dataset captures hourly stats of a driver in a ride-sharing app." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 423 - }, - "id": "sIF2lO59dwzi", - "outputId": "8931930b-b32f-43e1-d45b-de230489c7b8" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
event_timestampdriver_idconv_rateacc_rateavg_daily_tripscreated
02022-07-24 14:00:00+00:0010050.4239130.0828312012022-08-08 14:14:11.200
12022-07-24 15:00:00+00:0010050.5071260.4274706902022-08-08 14:14:11.200
22022-07-24 16:00:00+00:0010050.1398100.1297438452022-08-08 14:14:11.200
32022-07-24 17:00:00+00:0010050.3835740.0717288392022-08-08 14:14:11.200
42022-07-24 18:00:00+00:0010050.9591310.44005122022-08-08 14:14:11.200
.....................
18022022-08-08 12:00:00+00:0010010.9948830.0201456502022-08-08 14:14:11.200
18032022-08-08 13:00:00+00:0010010.6638440.8646393592022-08-08 14:14:11.200
18042021-04-12 07:00:00+00:0010010.0686960.6249776242022-08-08 14:14:11.200
18052022-08-01 02:00:00+00:0010030.9808690.2444207902022-08-08 14:14:11.200
18062022-08-01 02:00:00+00:0010030.9808690.2444207902022-08-08 14:14:11.200
\n", - "

1807 rows × 6 columns

\n", - "
" - ], - "text/plain": [ - " event_timestamp driver_id conv_rate acc_rate \\\n", - "0 2022-07-24 14:00:00+00:00 1005 0.423913 0.082831 \n", - "1 2022-07-24 15:00:00+00:00 1005 0.507126 0.427470 \n", - "2 2022-07-24 16:00:00+00:00 1005 0.139810 0.129743 \n", - "3 2022-07-24 17:00:00+00:00 1005 0.383574 0.071728 \n", - "4 2022-07-24 18:00:00+00:00 1005 0.959131 0.440051 \n", - "... ... ... ... ... \n", - "1802 2022-08-08 12:00:00+00:00 1001 0.994883 0.020145 \n", - "1803 2022-08-08 13:00:00+00:00 1001 0.663844 0.864639 \n", - "1804 2021-04-12 07:00:00+00:00 1001 0.068696 0.624977 \n", - "1805 2022-08-01 02:00:00+00:00 1003 0.980869 0.244420 \n", - "1806 2022-08-01 02:00:00+00:00 1003 0.980869 0.244420 \n", - "\n", - " avg_daily_trips created \n", - "0 201 2022-08-08 14:14:11.200 \n", - "1 690 2022-08-08 14:14:11.200 \n", - "2 845 2022-08-08 14:14:11.200 \n", - "3 839 2022-08-08 14:14:11.200 \n", - "4 2 2022-08-08 14:14:11.200 \n", - "... ... ... \n", - "1802 650 2022-08-08 14:14:11.200 \n", - "1803 359 2022-08-08 14:14:11.200 \n", - "1804 624 2022-08-08 14:14:11.200 \n", - "1805 790 2022-08-08 14:14:11.200 \n", - "1806 790 2022-08-08 14:14:11.200 \n", - "\n", - "[1807 rows x 6 columns]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import pandas as pd\n", - "\n", - "pd.read_parquet(\"data/driver_stats.parquet\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "rRL8-ubWzUFy" - }, - "source": [ - "## Step 3: Register feature definitions and deploy your feature store\n", - "\n", - "`feast apply` scans python files in the current directory for feature/entity definitions and deploys infrastructure according to `feature_store.yaml`.\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "5NS4INL5n7ze" - }, - "source": [ - "### Step 3a: Inspecting feature definitions\n", - "Let's inspect what `example_repo.py` looks like:\n", - "\n", - "```python\n", - "# This is an example feature definition file\n", - "\n", - "from datetime import timedelta\n", - "\n", - "import pandas as pd\n", - "\n", - "from feast import Entity, FeatureService, FeatureView, Field, FileSource, RequestSource, PushSource\n", - "from feast.on_demand_feature_view import on_demand_feature_view\n", - "from feast.types import Float32, Int64, Float64\n", - "\n", - "# Read data from parquet files. Parquet is convenient for local development mode. For\n", - "# production, you can use your favorite DWH, such as BigQuery. See Feast documentation\n", - "# for more info.\n", - "driver_hourly_stats = FileSource(\n", - " name=\"driver_hourly_stats_source\",\n", - " path=\"/content/feature_repo/data/driver_stats.parquet\",\n", - " timestamp_field=\"event_timestamp\",\n", - " created_timestamp_column=\"created\",\n", - ")\n", - "\n", - "# Define an entity for the driver. You can think of entity as a primary key used to\n", - "# fetch features.\n", - "driver = Entity(name=\"driver\", join_keys=[\"driver_id\"])\n", - "\n", - "# Our parquet files contain sample data that includes a driver_id column, timestamps and\n", - "# three feature column. Here we define a Feature View that will allow us to serve this\n", - "# data to our model online.\n", - "driver_hourly_stats_view = FeatureView(\n", - " name=\"driver_hourly_stats\",\n", - " entities=[driver],\n", - " ttl=timedelta(days=1),\n", - " schema=[\n", - " Field(name=\"conv_rate\", dtype=Float32),\n", - " Field(name=\"acc_rate\", dtype=Float32),\n", - " Field(name=\"avg_daily_trips\", dtype=Int64),\n", - " ],\n", - " online=True,\n", - " source=driver_hourly_stats,\n", - " tags={},\n", - ")\n", - "\n", - "# Defines a way to push data (to be available offline, online or both) into Feast.\n", - "driver_stats_push_source = PushSource(\n", - " name=\"driver_stats_push_source\",\n", - " batch_source=driver_hourly_stats,\n", - ")\n", - "\n", - "# Define a request data source which encodes features / information only\n", - "# available at request time (e.g. part of the user initiated HTTP request)\n", - "input_request = RequestSource(\n", - " name=\"vals_to_add\",\n", - " schema=[\n", - " Field(name=\"val_to_add\", dtype=Int64),\n", - " Field(name=\"val_to_add_2\", dtype=Int64),\n", - " ],\n", - ")\n", - "\n", - "\n", - "# Define an on demand feature view which can generate new features based on\n", - "# existing feature views and RequestSource features\n", - "@on_demand_feature_view(\n", - " sources=[driver_hourly_stats_view, input_request],\n", - " schema=[\n", - " Field(name=\"conv_rate_plus_val1\", dtype=Float64),\n", - " Field(name=\"conv_rate_plus_val2\", dtype=Float64),\n", - " ],\n", - ")\n", - "def transformed_conv_rate(inputs: pd.DataFrame) -> pd.DataFrame:\n", - " df = pd.DataFrame()\n", - " df[\"conv_rate_plus_val1\"] = inputs[\"conv_rate\"] + inputs[\"val_to_add\"]\n", - " df[\"conv_rate_plus_val2\"] = inputs[\"conv_rate\"] + inputs[\"val_to_add_2\"]\n", - " return df\n", - "\n", - "\n", - "# This groups features into a model version\n", - "driver_stats_fs = FeatureService(\n", - " name=\"driver_activity_v1\", features=[driver_hourly_stats_view, transformed_conv_rate]\n", - ")\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "im_cc5HdoDno" - }, - "source": [ - "### Step 3b: Applying feature definitions\n", - "Now we run `feast apply` to register the feature views and entities defined in `example_repo.py`, and sets up SQLite online store tables. Note that we had previously specified SQLite as the online store in `feature_store.yaml` by specifying a `local` provider." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "RYKCKKrcxYZG", - "outputId": "f34aa509-1dc6-4e50-e8ee-12897138f3b9" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "RuntimeWarning: On demand feature view is an experimental feature. This API is stable, but the functionality does not scale well for offline retrieval\n", - " warnings.warn(\n", - "Created entity \u001b[1m\u001b[32mdriver\u001b[0m\n", - "Created feature view \u001b[1m\u001b[32mdriver_hourly_stats\u001b[0m\n", - "Created on demand feature view \u001b[1m\u001b[32mtransformed_conv_rate\u001b[0m\n", - "Created feature service \u001b[1m\u001b[32mdriver_activity_v1\u001b[0m\n", - "\n", - "Created sqlite table \u001b[1m\u001b[32mfeature_repo_driver_hourly_stats\u001b[0m\n", - "\n" - ] - } - ], - "source": [ - "!feast apply" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "uV7rtRQgzyf0" - }, - "source": [ - "## Step 4: Generating training data or powering batch scoring models\n", - "\n", - "To train a model, we need features and labels. Often, this label data is stored separately (e.g. you have one table storing user survey results and another set of tables with feature values). Feast can help generate the features that map to these labels.\n", - "\n", - "Feast needs a list of **entities** (e.g. driver ids) and **timestamps**. Feast will intelligently join relevant \n", - "tables to create the relevant feature vectors. There are two ways to generate this list:\n", - "1. The user can query that table of labels with timestamps and pass that into Feast as an _entity dataframe_ for \n", - "training data generation. \n", - "2. The user can also query that table with a *SQL query* which pulls entities. See the documentation on [feature retrieval](https://docs.feast.dev/getting-started/concepts/feature-retrieval) for details \n", - "\n", - "* Note that we include timestamps because we want the features for the same driver at various timestamps to be used in a model.\n", - "\n", - "### Step 4a: Generating training data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "C6Fzia7YwBzz", - "outputId": "58c4c3dd-7a10-4f56-901d-1bb879ebbcb8" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "----- Feature schema -----\n", - "\n", - "\n", - "RangeIndex: 3 entries, 0 to 2\n", - "Data columns (total 10 columns):\n", - " # Column Non-Null Count Dtype \n", - "--- ------ -------------- ----- \n", - " 0 driver_id 3 non-null int64 \n", - " 1 event_timestamp 3 non-null datetime64[ns, UTC]\n", - " 2 label_driver_reported_satisfaction 3 non-null int64 \n", - " 3 val_to_add 3 non-null int64 \n", - " 4 val_to_add_2 3 non-null int64 \n", - " 5 conv_rate 3 non-null float32 \n", - " 6 acc_rate 3 non-null float32 \n", - " 7 avg_daily_trips 3 non-null int32 \n", - " 8 conv_rate_plus_val1 3 non-null float64 \n", - " 9 conv_rate_plus_val2 3 non-null float64 \n", - "dtypes: datetime64[ns, UTC](1), float32(2), float64(2), int32(1), int64(4)\n", - "memory usage: 332.0 bytes\n", - "None\n", - "\n", - "----- Example features -----\n", - "\n", - " driver_id event_timestamp label_driver_reported_satisfaction \\\n", - "0 1001 2021-04-12 10:59:42+00:00 1 \n", - "1 1002 2021-04-12 08:12:10+00:00 5 \n", - "2 1003 2021-04-12 16:40:26+00:00 3 \n", - "\n", - " val_to_add val_to_add_2 conv_rate acc_rate avg_daily_trips \\\n", - "0 1 10 0.356766 0.051319 93 \n", - "1 2 20 0.130452 0.359439 522 \n", - "2 3 30 0.666570 0.343380 266 \n", - "\n", - " conv_rate_plus_val1 conv_rate_plus_val2 \n", - "0 1.356766 10.356766 \n", - "1 2.130452 20.130452 \n", - "2 3.666570 30.666570 \n" - ] - } - ], - "source": [ - "from datetime import datetime\n", - "import pandas as pd\n", - "\n", - "from feast import FeatureStore\n", - "\n", - "# The entity dataframe is the dataframe we want to enrich with feature values\n", - "# Note: see https://docs.feast.dev/getting-started/concepts/feature-retrieval for more details on how to retrieve\n", - "# for all entities in the offline store instead\n", - "entity_df = pd.DataFrame.from_dict(\n", - " {\n", - " # entity's join key -> entity values\n", - " \"driver_id\": [1001, 1002, 1003],\n", - " # \"event_timestamp\" (reserved key) -> timestamps\n", - " \"event_timestamp\": [\n", - " datetime(2021, 4, 12, 10, 59, 42),\n", - " datetime(2021, 4, 12, 8, 12, 10),\n", - " datetime(2021, 4, 12, 16, 40, 26),\n", - " ],\n", - " # (optional) label name -> label values. Feast does not process these\n", - " \"label_driver_reported_satisfaction\": [1, 5, 3],\n", - " # values we're using for an on-demand transformation\n", - " \"val_to_add\": [1, 2, 3],\n", - " \"val_to_add_2\": [10, 20, 30],\n", - " }\n", - ")\n", - "\n", - "store = FeatureStore(repo_path=\".\")\n", - "\n", - "training_df = store.get_historical_features(\n", - " entity_df=entity_df,\n", - " features=[\n", - " \"driver_hourly_stats:conv_rate\",\n", - " \"driver_hourly_stats:acc_rate\",\n", - " \"driver_hourly_stats:avg_daily_trips\",\n", - " \"transformed_conv_rate:conv_rate_plus_val1\",\n", - " \"transformed_conv_rate:conv_rate_plus_val2\",\n", - " ],\n", - ").to_df()\n", - "\n", - "print(\"----- Feature schema -----\\n\")\n", - "print(training_df.info())\n", - "\n", - "print()\n", - "print(\"----- Example features -----\\n\")\n", - "print(training_df.head())" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GFiXVdhz04t0" - }, - "source": [ - "### Step 4b: Run offline inference (batch scoring)\n", - "To power a batch model, we primarily need to generate features with the `get_historical_features` call, but using the current timestamp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "rGR_xgIs04t0", - "outputId": "3496e5a1-79ff-4f3c-e35d-22b594992708" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "----- Example features -----\n", - "\n", - " driver_id event_timestamp \\\n", - "0 1001 2022-08-08 18:22:06.555018+00:00 \n", - "1 1002 2022-08-08 18:22:06.555018+00:00 \n", - "2 1003 2022-08-08 18:22:06.555018+00:00 \n", - "\n", - " label_driver_reported_satisfaction val_to_add val_to_add_2 conv_rate \\\n", - "0 1 1 10 0.663844 \n", - "1 5 2 20 0.151189 \n", - "2 3 3 30 0.769165 \n", - "\n", - " acc_rate avg_daily_trips conv_rate_plus_val1 conv_rate_plus_val2 \n", - "0 0.864639 359 1.663844 10.663844 \n", - "1 0.695982 311 2.151189 20.151189 \n", - "2 0.949191 789 3.769165 30.769165 \n" - ] - } - ], - "source": [ - "entity_df[\"event_timestamp\"] = pd.to_datetime(\"now\", utc=True)\n", - "training_df = store.get_historical_features(\n", - " entity_df=entity_df,\n", - " features=[\n", - " \"driver_hourly_stats:conv_rate\",\n", - " \"driver_hourly_stats:acc_rate\",\n", - " \"driver_hourly_stats:avg_daily_trips\",\n", - " \"transformed_conv_rate:conv_rate_plus_val1\",\n", - " \"transformed_conv_rate:conv_rate_plus_val2\",\n", - " ],\n", - ").to_df()\n", - "\n", - "print(\"\\n----- Example features -----\\n\")\n", - "print(training_df.head())" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "ngl7HCtmz3hG" - }, - "source": [ - "## Step 5: Load features into your online store" - ] + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "p5JTeKfCVBZf" + }, + "source": [ + "# Overview\n", + "\n", + "In this tutorial, we'll use Feast to generate training data and power online model inference for a \n", + "ride-sharing driver satisfaction prediction model. Feast solves several common issues in this flow:\n", + "\n", + "1. **Training-serving skew and complex data joins:** Feature values often exist across multiple tables. Joining \n", + " these datasets can be complicated, slow, and error-prone.\n", + " * Feast joins these tables with battle-tested logic that ensures _point-in-time_ correctness so future feature \n", + " values do not leak to models.\n", + "2. **Online feature availability:** At inference time, models often need access to features that aren't readily \n", + " available and need to be precomputed from other data sources.\n", + " * Feast manages deployment to a variety of online stores (e.g. DynamoDB, Redis, Google Cloud Datastore) and \n", + " ensures necessary features are consistently _available_ and _freshly computed_ at inference time.\n", + "3. **Feature and model versioning:** Different teams within an organization are often unable to reuse \n", + " features across projects, resulting in duplicate feature creation logic. Models have data dependencies that need \n", + " to be versioned, for example when running A/B tests on model versions.\n", + " * Feast enables discovery of and collaboration on previously used features and enables versioning of sets of \n", + " features (via _feature services_).\n", + " * _(Experimental)_ Feast enables light-weight feature transformations so users can re-use transformation logic \n", + " across online / offline use cases and across models.\n", + "\n", + "We will:\n", + "1. Deploy a local feature store with a **Parquet file offline store** and **Sqlite online store**.\n", + "2. Build a training dataset using our time series features from our **Parquet files**.\n", + "3. Materialize feature values from the offline store into the online store.\n", + "4. Read the latest features from the online store for inference." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9_Y997DzvOMI" + }, + "source": [ + "## Step 1: Install Feast\n", + "\n", + "Install Feast using pip:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "rXNMAAJKQPG5" + }, + "outputs": [], + "source": [ + "%%sh\n", + "pip install feast -U -q\n", + "echo \"Please restart your runtime now (Runtime -> Restart runtime). This ensures that the correct dependencies are loaded.\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": false, + "id": "sOX_LwjaAhKz" + }, + "source": [ + "**Reminder**: Please restart your runtime after installing Feast (Runtime -> Restart runtime). This ensures that the correct dependencies are loaded.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "OZetvs5xx4GP" + }, + "source": [ + "## Step 2: Create a feature repository\n", + "\n", + "A feature repository is a directory that contains the configuration of the feature store and individual features. This configuration is written as code (Python/YAML) and it's highly recommended that teams track it centrally using git. See [Feature Repository](https://docs.feast.dev/reference/feature-repository) for a detailed explanation of feature repositories.\n", + "\n", + "The easiest way to create a new feature repository to use the `feast init` command. This creates a scaffolding with initial demo data.\n", + "\n", + "### Demo data scenario \n", + "- We have surveyed some drivers for how satisfied they are with their experience in a ride-sharing app. \n", + "- We want to generate predictions for driver satisfaction for the rest of the users so we can reach out to potentially dissatisfied users." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "IhirSkgUvYau", + "outputId": "664367b9-6a2a-493d-fd78-6495fb459fa2" + }, + "outputs": [ { - "cell_type": "markdown", - "metadata": { - "id": "KCXUpiQ_pmDk" - }, - "source": [ - "### Step 5a: Using `materialize_incremental`\n", - "\n", - "We now serialize the latest values of features since the beginning of time to prepare for serving. Note, `materialize_incremental` serializes all new features since the last `materialize` call, or since the time provided minus the `ttl` timedelta. In this case, this will be `CURRENT_TIME - 1 day` (`ttl` was set on the `FeatureView` instances in [feature_repo/feature_repo/example_repo.py](feature_repo/feature_repo/example_repo.py)). \n", - "\n", - "```bash\n", - "CURRENT_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%S\")\n", - "feast materialize-incremental $CURRENT_TIME\n", - "```\n", - "\n", - "An alternative to using the CLI command is to use Python:" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Creating a new Feast repository in \u001b[1m\u001b[32m/content/feature_repo\u001b[0m.\n", + "\n" + ] + } + ], + "source": [ + "!feast init feature_repo" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "OdTASZPvyKCe" + }, + "source": [ + "### Step 2a: Inspecting the feature repository\n", + "\n", + "Let's take a look at the demo repo itself. It breaks down into\n", + "\n", + "\n", + "* `data/` contains raw demo parquet data\n", + "* `example_repo.py` contains demo feature definitions\n", + "* `feature_store.yaml` contains a demo setup configuring where data sources are\n", + "* `test_workflow.py` showcases how to run all key Feast commands, including defining, retrieving, and pushing features.\n", + " * You can run this with `python test_workflow.py`.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "9jXuzt4ovzA3", + "outputId": "9e326892-f0cc-4d86-d0b2-f33f822f83a9" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "7Z6QxIebAhK5", - "outputId": "9b54777d-2dd8-4ec3-b4e7-e3275800a980" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Materializing \u001b[1m\u001b[32m1\u001b[0m feature views to \u001b[1m\u001b[32m2022-08-08 14:19:04-04:00\u001b[0m into the \u001b[1m\u001b[32msqlite\u001b[0m online store.\n", - "\n", - "\u001b[1m\u001b[32mdriver_hourly_stats\u001b[0m from \u001b[1m\u001b[32m2022-08-07 18:19:04-04:00\u001b[0m to \u001b[1m\u001b[32m2022-08-08 14:19:04-04:00\u001b[0m:\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|████████████████████████████████████████████████████████████████| 5/5 [00:00<00:00, 346.47it/s]\n" - ] - } - ], - "source": [ - "from datetime import datetime\n", - "store.materialize_incremental(datetime.now())" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "/content/feature_repo\n", + "README.md feature_store.yaml\n", + "__init__.py example_repo.py test_workflow.py\n", + "\n", + "./data:\n", + "driver_stats.parquet\n" + ] + } + ], + "source": [ + "%cd feature_repo/feature_repo\n", + "!ls -R" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "MJk_WNsbeUP6" + }, + "source": [ + "### Step 2b: Inspecting the project configuration\n", + "Let's inspect the setup of the project in `feature_store.yaml`. \n", + "\n", + "The key line defining the overall architecture of the feature store is the **provider**. \n", + "\n", + "The provider value sets default offline and online stores. \n", + "* The offline store provides the compute layer to process historical data (for generating training data & feature \n", + " values for serving). \n", + "* The online store is a low latency store of the latest feature values (for powering real-time inference).\n", + "\n", + "Valid values for `provider` in `feature_store.yaml` are:\n", + "\n", + "* local: use file source with SQLite/Redis\n", + "* gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis\n", + "* aws: use Redshift/Snowflake with DynamoDB/Redis\n", + "\n", + "Note that there are many other offline / online stores Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/roadmap for all supported connectors.\n", + "\n", + "A custom setup can also be made by following [Customizing Feast](https://docs.feast.dev/v/master/how-to-guides/customizing-feast)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "9_YJ--uYdtcP", + "outputId": "af56a8da-9ca2-4dd9-f73c-a60dd3e1613a" + }, + "outputs": [ { - "cell_type": "markdown", - "metadata": { - "id": "l7t12bhH4i9H" - }, - "source": [ - "### Step 5b: Inspect materialized features\n", - "\n", - "Note that now there are `online_store.db` and `registry.db`, which store the materialized features and schema information, respectively." - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[94mproject\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mfeature_repo\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m# By default, the registry is a file (but can be turned into a more scalable SQL-backed registry)\u001b[39;49;00m\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mregistry\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mdata/registry.db\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m# The provider primarily specifies default offline / online stores & storing the registry in a given cloud\u001b[39;49;00m\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mprovider\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mlocal\u001b[37m\u001b[39;49;00m\n", + "\u001b[94monline_store\u001b[39;49;00m:\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mpath\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mdata/online_store.db\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mentity_key_serialization_version\u001b[39;49;00m:\u001b[37m \u001b[39;49;00m2\u001b[37m\u001b[39;49;00m\n" + ] + } + ], + "source": [ + "!pygmentize feature_store.yaml" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "FnMlk4zshywp" + }, + "source": [ + "### Inspecting the raw data\n", + "\n", + "The raw feature data we have in this demo is stored in a local parquet file. The dataset captures hourly stats of a driver in a ride-sharing app." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 423 }, + "id": "sIF2lO59dwzi", + "outputId": "8931930b-b32f-43e1-d45b-de230489c7b8" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "aVIgSYhI4cvR", - "outputId": "3c60f99c-2471-4343-83ed-cc60a6a9c3b2" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "--- Data directory ---\n", - "driver_stats.parquet online_store.db registry.db\n", - "\n", - "--- Schema of online store ---\n", - "['entity_key', 'feature_name', 'value', 'event_ts', 'created_ts']\n" - ] - } + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
event_timestampdriver_idconv_rateacc_rateavg_daily_tripscreated
02022-07-24 14:00:00+00:0010050.4239130.0828312012022-08-08 14:14:11.200
12022-07-24 15:00:00+00:0010050.5071260.4274706902022-08-08 14:14:11.200
22022-07-24 16:00:00+00:0010050.1398100.1297438452022-08-08 14:14:11.200
32022-07-24 17:00:00+00:0010050.3835740.0717288392022-08-08 14:14:11.200
42022-07-24 18:00:00+00:0010050.9591310.44005122022-08-08 14:14:11.200
.....................
18022022-08-08 12:00:00+00:0010010.9948830.0201456502022-08-08 14:14:11.200
18032022-08-08 13:00:00+00:0010010.6638440.8646393592022-08-08 14:14:11.200
18042021-04-12 07:00:00+00:0010010.0686960.6249776242022-08-08 14:14:11.200
18052022-08-01 02:00:00+00:0010030.9808690.2444207902022-08-08 14:14:11.200
18062022-08-01 02:00:00+00:0010030.9808690.2444207902022-08-08 14:14:11.200
\n", + "

1807 rows × 6 columns

\n", + "
" ], - "source": [ - "print(\"--- Data directory ---\")\n", - "!ls data\n", - "\n", - "import sqlite3\n", - "import pandas as pd\n", - "con = sqlite3.connect(\"data/online_store.db\")\n", - "print(\"\\n--- Schema of online store ---\")\n", - "print(\n", - " pd.read_sql_query(\n", - " \"SELECT * FROM feature_repo_driver_hourly_stats\", con).columns.tolist())\n", - "con.close()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "AWcttaGalzAm" - }, - "source": [ - "### Quick note on entity keys\n", - "Note from the above command that the online store indexes by `entity_key`. \n", - "\n", - "[Entity keys](https://docs.feast.dev/getting-started/concepts/entity#entity-key) include a list of all entities needed (e.g. all relevant primary keys) to generate the feature vector. In this case, this is a serialized version of the `driver_id`. We use this later to fetch all features for a given driver at inference time." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GNecKOaI0J2Z" - }, - "source": [ - "## Step 6: Fetching real-time feature vectors for online inference" + "text/plain": [ + " event_timestamp driver_id conv_rate acc_rate \\\n", + "0 2022-07-24 14:00:00+00:00 1005 0.423913 0.082831 \n", + "1 2022-07-24 15:00:00+00:00 1005 0.507126 0.427470 \n", + "2 2022-07-24 16:00:00+00:00 1005 0.139810 0.129743 \n", + "3 2022-07-24 17:00:00+00:00 1005 0.383574 0.071728 \n", + "4 2022-07-24 18:00:00+00:00 1005 0.959131 0.440051 \n", + "... ... ... ... ... \n", + "1802 2022-08-08 12:00:00+00:00 1001 0.994883 0.020145 \n", + "1803 2022-08-08 13:00:00+00:00 1001 0.663844 0.864639 \n", + "1804 2021-04-12 07:00:00+00:00 1001 0.068696 0.624977 \n", + "1805 2022-08-01 02:00:00+00:00 1003 0.980869 0.244420 \n", + "1806 2022-08-01 02:00:00+00:00 1003 0.980869 0.244420 \n", + "\n", + " avg_daily_trips created \n", + "0 201 2022-08-08 14:14:11.200 \n", + "1 690 2022-08-08 14:14:11.200 \n", + "2 845 2022-08-08 14:14:11.200 \n", + "3 839 2022-08-08 14:14:11.200 \n", + "4 2 2022-08-08 14:14:11.200 \n", + "... ... ... \n", + "1802 650 2022-08-08 14:14:11.200 \n", + "1803 359 2022-08-08 14:14:11.200 \n", + "1804 624 2022-08-08 14:14:11.200 \n", + "1805 790 2022-08-08 14:14:11.200 \n", + "1806 790 2022-08-08 14:14:11.200 \n", + "\n", + "[1807 rows x 6 columns]" ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "pd.read_parquet(\"data/driver_stats.parquet\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "rRL8-ubWzUFy" + }, + "source": [ + "## Step 3: Register feature definitions and deploy your feature store\n", + "\n", + "`feast apply` scans python files in the current directory for feature/entity definitions and deploys infrastructure according to `feature_store.yaml`.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "5NS4INL5n7ze" + }, + "source": [ + "### Step 3a: Inspecting feature definitions\n", + "Let's inspect what `example_repo.py` looks like:\n", + "\n", + "```python\n", + "# This is an example feature definition file\n", + "\n", + "from datetime import timedelta\n", + "\n", + "import pandas as pd\n", + "\n", + "from feast import Entity, FeatureService, FeatureView, Field, FileSource, RequestSource, PushSource\n", + "from feast.on_demand_feature_view import on_demand_feature_view\n", + "from feast.types import Float32, Int64, Float64\n", + "\n", + "# Read data from parquet files. Parquet is convenient for local development mode. For\n", + "# production, you can use your favorite DWH, such as BigQuery. See Feast documentation\n", + "# for more info.\n", + "driver_hourly_stats = FileSource(\n", + " name=\"driver_hourly_stats_source\",\n", + " path=\"/content/feature_repo/data/driver_stats.parquet\",\n", + " timestamp_field=\"event_timestamp\",\n", + " created_timestamp_column=\"created\",\n", + ")\n", + "\n", + "# Define an entity for the driver. You can think of entity as a primary key used to\n", + "# fetch features.\n", + "driver = Entity(name=\"driver\", join_keys=[\"driver_id\"])\n", + "\n", + "# Our parquet files contain sample data that includes a driver_id column, timestamps and\n", + "# three feature column. Here we define a Feature View that will allow us to serve this\n", + "# data to our model online.\n", + "driver_hourly_stats_view = FeatureView(\n", + " name=\"driver_hourly_stats\",\n", + " entities=[driver],\n", + " ttl=timedelta(days=1),\n", + " schema=[\n", + " Field(name=\"conv_rate\", dtype=Float32),\n", + " Field(name=\"acc_rate\", dtype=Float32),\n", + " Field(name=\"avg_daily_trips\", dtype=Int64),\n", + " ],\n", + " online=True,\n", + " source=driver_hourly_stats,\n", + " tags={},\n", + ")\n", + "\n", + "# Defines a way to push data (to be available offline, online or both) into Feast.\n", + "driver_stats_push_source = PushSource(\n", + " name=\"driver_stats_push_source\",\n", + " batch_source=driver_hourly_stats,\n", + ")\n", + "\n", + "# Define a request data source which encodes features / information only\n", + "# available at request time (e.g. part of the user initiated HTTP request)\n", + "input_request = RequestSource(\n", + " name=\"vals_to_add\",\n", + " schema=[\n", + " Field(name=\"val_to_add\", dtype=Int64),\n", + " Field(name=\"val_to_add_2\", dtype=Int64),\n", + " ],\n", + ")\n", + "\n", + "\n", + "# Define an on demand feature view which can generate new features based on\n", + "# existing feature views and RequestSource features\n", + "@on_demand_feature_view(\n", + " sources=[driver_hourly_stats_view, input_request],\n", + " schema=[\n", + " Field(name=\"conv_rate_plus_val1\", dtype=Float64),\n", + " Field(name=\"conv_rate_plus_val2\", dtype=Float64),\n", + " ],\n", + ")\n", + "def transformed_conv_rate(inputs: pd.DataFrame) -> pd.DataFrame:\n", + " df = pd.DataFrame()\n", + " df[\"conv_rate_plus_val1\"] = inputs[\"conv_rate\"] + inputs[\"val_to_add\"]\n", + " df[\"conv_rate_plus_val2\"] = inputs[\"conv_rate\"] + inputs[\"val_to_add_2\"]\n", + " return df\n", + "\n", + "\n", + "# This groups features into a model version\n", + "driver_stats_fs = FeatureService(\n", + " name=\"driver_activity_v1\", features=[driver_hourly_stats_view, transformed_conv_rate]\n", + ")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "im_cc5HdoDno" + }, + "source": [ + "### Step 3b: Applying feature definitions\n", + "Now we run `feast apply` to register the feature views and entities defined in `example_repo.py`, and sets up SQLite online store tables. Note that we had previously specified SQLite as the online store in `feature_store.yaml` by specifying a `local` provider." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "RYKCKKrcxYZG", + "outputId": "f34aa509-1dc6-4e50-e8ee-12897138f3b9" + }, + "outputs": [ { - "cell_type": "markdown", - "metadata": { - "id": "TBFlKRsOAhK8" - }, - "source": [ - "At inference time, we need to quickly read the latest feature values for different drivers (which otherwise might have existed only in batch sources) from the online feature store using `get_online_features()`. These feature vectors can then be fed to the model." - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "RuntimeWarning: On demand feature view is an experimental feature. This API is stable, but the functionality does not scale well for offline retrieval\n", + " warnings.warn(\n", + "Created entity \u001b[1m\u001b[32mdriver\u001b[0m\n", + "Created feature view \u001b[1m\u001b[32mdriver_hourly_stats\u001b[0m\n", + "Created on demand feature view \u001b[1m\u001b[32mtransformed_conv_rate\u001b[0m\n", + "Created feature service \u001b[1m\u001b[32mdriver_activity_v1\u001b[0m\n", + "\n", + "Created sqlite table \u001b[1m\u001b[32mfeature_repo_driver_hourly_stats\u001b[0m\n", + "\n" + ] + } + ], + "source": [ + "!feast apply" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "uV7rtRQgzyf0" + }, + "source": [ + "## Step 4: Generating training data or powering batch scoring models\n", + "\n", + "To train a model, we need features and labels. Often, this label data is stored separately (e.g. you have one table storing user survey results and another set of tables with feature values). Feast can help generate the features that map to these labels.\n", + "\n", + "Feast needs a list of **entities** (e.g. driver ids) and **timestamps**. Feast will intelligently join relevant \n", + "tables to create the relevant feature vectors. There are two ways to generate this list:\n", + "1. The user can query that table of labels with timestamps and pass that into Feast as an _entity dataframe_ for \n", + "training data generation. \n", + "2. The user can also query that table with a *SQL query* which pulls entities. See the documentation on [feature retrieval](https://docs.feast.dev/getting-started/concepts/feature-retrieval) for details \n", + "\n", + "* Note that we include timestamps because we want the features for the same driver at various timestamps to be used in a model.\n", + "\n", + "### Step 4a: Generating training data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "C6Fzia7YwBzz", + "outputId": "58c4c3dd-7a10-4f56-901d-1bb879ebbcb8" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "a-PUsUWUxoH9", - "outputId": "fc52dc04-db87-4f48-df36-d3941d485600" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'acc_rate': [0.86463862657547, 0.6959823369979858],\n", - " 'avg_daily_trips': [359, 311],\n", - " 'conv_rate_plus_val1': [1000.6638441681862, 1001.1511893719435],\n", - " 'conv_rate_plus_val2': [2000.6638441681862, 2002.1511893719435],\n", - " 'driver_id': [1001, 1002]}\n" - ] - } - ], - "source": [ - "from pprint import pprint\n", - "from feast import FeatureStore\n", - "\n", - "store = FeatureStore(repo_path=\".\")\n", - "\n", - "feature_vector = store.get_online_features(\n", - " features=[\n", - " \"driver_hourly_stats:acc_rate\",\n", - " \"driver_hourly_stats:avg_daily_trips\",\n", - " \"transformed_conv_rate:conv_rate_plus_val1\",\n", - " \"transformed_conv_rate:conv_rate_plus_val2\",\n", - " ],\n", - " entity_rows=[\n", - " # {join_key: entity_value}\n", - " {\n", - " \"driver_id\": 1001,\n", - " \"val_to_add\": 1000,\n", - " \"val_to_add_2\": 2000,\n", - " },\n", - " {\n", - " \"driver_id\": 1002,\n", - " \"val_to_add\": 1001,\n", - " \"val_to_add_2\": 2002,\n", - " },\n", - " ],\n", - ").to_dict()\n", - "\n", - "pprint(feature_vector)" - ] - }, + "name": "stdout", + "output_type": "stream", + "text": [ + "----- Feature schema -----\n", + "\n", + "\n", + "RangeIndex: 3 entries, 0 to 2\n", + "Data columns (total 10 columns):\n", + " # Column Non-Null Count Dtype \n", + "--- ------ -------------- ----- \n", + " 0 driver_id 3 non-null int64 \n", + " 1 event_timestamp 3 non-null datetime64[ns, UTC]\n", + " 2 label_driver_reported_satisfaction 3 non-null int64 \n", + " 3 val_to_add 3 non-null int64 \n", + " 4 val_to_add_2 3 non-null int64 \n", + " 5 conv_rate 3 non-null float32 \n", + " 6 acc_rate 3 non-null float32 \n", + " 7 avg_daily_trips 3 non-null int32 \n", + " 8 conv_rate_plus_val1 3 non-null float64 \n", + " 9 conv_rate_plus_val2 3 non-null float64 \n", + "dtypes: datetime64[ns, UTC](1), float32(2), float64(2), int32(1), int64(4)\n", + "memory usage: 332.0 bytes\n", + "None\n", + "\n", + "----- Example features -----\n", + "\n", + " driver_id event_timestamp label_driver_reported_satisfaction \\\n", + "0 1001 2021-04-12 10:59:42+00:00 1 \n", + "1 1002 2021-04-12 08:12:10+00:00 5 \n", + "2 1003 2021-04-12 16:40:26+00:00 3 \n", + "\n", + " val_to_add val_to_add_2 conv_rate acc_rate avg_daily_trips \\\n", + "0 1 10 0.356766 0.051319 93 \n", + "1 2 20 0.130452 0.359439 522 \n", + "2 3 30 0.666570 0.343380 266 \n", + "\n", + " conv_rate_plus_val1 conv_rate_plus_val2 \n", + "0 1.356766 10.356766 \n", + "1 2.130452 20.130452 \n", + "2 3.666570 30.666570 \n" + ] + } + ], + "source": [ + "from datetime import datetime\n", + "import pandas as pd\n", + "\n", + "from feast import FeatureStore\n", + "\n", + "# The entity dataframe is the dataframe we want to enrich with feature values\n", + "# Note: see https://docs.feast.dev/getting-started/concepts/feature-retrieval for more details on how to retrieve\n", + "# for all entities in the offline store instead\n", + "entity_df = pd.DataFrame.from_dict(\n", + " {\n", + " # entity's join key -> entity values\n", + " \"driver_id\": [1001, 1002, 1003],\n", + " # \"event_timestamp\" (reserved key) -> timestamps\n", + " \"event_timestamp\": [\n", + " datetime(2021, 4, 12, 10, 59, 42),\n", + " datetime(2021, 4, 12, 8, 12, 10),\n", + " datetime(2021, 4, 12, 16, 40, 26),\n", + " ],\n", + " # (optional) label name -> label values. Feast does not process these\n", + " \"label_driver_reported_satisfaction\": [1, 5, 3],\n", + " # values we're using for an on-demand transformation\n", + " \"val_to_add\": [1, 2, 3],\n", + " \"val_to_add_2\": [10, 20, 30],\n", + " }\n", + ")\n", + "\n", + "store = FeatureStore(repo_path=\".\")\n", + "\n", + "training_df = store.get_historical_features(\n", + " entity_df=entity_df,\n", + " features=[\n", + " \"driver_hourly_stats:conv_rate\",\n", + " \"driver_hourly_stats:acc_rate\",\n", + " \"driver_hourly_stats:avg_daily_trips\",\n", + " \"transformed_conv_rate:conv_rate_plus_val1\",\n", + " \"transformed_conv_rate:conv_rate_plus_val2\",\n", + " ],\n", + ").to_df()\n", + "\n", + "print(\"----- Feature schema -----\\n\")\n", + "print(training_df.info())\n", + "\n", + "print()\n", + "print(\"----- Example features -----\\n\")\n", + "print(training_df.head())" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GFiXVdhz04t0" + }, + "source": [ + "### Step 4b: Run offline inference (batch scoring)\n", + "To power a batch model, we primarily need to generate features with the `get_historical_features` call, but using the current timestamp" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "rGR_xgIs04t0", + "outputId": "3496e5a1-79ff-4f3c-e35d-22b594992708" + }, + "outputs": [ { - "cell_type": "markdown", - "metadata": { - "id": "SRY87OMBoK_Z" - }, - "source": [ - "### Fetching features using feature services\n", - "You can also use feature services to manage multiple features, and decouple feature view definitions and the features needed by end applications. The feature store can also be used to fetch either online or historical features using the same api below. More information can be found [here](https://docs.feast.dev/getting-started/concepts/feature-retrieval).\n", - "\n", - " The `driver_activity_v1` feature service pulls all features from the `driver_hourly_stats` feature view:\n", - "\n", - "```python\n", - "driver_stats_fs = FeatureService(\n", - " name=\"driver_activity_v1\", features=[driver_hourly_stats_view]\n", - ")\n", - "```" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "----- Example features -----\n", + "\n", + " driver_id event_timestamp \\\n", + "0 1001 2022-08-08 18:22:06.555018+00:00 \n", + "1 1002 2022-08-08 18:22:06.555018+00:00 \n", + "2 1003 2022-08-08 18:22:06.555018+00:00 \n", + "\n", + " label_driver_reported_satisfaction val_to_add val_to_add_2 conv_rate \\\n", + "0 1 1 10 0.663844 \n", + "1 5 2 20 0.151189 \n", + "2 3 3 30 0.769165 \n", + "\n", + " acc_rate avg_daily_trips conv_rate_plus_val1 conv_rate_plus_val2 \n", + "0 0.864639 359 1.663844 10.663844 \n", + "1 0.695982 311 2.151189 20.151189 \n", + "2 0.949191 789 3.769165 30.769165 \n" + ] + } + ], + "source": [ + "entity_df[\"event_timestamp\"] = pd.to_datetime(\"now\", utc=True)\n", + "training_df = store.get_historical_features(\n", + " entity_df=entity_df,\n", + " features=[\n", + " \"driver_hourly_stats:conv_rate\",\n", + " \"driver_hourly_stats:acc_rate\",\n", + " \"driver_hourly_stats:avg_daily_trips\",\n", + " \"transformed_conv_rate:conv_rate_plus_val1\",\n", + " \"transformed_conv_rate:conv_rate_plus_val2\",\n", + " ],\n", + ").to_df()\n", + "\n", + "print(\"\\n----- Example features -----\\n\")\n", + "print(training_df.head())" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ngl7HCtmz3hG" + }, + "source": [ + "## Step 5: Load features into your online store" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "KCXUpiQ_pmDk" + }, + "source": [ + "### Step 5a: Using `materialize_incremental`\n", + "\n", + "We now serialize the latest values of features since the beginning of time to prepare for serving. Note, `materialize_incremental` serializes all new features since the last `materialize` call, or since the time provided minus the `ttl` timedelta. In this case, this will be `CURRENT_TIME - 1 day` (`ttl` was set on the `FeatureView` instances in [feature_repo/feature_repo/example_repo.py](feature_repo/feature_repo/example_repo.py)). \n", + "\n", + "```bash\n", + "CURRENT_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%S\")\n", + "feast materialize-incremental $CURRENT_TIME\n", + "```\n", + "\n", + "An alternative to using the CLI command is to use Python:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "7Z6QxIebAhK5", + "outputId": "9b54777d-2dd8-4ec3-b4e7-e3275800a980" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "BrnAEKlPn9s8", - "outputId": "45f7f075-5243-4fa7-dbd4-63c0c22a68cd" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'acc_rate': [0.86463862657547, 0.6959823369979858],\n", - " 'avg_daily_trips': [359, 311],\n", - " 'conv_rate': [0.6638441681861877, 0.15118937194347382],\n", - " 'conv_rate_plus_val1': [1000.6638441681862, 1001.1511893719435],\n", - " 'conv_rate_plus_val2': [2000.6638441681862, 2002.1511893719435],\n", - " 'driver_id': [1001, 1002]}\n" - ] - } - ], - "source": [ - "from feast import FeatureStore\n", - "feature_store = FeatureStore('.') # Initialize the feature store\n", - "\n", - "feature_service = feature_store.get_feature_service(\"driver_activity_v1\")\n", - "feature_vector = feature_store.get_online_features(\n", - " features=feature_service,\n", - " entity_rows=[\n", - " # {join_key: entity_value}\n", - " {\n", - " \"driver_id\": 1001,\n", - " \"val_to_add\": 1000,\n", - " \"val_to_add_2\": 2000,\n", - " },\n", - " {\n", - " \"driver_id\": 1002,\n", - " \"val_to_add\": 1001,\n", - " \"val_to_add_2\": 2002,\n", - " },\n", - " ],\n", - ").to_dict()\n", - "pprint(feature_vector)" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "Materializing \u001b[1m\u001b[32m1\u001b[0m feature views to \u001b[1m\u001b[32m2022-08-08 14:19:04-04:00\u001b[0m into the \u001b[1m\u001b[32msqlite\u001b[0m online store.\n", + "\n", + "\u001b[1m\u001b[32mdriver_hourly_stats\u001b[0m from \u001b[1m\u001b[32m2022-08-07 18:19:04-04:00\u001b[0m to \u001b[1m\u001b[32m2022-08-08 14:19:04-04:00\u001b[0m:\n" + ] }, { - "cell_type": "markdown", - "metadata": { - "id": "PvPOSPV904t7" - }, - "source": [ - "## Step 7: Making streaming features available in Feast\n", - "Feast does not directly ingest from streaming sources. Instead, Feast relies on a push-based model to push features into Feast. You can write a streaming pipeline that generates features, which can then be pushed to the offline store, the online store, or both (depending on your needs).\n", - "\n", - "This relies on the `PushSource` defined above. Pushing to this source will populate all dependent feature views with the pushed feature values." - ] + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|████████████████████████████████████████████████████████████████| 5/5 [00:00<00:00, 346.47it/s]\n" + ] + } + ], + "source": [ + "from datetime import datetime\n", + "store.materialize_incremental(datetime.now())" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "l7t12bhH4i9H" + }, + "source": [ + "### Step 5b: Inspect materialized features\n", + "\n", + "Note that now there are `online_store.db` and `registry.db`, which store the materialized features and schema information, respectively." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "aVIgSYhI4cvR", + "outputId": "3c60f99c-2471-4343-83ed-cc60a6a9c3b2" + }, + "outputs": [ { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "uAg5xKDF04t7", - "outputId": "8288b911-125f-4141-b286-f6f84bcb24ea" - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "--- Simulate a stream event ingestion of the hourly stats df ---\n", - " driver_id event_timestamp created conv_rate acc_rate \\\n", - "0 1001 2021-05-13 10:59:42 2021-05-13 10:59:42 1.0 1.0 \n", - "\n", - " avg_daily_trips \n", - "0 1000 \n" - ] - } - ], - "source": [ - "from feast.data_source import PushMode\n", - "\n", - "print(\"\\n--- Simulate a stream event ingestion of the hourly stats df ---\")\n", - "event_df = pd.DataFrame.from_dict(\n", - " {\n", - " \"driver_id\": [1001],\n", - " \"event_timestamp\": [\n", - " datetime(2021, 5, 13, 10, 59, 42),\n", - " ],\n", - " \"created\": [\n", - " datetime(2021, 5, 13, 10, 59, 42),\n", - " ],\n", - " \"conv_rate\": [1.0],\n", - " \"acc_rate\": [1.0],\n", - " \"avg_daily_trips\": [1000],\n", - " }\n", - ")\n", - "print(event_df)\n", - "store.push(\"driver_stats_push_source\", event_df, to=PushMode.ONLINE_AND_OFFLINE)" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "--- Data directory ---\n", + "driver_stats.parquet online_store.db registry.db\n", + "\n", + "--- Schema of online store ---\n", + "['entity_key', 'feature_name', 'value', 'event_ts', 'created_ts']\n" + ] + } + ], + "source": [ + "print(\"--- Data directory ---\")\n", + "!ls data\n", + "\n", + "import sqlite3\n", + "import pandas as pd\n", + "con = sqlite3.connect(\"data/online_store.db\")\n", + "print(\"\\n--- Schema of online store ---\")\n", + "print(\n", + " pd.read_sql_query(\n", + " \"SELECT * FROM feature_repo_driver_hourly_stats\", con).columns.tolist())\n", + "con.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "AWcttaGalzAm" + }, + "source": [ + "### Quick note on entity keys\n", + "Note from the above command that the online store indexes by `entity_key`. \n", + "\n", + "[Entity keys](https://docs.feast.dev/getting-started/concepts/entity#entity-key) include a list of all entities needed (e.g. all relevant primary keys) to generate the feature vector. In this case, this is a serialized version of the `driver_id`. We use this later to fetch all features for a given driver at inference time." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GNecKOaI0J2Z" + }, + "source": [ + "## Step 6: Fetching real-time feature vectors for online inference" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "TBFlKRsOAhK8" + }, + "source": [ + "At inference time, we need to quickly read the latest feature values for different drivers (which otherwise might have existed only in batch sources) from the online feature store using `get_online_features()`. These feature vectors can then be fed to the model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" }, + "id": "a-PUsUWUxoH9", + "outputId": "fc52dc04-db87-4f48-df36-d3941d485600" + }, + "outputs": [ { - "cell_type": "markdown", - "metadata": { - "id": "lg68gH2sy6H1" - }, - "source": [ - "# Next steps\n", - "\n", - "- Read the [Concepts](https://docs.feast.dev/getting-started/concepts/) page to understand the Feast data model and architecture.\n", - "- Check out our [Tutorials](https://docs.feast.dev/tutorials/tutorials-overview) section for more examples on how to use Feast.\n", - "- Follow our [Running Feast with Snowflake/GCP/AWS](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws) guide for a more in-depth tutorial on using Feast.\n" - ] + "name": "stdout", + "output_type": "stream", + "text": [ + "{'acc_rate': [0.86463862657547, 0.6959823369979858],\n", + " 'avg_daily_trips': [359, 311],\n", + " 'conv_rate_plus_val1': [1000.6638441681862, 1001.1511893719435],\n", + " 'conv_rate_plus_val2': [2000.6638441681862, 2002.1511893719435],\n", + " 'driver_id': [1001, 1002]}\n" + ] } - ], - "metadata": { + ], + "source": [ + "from pprint import pprint\n", + "from feast import FeatureStore\n", + "\n", + "store = FeatureStore(repo_path=\".\")\n", + "\n", + "feature_vector = store.get_online_features(\n", + " features=[\n", + " \"driver_hourly_stats:acc_rate\",\n", + " \"driver_hourly_stats:avg_daily_trips\",\n", + " \"transformed_conv_rate:conv_rate_plus_val1\",\n", + " \"transformed_conv_rate:conv_rate_plus_val2\",\n", + " ],\n", + " entity_rows=[\n", + " # {join_key: entity_value}\n", + " {\n", + " \"driver_id\": 1001,\n", + " \"val_to_add\": 1000,\n", + " \"val_to_add_2\": 2000,\n", + " },\n", + " {\n", + " \"driver_id\": 1002,\n", + " \"val_to_add\": 1001,\n", + " \"val_to_add_2\": 2002,\n", + " },\n", + " ],\n", + ").to_dict()\n", + "\n", + "pprint(feature_vector)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "SRY87OMBoK_Z" + }, + "source": [ + "### Fetching features using feature services\n", + "You can also use feature services to manage multiple features, and decouple feature view definitions and the features needed by end applications. The feature store can also be used to fetch either online or historical features using the same api below. More information can be found [here](https://docs.feast.dev/getting-started/concepts/feature-retrieval).\n", + "\n", + " The `driver_activity_v1` feature service pulls all features from the `driver_hourly_stats` feature view:\n", + "\n", + "```python\n", + "driver_stats_fs = FeatureService(\n", + " name=\"driver_activity_v1\", features=[driver_hourly_stats_view]\n", + ")\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { "colab": { - "collapsed_sections": [], - "name": "quickstart.ipynb", - "provenance": [] - }, - "kernelspec": { - "display_name": "Python 3.8.10 64-bit ('python-3.8')", - "language": "python", - "name": "python3" + "base_uri": "https://localhost:8080/" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.10" - }, - "vscode": { - "interpreter": { - "hash": "7d634b9af180bcb32a446a43848522733ff8f5bbf0cc46dba1a83bede04bf237" - } + "id": "BrnAEKlPn9s8", + "outputId": "45f7f075-5243-4fa7-dbd4-63c0c22a68cd" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'acc_rate': [0.86463862657547, 0.6959823369979858],\n", + " 'avg_daily_trips': [359, 311],\n", + " 'conv_rate': [0.6638441681861877, 0.15118937194347382],\n", + " 'conv_rate_plus_val1': [1000.6638441681862, 1001.1511893719435],\n", + " 'conv_rate_plus_val2': [2000.6638441681862, 2002.1511893719435],\n", + " 'driver_id': [1001, 1002]}\n" + ] } + ], + "source": [ + "from feast import FeatureStore\n", + "feature_store = FeatureStore('.') # Initialize the feature store\n", + "\n", + "feature_service = feature_store.get_feature_service(\"driver_activity_v1\")\n", + "feature_vector = feature_store.get_online_features(\n", + " features=feature_service,\n", + " entity_rows=[\n", + " # {join_key: entity_value}\n", + " {\n", + " \"driver_id\": 1001,\n", + " \"val_to_add\": 1000,\n", + " \"val_to_add_2\": 2000,\n", + " },\n", + " {\n", + " \"driver_id\": 1002,\n", + " \"val_to_add\": 1001,\n", + " \"val_to_add_2\": 2002,\n", + " },\n", + " ],\n", + ").to_dict()\n", + "pprint(feature_vector)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "PvPOSPV904t7" + }, + "source": [ + "## Step 7: Making streaming features available in Feast\n", + "Feast does not directly ingest from streaming sources. Instead, Feast relies on a push-based model to push features into Feast. You can write a streaming pipeline that generates features, which can then be pushed to the offline store, the online store, or both (depending on your needs).\n", + "\n", + "This relies on the `PushSource` defined above. Pushing to this source will populate all dependent feature views with the pushed feature values." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "uAg5xKDF04t7", + "outputId": "8288b911-125f-4141-b286-f6f84bcb24ea" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- Simulate a stream event ingestion of the hourly stats df ---\n", + " driver_id event_timestamp created conv_rate acc_rate \\\n", + "0 1001 2021-05-13 10:59:42 2021-05-13 10:59:42 1.0 1.0 \n", + "\n", + " avg_daily_trips \n", + "0 1000 \n" + ] + } + ], + "source": [ + "from feast.data_source import PushMode\n", + "\n", + "print(\"\\n--- Simulate a stream event ingestion of the hourly stats df ---\")\n", + "event_df = pd.DataFrame.from_dict(\n", + " {\n", + " \"driver_id\": [1001],\n", + " \"event_timestamp\": [\n", + " datetime(2021, 5, 13, 10, 59, 42),\n", + " ],\n", + " \"created\": [\n", + " datetime(2021, 5, 13, 10, 59, 42),\n", + " ],\n", + " \"conv_rate\": [1.0],\n", + " \"acc_rate\": [1.0],\n", + " \"avg_daily_trips\": [1000],\n", + " }\n", + ")\n", + "print(event_df)\n", + "store.push(\"driver_stats_push_source\", event_df, to=PushMode.ONLINE_AND_OFFLINE)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "lg68gH2sy6H1" + }, + "source": [ + "# Next steps\n", + "\n", + "- Read the [Concepts](https://docs.feast.dev/getting-started/concepts/) page to understand the Feast data model and architecture.\n", + "- Check out our [Tutorials](https://docs.feast.dev/tutorials/tutorials-overview) section for more examples on how to use Feast.\n", + "- Follow our [Running Feast with Snowflake/GCP/AWS](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws) guide for a more in-depth tutorial on using Feast.\n" + ] + } + ], + "metadata": { + "colab": { + "collapsed_sections": [], + "name": "quickstart.ipynb", + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3.8.10 64-bit ('python-3.8')", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.10" }, - "nbformat": 4, - "nbformat_minor": 0 + "vscode": { + "interpreter": { + "hash": "7d634b9af180bcb32a446a43848522733ff8f5bbf0cc46dba1a83bede04bf237" + } + } + }, + "nbformat": 4, + "nbformat_minor": 0 } diff --git a/examples/rag/README.md b/examples/rag/README.md new file mode 100644 index 00000000000..88775fae0ed --- /dev/null +++ b/examples/rag/README.md @@ -0,0 +1,87 @@ +# 🚀 Quickstart: Retrieval-Augmented Generation (RAG) using Feast and Large Language Models (LLMs) + +This project demonstrates how to use **Feast** to power a **Retrieval-Augmented Generation (RAG)** application. +The RAG architecture combines retrieval of documents (using vector search) with In-Context-Learning (ICL) through a +**Large Language Model (LLM)** to answer user questions accurately using structured and unstructured data. + +## 💡 Why Use Feast for RAG? + +- **Online retrieval of features:** Ensure real-time access to precomputed document embeddings and other structured data. +- **Declarative feature definitions:** Define feature views and entities in a Python file and empower Data Scientists to easily ship scalabe RAG applications with all of the existing benefits of Feast. +- **Vector search:** Leverage Feast’s integration with vector databases like **Milvus** to find relevant documents based on a similarity metric (e.g., cosine). +- **Structured and unstructured context:** Retrieve both embeddings and traditional features, injecting richer context into LLM prompts. +- **Versioning and reusability:** Collaborate across teams with discoverable, versioned data pipelines. + +--- + +## 📂 Project Structure + +- **`data/`**: Contains the demo data, including Wikipedia summaries of cities with sentence embeddings stored in a Parquet file. +- **`example_repo.py`**: Defines the feature views and entity configurations for Feast. +- **`feature_store.yaml`**: Configures the offline and online stores (using local files and Milvus Lite in this demo). +- **`test_workflow.py`**: Demonstrates key Feast commands to define, retrieve, and push features. + +--- + +## 🛠️ Setup + +1. **Install the necessary packages**: + ```bash + pip install feast torch transformers openai + ``` +2. Initialize and inspect the feature store: + + ```bash + feast apply + ``` + +3. Materialize features into the online store: + + ```python + store.write_to_online_store(feature_view_name='city_embeddings', df=df) + ``` +4. Run a query: + +- Prepare your question: +`question = "Which city has the largest population in New York?"` +- Embed the question using sentence-transformers/all-MiniLM-L6-v2. +- Retrieve the top K most relevant documents using Milvus vector search. +- Pass the retrieved context to the OpenAI model for conversational output. + +## 🛠️ Key Commands for Data Scientists +- Apply feature definitions: + +```bash +feast apply +``` + +- Materialize features to the online store: +```python +store.write_to_online_store(feature_view_name='city_embeddings', df=df) +``` + +- Inspect retrieved features using Python: +```python +context_data = store.retrieve_online_documents_v2( + features=[ + "city_embeddings:vector", + "city_embeddings:item_id", + "city_embeddings:state", + "city_embeddings:sentence_chunks", + "city_embeddings:wiki_summary", + ], + query=query, + top_k=3, + distance_metric='COSINE', +).to_df() +display(context_data) +``` + +📊 Example Output +When querying: Which city has the largest population in New York? + +The model provides: + +``` +The largest city in New York is New York City, often referred to as NYC. It is the most populous city in the United States, with an estimated population of 8,335,897 in 2022. +``` \ No newline at end of file diff --git a/examples/rag/__init__.py b/examples/rag/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/examples/rag/feature_repo/__init__.py b/examples/rag/feature_repo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/examples/rag/feature_repo/data/city_wikipedia_summaries_with_embeddings.parquet b/examples/rag/feature_repo/data/city_wikipedia_summaries_with_embeddings.parquet new file mode 100644 index 0000000000000000000000000000000000000000..63270802fdf5b1b53f8b4b619d6030d57249729e GIT binary patch literal 2028051 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%0D}(c*E@Sm=XL)5 z6s(;b4D-i6g@dr0T3XLx%gvAY#eF13)vly34>DP;Rv6}I_>i=jJ+*~JfY@#^3VTzH z#_=y<%?fe0|LGT|7u<`rXNEBuoqqPgOkhhMd9f>=HG(;xobb#sP1-o=8Vkw1%l2i-H|)zahA^EWu%Fn$zUBx>p=1fC zQ!4@z_f;|d?In8M;*YXl53-iFGvHN_%AVy-pbrsBAa9lr2Nudg{`~QfE)fpzcMCH$ zYol?vW-2z>oTQyfQ`vf!0{a}x@YcF+rg~o=&YmgU`4omQZ^% zTxlcc_pTK3)fmF7Mwpk08dm09r&rkKAw&vgMC}L+I7oly;%(m zo_3<@u8aI>;W)=VFrlVvFL~cjb6}(;Pg#?;!M@(}d_nSVrk-n!u9MPneaInb%-N1V zC!N8HL(;IBpTZUSjE7%hk`zB~0gd0M4)(K8L6PYtR+y(swy#v-!^h*C*qZh9Z|rDt zO1Gzv9hXqLz8n*(7K6mEB5-I+V22KNF%5@s{=`aWJpFuto45Nb|GPeqI>is6;pyL4 z(_VmwY?^SDP)8oF-VND1tQ&ggN;mwn&Bf!k5*HT9jAFNseq*PbJ*gmeIrPpvg;782 z`D@AXeENS6A?e+EwyiD;98xaQh&-MbxRkRrkGUAAeVnAP|HYc!{dn;AA=uW{&3cwu zaSn^FafUS^Fpx5yHyC@LS5c~E54P)2?uC(*b!;q6ahOOybG(Fpxjg%~A%)t_CsHw= zz=AI)K;K3M*!3osf_{uZqw^!^y=^wx_$9KPcMC9MeUOmlK4ZIds#rqmXL4_TiXj93 zv^^snzizpYX^u;U^%thJ@z4fl{OmRkC6~Zz8FLnSeJTxFdoT@SNsx&>1HUAK=|OHH z_Ua0G^_0`lytWcnebA!U+s2cBau{3oF}{AyaV4`6M(f$LuN=m4nKbU@Z8P`FdID`- z;FE>x!O_eFHh3|RI(BD6b4V>Vj0vI3+V^2`StuM(|I0K(jT>%DTR>>85oI>!fbF6` zFvIN(gjuPuMK=Zf+I}Yx@$jeH7Pr~`-Yz`i5DF$cw&9APgK*C7B79bO1X*F*p#O(0 zFgrJ@dNhx`Ui{^jGjq1bp%?WI$_r}-b?SY$!n21)|Ig5Q{{z`ZaafU5 zl#n8nrZSU+=RODxBNe4YN~NhJk!aW%8KG2UCrT2@c+OEm$w-3|jkiG>sI;lx=MUf) zKKFe;=X|g0Yn6&K&PYLAxH)${w3HKHy%H4K-9WTVjTU|?hpX4u;D-s9u-8u=-r!c2 zHK7(=Qk_ZM%#|*7T@rRI@}P3fgLbxC;Wim*GCLiD@tXs=wDT{~_m#jepBf1=j?tiZ z+?|5Gg}gQI4K)MFbbPWjc)F)jx#cbpe2`QpXnHMsoao9^;qJPZ1!D_k;6t1bv$T0j zo9@}cgw!)QF^+JEb`rjQ)PiT`q>!mzKDX}V5lSvE!Lu_W@V3S`mNW7!?HS_>0ii+Y zJ1d5Xe_uhxzlMNEq&aDP%cM<8OR09jR#J0ViZ1hi;FP&4^mj!Xt@QI`&%esRiJkIn zhpjYBoB%A1(%@TaHth~S#_jbGp)E_}7-}Elvxbal^k(h|s-tc!|5bzS`Oz#z{4mx{y9&F<=gmq zBB`V)w8O!ZW|q&UfyI;XT(oLLo}CHxo>Zm$-ji7Jb2C|lTp(BDRMs!d+GEG8pyvG_ z(JoL0DlQ_WhX?Y~LAjvqBye-l&A9#FBkt0PCAiku3U;xhs6MujGfFLj$jyBj&3?6u_JdJ;uk#6Y%YADBJdWv)dP@ zKM$qOS3(ZHQ6595mtdvRMEbTz1#Fkxg0kCFnckFerWNkPhJNWMGq)`uy(Ah**^6%E zNx|XDWyRh#?D_D+C@U7iO0GA8?tB|4GJ3`g`|i^ueNjr(=zvCZ3uw@sSlO

``;rxU#<$FHIQ@8z7HfvNBrqrE4z4fLzB7wnSvu4(-p$a!1+~j{a$f84a zKOeiZ7}WZHGVK)Mth+yx+;er=h3f$5LA0d`htH9zoa^NbpG`gre^*a|)!snw-+8kVmzDhe)2kqA!WTFgW6!Vtz5+H* zQ=}1@I`qZehrEvrqYpzH@rJ((MS8n(d5IUfMEQLbc(k2&?B>TOtn#%9SG9H=e28?$0~uHGtdP^tA42ThO)L63KcB5y;Yp#b zD^NbHj=9P1Vw?777`1Im;@^Leg~V6kGpv__+!`#112m zHOEOOFdAO3yTRP|Y0zNFLfpJ45BBCs)5!Aa5U-cQ{=p%-ACu0{DUPCkA#Gloz8Q8- z`G?tMN!+0=@$_n#H$8Pb0{fkmF}eH+I9!r|t3y7)u|4;(f0-Y;b;rv$Mx=wPievvKVcgc4ZiLXjp>D zGDqe)JOM{9dTLbWb&P72>zPxk0oA5g(VmqGkg&W*aLjb$O8splBkbmzj|u1GtJ^5v zhU*noA>tZs0Lf;hgl@Mn6XALfVh9 z^l`cttsEEwOTO!X`O!7-)KStXTH_G-+eShOY@xlH^(>-oC*AOy18EQ5^3|iOxtCt= znfA9UY_`B%7JD?%zODe8_1+NfzO`lJTCVcFyCdk-T9L}+n=A3io*DRhStnDgHln2c zCt+*bk&58~?cA&XLh0IVCDMBu2O33Zg}iV8e2Je(D^?caK;jzkUs=y@Tqn+!InKpQ z77k|Oo51$68e4yP4!DKCWHkqf7KD$3Gg~)c_fsJY@%91hoO_Zbue(Y)0wXS;qXk*E z?N}N713O#SK-N-s&UbY;-&nbhs$R^ZZFcJ^I`;}bvKT{mPF$0y9b3S^*F^4dPZHV7 zOX2w<0~-5#C>}5<#U%!^81%akZ~p4RNqGq<{*=+A3Sr(_b_vRKuW@NjeQbEY6e(&} zvZI;mY?Mq9f4}qz`!U;sD%L1g4sqB4qhLH2dOyiX!Y2R%#;>ce^C-ajj;r|lOe?nU zy#&|(twzZjZMgB!hbd0bh4_&f5av3LszoGNs^EE1R`jFx&r_JR{UI9qUjQ`TUCc|p zbI0pu2_$n(kt!y)fRk!C{j{`2!%My(_sx@=)1*LEEwyZHrz3p1If(-Mo^Ud&6JXuM zo4mix5z@Kn3LckZ$UDCU#GiID9fv98v2l3iVN-RMyM7neK3_{Y&7WBLe?=gcu$%o; zImfa?Gx-{7q*WsxLh9b1m?f4?OO7Qmf1f>Eug*VCB%;x1XZK1rye0-CTwbyh181S& z(>C_>$9*!at!89U53@?P!+$a(D>d^U;$HnHxOiw`k!5Qd3SG6h-!+OJTSTxiKW4Ip z0-GoAafR(`-49#;UP3(X4u>`W^6Qq4VS}HBLEbubI3#3FRD&CgrvDKE8_SEVTh5C9 zvyC@8|F;Lndqu)uw_w)gAVFG|7mPM(y3&chEVw5V!t%}D^PI_Ul!&Kjyg z>(xY9Fl`1!ygJSdtSuoY-3$6;3%JrpTPg6pBb|`Xgid7#>X>dw-IpCm%wHRnJ~m=l z`B_~0s}Jm-_mOK!6sf**fq&}+2C~eOs zLh@!m>QnlGE~^9S5Qf5}&Bol#H?Npi%yud^vlH00v!MG1X_e1y)}EP&)t5zB%}{G_ zw9jE@Rb<)9UULxDMk-r%0F^UUsOQuOIAJadgJ-U=p$iNkNizeA-}tkit9gh}R)Blm zCQL5Ujh7!)#g6|ehmLE0{H$xnMlYXfyXhXr&CvkO+cg;b z&j(hBhO?KqiJjU#3o3%8pljs`#$MIq?#<82<;P1>@ym4V*{?#SS&d{iBoa-t$)gD0w#r!+_=0uSEi7f(}VQo!|a1k2MBd^Z|j z_z=G%kp4vxzwSSZS%Qa5srMsS(~<;IeM`7y^K)?J&@bc%b66)mX5W%lvvZt~^~zla ze~dM`$e&Zep{Nk;icTV%=tDiqLs4VH4tV6{f<;LMs2r@!UU=u=;e=MUpn43QJekXO zTNZFNZwgtE_5|43W(E<%zZx}Pt;4@%QcxuMn;TxFSb6K3GIv$40J?)lqU`Ay^6dLg z5N^e#C5Pjf_1+QTrGY3qR;=z<7tYM20YZT^#ZSaY%>*nDC3}X zQwHcg(4ei>*I98~pTIM`!JZpVay2TE^p zq_pXJKt4|BZl476%JbmGM@hIHAVGhla@qRyMwT&39L;Cy!vZ@oIv$h)dkWUDtLoaM zCcd8v$9>_{mIfG&jQovl30u*vupK>(XVVy6X-vNJ0#*78;GOWjb-g;5)AA_>d+`X& zNxomjBU^UkEAhy7|beJG5xSEIJqe2sT@Fu|;QBQugaePXCn&Z!6YTH1X#+(irx{{jYQINXkc6A%h=A%Qm_@*(QnJs?3&bU_;Sru$li{m-%kHg zi?}meG;Is{k2t{8W@?iA{e>90J|DwGR)C38I14lG#*N<}kBI_Lh!8CT?! zT+})k<*NqGs)=l^{Z1Gd&|n?&Bxv#@c{-OjsS^AjyKKl(*q;W4 z*i#33(uLY-%==rh;BQMKrQj?!Yvp%xFW3Z+J@>LL-ZAK((9Bwj)`HFD`LK5WW^#VK zRB(=E;F#2Yklqypg{8lcBI7wFYcWVDEW)PSr*P`w5S-Gq8ZMa}fGwBC!jo^o>||y$ zIG<@{LvIV;6RRE?H9ly=%?1;3ky;)abwq<~N)r8i+kj2ufJP1*3!aLmkbc|-!Y`_W zPe=}meg4PHHfm7hX9l+)CSl~qVerOkfQ?EU!g><~H^{DE6|+Rkv3!#uEm~yEHcvi? z(=t@a;=Me7pl3H}?|6gNu0==+G0ah;71|7(U{TR~UQw7q2HqY-*_JGljp5<=x^8Tq z*bNmqOQCIE3P_yEWy;G_L0V=iUY9rm!#mGVtk+vyIE z=F6Gv7=cr(uao_*AUvX*idm1&L8A6Z*fq(5tk#Otl>ZL$8BRCY;WBFsJ+XtX(|pb) z`V0D2C17l-9~JH=Bv}0Vud~JX*f>XvqeDn$zdFQxsqyT3;T>C zZjfGo5|@^&q6@*JD|am&1I2rg%`^EPF48Vb7q?1~{~L3rDVq;EKVR_RXv3LR0%gk=La=LS9=O}z#5 zYmL$IcKr&;7Jty#)zzLU|_{KtVlY?bsv?0W!y4aXsyf+e2W5u z@f$#Qfh>K`4Wvb<)Iqyq2zEXVqliCgtoYz_v<;XDd+eg{;E)8e=$!?zGvaAyvfzy@ zlZJb*ui@x+;SF*^4S&5l0_7p+D&G4D8Crw8&~GVA#@Ft_F=3{Clskvp>Ngr$J+`M` zO=sA~@=6M|xQ90RPq44{W_YMc3oDTfCcZ@;yanglUGrg;-)-~h&nR!4qNNL4ek>pj zRVSL-w~Hyd1yUU60iE-1LvG(}?25F5jQeM4jO7jzNqj`j52nM=p$eQG=yXw=v4W0)~iJ@H@0uk@o|R78?HG+)wTUhg4NEws=#KpWnnjoe5yy@A!gG zbTA(Od zf~%99DY8HVJ%`VL@4pRLqR$d~tC_+gJ6CeQPi?}p^B&^roL9JP(^yL182~x#3%B>d zCEjDc59ut9#M#q=n7O$&85>_^U#%7kyyR1Xm;1*)%#J4o-xw+!?ZW;|)a0&tCs5e5 z%kb`Z82$ch0VnGwQOcfD95A=T8(KyzQ{f>rp7_kG+dU@n&k9EN+pd9Jy&S9#K0_rJ zR}kwzi}st27cJmlR%{;90?GA5Y3y71bXygt& zv33q5=NQ58wi|qhM;1<5cZt4qJi<+uiEPn^u~gO|9({_hhH8{$qPeX0XTpN#C{>S$VBbboKukjPgN+I_`_L^*>G4{7lV%;W)}Xzv}ExI zT%NpyQuym=9x@k%1~JApG=W~sWNxp?Rs0b90reL6*3hzJC>Y;W`&8{~Q<`DQ2r(c<^^y3%M#KI9YlrY`QRuhwdOnYCKfSJ-+<`t;GNd(B@IO}p|Zh4HnGaLyI&!Io($AjM5`U=UP!Eo8j zk1fqQ4vYNq1#h$t-LezT!`|0S$?82u3tpt2Lu&9MyVr0X8_hoXRzu&iH5eQ_0g~bq4$uZ+K6vte zBVA25r;uo1%L+^QZ1Wg8$51HZZUR4m{$( z;nhg^b^I5zH_ZYcgKE%TXG<&pdcgbHZ!rJdc`DzWiOLQaVcxL5qU0<+cllE zJi=+~osFa=WQ_}7Ugx(zAI)x$*+pkRC~@}^*J9SZLqd6KBJSNP_>h+@WOEE|P~CVV z{P5Qj^PCc>?WGay`VmOMqi$o2?|fFdL>349RS|KXz^b+q*e@UbrZQBrIS< zO-|v+>muZtyai={+S8DxaNc`r5Fa(7juq~TBpyol^6stnaMeV+eDi|`vPw3 z#C90mmqND`oVcWcHuiIsJF7`K4rk9y5x#fSV6@0is`)Ag_M+$L%6DU06X`%oZF5L& z-dAQkWFpu#+L=ma9a>$ z=@NRX=#WD6ISRRMj@=9IlF5mEy!re>_EJTay3@CS)BVqU^v7cMpYCKPpRyM!R2IQY zM?^WL574~*8j1W_gPl=T?B?HVY`u3F_Kzx{UB@!<(A;5_CtuEjFY-o$b9^z-=1H`% zK@s-P$ix+Cci>>JIb~@b#j(2=u*)}=fy%Li&|us{;R^BmOSK7@XmkheB%TI^h;i_?3DkM0Jsd;I`+=X*j)_&f05F$XRVc}Sn` zHehgD2XpTH!gVZlrA+fBpgDOmoxhU-ofq@i478=irV^ZL^C{Lb(8yeb9brn$DLm&X zhu@|j#nfr>u)=LV%Uv!w?`tiLc5HS*<>Ox%r)v+d8Yf}>=m~It&=m{*R&k<#dH$vftM_*-;YJQ*$cj^PB!mQ9e>C7 z0X}{yc%&a)kIc#>n&@pdqHNlzP!haH%56? zDSY7IW_o<;M}$-78ex904kM}_v&pvN#xD)-W44f)2>sd&SI<3RNyh`Z?zmXorEU+a zyCrGIqj*+!yM)*1%!8q;d7SktnQd%r#fO&@Dk7IelKA-zM%?c{Nc`&xDc)~s^YBl? z4q`5~t3_gO!B!mRszOIamQvCkCH~fVOV--aO1>Tuc2YgG=hm76m*_v_1su`b%bZ}uO>6m_Gk2D z=je(lKhI*=b`=&VI)!E?T}HQe#YSxl`vl+A7ixMkl@#(tL2Tv;nBaAYA6`3}syRs* zfwQ=hMJqXpq3y6ezKgXLPD#AkN#2Q7G(n>&r6&mc>kG2l&a7?V>P1AU^4#joSqGhFq-1AlbRmd;IG#6B-Lm^7al*vwf<{aChEbA z3`@8-W=Q42KmF|B^(>~<9!NElTX8|E1238}pPzK*6g^jH;nuDU#1Bjde(PQ-UmKo6 zok_jeIV2W3bd|Y;U9uFEa}LEimhclMb+ee4!XDqDl_hQ-%|9L2gL!ZNgSHf&tIn{b zTRG>c%^;2jUVp~9)@iW&whhy-_N33Prz=cno0H!|4$rICOp^g-Gw*B4FLm4pPeP8Imp=BG@};|>1k6y8xs4^jT^A;m4z z4#0)0CSb7P1f_1*Kz`O2woCpD`7WqqXH1xT`)#1W4cWcz1BMe~e{hp}JlH*t zm$>yvKm9bdq&%la3|Jn|Jf1h9^QgmEv3MGDjE$q2!Z&4X=`dKhx{HREF%e1dtJ%-MgnVJK8_L>*YmHJuZD@|7)f=X#C}anNUU;( zO&)D@B*GP94+hfqQ+>?)h>-2x_p3tXj|7-Kv8VNxfZ!Jm%vL>k2u$9WAz1cEtE$-~LIwmQ%fc%}kQSPn}yjJ$B z_#$=|U+s=Sn~CA5x&0{iZdPXog17Lw<59?ULx@NVCb`SM*{I4ssR8(?MO!ol3%zNiAa`G&F=#LY6r`?I}03u@D3QOB$^Hod@^9 zYa=vQ0eyUU9Av}3z|O*aOgvwV_6B-jlx9i(x6Dj|T6PJ3s<~63O*asma(zn`Z`ClB z#9?ga8V8!Y*MzL?LRi^|bS(RFnF^PhLR$4gxOO_5Roo6nzb)y^PjVg2-8sm0oC;;{ z+JufCtbil^4)C|SQSeV1Gk&0r<`&GR4L6TL%$zvL+8fA+?(4uQZJ$}2%{Hi(JZ$t% zXEZcOm$Rbtrp)`tbh`3?Z8y>suG1e(jp{Q zI;?V`RSZU^oX5cv(s0tzhgx4mAJsUQfaAnZP(#2|R#ocDj4U6~sJhm0dsnM=@} z*Pn35vKTg3QPHSD$Y_SCFQSm=8yo*qj#*h~;EAh&;5L6ENdLE%jn>oC_FRCv}`h$DnZM zYtyK)e=wkD74&XDfFpEAkwfZin%O+L%x!EQJg!Z~PRmEkMJ|N8G+p3o-4xRNHV(_) z1ksTF9iX0i3si3J0mH7p=yUi3PV|_}_L#{*%nZT*otT7cb*E9QdnVa83bIS83|zH^1o=7ZjXL3VueRM=+HQ}OxM zGd|~W3BBv<;i?baVymWU@SDF6Rva}8U`}O+anFP`;Mq8uygr06jp#`D$Ik;fei(0Q z63<*jzOwn|%4}DS99^p#3XK;S*Z6c5D@|NUk~?0a_pW%Z+(FBD&hjgi`7sFBERtY~ zlM-Oi#DLByNP%L&CYZaSj_ha!&P=&Zt1bFD3EvKOK{bRLU0$<^vI8tN>j|{iRMO^z z*)S{P7V~kgWpn0T!-u8K`0joj3;E|w9TI&g+WG`Hex3?(8_!V184gB;UL{g~#ag=3 zA=rN+HD5?#D|^mj$GaB-gYQ5A@q#NT(x3L53}NM|N8m%dFb9mf!N%)`;Pk*cepJaV zW>i)Qk3wDu&Y5%UJJ~^2h&r4(>)3K{~#o{~L zFzAh0{#%8NRt-FKEN6dTYQP$?P%M#N3B9)`z*i?THgDTj2zjCmS#CpF@P{(K;I=Le z4h>$t~0iHefL(O9}M zS%*42XRuS#Q(58&R_tpj36uRG^xlu zFJBj}!_36gFlxtrJSq7a2jtx;v-crWS|4W=;w`Y{iGqV`ktscu5dl-F#oW*%AKACN zW9aJnb1-7gDy~lKHlvxjWM|ujR%a!k@#k{(`nx8ooIA`e{QSh_2f5R@jmu!;kt;YY zZ9nNR34-ar4WK?_1r(gNrVa3%Wj+}Jb0r(;;;|9TK{1_v8lSOoX7g!}(Kz*$u9yIBO z=1_OZKOHIxOhA0dC$!+sQg3Q3$$veJGN!7?>^R_xg`i^M zT`bg$=5=46WP_1Ia7pb-Y$;rgqGvKm=eiZtd^rQ_%M5v2`xv_7Go8GD9W@RyuVuw&h2MxM+2addYyED>A;+SzU>R1V=~eNnKRp$}0`0cb57N}rbv zkfw|$rzbdareGI}ReF`Y(VuMKP4zdKMmzcZP0G%y?{zFmo1i1R6fU;Fujj*IREGIsBSLizki2-C6y3 zP5U`r+c};d#PAezM;Vev-=m8Y$54t>8dJLVwP;j;u-}|F57*?CqPE;T!E+gj6~38# z;?F2tQ6OZ-cSyksB-Z(6HQsE=VuKS$)32Q6(3l=eS%cm9Fe(Y-9R4Ft;a7!eS_6qS znZV>nNpQb>A#>ERfu2E6l>3hi{>se365Ty?`+WqhYZv?#i|(qZgbvZ&f@!rrIFz^98PRONaMYn2meY4~m^ zy(dcM$7ET{cUKry9>A*nYEdFVvRp?wj9^X|r5{%Z`)lHrp^L6E#gG|XO#2yfJFplk zM}-N!EakB6WD!l->H{`{I|zGH zLr~pB3BK)+<4-G}jv5H{*kbF{SNkIHc!LTZt;tg1W#LH7Kw2)0Hds_+H3|)7->bz z{`?nlbMGvwxcCsS{h9=E>z<)~^J|P-HI9F~RGv;N+$;C5v0-MXhEVD5kG#{a*|bM- z5A=;Shi^;EU~b=a7CS~;?YH)g=D)SFSh=5UrRbS`*Y3`OVsL_AD)=yI_x%@@vv z>zCfK3Az5@aI*$$!i5gC`{^w4Wj(g~STRe7v1FNR!=@>ofZIN2+1bBkEa;yC|4t=~ z8ueDvWB+Y*c4IKJXq5qJubohHp^$x6W6(T&F8s3W$Ga8>DyIDhkl*jgCvLe#+8ZQc zSwk?B|F#$wZw`caA+zAw*J)6vSVDf~A$03QJ6kGy5Gv;#=Pc$Ng3{+D5HTW$$&DKV zzT-Z_#t9arT>6R0EPucaXg0xlV>uYBuMSH?*Dz~?hwvorvQfgPjkGsb3Mwjv_jHvV z!!||!pZG5rBYBLaR|LSu-~`;d*9o^~i_k3_J8G32$2%CB;nD7+tY_C8rqa7;#QB0f zq5;qGkhcInXY7|s>X6uU+|r6ADY1ue&ms3;E#%BLz_`Pb|n;7+=iDum!W&V zE4@y%0PB4lxQWj+Xopu88G6b?bn_ielxF&@p*8m;xaoE~X^PW2G?hrCc~?X#yPh_(Ut`2!azg^%60M|5 zV~5eaFCVz^^TxA;i&yycS~arYIE^pd*v$Q|{(zq+2clNfHL#9K!D1sVs5vi9saG7q z!|xJUl!h@$Hk%H7bW=kE- z?{S;13Qe8&i{JTRL;3Huo^0#oREQAn^laHJOx{L=E8cJlk0zey^lV~j)Zo4Hp4ZDk zba4XfJt9i$?T=B_+D^V?w>aUKLiWCL2JN(V=iJnGz|=zdN*C|7G)mM6Cx8+?vua=; zMK}oB@tG?2z2N6uaH4$YTaZ6u0KLb(;I^$f2JOa9?5?&9CpSNu#kl&=c)!z>eqn&s zh)$_|)3Fc^Onv`2zdcWmLR=8vSx}r+?v} zNPA#9q<$F19+gDE-!)?)H#Zi8qAr1gasU~%rBI~a9j?poKZ?HJ1jBsGnd&SF(z#H; zedU*QpL(@d7m=Z3X0uOqKGyR1Qw9gwr-f4m0zpx{T`9jCrul;yo`!T&B&QzT{v1VWb5M`=D=4Wdm;8(*zZ>g4!Yyt z@$i;n#TK-As>@iCskjeDtEYb<36u@egcRI{7SINQ7Zi2&&rZV z@;1Jc49kVgyTjlx{B3^_yn0obbYwbCd0fOP1+1iq6A|V2GQ!!dt^Hu>lSNIcrc}4> z4E#~qOy@tY;eW?6Hdjfi@}J=D$sJb(&Zg?1cD0u!2{YW(YXvNJcsqR9TTY_CpR)|p zRPLW<6dX9ybu}BK_MFu9w5}>6Xkywhn(> zu1DL!$uLt=1HLYZ=RE98nWtAjZoU4GWy`dH2Zn&z4O@D&Zv?5Dt)~&OkxcE~54QMQ z7uI>#v9g3{>|2q{oNYc=9E#s7zDOdUHM~7e+QObuMtKe?J4~(A`)op6vge@75Cf*V zTSs8~y}(5T+5FIa)XlyEJB)^brt4Z>^5+{Ykyr zT(Kz?dcy=(>0cli%@{-7-tGMU<*7Kt?>9SbJyMwOePrJL7wGy7OUT{-na$J>CtcGb z{FizZL`Iv@5&dGk(O|@jf8%rZ4P>d5z;13O!My_kr}vG4QXg5Q8Qk zWLEo!k!hGCw61C9WT(ww$*x8Gp`}T@o?{~E#)>jAxseoG8qFH!^mAvP++xMkAM+mQ zjJ^s&7oUi0^cs2I+?5K)2{9dpu?(8YfSt!w(n0!39%LMVP&eS}%twPX)Hf=`5LVo(tw8 z8=&~RE=5@XV2Yo|;?5GmS!0?>hZ~!r`*Q~K7rgokFS0P`k1|`Iv=OYvALH+QmVz*U zWAeEp$z#=UG&?y1{(f;}Gj^Q=Gd*j(zjYEAd7lC2Hx7&fj&pN%h|;~3Fm|--7q4>L zg6>qj#`K)ScqC&VblR_iF{g!Ypo7!k?&OKMxYsvT|fH zCmDkp>iJow`BeJr9=FZ9pXsDX!AZm6Fd#fQ_tf2k4zn$Axalo39hd_TCYjPn#dzo} z4psoZ)TGHfn4BgmE4{d>eUu**`rT5&9IkU(HEnXClkn~*^3koX;IN_+`gO8Mec3qdUIgsf`^CKF_F+^%YaDF* z>CJN&Vpx8ijB!uGPxv-GiNZRbaTl~+;f4VbF4ska){mTo71xw1FK?Hp9&a1kTwKJ- zK3dLFZEx`%ny>KO_@%nawKjw()jFxbMV5gp8H;v0*3O(_}JOQ;P2{Q zIP>Hwa}LzRhxZd8R#nbupI03^bv$I*nOi7Z+?e{eg|Qf$0T!I7Ku=PNC62x9%mX$@oyLmNvC7I?Dd}TxUzoQfT{>LulyS4E65k;J1|_eD)ba z`;1gENbL~%xX023i-mMy(Qi;bSxD07Gf6Vo5e6=*l6vbgv~B-JRv%jN)S5hUne&Bf zwiD+UA2|(gqU_M&Q51N;)+3GWYoWR2H&fU8MvXO7Afe|bUX!@P8uUy+n_I@^4=+OH zVlUddxDd4JOqsFN8+JVPQ^n-y!)R;R&%cS7&Xyh2qec51=!K9;cA94nWgqfcNbE#5 zVew?6SJ9hkMD#5*+gQb$7H6T_ErJuDRv=cZu<>b^(9hs7)7$flo3^9~C4Ck#>&cG5 zH5StH702mp&J=+`@}|?y=4^4U1f6J_KtGHBGK;`U*#E)@wC3%?u6}E94p1~+6ZwsK z>ZGs&X-`_P?-KngF@l*d!%^j{2e^;;PU9x0llebKlJiR}S|a%b_hr0fs>M4<$$24E z@6P3dEJG;9@d=6?8%eL9pJp2Eu`qgu;MKBt#crIKOM8}m=C14+UHKv)fvWmuS4@1f znLQ~UPL9Dg;FP=^uH9~f(CTy?dQg~fbY5XA!>@C7Q(v(Z2{GE{5DEuxUIgW(XSnN~ zOEGuHS=?*?l>2HOO|lvj;O(P$sCjY#GH1#Q{q?u-hqEk+zAtAReo8^}!Mm)is2S5f z$kH-7Co+nx$K^xQ;CHwf9TB|e_x}FDUWFMjxZ^uht#{`(zYV3f3Awac=wMb9=1Did zX|k#yU25-*qpXcXE7YK{hh+gyjn*aN1b8DmqS@;Nf=45 z-NLkYJuSb{RDoXCy09-Y79I(?;f8%iFxA=@MiyKk1>-_4YR?vK%#d$PJmNI144Mch zlna<^#Ccc}=fXN(=2r}EYUdx^`haf|;$U9)8?1b`fgL&(iShgES?xQa8_MAZGf^G` zmn$`4%zT6;-rxA|TSC~@R5cpzw-A$`F ztpmBP2Qcc=2s*bXj6zVm2~kF5WtF7NcwIp3hSzPQ4lN0X5Jc~6=dOFFz#0G-7Z(4u-1 z=J4LaqP5v@URRGsG?n4t6MgRIFUCvjDP1G8o3~BH4_~~wgk@=K@vMF@dAscrUM#So z+8lfI>bN|vcFAUoLZ_pU-4gP+gHyb;<=`*YnsoGVeeN@TXnsHyUuA?wDfLU^e8nL; zrXvUCn*xYuRVx-QtYkiHH{sd+mrHVjMu}3tF#cY%o0PBFfI<9|fj?}RoE%LD(_d1s z{BSb$Sd`94Q@)>H^EwZzBTS*;BB`3LiVQ;WdM&bb!lZr1{HY_tYU$^T`I^ z?*2%F%n%-*U?4*CKPLX6EH(0Wfyf`e7PEvTU|zEhj{ltnv(i*?%BUdKnv_o@%#As= z{wKHpaH# zf0|63+$8`^7T5ZGpNSg^DKuqq&ZRfk7_lfvA};fnEGf@}OR95lALnrFu;p^4hxU^V zO?sS%ZVGeE+q>pPoDs~jEhMQNt8cPYGGuXlkQD(Bs%^v>IGk)j^Gp4}xOxIS_zuHW zxfx{6wrG6Hu{g#YhKQ^DZkRK4iY~tGk0pN&(wdK{WJ^s_xXq6HjPBG zlFL)5lEx~~C+_5=U>daPajdOTq{C;>#*%Lfpm|?8uXc6 zha2C1U~Cs`MB8I)p-wUz{qnBk`c-wbUFBivsK9eFPjms8Fw@bvMwU(VT1`g|T*QAb zdP$Dr6Oe5fC!2#SpzfU+d9Kz*miE1X_3e*|lbv*}N!750?;S&GQkaVIdlVpR%W3*R z%K&>0@DYzSL(H(>I7yb&#e{?x)JN+9*8}^@NY0C|vEk-Ii#FBMCWivfvwDva61K)o zv8vFfU{9Am--(aMMKRvb0FTa&#Cx~?u*6CHvt z{_w$9-6izghz{Oz8e-j7G?C4}9dJ=IKPu@fp!teGoMZbC0_!#+6^w-q?t5TCj0Z}b z>LID_`Cxj2&$4!A3L5St$bVLg7IFLHA7?idRWbwRoAEG6%F%(#cnj?g#m(pTFtUj& z=+zti@My_yw76slFPEwUHN42AxIUmd7ls(G**>tdsRjLKrGa_OTSjrj20A7C$ZH=U zT{pk7R_~utm7S{~#Ca~-%YPtT)ES)S2ZDaa9%7(>5>nMNh#?#Wnr;FG&wlX~?GnIT zSP{n(T&bcY=X|M7qZ9Lwvd0Y+>AasEAY!frVGl2WWM@8_=QWdIi)o~2wiJ#o_ks+; zakA@ZD${Fu2dN9{>FjkTMe@9L<=4JIzim#Gbkzi zkKM9T*mA?Z5@ztAA#U9+0b>PwiD{l0$__lE3udIUH7!fA)u8~c=aoZcAIEV}5TKW5 zhOlyrF4EbBl`v4=h>!TRf!B%PE+_%toBc2buh0iCZxEjSA#!Fo7!RLb3uc*LXqvJZ zrE9LPx(JbfyU1+s0KDg^Le*BM)6Lhk ziTZv;+WOp&9AmGLatB9Tw{afZwaf?nehK1O&rvivR}a-n=47$zC_}5(Q7gV6Oj)y) zbGXe%xi?Fr;`C*(?~)hiZ!8D*%+KWTO-mG6Faw>x$dJFhah9p~Ai|MOBxB=O#;BtvVk`l3=bcekqccW%ft4X0m zG_DJcHr%JuhdOHZl>8+5o^=>Blk>Vb?85G+{&3oCm~?jS#SU9tR&`kn8O&0s4R4gd z_Ztm~{^}8yzn85MitexRmvzQ1N3Vnb-#lyd}7axHX$>Zn{skuW%lKXX8}TGK7wIPJ*jHw?bynGEm&GoSb&O-7%hGwj5nJR-IH9CM1^!tECY80Cu+Xp02T za|U>gx6h%)dd^eQpoe#FYT~hh0s5}y6H&gg7DHV#shMmVq@-QMl5}Z&Kk*m$KS*Fo zdyUY{Ujp{aHINPKYKi*q8d}2LD;<9a)9o4;V6ov2a#Ca%8|E~SC$^vIq*65uu{eSG zgWP@b&>|!?;;?$i9hCcoYEvAvus*eudjsBv`4bCW2vL~l8b(bt%di@2Nwj&xpnC7C% z9v%GcB1ixD++xEs?$e32BiMZ^ohZ(BMXU1Tu+YyCh5{ow4|xw1R_Q@%Fc0r)9b^RW z$b;{4HJ)AMY&dLP#wu*J!q2Ddn1U2zI#YKijWEf=?|!l<|K$P6*O@5&$bX*Zhdm=9 zE5dMewJna`J;LZk%;FqQ?zCI-GU!<;gNU9IsDy^o63s$^RD{=Tz2QK; zDTqwDiHeC1H6B4(BvYpo*ss={J24T5gM=|_@B+JP^$l9-md>agkfQ9B)#%Hee`cy$ zlW(QRF{(-uErxHhR}=K$qeL1?ac|Ss$pjRH-C=NE1y;$2q18 z^U0rmWS0nSify>|MJ^rJ(1+9CCt3b=&A?vG5JIJoF%B-W#N(1Th|NzUK0BW==T`uX z&z}WSy$LX>QjQ($BSfElO@}OBk-(2D$i#sjsqitOx=X!7Fw9atN;92|ZrRKs(I%Bk_ALic%m ze50A(K7O4P1sTDgI2GLROdN-c5}<2y868^AIZO3bsL|xvXsvROyf&|+iE$B7KmLTI ztiB9xjjx&3L)`D%E?oQTo+q=|WSmq6oFaklb<~WUhvPQeF!q-v=q)`8D>A*I-|!Ld z)Pfus=Q=GXFYZRit-+|-D23`nJ4tfaIrgA*5RCs!1QUt7p#LTT|4v^}(|LzmI~pI7 z%(p`DQPBa^9hGUp0*qcFm4Ps#zsW{{**FnWB4){RG(l{bt(+rEPqK)G+m;MyfE6B zY2r3FRXBO3j%K}&z%S!7X^Y!SVyA8jDH#z++#8A3+X5`(dT_$Bc{H}I5UhX?XT+qy zk_Vy~t29AR*oDAr>$x?W8dpFtk#hkDc!9?aZ*-O8-o%T9p|Ze@bJ2;xr=B`8WE)So zmt~TxEC?r(m*6M!5nh(iBos07guKF7ShGlwUbYw{4Ci4H6|W=`w)$uoJq0y9iY%6Y z;LI*G{5+`!K||@`JRzxTVnb5gb>`K(Xa(n0&_{^paNsPF~{=Q+jVu z*GwLSaO?uM@h%F+{cR*Wy_06ilgIjEk4NAT1{%=>98Vxa={h`Qo(zh9fdC zGWHH}i8Fw*h&()Uq!~R9X%ZLza=NSaA)S3;GiBnhQEi2t*s|ywHFqr|pQi64VYg49 zlzKc$9-aciA70b9&Gzu*V+jWErGwO90*;C-MDdpW-1+kxS$p_6mY58~6_r$684`wz z`7*$DqytaAXk)}jPomD_TCo2U3S)E9P{&%6nCvXVft)D3RX9LBG&Sil+x@WNcra;B z{Y50?BtUrAVPqFpP!~T+7U2|-et8z-mnJf6HNFy0t8{u>aD;Yey|wUOELE$2B_8d4 zH`c_h{am_pfFql8EIyz4`B)zAL2JK?fQ>UBGr;+XLN<28vz@!4He`rA<2h1W8)vJZQl2uBQVXn`lGv-4Uezo+8JVA0zcw7C`v_%Kz^AQ z@yiv5Pgm8+`-^MnnY)W|!F3<9p!+x3aCsi#_uarQsku*m|7MU^2hL#9t*i8(uqdW} zc|lJs+yd{eE@2ePyy0tGKIwhm0SV8v!P_~49B8yg+$K!Kxa?8QM$?+DhBH|`9TO;> zXttPYzni{{oXhoH${6udP~SoZGR^fsE;od+c{q=z4Ld>7(k-|>@dTC&IItI-x8h`le!7l3bIJsG zlb^Q!RCCu84 zdJg;4tcX-9|EA8ROL4~#A6D~1;d{ronmrXYcwF;F#9{Ynd_UICldlN@tJSSEk9+HU zy(t{GY*2-sL^Ejl&9b@0T*m8D5iY5$CSDslA#zm^`5S+gBxXm_4#ytox>k+9GW2lj z69Y_-QlNq#eR0OLrO?vdMjpxgkh{u1>DGTmc-XF@rvGdZ<`@@o%<&}pDnAkHes+`f zVI7n|X%`K13!^#aX0YVoF0`E`3|8KMNQ1H$VsHlTpL>&Py9`s;y`@yFRG27z%ck9@ zve6}AH=bT2Lp59488t^9Zh2ab)3v8T>X!%fpQ!~TtE|KiXSPEjmtz^^-;M7iSHsh8 zF}P!=is#QI!x6Q3++iM%v8vgidf*6Vr9G#1=U>z8MjKe(Hb|ZtKS!B&x7eVlVYFXA z51)Tg#ZQOxm~9rH;Q8yfWL9$>317PczFyjZI@ykReET+7(rOOQCpY0zu`jf2i#J`J zIvEWPUBZ#ATBP4!zV>tR2nyU$BLlfAW&YVfipSk)nZqV@2nk^BrDTx@y9{t?>P0fX ztQh#VUxI%l7r_6n3q2`3l}UFAAv?VbIM+@QmXY1`f%G1p$=E?6CEw3ZjrhSj`OSuI zkEw9I!4>19_i`@69W-UG0rrK2qRyiav~%unDt6KtcDHKLm;WdZd#uDVD8lXk?Sgg@ z2E~U?)6h*;7#??z9OSqjE(ZM|{$wwGq?1jPMQcD(pVnMmBvk8A%ZJu+q9`e30%hx7 zlDn5h@y*U^`pnjYw?^8N+%4Kca%2hK-!U8Y7CVy+R~ecpw25R`RFiElBfxOqRPe5i z0q=vWEsyVv!pmOINu{fznL~&mZa=6B&kS}^|L!4L^G6pnEp?d+ou|+=R)@1b-=?AV zV;m1z4fOl-i1%+ddQYzs&4UHd<~l=9U#e&JoHxP+{?TO9-Z)6>Vld&RCw0EhK_*yYazvLHK;l4*AVFj$6|V=s79DWxf^Q^^s*@+wvTCWE_LnCRfP(k1Dv+ zx`myn_u@@@a-BJUHJ>V6Ndkku+hnnpGfmwxPJab)oV2`EbbT;C>~I|+r$(2dIJrVa zFE!vhvIv|q4Po4V8!WUr3FB@haOAudw#(1J(tHi(TlEwAJy!vIGz>M-@mn+gwWl8m|l*v*z5zCr^P0Y0q*T~wGyEXCZNtAcT6m?VfprHLC+;@7Ek!_rV ziK_!~`7e>^wUd65QJL?w+y%?op1^;P0CBo6`Q9}{1YKb@GtJ>qF zvzh?eg)m$80Fb08SR=H7{>oM)M+6^sc&$qv(p~8qhFK9sNErKA| zb(TE#HvsXZF|xwMkvEz*EPf7q=xrSSb}c0W z9ESu3j=+G34;2h(CRyQe#I%QitxN-bF*_XU?VhtAcT|C%s3Po_HKwuNuHZfU9?_{d z1+GWJnHx zsQfa6^sYDqvwmNrf6$Sb&$>@PwtIrEu{zR~MZ}fXP!As=xSCW*#fy@$(Rs4isaq^6 zIyI7>BaUol_)M(b^N@D9`=Fq1Hk@lEyqFha7<{>xn#=d1$iAs~cF#>v&GKX0<}IS9 z&UW$Y-erU5uJyde30)LOXd!o5JzDrn4<&R3L8d#6x3Zd_3@#1BGu8J|^Cb7?$J>m) zTcpvnbsN!XsKNU`uaFMs#h70^8w=q!S*Pj(tFO+&Pvx)3YIPBK)V><&<$A1$o}h*& z;;=*X2^(2y45byTh~_GxS{sWnJbFQl#+FD@afvbdEI|o>ubKfXzgRMQre#cl(F!;f z`-ok8)CsTE2w`^&CC?^aGmd%((AoYfFkk#p`oAk^9=Z<>)_39KStrn@PYJJ1O9KOs zCK~-G6CdB6Pbzw`@x zH`9PLt@NkMyX$E3vfosG(Pvn|P6NYh5g_<83fI5Pqo>x~qyD+7H1WzE+A~`pwKE$? zneJU$7pg?XtczK--P=HtG?TZda_Hd2II@bDLi)#F(~vIdT65J$)FIOrC*-!1PW4?i zdkr{4*p)ym{}+jqBxaBX33ar1vX~yR@~xr%7lZ4j$de(bYjj0ZIAj((lc;_lBA)dIe}A1u)_vuAsyh?O#C=_?i$6P%8YaJ##=;H}b2~f6q1k#3wf@36;IqNW}JJD<8>COG?#;}PLHHJK8Le`x2ln$^GzL}GVd%I5KMGJon_7%n( zE7lQ@vUPa)?;Cot=r+e%UP2_#$)o;tPmmv}Wr|-r!p6ZG*cMktCbx93Pye0fzDX6% zp~pE5_$ANO zj~a3oZr4pUwDN2n9zPk)sy#J@!vR+y>ELoS_*6i@CVV2!$BsjZM+3u_2*B2*ZJg63 zgIH=#hmJXCsF(CdkPb|Mn%Djoa?BQ&JT~I?b2Ojp z3r^b!=kE&S{6!%c=X@SCHREaM3~uhReXixIDMx7eH3@ob-54YCB(&y(%n@wn8({4_ zXJP-mMp!=4!T$2Rib}HtBEK)%gKG^O(Pokg2-K)zSX2*l?^rfHRI>{fHTu(Phi0Bp1T=&rjThcGnUoMxZq*Vs| z*?gbOII*AX7F9sY+4{uw4NG?^>?FH4=JSkU0bX~zgxgkkkT6^BJ?4c8E-0yGoISRZ#bk4DovVflPamM*_WD==97pxZJ*ymtPu;aKEGSTH(;ef*^vug|taVU-L#EbmT^Yo;+*jjTa(VK?+o zdrRgENW<}RKJZsrjs?r3={3_{a&cA!bln|B^Yw0YUA8z=)~m-vm2s~7@EuIdoia3O zQLp_?%$dPHu`)a9-DLCKGjxGs0fwvP)6e;d%=f9AX)Avq?r2rEtlY4IxaJkZ^ALvLzQ zM)Cd6qv)YG4+ECRkuyuB!S1C#-sSGy-!_>8R_b8O$7b@S(*i&3O@@*0>gXhV z3Ravp#zy~Nw7E-!b4VS4z-`yjS}2wV<)5O7Mg?S&l_OZ&-XXJdI9KPzN$}>-76SVp zk%O9{Xeld=gM}(IHihdiXU&1VFFWD7fE>J?V+k)SKNE6A&vILM6xH6b5=137l6U@@ z?0P4Tqko|UGuACddtNU6uqT}C-noZNIL^oU9@E%Io`ztsqzyhD76b<=KRmaq471a& zb7#S)Bxmq`&AE>=sD9HHR%+A&z7~t%!b_6a>gLNBy0ydC_sOK(Er`N}sVH@1lBJe^ zF!)xCFiqR6;j7|fdYh}>N@ZBUwGuAx&HKjG4w#|w-0kd-6&%;&lO7x>48}Ph_;_2- z3W3ZYBha0c2oiN0kk@p83Sc1n#b$zxc}BtQRaY_MV=NqewjGS(X5i76Rb(gU!OBo- zg3T9(XhUNuntZ-at(4BPMVE{qSSOc#QoMn*Ij_Yd`;&3=nX}B?PoBgr`9CK5=sW5s z>H{{K64)13Og`Fj`$eZNraD};Fu5lTADzYE@2)hI4=*LNk4rHS>i9abC3as-HT#o@0Y-FUI^L6&7r|ejO6f}Fd>nb>7r-K zbmPxz+%ve1o_O|_(W#QaD0K(atuVtsv-0t|+f>Y4UdB`@Rg$O22WXDx4*L1Sd31XG zi;Ug84Z?0`$N_ax%kSdRaQ9{g+3n*97a~5>6)_F)==n)tPmYjZHyHTIWnt`;458)x zLMoae2!*EMH?Pfc0e3Dt>}9o#dPfUFSa1}+>wm>ITl3L*XQ#ukwKx9$S3s}+TY<&v zcEIlWDUAK_Q#zS@hY45Oih8f6p~!0?aB_$N=Qt6PsL1&*zw5yQoujy;LlAd#C&6%6 z3o!2AnXd^48Fy&KdC8G5F;5@dWWN*5W4U;Hwm7I*2Xg;Y&Xu@y5r}F(rN-vF;3_V~ zczTjNebGyQ4L`tB*Tm^u?O`xmP0-VO9^Lp`BeMD3dhicO!DAd3<>zrF?EQHf_1fPu zUrtnjPFV>3z~yif^jG7S_&eCGvktW5dqKfA4AZ{L!*1@3R3)7Qk?V!&swPK#rnwBq z=O3XjziV^8>Tu}kFF+nQe{*{+h^xygsOk=B^7XeTeyi?cw)fx03aw{UDbpHn*PO*^ zp>5d33^0q;|D#hM@YVYHJtm@xN4YihAIq-Jp~u9h&<@rbLS}hk#qJwqgVh@vJS0z* z)||p$mQTn-{&KoQAc?Z`4`M@X80ZLfFq3Dyb6l9UG`=bmbmdFAY~yhncwU>EWlaUC zyDW_`n1MHn)8}S2>b&nVskE+h zBTSC$Lcd*E_z&4ZWJ280vg(y{_%8+#6DwZB4d*!rGkD1`g}eM{>)kYXs?bUI9aaUC1`Wo! zzz4_P#goV;Axq8@Lcb@tD<084%Gzt531ZqpoUeoPIs$j(68u3eP0vl}su>Pyb&5!LYdqpvuyuMz*QbsW-JU!!%1#Ei-!Ivd_nEqk}0Pcv{E$JA5SfyfsMBnl17u~%JL-PuF-U(JugU$f7JIpG)%mhI zU2;HynNs_Q+&f!>Znm#cCiEaGZ;_!^oxWs)w*;u1Sb+aV=V6upAe^x-q&XteIP=3- zZcqHjm^JSwKVFSO^>QcN5N{4)o1)3_QeC2Xi60*%gp=b#;WXoI7)-mbgNMH&z4+)t zly&ZZ43Tk#lD?&&9&`Xcshy{Fl0~@j{3f_Hkxa4*W`o#RBzeEd0?bl_Q6_LNvvlE3 zGVG=ZPNuowv*#6i^II@^*BpuP=6o|{kJn& zlh3lW@7GbP;nvP-l43H4$9aXnQ0nqq5Z_N;fQ9?hx%oTC#(0~Cih>nn;RlWZZU33+ zo|=aI)AnJuY#!mWOou7{LNMp~5=dBPh#&jrf#|byyiOAd`tp_+(6_TNUrLO=2|GYM zRvD2p!F-%nYRt_+8;Ny;I=t}yMvqOGrJ2Ubbge)eQFu2&c9l4ycGOSyVU{z;@am(w z?(yW*g<*`dP=^Wa%$mSJZG5`hl6haVk(L(vgRUJ9MPgEMutzfT+}K6z=Q5^G zk}M(k;ZRMli#%HCgp#bNN#w#n1MGQ_!>H}rLjz;JV~nsZYGu75b7nj8cve%WDCIi; zN}Pl79>+GOH}HC85f%t$pjDkM_Fa~+w9L)o1#5~}-jxR2H_o{mjQ`TtOMHm$*6)m} zY#?1`R)!C*O@plYci4)T9HZaT2z@_4C3Co$-%8&tu=V3xi`sKL@ywH8jCxl>1#WyL z|4GYYpLRLjbJdvyaEv0wO)*q&)*XCs{RV{CpC`NW=VFUc2{HG_Pc4Mw78F!|jo zD3}zEs{iiN%X3l)w6kpbWNrp5#5qE*zGFOod7ypOV`3Syhr|y~#_Ka@&=(rwpw6u= z>%wN^iDnh}6HtgIQ}`fk`W#;7OBW|M*@Cx%R@uJQC!l_(4$K>PMGkxC zGL}Lwh+U~7y`Q-r+~s5~n~Ic3MwAbn5conKhg>0p%c58n#Y7@`>j|F7{=x40=7kSG z6p%H>jifGfh&=ZfB)-dyF>%+7+Nk)e%I~G${ywGRF2eYJ z;ZfYXEtI|F=Y;>sG&0u{2I$BvbK0-80Ec}I!99PRYz#<(t#g*ZMV(dP{Ne>M`8~=j zU;G3u9y&m!r8E{@;TT-D#?ag@kABx0sd@Ws^eIPXW#}U^t?xYJ9iu`$`MT&KzEpBA zH;nF{6OWdqm5`!ih>@XHFm`N-@JNkr&3Vf7%^0N8FIE8`3{#oy|3Kt)C%qAIl}PdC;FoU6Xi zCk=31)KwG2vUDMUdkbyk_#W|pi>dp*?WDdhl6r+SQh2_Rl)D6jodOT^^PFJilq(Q< zubkZ2e;7T#m6Hm23mR-4Mt3gD!CQQL@%Y$DqSJg4@<#*FSy_&FYEEO09640;L)ES3 zsgMhvEegjn2RXRdahmfl2Z8J=h9`Dw1^kVj;7#oe1-+3=c;%Z43G7z5UO63o zrtw+clrzR%M>+SLQ51bUegZcQJSB+-N)_XMY4@Tkg=`g+(OwOcNPhKg{f_pGz9%AB4YKW#C)sQfB_?3@U$P4w}|BgMHjo z@;OZq-!eN$zid16*J%$1bd^%cUGK@s9BT}o^oMM0E{1olQMCOB$EKVZAe*KulA(ko zvQu+ES+IAYy5+YPKABsArDtt<(LD`#;kmJ~c^<&ea-{}|}8_)say@qU0Xj9S&P2e{5m<05C!+8;=3 z*3XBcNp0knUo7c*n+iV!9^q zTd4*#JD4cWE62DzmfXm5h4C(=-)3(j2csPE--95yU1J00m(Spj#AWa@SPXq+L*Qv& zH3W4fS@>4<5Qp|S5cdqEPCAbv`t=@q%S1*UZ!AcnU;{=`JwU=Z)j)hp|>vZs) z54_bVg!xJJxPDs;)3N0Q=(b%aPwE1|r)@vmt+fJbF5SU9O^!s$W<3r(kb{|Aw60}t zF!6^pG8P$t8V94rOfDPJNfDQ!Ksp2X?ykl4M@4Ft_tc`xiViaJrUib-Eu{yVKQrdx zZj9;1Ux|l2&EGyu`n#)Hi@(B}KGF>m=SSy}%mt>)_MR)3I@Z z9V#|e&_$fT>(G>`wKtBeq$8KRsOdQwy3G3-rHT4#F2b=)eD_7zDt|)e| zTMWH$-x@5IFCri72AKE9Oz`*8$HYxRkDfX22RDv-qqp&OP@Z&`HgisfA^CRZ^0l3` z&zG-OxKamRUz-h&8V*87Nh}^scOrf9vtf&X51#zEmyVB$;2fo!?D*3y5cc9Sk9<0Y zYE9A@yE+6e>N5COS(eT#sHR`UIlt-XqsphMD|2O4h2g=$*6}d;RsIZxHr&$R!69B!+UqP+Ag#A%)#c87Ri)!u2;CeZ{73M|p6W-Y6F#)s^I zH8g8P4$~Tfse$=!jO%*JDA*<8{i6NYpqq=IzvnQ=-mj!|ohrIoMsUu~Ss=iApqX|J z*)Y*e)JK9y*CI(v&&z^%*_ z!+2%}`S~dgE_&+G@BJmHEqH|dG2nUtMrE|kb~Wqc-9eM91;FO80IKat$Ep^0aJMst zUW4ys-u+azcPc-m`+g*?B`;}b@gkT$&z%(5`e4PA5+WB64f&7m(iID$@N@WnyusZC z3Z`xVXU{*RueX$NQX%N)TL(!(3t-RFAUt}c1v{L5Xjiuzqw!`H`998h=tWDRNPQ+4 z%x)n2g;&67l@KtfeNMMDI>U1*FYu4&X6Xkjsrk39s4Ex*x1636%}0lz62~kulJ~=d zFH7+>6M~D7%PHzoT)$>6-5+y>N_ORei|;8s`l^xcp?SFRz)esx<|Cg|?@^~W1jxJ+ zD!9l1iYM(SF5fz+!7_9F$>$0F-;JxaL^4S;x8_V+egcljN8q(TL8R6T9} zmt=p>$A0TXEKAO$PWtIMvM?MQPc`GOficS0In2nAHeNzoeWbw7RMPm?k*fciiQ8i> zL3rajVEK*EBAt6@=*WkL$E)$;A$2-Qw3KU1z@YpW%jjRIni&t&bfMG@MLcb ze)qUaZj0oiTDu2(_V7cpr1b?6yEX-;CEdm>-`B8Ae*sLdU56Uq7<66wjQM!C4&CP| z;Wmf?2|5F;Um^_suz&^6#4Q)phG6ymUF2tg0CYcWhgFBy!mU0Ld>lbIeq}0sEPMbj zIy_;^Ac099*a?vzIhUm137mR#lwM!94n8lrgM(#9h|&)Me7^o0cZX`B#Y@|m%Hg-f ztz?X3$?-$=MqRKgSPk+Of;c@<1@(Frq08Q!*|Pa9yZkC{n-qYmhA(t&HNyu zW={8~B@xrFkr?aO1$+j*aQ0RKP7KFVk<_+GzcE+x@sk}9w-<$G{TM9rUW;W1%5k@k z5{cQHMUTWKQX}q+nf2)mF5NnZmPx)nkA-kH z9KbNVfb(00XzREA^p4wr~;ba#wy(b0tep-aZH~SeSe?Iuun8Db8KS{4I zuRtkAh6?|8g;(PG5hQ!7Ab$55QtNk;^kr(}59T2=XRZaN3NOL;Teg#mIoa?smHV!? zNz<|!@igV=F7V#AlJ@P4!NE2HE8^v8XU8q7r}mLNyH;H8ZO9hrfJQ8$?%mB`d7jf`?Zc^udXpHYkxr^h#!{cSkdxz>TvvbE=h>; z$BEG~y5osN?LfF4-sK1poA@$<}8XjE+JJlYcz8oLg^DO4co#*_m+T`L( zj!V753-9+|B24cS^tG4cxI-m&BKH**8@!>q@hpsKKX318>~041>HCD@VW=d zzf?6`7;K8>CX?y%ow>l%&?YNB?_o?e;=$L0#fnEURAlW)jpnr-w0_zYXes|n4sjW( zS+}dmiIgPJ-MtFCm6~X%NIA13Q5Tjag}`1584g%9cBGQ$u)iRYH1))h^IBJFKy(RO;XJ&f6bExuqHvy< zL{!Eid3q;vCu6&E6W$5l36_-|?A|AFSawboK3#YUItpRIhrBLnZa z?&_TY8JK#ypM4F=c(dgK%6;e|%iG!*nT7w661;=s=hvgtTp<)2N<`biL0ToPk9*&w zVOd!#ZJU3Z?H@jj;^!)f!b1_5H28{HWAz1&orr`6o-LFN8ez`K4!ZP)C0$$anAG|& zW#Ur4l7%66aQQ}ASSNfHUfnrF8=rZRt<7byA}fWy;IclR)j7y}k%q#xfwVg}foPp+ zq!Q~>@$a=%5;v*}GDF|kYtMhPI!<$OJaUN6jUQut%QL9Zg&?$eP(@>2sFAAOZ(-}M z0qWmX#~d3p1EH6Sbb-)u)IZq3MQMx4-IFtL)YyX_adKkr403x@(pfn0=^t6qY=d8u zN}0H97S>EWC+EzoV8<*8%gk;sY<}2HmnMs|TTkjjHP^ds{qhYBI6rmI%{8KrYQl)L zPZBXUm_-gpB(eLx{h=4GWYZ~1I`l-_ZHnq;?6$*Icus2ywRrW1o)@ixszI(>pX*15 zKGeg8vQQMMNuI#kxq(^@(Ck7Q~@N2s_+O2p6RcG@#<`CxiX32CeQ?uhFGxo+UuZfk$%CqO%&# z+Aahe?%qw0}5SYU7q{+Sj+d9erl{dgJElWvk{ zU-m=d22YG~=P|oFhjAv?Ps$QaWB+h-`Sywv=xYC%cOkW!=(@}xuB&YDp_?gg@N%aw z^kOK#!~ya*$sJA|jiUpN_leHc3-D_{kKQ?a1#Q=yfT{bG_x;_ZR5p}5t%de zJO>+z?7j{We12ec+YAb3B`_!6^I%#Zg}SBb&@^fSUbb9kYNI%4)%w$)n^iD%#GM)c zav!XMO2{qIY*d!f0;vswwBx|!TG3opaQLYTQAZQ0vI4hmZmgv(R{?bXI-;5NWUeEA z8jSMA=`+87_|}qZ9lzO1mmgbB(qvL-(t}U*McY4gY3J6@2eq*9M=X7$V#Ihin4y1| zA^5rpP$DKIy3qu zzW82tANF(H2BCqOG{R7VcnmIJ)JG10>z5|lGiebddA??T9|}Q{^iJ~Ayq)87^N^>O zfKgVG@L#kZOcwvlb*6+c#XXH2KE_Wvg#_`TL<+jy9VEGFZ<&j$%=VPboHc5H1*E)kL-xUOr?U(U^X)Zayb?T-yWMabI=U}M$ zn)aW$PD`(>!%t!F@ab<;Sez+{^9QDZSf&xY`mTg=Pc-5E;&b$?1Y)Laf;BZcKsDNHKAm@BSGp^Ls(J({-H3v{AM(Ir^#P`9&JAWufFm31 zAWEh0w32l+g9O|)p{eFKIX8O`iRAaEY4`nDr&&gr>bZw*ebdF$QH=q1LoQ6U-^FDD zZuSrMVt#6nD-%e*Ok5Q@X)O z7|$e)YLhLu#+}AZ_IF5xTL$@{O*zI7l3t}A*nhVb`3tRKcC8ETUzJ23RiqK;P1~Vb z!WqjZ$I#XP<&YSg@8qYSDdSZgPj~rSLyuE3Efb1^8j~Y5&99$E4{&@dvF$bd(e3m; zJSTrQDA%5OSHwIB{~tx?9f;NYhVhUPlBkS|7MU5D=eY?#OLX32{1 z%@*%@ZlsbV(ULTb_EZ{@^gF-*{K@OQ=RD`Wuj}*qV4Q!zob;|)h36i%M;f>l5Yu>y1WB*F(PIhOQ`3Q?SGv7R>SDB$DJJ#=R0dHf;22Xx*4 z(I3N88`tl=O-f6*!Ii6kk}iw!ZN4g8IKa7pbG1q1DCcYuIs<=G`!GE>9%Yh)@YC^+ z%$|A^qGi6HI62sn?z}Q8w)`pc(n=XckG+AZ(I08DSRdZ?y#SA9w~#EW#QJINrc9fv z1AKZqo47$TCjQQ#ZJCy^<+dHba6IatI>dRtT4~(!(=ab&09)FOP(qp?Ry?W4U5;Bp zC&mGexf^3<;Xn4sp+1t`Vn$MmKZ3*{%MP5*ftTF%eB)?3$@ncwQqE7NKMG6H?Mx}n zZ%v>P@!I%TZ7-eg&64YSl9az+8!yN*koW90d(z4dw=VroGnqSd&Vf)WeXRz9mEy?7 za$~B<2w{YMAzgD+8gxzF>EgphATsk2oo(-*yDi3sI(YUdT7JiAjMq30*X6*IdY+ zE|1Hr34{*{HhMWUvf|@ch_>7Tj)R;=rf&a_oQkeuZ*EmY`N$n?-#i~Kt2Gtg?p}d+ z7e1ut=s307uZ;tn9bx@BBl2)ung)G|hfldG_&Bo>=lKj9s}x9Zefrmsynm3+OS=xH zf+a-bfGiwz*p2n~wi1gOGEn6BfaGtL2BGDvIfk4TRhM6l5sx#-`Zdj7Vr5zCeO^ZC?Ig)d3O~A*SLT+!5#X~P*A#CYa_@2V!InDNhcXx&%q~tlieDsic z*_1`qM^-^WpFKG}rJkLuo=iV;i3+ua+o+i1BKS|WhMbV_#j{~8C|WRsgzHXYphL0pPz%d-f>ivcLZ-foJTx{Kasnq6>)`5Hu0C^_E(?A@x%39 z=(O}OCVb>Rmx4Fsk~}~1shN`c3u_wDYa_S@PjIeuQ4p$pQ!lP?mp0#@itASzqGiuz z>dR#YK2-P8Cz6tQIVpqpFgRTwD_RT(;27Lzo`7OKVN@7r8B=OJ$g_~!7z zjh|ic$Nmg$oU8;N{X-#Q_6K~kR0$`|?I4lLTS0%v7_oC{BCpKvApb-&DL$tTulCOd zH$F>rIM+m1FIk7J25B_<$0p2~^qCyrS%#}Pr=gtZY35L46)x&Cgl!%6WLmKIppOMYgoSYHXP1b0!2(8sk>4Q zV&BDKQG6i2D#=rNaMVI(whzAtj^PYq@%wA z!&?W@n|~(9SlJKV?N^C(urT?)g3xfC1K_ECg{s%NgX{MO&^kH`D|~O0TSD9%_Xx*Q z*<+2NA_jPZ$Y4NXC@r5yS@Xe3U_Hp4V=|>Vhu{Vr_&p6>^K;0@_2$?+w*yj@F4JiF zd^T&Z3M21#8I+=v=)A-_*w4QgC6MFIS?1!7t*vxQWGLJ#Q z$~hpMR)7lMI5)w&>zMjEoURe&W|r8*G;>9T}~N#=tN*hN70zw_XHX)=0SE~dpH zT2%a)C7CT=1|tSD!G5znjjW4g&IUZA74V+juXCO~zBd>HuV#Vi?rs>pa{x!asF0oK z-wDI~t5kaz=iI3chkP+hsxR=1IKA$nEk6Gkd?88O%mmT8qmSO&YfP8w+#!Pl+!?YX zkjj6%gd>mm;C16Un6n@RjeI4^h0AN1M{7 zM5Du#C_nIlfWBPJ|F|0-3UD2xC(|*;@Eo3*R!9#fE+KrTm&oS6O_;Fd1h}6yL)@W7 zn+F9T+Gh#bbyBp^G^T+pDolXd2@hsZPZ*ZI62e;!LMos%EVM1|3tV zt8Y65M3vC*oWpwC!yI`Qxz^jN-FVPedLU zXj|jfou}!p>Q|t2WET6~V?P}H(@IxQNe3C5Q*i&T4*or@iN^v5$i{=kWKUr;$$6NE zsUIdchS@wP9U)JkYg-f%yCB8Xay*iS#U8}}$7b>~K$Gh5yD`!mVqjlJ8k69x3zafP zbm$=0#kvt!ZcKuZp?=)!bc2R>O~dVR&LG%xmc4FNPW+Q~=$LLWF*9?*5lu(3c(fSy z*i8W{$q&2}%kb;1U^uw&D`s%44PJ-CA6zU*398n>VNCm zJ=h*T2VMTR;G4P-);{%yyID|{}QC@E!)Y6Ln&35?F=`JOyR{F6C8ZMkuagV zKyKCn6kV?fuYG-(zj||+O)@UnlbXn`RQp4%vdi%1TWRhb>VnXAflU^hO&;qu!xgt+ z+UYYNjtSag-SkmX~Ywz^AOil z$#X}GusVAC>rLwM-GekqT%;PJ_9$|1y2GAiL!ZK(_^m-1YX1CYbxmdPr|A>& zbVC$5(VqeQ2X+vJI!CZmJx){1gPH&4HK5zu2yAIyNBeqHF*Q~PuWV98_viO%SRy5< zYd+HF1&Q#ybC3<`$U(U)oL7cr@XyUKD6P0n4_(S;gqa>9>7vasK>)A*To02{)}U;j z6jsY!AfYPOM7Bl=Hzjj(&dm9+H3MOv#}-^y)5av%b57~460B%z3iW)j0OoHDWhR_= zaxQ{&ST_G4N_cH$W?CfS<89iodYc+p$p&Mc^$58gHIBL>49zyrpyp!>$%4v-khA6) zb7)359?gpe<)yo^*v*{CXZMp~6&sMbZH+T`pJL8GZ6W*T>;wLvAtZbHLh2sqkB2`0 zBNhKjct57`s1wLw1H4J7se$ z9r0zU#VHBibkiQX^2Y^y>hPRyT$v2}KwlRtWDVX>V$e-W?*l}bY z^I-smqk`zl+ zVRK0;WM%7_{J63L#|Y*8$$fGGzV+(SVF}&etRbyN-t7?<{X-$XXMvUcDu=uPH+ zYaVp%h7jAt-(E_ugRgokMu*59AmQnE9k~f#k47lasM@WXuFjP<<~q=jpG#r zGBsr3dV98E`b*OBdNMIw?*dc3PQoj;lQ>^J4yNaaiI-S2j<$1|OOlIG<}2s7Z#i9#0=qQ!7zSYgD5n&x_#hzy>(IsT27;=2D4n zaWJ;bq${5+0Fz2bP~*PW1MwTtY)>F~ja;S1@-=kxU@^lV(So|WRN$B6L8GK3YfMkL zO&TSyFtzDic1t=KFDA}ue4NGySGH%vr?W@NczHbDct1*;*BMatdvdrwFPgs83!-rX zzi5Ys0NMWHA3bb2NJCzQ;*96o)O$TK4=x^w<1H8U23|oDWnusz zOHC3Wl!voCzESmgQTX$`IAEq1`+RjVBOh}dcmJ4z#V(2X=%4~9tMTZ_qE6uL7skAG z=i$tQ`_xwdE<_z)2sbK9sLnxgYYrpGVDy;z^oC^q1OawF~#>jLs@w&X=;mNJI*wrUSA0Pq922f z&jrwuFN(eJRTkF#(?|DeA=0tI3>21?;b;CSaML0OZ+fQFpMfGKLDN}cyLUI-wuqxI zf<0&oi6Y7y=Yr~*MEY4L43xS<&_Zq&sQfd7hvhxA*D9ZsJMLm^bF;AMp$j@$D1+<0 z7<#hN4J&I+P~C^cwSI+|BNfgB3zbwA`&;5rT|1XSenVcFroqhXe(-U|YCOKnknFA1 zHEG@9gIP07Urr5HGp$4=IY$HDeHpsj2K zNnb62S-Tj1uXLiNkI&N_>qiu~??I*gw~a#@3TSM4C}jPPr7yd3@n)|o4FA0XMJo$& zT7Mxrf9W?o`l%9oUtYtFUtOqP*K4@?8S|mV&ds1>jZP=P=FgEiKDPVM~^A z9(fn;+1|c~s(YSCyTvDQs+$uHk;+CHuOH;p?ZxOQ>xCMg*GY%?8S*5J^G9A-gr7rK zv5jh7#5Y|KR?JkP-~R1F0qg$yls!f07wHGHj;D}yl;a_Xj`1=+Nl>Nd%Gjy>k@}5i zk|_)Aq4{P2obpZLy3!A6-vj27G57f|o=~AdMUp@%_c7c>_W=08qqtrMi2J`s8vhUv%;&hI=dw#wuCN$5*LiR6t z(l`for`(7<>OC2M`aQ;oPo*T(6M*qL#{JF7m|BqxPpuu`bcG_(og>wFxKoO84&RT3 z&*agpK?WKRs9k|cG^VwEVd0L6uZlZK&fjk*t&G5X%Dbmb$ z7)Ncdfc)(u`c|NhI(C=P|GsFUZ0H*B^6iG3#t|T|cMddo9AD{~B`OOX0kig(#Fqad z97^wtXsR!WL+}pI~Xe9OTmV#5$^1x#vmjvoIW9D)-jJY-go-`Q1-&Pa+^Ykkzbf1Yn zbe4&9Z4(?h(!usPB@4T_;CqifjV7lym#LopY#wfEBI3WCnYeav8Rzfkb@)H_vQ8 z2QzYuF|BqN4j!07T0Tt1)LBOOs4|%v+MT4$+5Gs?`w?7o=)?N89dw^T3&w4;1C82E zytNG?aI1d{-169uE_ps|{ApjF?IL@!?usF`s^#OPY}EN+D_KW@M_hAP&cEhzgl2%Cl<}Z z7P2?m?7=Kn0`K*T!Bo2ea2??sY!$XR`KBfJKC_bB_eZnF_4ROtnT7Z6dZNR!TjWG{ zCZpmMh9-}V=$o6O+*x%OiMyGEGj^_FJ;mNM?0!?q$V|SC}0MhehP;SqV5&QOcOtEyrJH7U3(`75Loq9n*BJ z1@foP#x>t%P2wsffCN7_npZo;#Jc+(#&P>XbvHMfy(tzx?AZd=`Xcxu2B~CdCjGi$ z6RKa6phmUJ8KVf5ZySx^KSWOI3z(FTND%r%3sT*b;X!l&5z+8OzRUsA zI`Bde);e=;x3HNfx7^Qz%R8N31d-(mx;yY#R-WDN} zeQY1@=vWH>E&7AWlb>KRm${XhF$vX=i=h;k!Ma*r09&}9Kl`6zHgcyB`SU-hS!^-W zBB2IKU$0Srd5Ok~iCt{B)LE+dONw20ARa@%3gGXDvG8dfm)#lOgn}=UsMkITxUJ-m z;oQtyAs`!Sd*9LwIejejuK-p)mh-VEFm?ioWS@=!$eJ(b-RsTAzUR3ZVhFgJo0ACK z`OO9g8F8!wUl?9^61J%Qq`$XGgUEjiu_a&=$@t_BE5feQg2h6ldr>-BbDF!~N4ntk zldCZCuL-T;Z(|F#Bpa`n=t9lrZ;^aHcQ{6+JB3HVto8C(2pIiL9oOC_(ysi(esVVn zR9=E#HcyAa$H)kde1nEJLNKho5LR_vg*~!oK>hAPY_1q0c@xb%`kf!$zoj#~4Xf#{ zt^BNC7Qnh)YM5Mnm@OGpgsR8$VH>{%MDU(4+A)|2;YX zTN^cZ{bfJ?An@upGQTrx+2%QdaM+-i#y3xgONVpGhT|{6S%<)JOdkCh7`FiI7~R&GG$ynG0@oDN6bq}dm0L9pnT3(EYogQdS^ zAl0#tZuT+7tFHF2cr>5QsaN1SDD9-<;B3t09Q6}dYpLt0b#P9apPK6?V^6^qcKEIa zSVYc83yx)uY*5XP8e4>8;TBhS6i#T{irD3jT4o=U?B{g-LG_1B9uO?rI zRyv1_U6Mh$NfWqlSPYJj{$rgFPN!+f!MOFtO#GI4m-C~?(KmlXS*M0k@<-2|cWPH4 z_uMR_(OWdIGs2Jru6;n1ni9#0s_Bgz+A`2|(R=1i-#Bp-!C>un zhPiCW-FMn332u2z-Pf zyZ*}(UeycHN+Q;Dm07X>J1=Lp5wy$6qEgc?baVTURg=96-|l3?sM|B}<~%&39_!ej zwJkK9uM#5UddY+Kztr_pD|KA|h@@}iTJ@qARk~rtE7>nNy=e}nh-Y5 zl4;a=mW(a??CIfWA|RtKje-9*lSlj_7zGb-W>GG^nzjIi+pQr^o%JE=m42Bc?M}L*a#(8C1K(G0thudXIQe}Ez{bv6;f}c;Fk1l zbiI}p255%D4l7CO_&SBGbj<_4q+!zBri0zhdDOSD03NBwVMWqxTyRYRTfbca)z7Qo z``-~p?~g55dV7&isyX;3YYIL;8cTNnTS@noq;edKjl_EIIrz1XV&Ji>)c400tdsU9 zAByi2YmFMRqh%uujz47844%R7K0A74LXEsCOU51BQ<=BiTuH-aDw-C{!9*{m;tK`w zR`X=0YV;7~DXt_QLMG4|dWB|a`4Mlf+hvk?7KInjz#--lY0j>#?>YF9N&iz!nCW|n z@#`!2%B+=Mx#a~uO)_}-S3l8Q)dNR7lkoG_JUr}I4bKl&Vc?{HWN@7~Rei1sCkCS6 z(!;;xtB(;@A9+U~ZCHXEa>ck^eH(e10Zi`VN|f?Bha#{M+~#`Ga^ob@w&*kpte#9_ zoGtONNCP9eV;|nSBmv*_lOajY6M0?f7`JkmKECpj>%SBbgV&EqPgFPwEK`C#W(n{i z>^*6YQlqb~Pinl`rc4j*dqMUlY;Hn=sDW4G&Rps~etJZ1hM@_2<2IlA^N8EuM% z{d@mW*F#&eW`-^F>>OuzjtzkD@kUxVoP(x$10>nv6mC|pI0Df;a4pTZcjMP6qDe>t|k7 z%VDj;cZj&6M|a-i&Q*gK$qc!P zt$r5!nNjq!Mb*Iwh&VDG=d8L86?#wT%(P_Cb{>YhJJaaNAC)qDX~5?RbExkPqxdp!ZUVsf#y7zED=;5orq zGI4Du_LvJpiOVPAw`LL!eLaF5OLl-^*%a#0*-ek88;R_zGp5Hs+@~IIzmdCV_G7ix z7n&&ki;fKY;C61l=IPx*lH*li_Tn;ZcsU>5Z-`@Gh0Ec`I0*Qil z;meV)WJ8}Q9PuziuO53E<|&R^%28zB-%7foeGrdw?1;XUY53%r53RacPe1x8;Pum; zjCr6I+&vnMdZSB#Se23o&iUy2a-2PrVnyxt=HU51yXaIc2V7qti#E2;4V<;5P;|=` zOpA9QpWOKv;gTrk0-22m?DdJUmK~jRPz}GWtE17gcEK#UC^Vnt3vXOZu{SM{_N>@M z?(_<7W-*NVtMU_wwltWemP28Kvp$t)w zw-l#q?sLEUfE2l{d=l4BJAh-gnnWh*SUffbFmICeuH zw2nNXf(@<^_%DVC%6o~&9g}0hnaZoJ9iJ{yyvQcPPo5Z8lQM9(=C8omxN9{#pg)CN5I#eq9*L9x zvi{N5fm^g(zlL4zQ%!CiN(cWHOW6P~Q@ZgekQ4_!VlkozT@v{yl_E`J{bk^?Ba7Ky zwdkYxlbCNRyRu=W9&tLo3Q|r8!DeSYdU?o^?DQHWA6h%rr$rOYO^%HjSayrP7uIKT-EL8K zVI#~p>_dmM$LLsL8I%N{h6Rrr>BwbUs&hjZzhlX0F%y zPv08S8n0tr!*swCnY3*_ml=&y#(-ZxNN~a|G+$YaG)fFSsuNLuB%EfpW}up68aT;~ zHCV}V2li5RwtFtO&pNUaR5=&2Zn!DYpEi$l7RQ0a(?;U3?<3<=wGuji7clE4?2s>3 z6&6}q;H*>4Oyfu_{`#H{CF5qGk~fE2no^B zfZ7Kii5IJ4GAZXS8=jyJ>A$Xm-oi$rrtC&CUe6>RrqhUji7G7feo7n{YJ=(Yc9e{` zPF_WZ16kbw7Z;|{8Sm%f_tX*M^v#46Z7HEympO(Aq~PiN)3ANU2XeO92_JXNH9BH& zQbcBC4&5{{3E%0oQStD0wpWZtRj!)iw(vSw>pw`=4puX^ifZ`l)Fe9WtPOgicVM^H zbFk{aNwmXwBAhpDYQ7#}Ky8eufk2e6VWgCuVw_HOJF@Nsb%256Oq=OYLYCm$j`548^?_FX^Nj zF_7Xoc-DI|NOo%jzMWkJN54d1dyX*Zv?{?S2LXDO z41`IrZ|*t6J(Kw~ZQ)$}dVU)6l?y@ux4&;@7h?q0V(3w4@+NXSG3)9e;TK=Ph>H$d zw?&iass>!Mv4qM8H_^va9#Rb}9x=E{@n^$FHpeoKea!98f32HA=ABxJDcrN(T0M=f zpVmX9W&22Y+d;g4q?}yO?IpKvnSg?34yiAc#!^QYc((l(HCxJOa=B#+EIc+yCkbug zJh!@ps&eo6N=e4g25UjQvJG~3^59+ZIM_Ph$8R47$;l6zAmVzDh;i8!PmvnNA^9uk zZ<3^5_Lu0Ju0Z&7uB${Y!avgc!Bfw-3jyvh_lzKS@(f5f$M7@{Wxs|>L`Tlws8xAG+IZmMnPmEYP zy=05Jt-z=&pXdi<(cNb)K)w4ey-@doEO`=+EMlpD2(%muw-PUl?EwJLibhvm|gUk+*q7QwAA!rdh~Th zcA$lrR?mcva?b0!(VW>d%)`B@??|D*1+4tB-#xLjNo8#l;{}8aqzXv$xUu87{=a#0Y$Jb)h|@4#XArlIc5QjSLt*-W#b8R8xN~ z3eEF?YyQ>7wbeDG+t~_tE6Sr%PAxfGu$MND-JvIRdGKOHf!fRr#&XXWBu1?g`hTdy za?1-e_lZ4wAWk3u9nfQR_bni&e+-b~)FaFkv1sD5HGs=Fas&EQDV*=054z%;A4+P(|1+kO_Lmg_P8?+{-X>fx;iy5z)I6p+kfx*}#jhB}IZ&Ru6ntmbloKegEc z?YZc2${uz$?PgZjzaY2F{h>4|5+dY3lLjsWv3o}j{cr7E`tU?Jlw9|Omdgtu?AQj9 z`A-2tECHJHixWD0c;mElH}7xCXEX5w8riCo(mK{ib{fD7kjAY@K3 zieKw&V0TF1&!Y7(zjG=cov6Xx35SF$9XWT3N(6lVJq=3Zp5wU%0x0=e33+`6^q`C` z=sf4%{TwARF*pU+@~gwVC^Jy`m_T}VyOYr&FSw+h4Z?D<=;m`9c|03@!JYLSc6_5u z631T)xeD0}qG`o&GENLRVcG~VZ&Du6We)d<@#HG9PxJ_8u38AKxqIo+O*2vX&n+gz z;vrelcoU_Y(;%(Pl{_(ULQNB6e3;6IRePT@!+Hv^Em8pA9qPwx)5@ryO)O)+LzaGP zR)W8F3OvEKJXGFvhsupqp#H37&}C!*qoZ1M8RyN8O5C{pp_uq z*b%Uc793az;*C?Q4a?V(FP+@1)O;sy%JU;KUpV%7_C+cnr-;WxzQLE2RS-&b;8YmJs>tg%`WjsG9LX=$#sjD+$4!Z<0&9>vyas5Y&9ON zPNz{m4ArGTDMMGE>lbW)JbGxJwgPE0G`V zT&C5nAGEg*kab~$^y2rcq$1=zx#HG>Es+!Skm@A;%gG4?xM6=Rx z5?Lm`;6QFzb8SIT)1^+I)!9cA(tT{J_@SiXtuB(%&%^T6i zj`}{fX6G|3w^cHcp+*=jr;4Lj$H^3(2|818Ix#l(V(fnkLza6DUD+ND{rv{Ch?xux zjrp{Dg?>ibaxeHwJ= zCaJw(RDGIgZFq+vM!V_AM~*Xm$_E@sKyx1F<|6;yz3+G6qMpJRgUK3VqsT}&$yTcdLDBP8r9=ZC=b)ItW zB2-*|gp6zMp`Wi08T)q^GrM+Og2T)5$&M~LF1x3V{5u9&&+#R=U%Zludit7*eYgsK z-zkZ@B~2zvW|KeC^Qic^E*Ppg;Y8mVRFxINcAXNY(!&)3z81p_{exij=pOE|jfUg@ zhH2vQ0@Bre2Vu4=UX{`z-~NB!JyOr{W7L^-@>8+@vLUOjo(?tE!X|&OY$HSCHmL3z zj}DvNApP=9LQY>Js*9890X;+To%V|?U0@7DV?9Kfmx$9V3%GpuISBI4XS8<@&@RO@ zXh#}|*{mg8521!v=#)!d&da2qe7O!!`)``d29j4c>7e~K7G>()NN{HqwpIKj$@d@A z4Nh~J@(~+$0SA0q!F})k1wxZr3n`H>p@U6z&@~bQ6=OQoN1f|t zhLjP3E>o~?R{^DUZJ0E86l-|9F#7pM=(Obgtsx81;ly^~8RA>0eYgr7ldYt$OLyZ% z{%MTP=Ps%?B!dlFe_6Mue@IYtE*^>GJX{SDpg9%^lTI%M$*do=TG5*V1sPJ@qoy0_Vk5foL&8kZJl%(Y#q+h z8ykh7R^c5z)at?e{o@NUKRN^bIs9$1rZkG^b>N@rH|aT{sTeb2PbVAC!0FtI!Xfe` zePweQziY}vPVQ>zxTl%)hMT~H7g-$2`~n?b?gH(~nzV)+Xa^ZjXZ;r*fNCWM7E0a0 z+xsuT(E|@?kX|v-jO!yaY{gCFXRUzM`*=h_&L5|A^gx)60r|7cof=Qoq_5_kr%4tz zw8kzC-`AceQzx$>d#r2N{$Gy>xh_syJ;c$z_a2?)L95yq7r>;lLSP@8;(pmQ)XvC7 zJEc7OX+bY_5|zT`z1JaTX9~5%baZ<2l4Na<2Hiqqxa%Q>8za^m*XkwF$sSW+_E-s3 z5bGnRZJY33WIEmR&^*$>MeA$*NX{&R?<|x4b?L7R#5y3BlF4*ty?0_+K_N{oV+DZ9K#~ zD*Br?3k1@gLUN$En?npQism(a7v)yQ3HVRWo$2oir1kd#vE{QiIXG(*irUo?-LNP& zuQ_9%> zw;r6&@x*6Ab+mfm792gSNzz87aeUEj@@k(C-g;^c&MSrJt0B#XBg;?1+n=Fi!)hmT z;dTboleLB$kz|qpx69}`+Z#gnCv!-h4&wY2so(vJoX%8@7#lg_g(nH@h{Hu=A#VZf zmv4sH##iKm_j=+#X(KdC?85u0<`A;o0p(m3Vd}6big-uTul9$j<=^G#o-B*QGzj-_ zkCNf|SkQHTgnn!1H!9{iVuKZdJiZ{DyXO(zD|`cb?f;PP1xbu#%rO4YDTi$?*B(c-}_McgFj&RI%T{a zlZ+2jDC1|a3p*>b*-L%Rl+W)-gsJB!dr~Np=8x}yO$I4o@Jkks`yIh^yfU)G&X5%O zXo2jZgXs36gIvxQ#MT9miJB*$iG9{0GR^BLF>JEMmb6FA^CP#Y9P;R!Sq@}&%SH0v z&G)28L6Io-PQr!%GO2+9r*Js2$XM!ZGQE}Xo2{E0h6Vq92EIHQXsQ27x^%t}URDIW zE{kASHd~{CdOf|=zz`YPK-y3k19sEHxYf}|vN^Q@&Mtom>T#+Nmn?@t$2Ksckcvi; z9OA)g21qqaHO?$L2&UV^$Pj-xk&aFzN!s7&lfWz7^{RkhE%lgDuW+)hbth(uTnD}n zbD+HAjPctVOR)c}0WT#4U`zF6lLg*x?6IeS!9G*qNlzK@=j_A~seSA=@k>}CG)%)i zc2n({8Z=Qyn}~C`ACZk7z`F1n#&_O_%a#@N^ZY*YeU${ZpL@??0o%x$v+?*aZvm4R zFJO{1FhUgcK4ALy8xdX4LQu`B3~!J`Op5zM>ld4|kvVFtuRt))i;=;l-``N`)5(t8 zG|*z(pCr+*2{dMuliAA-6ZgylLOUy9&qg<@Ig$DQcrmQETLxnNQ!%h49c2_KW?qpe zEj9VyNgY&)N;dX#Ro7!atJs<|56PQTnZ_5BDxgYt30`asr2)EA zv0Ka>CyPa(l&cwP#k5cX!R4??ISrJpSmW+*q4;T76W3H<#l7aibd%0os?W+(!NXh~ zNWBDut4Hah8{Eo#=ru`8Rlq0WA{cq{1G6fjkR&B{6Unh&eB;+eEEv?2AF}k$B;>jEu!?&HFrTiVi>FAyJYPw;Wq;GyLH8N2_304p$-6+8arfHk zC+g{S=M$tnatrj`i$_5vPTLigMl!cvCc8EsCPR)tsKwVH$Ox7uJA?G#I#*+=+FS=m zUuqCFl~eS-;wv_=o@Y_$I$Wjq} z0^8XMZxd?bs)hY8b;-ej&+Pv1LD=;78Skw@KC6=D3e_>f#6?p89BkgBzMMG56rH8h z*LCvvq(Xt%EJC|R@kU+qNwCq^70*_6lf`{i%oT;L5cK63eQy~{A0$(uRK95ERiLg7wt!Z?aI)+}l%*6-`z&5&;>`t4FBgI-I z?3E^7{pCnklmPr%kORIGx3KB69(S}V#Ed;*tft)^=7st)DtILTGOm9njZJ0PXH*IU ze)CDqh&gq7)JZFjx`LGfkC(>R#k*9P4qpQqa_Z7MR2ja3J(}i>Po*}V{OCj%zuE}? zRflj6r?YfEB8OF{Vo*u{7Zac5jscq%;|<#gD4re_f%q$MO4rVKTvr)}QVqYUH&?k=77!+fI7O>SWo6_ti9*o5hWN$Bh(q+q zK`R%V(cF-NZyr@K?dNUrQeXtmYZqk_t)V+*hGQ zNefXVLMl{BgwWo5Xo;2*WmKQ2b6uf?Y>{LoDSPiyzUTW7^h1yLIp@Bw>-Bm*Ctgb> zvDpjwyw#1gblY*5lQWLN5N{Js%^kOS%*L?AIxxJ_8~-kdqMc821yt-**dHIqhL(iW z5RH{^ZhA43JiURrntS5s##{{8d4;`aFCj;CKipd(|B#iwAQ@L@nuJm563fHgN$xdl#apAXWx$eL*BzJQO-MQZZI_;JmtUALCbAlj1 zvK|w6s6xx)TC(~662H|Rfk#jFgUx|lw)n>l{IEm>&W1nX56{wPj=K|BSB)G^+)_-U zdI_vw?J1~-q+n~sL4H!CIEJ|Ir7%Gcx?$fW_HnH^r#td3`{P*yzI%M>--;)ka^jBR zWrJJj2R{?NBEHwB507O}0>8nkQId6EOA?sDaW8af>f>q}7n1kXll0^DYP9INNoVg) zCQjB1x4c;fgL>Ym)_|;S{b)LNQ~`qnw9s~f5IuEqfr2Vo{=;^E$UA%#Pt^9}rmwm9 zz%-s}m^~!*KPOfDP?mAij(lI|3yMjk22oS)(%Bdx{vSh*r^Zv7(FHcM(|}E`szaTe zELhWTN$$}Vm@hsV%7eSG^UxWZqTWm{7nXwgguS5ba1WJQddb=A7r6cv!?E(Qv?5rU zGq^bn@6OO_7$H^+eZmOh!^t7|{%16AWIl;jO#6?0e>;jC=e84fMzo@@*Ap1OmW*jLVBq7S`FS714X zvCwy^gZ01MLSbKSvt;|1RH8qY&a}FdrTJ56Sa^YdF4m385n1$PrX#)Ud&s`5Hi3~f zVl+zCjy?qL1{sr!n4>aqE_(e;ILXOV<8B99sHj6ya|*#rdJ=ndw-^kQoM60J6};Ll z$vifh)As7+6nAYr%owi=S(fHZB14Of^|^~*+sZJ{Ya5t;lfjj4BiX6Bktkamjp9$W z@N(rNZgQt4zj*RrmT4-iffKfK=L?3LPMNIDM>I&l_*o%T@e5VZ5M`sDMutPm==e;moC{xI;FD*K5*; zH?R#lx{KLy6`rZI*3$6etu!*u6{_A{#vl6(sA2sk_;9G64IYap=fKZQXTkvr81)a{ zv}=J*;$Fygd(U3wCqU8WdaQUoOHdGrv%s=>l&~tC3uOl(ymluQe@mmnH{`f1v8D_y$-vg>xxkXuctdi<8j zwEoA;W%69K`~HDGjk5&TjjO2n(hl-W7lEz1r-a|4GfmdhgSd0eu&3lTdlmHo#Ih`z zdU!SK+dBMQ1V*E?>KB^l8O@N_s26+u%QQcL>4Hthz# zVCw{Y^=~PdoLx-@1*7PQNjq1x`xmb&Uj(0*m_wq=HVPd#T)>J*;j}ajm>CoUBk%e{ zj;|lP65D~9BbgBE_aw7$A39{MPZz&*v5hiUpnca%rZ-&!O}ZZ8%drx4W$QGGoCfG? zF%dGd(*?cL820>|KHa}yBw2VQn0&S?l27z(L7k#NHy3E(b(c;YZk)uKZ+JXk^VG>FIlV=l@chP}s3|8L~Lqh>2%`4QPXUIq1>)bjBOZ0#f z+m_IayTW}WP>jNEh0;V}Ej`jpog8^tu&z6d4X;E@jj!yc=RUFZBkm;%a(!!1Q|)b4>|P`GS9=z_+IbXLPSYmMpnUpwIfCC>BLn5%7O~$qR@WOHPh_`q zCs2`#56s=W0e^;{U~kSFP)}?PIW?~#?eqI^)ORu1cD5hghJ>=vXCl14ry0&ylYqva za7gB>ak+&OxolsCOQLR4OUY~4{YH$u9s_@op8WMo$9yjVOFj?>CYM~ za+>#xiqtFE;!}=nEg6Gb{-HY28Hy$Yx>e+=GupJPQH{SNlLlXAjpZhK=91|=1u{uV zz-~nenrCfGdnEO-_}K%xDz}I}UI>9HI|Nm;-)k6WZ3f;K%~8SS3qQwnoT<538g7C`2`td6Z@1$tW>&+BJuM_8(%$9Gh8Wmm67p7(oVVcX5{5QrKiD0+nqe z;f1i?P`=b4toaM*rRXAN_^p`RIM)jv-mismZcBx@y(ZLO?BTvd_b^;GlSW>4!oBd7c9HNm|8g*62+5~k!0X)Z7xted7=)y(ty`_eCc>Wndz7is>%GkEFW6Z!@lD}zWjSsusAcQq@Ici#z zu~{5;oZ1OTTyL@DsaM#8J|Cg4;?${tmg-lt5L#iP5c0x{wEeIi$|HR`58|q_8c^k;I4wVl~ltl$#;Cr zv**-2q7|&pnsP?nFAluI9pdd>fp~o~-(a;wl};YG02j zYXJ0(=f+HuWnn^V zEz9|`5;hMnM}4n8_Ay-!rhIsY-X}hrT-+qdp18eY(SBE$Qpt#hYhsC@@Nxz0uF)bL ztA1{!>_K$c8UsPQRG?yAG;I^~(6M$`ch?C76rURb-S#gq>UAwDae_*C z+hvIKmZ4eS3)q9y4BFIsg*kpZ%*+hma+{tN;MD9ewq+ok{p+@)2k)OsG_|gw#BINs zrs5@j`hQ7ua8)xk%Fm>2^~+)1q8K>dxq>~tlth;tHgdd45U;&m*0eeG2D?A_+IYyB zCVVP8i>YqgN8hrq)85tNNFmk(Pqw7ccNsrsO|GC~KO5r;dQq}k=x{itQ0@E!T#1$< zi_uQzF5d5^&t|6~q()fHC;j9GThy6iNh8#E#qhfI)pfxiDo`!(8qtPP6qt63{jLv2 zkE05t{#Af)TmR!0bPm=R_$O1)=Mc7a*dcbJc^_Mw_L%M^A3 zcWdeN-F7T06VPfQ|B>+xSJaYlhG4T3u)0T3SqVVuL+~MHQ89u*~dQp3#Y(m zVicsUOwXiWa;_Eg(LbP+%==v_&h8_VZF)$Fcl1Dp8xOnQzQWMn4k%BNXOFYQ8y074 zfTD5{-7+1D+7jt>S!pfl1k{j-i5vuelin=ZyCl3R*&N9DcJYKoIcMi zW$&L$k;LrL4G&UNY5vxAv@g&O$DMz`qB{5B^_#O`jwwf*JQlEL%DMF4TNU^(VGX4Z zPa*H8K@{qwjn9S~fJe1}L%;qXSMX~QK$m~LYLh3-iLZv9IydgSa{;Y7-~rngE{4_D zb-6gr_58CTOF=I%hpqkkn*SYEj{OdLghKU6VXrOTXzVlLHr%kXV^cn)!Mfjv33hj|;ya=yWQl+nUPjJu=CUKX zs&(_>HMk#8gL4UsskcZTi!+{cPSd67;hP)GCg34EcTKv%Y1A@y-R2xr6{TW9SPa}- zbp}nVZ?m)QkC;_@IXrCb#-o9T@cM!mSWRsqUB@kO^Xe+nST_x*WGS^BS)&wwoSBOhHOo6T>qR4MiO5XdS{>YMWGCm|A zz#H>SOuwsf)4DQ+xQ-{ATk3#)7ALqIyJ)&G?-3NIheOl(Bji?yP+YK%l+7l9!5%5_ zE;FJf?|Sg6uMUk548Wm_3Zc;=mdUFy+A~FlIsD6HSH~X`JODy8r&q5&?!{NuX%)_k z+ka-4KF`Ok_ku`r{&hMiW(#k7*1>L0jzkZwBB!)Ql+sM4Nm0+SwM3)7($k2S)O-qp zh>C5FlBLu2GLo}Y@6+e~#pp6rgc=o&a~D@mB7KkTEPkvryLg2|8Rujw^(!Fn(kG;~ zQ;Qymw6Ukx{a|HoEjsU7jDHlJ=tOuOu6Zm;KGNSgDfbK}zbqbK%sb9@>$rmTP=7e9 zDN4Hry{X6Q5#JS;&Z1XKu(f5=A!5x)s{M2k!uI^-qTSzP#I-u6o}$MFk8VdDogCp# zSjqCe=hH}05nAr1Zw^p)dnh;$F@b*b^JL_m%MK0h z!d0J?&z1QKnzc_^_*B6CDbE$EWgZG(GPxe*5)WbQ-(jYw1>I-W%;zk^h=D@qVzyvi z65Ibd60AQ1-#DA2D^uLz`t%gmzimA=I-Vu#E_X;b_NU)2p1^Hsqb;_Y;IN>CuV^1z zedAINJ0&-YyBBI`De( zVQ?8$O;6huU`nkzsc%;&PA?aZ4c-R77s{s3`aGDXMhO1mVku%t7yqJvH)%<=^A6i4 zvZTOvm{$CryDXwk!!~r%?|x}h!^&9pv|+NT^iDw+qbb~RFV><#eLB4tbHyjaCFuO4 z7c4dXDhU-7qTUd(y_sz|;{Z2pRY=#P6VILT zXYsmM`NobBFy8nSg&ezvjwjc_y9N`Jxijz3Kga2GLQIVwxh@B>StHpmoANZq5!Bc5Jq)>5yrMF>;j}a}SE8O*PI;=A$;b z4p|Nbu8iVC-!Ph5&CKIMKzCdu*3DUl@7Ld`w`|X%Pp_(AnaBp(n%Bs8%Ougzsb}!P z$62)K;XydMF9zJ~1+~T!VdmDSWn!Kl2dL8mRZ;WUyA2QN(Of5fz<&m>-I~RJY54~+ z9mCOL#UDJDx*fmHE}`2Sl&OU&!N$NwR{wY-EeKsemlM=Y|9tMnQUy(1*RD^Y@6WKu z0{+gKf64T#OyKB=MAA1PUO4ygLC%6Et2PyMN=4|$jJMce`UoEO&1DhI+T_tL=;$Vj zQ={)X#_hXK+DodaZ^#EW{Z>ADT;9l!US~oV2KT{TqdnNAG$hVhMTo9`6Y4_;Owlqa z(xj?h3!A4erIfN3&@njACiiV8(WtNd#a-cKCe&!W24+#BnI2z0;0Qk*N>MqZiq&-~ z;s_ys@$T(&{=`Eyir8`i3%f4y(W+t%KMcwk#4Lb?3$n@jpdu`?4KumAh2w2)bNGFQ z$(*BcAiQ{0&%D$ggGS?jQ1I3n-fT}{y_$!3@01t(C)uwoDd;KtZ(bagg;{}`|55Oh z3+1LfIm7~f#8LX30T{7PmVz%1qk%a)=;gSB*xpeH^#Kx~RFVjM_ zJ(SNn$<(C}Kx!AMxeXL-zAw45yA{J{SVhwi zi&rSRViMDq(xxH#yJ>$;5pJA3q@i}_bAC;$DDF-@$gW9jp;6Nwa+5N2aAZ#bD4f-# zD~6|-cn1e3DqA?$OPesWjX{5nD&-;5A+(e9w2m5IBj4>28gFpqXb zs+pK}CDS~;KxllCMuVMuNG)gmf8#(WVlZZ0_KZjsrGqzeeDRl*Cb|I;HV2n zWUu1!xj7JcUBE{@6{XaOFJwD;4}Q`qfx`hS!EF2}THxfzuZY~n1^Xyb=Pm|}CTPf7**p4Bs^BnCB7o!R4dDQ)?fIh{nX05i5N$Fx1mhAY*^@T*D zOyqF1{*eir|8&=<9*CyYlm92?=fTGE-NsEzkC4^+DUq zD<>rcJ)r^p9%}+0)rn6ZB|&BB?{Tv6P4+Chk!F~QQQfr9OfqIXZz^>G#5x{hVEi*& zv0aj8jEgg|TA0pOeRd|zxx-9bZqz~2$sJHraEjl*`99uCsDX(qy}5*r<;?R)5BKMQ z7}|*$(8s6izSugh#GdLRHgWKy`8ITEn9M}tfqG-1ZR(PZ>DhJ^^f}V4E>HX+X6BANCsWW+$mt#_L-PT+rYf?Xlzz*#%E4?Y+>~#=uIm# znW@JpJieF2cL3azkB4sOd^%*efh!M?rm(;um>dy-0|F9x^}8XI^x_OGcab5DF{kO) zkk7ba<`6hwn#!HZie!m1t5})MUa;CT5~8(asVn>!*=eg%ow=Oqyvs=f5noQVW_DXz@}To;^pkGxLmY=y4^+r zGZ-r%Mcheu@kH8`sw614^J&;I2bwW#1ZwY32mfvP7?=@C7xNq8kM|777avDbcb(C* z>=GJzDzb43gDBShl`qq1WmoF1f!T%?#$L|0q!_cE22>bl+zPk^=<|<-^+iA+rjqq+x3H!X{ zHXf0<&P?wJiaB>nZt%Yikor}d1#hv1+7)k5Dq9ANirzE7^}R5D)fe_k>N_{-_ji-# z#T?`u`pIY7$eU(6-{G=t%wb{9XUsNfrHOK;Y}N?@IhLUeQ*G998GX;Oyk$K_w|dav zrL~Y8n2%W(%URaW0rsK1k6)%JO`4zQ!j_Lt?BtCOW?U6Q0m)Nn^O#>u)@Ug`-)@hp z!EusX+p?kBQifFK{N_V<3-3Uc2YmmgLpa%HCATAR68v>phM{f8*vCVo1@&$gCqH)! zgOpIbHC7Q!HOo2A|LmdUz#16)GmP;SaUe4=$Oqof#urZ#C^1i+GJfx*7fY5xi$yIZ zbh?pA^#yX+FA!Z>65YF54BM=ExH}+;FP&v+Q@IyxWyMT0J(l`*hx2OtYjN?T1iF3j z8S{vo2q#Qm7*8HB0f+iBR1V8Qm^c>dqqW(%zy(kuIU1%ePi4<_gt@%$K{jiu5^Ze~ zr{cSv#u_>aSQ$6D;p#7O2#N`zX_vpFx|$=s=30;o5^@3gubFe1BrE&jOJ=jfIJf*2 zwDQtB=52Ef`dVkQ){>pj?eLgp37P?Ky%+&S*~%8jyyZpPE`dsbCTS>RgKYQp=vkD+-whAM#LqPivos@Rw5SDP>+9`{Z%Ds^LQ=mfZA! zo&jyfd`^J0sSRk17b?R;#Z4PmjWG2;myh+~yTNC0WW)QDhw7J$W`fz%t0raw!q5~$ zC}u@7-WA^0r`~AMt!=NlLETblu@Pn)Q6*rJd#>K|RssDv|BJ7`;X`iiJtoTn{K;WW z8s3sCVdwb-Jm6kQ@7K&@r!Gd2+MQk&f4G{yO-QZtD;oiC*L|eK&nrOQGYsl$cbY)Z zZe!WsnI`sriv>OX3M%M6184VMXF;>3v8a*=e!lAF~ozCqp+Ax^M77Q;t?7d_gO*wIr_LPM$-YV2{fe z7NnzS>i2PyX|(z`m^cb6;{i(mys|=s#@La|I%jmeHfPMe4i1_GER{u;; zT2FCgYGW31k{&hMuA}?Om*AhK2zyic5#87C z1ryQXZ2bBF7?IV)!t^}Z50MH^W9KBA{JsSFrOp)MbEn>|td!eh@5;u-%Qj@bO@K;s zO-gDPGBjRNaA5I#sw)?;=I;bdN3aXat&?Rz?*)bI?X|G@M?&qP(++M=!W8BlufkN zM>SYm&0^>LC&JkyPq}NigbdA&ZB#y(jH4%2@j4dMX^7rIkhHUAOT+A#)^cm`|EGp_ zl4t3;V?3?TyTT5Xg@F0%3{HIfNv1dU7H4?Rkgf;Y!LWn94T)k_5evSXzx?mgtiZbo_jpEnGp4rkEQ*_3;K%%pC`H%(GmTMysa7?|{(xJ&=v z!>?3^(QdZ`|)lS-hQI#%pv@GI;Q@ayenArLfO7M8zJqO3XFuzKip`l}+y6~$Aj zz%rXHypYEX-|%?&r!I_*y3d<`Eu`M|T52d;Kvr2d;KbG|^r&w!*2`>Gr9sIM6LlJGA#u?b89=<~fyB zgdU@Tzx#0bqY^5p*-RUt1vI7)gC8R*$X+u6R~-}w4?ShH+Ig5JrcEN1;vcwwdO7+{ zETumm4B3IdJ81Ox3dRDjp~InMtl2Z0jamPNicSv&x7G@X(i%b`qJB{J{V2EpLJ0Fs ze#`>BN3sbXLcR8HJ?Yw%z(UP=n45lz-yT!NeyzxXVP6 zKY2Dj&6q*IR@|)$ULDV_-|L6Nd!J+6?V0Sn(Czx{J6dxs_j)xOWw|=19v`B(32zVcGGJT8(hJBR*z?IpN(UkOq}jXW|I8$ddTxF zfq-|wnF={s|KS{rtq!66zdcdO-LRUO!zJb(*} z$5Pj8edsX!K>79u>Dth8XxkqP?%TJ}9}79SZ+n()oRCHpX0f2<7!D7!PQatAT#!F} zmCbdV51)ru0)NtyqKw~ROlm&WNENfY8iGQ8nHF^JE2aro3()ep9Ywwg0%;>5vv=15 zmi3&&1$mv=T(X!Nu024V4n?rlG=TRC%ebGz`r&P@6x=f#$NYqMqwpT)KAiA@Y#hq= zjok##!jib$s5k6WQZ9&+P+>JX&X&$FrX><@aC)RDdBR9Y)LY1Dp83q2RL`;G8cVXM zHfQCb>7*1SOV8p*le%~vQ}i#xYo~ST;oQTLL~# zxWTIZ{@}KFG1e0oE!4*q8#e7h{FJGLQxj%_6sOXlC1gBAH+3?P=PIfml0Gh zsxX)+O_yGYfcf|Z^yg{=FQpa4IoT!9Plvr+>W4q9(^)_#{*i;?-EpwLVhhwfDI(cD zN72;Cn8cPGuIi2YekFGjP$Y4!kVN5x|36|p$;B2~KaEe2D% zwVfeyp{xIu6F!az>q5`1@ehS>*?($co6 zI65(d_5P5=Uw30*X=^xsmVCpShfiWDQya-#3P5eJhU*D>h+{5ZWOGj>&@B3bHhHe# zv+o>)3;Jk(K_4@zciR869;{Bl(TIPqCeFH>t@5|PlUKsP+f5$tW=B(iO94zw?}6c2 zs-{{BBBX4+kcK#!z?_y=OwM}1>{dQwLp6k%mrOa1KKUOLm;S&w8yhmPIfN@N?}m|| zIHp>Enc}BMLa%`4`f+T4zuOTCvj!e8qaF9_=hP|T%t%ufE*HgehVDU)>PXDa3}$i4 zXPN01IjSyBW)}Sm zF~CBm;iKNzh9R4x*+8Tv#x!ljc11s|cFSNR{>YF^Um;$tMV#GM!T7C5@x!QYx;{h! z?Hv+e`s5pYgI)@4Jz_`8(<_*l*+u?(>@nKpbOAe@n&I;Gts{#)KuCkvWb#bYS7|{44h$D1Fr9eR>WWi2q*m{CS zJuLu_GYm{_E%N4DH8p`=!a!N`tKBK31*zevB6P+OnKA%{~n%~^xW?T4mIgCZLodTODYdM`83gD6=$t@Y&&EgVo z;rAOUnAUj)6(c8O(EWTI+$`w0HfxZYyCp^L&&Pc+W8k7Nk2tyB0uuKu7pjbV+4;HM zET?jU={KiPY8z|I#@VG|#`Jc!bA}l>t`)x1Lpzwk%d6O=V}hj@*0A019z}JVDD?JL z%pDU(&6_1?d-gFFs8>n*a->oI*)lq)n~ZY9gtN!=AWq$PIT;lvV@pFSHsB~w`SSuT z&wOK%R|9#weIcyy>kg>)&jqRHjS$-@0XmmQq|UxTKDf2SvS#R_|h&1TTE*96zB z^8r2aDRl6s2R3)!N8=D7YqH_Cv5o6(jzlcjiOU*--d!6Gg*T({B(ZsKuduhVQl{Ao z``}yYYM3u59&S#5#f~3ag2TdZm@JXdCEth0WVvLvI>VfMG-3vRExH4;uS)4-v@2Pw z+rZ98cPOo_mHcNsfV}bq?s{*PfXi_Ozk7M;sKi6K+9M%@C<6@+o^W!$83cJBpysp) zYB2bU&cU%%{XGH`j@W|cqbz>&Qzy!42*)Tt4^lh#g8viV!WH&DhTPORUPVO&D}oha ztxpz~FW3cPTNS|JjraW5O{ z`>+)~G@4MV(v7rs#Iu_z`_V68BlPY_pkpP4RMrs!qlZ@E>Bj*$=zEVUlY8mI@ElyL zp%`bd^*22+97=1}uft=EuYpH1L|~8EYI?h=7~ZuOkb{~QofA=p zMBi%o7%Zry@112~|Hhi?CRgKY8xQ!WY|H&MOb?haaiJ&ym$H;{ybC@OeX;g>2G z*mAucPA3gV@qYro#9sS31uts8gdymEu=FcCg1sE8gM7pYd&EubW|c@(l{DgAop z!_Tu9X>gdTL)U&;vnh*auwTuU*zz$LB!xK`*C=E)Vq(ZbV>M*WdxA}gLUwMUI^DQ% zoXcOciamMt8%qNE*|0DR*l0M7KUusTcKmgMcl+lccg%z?Z9NDPTm13nBXz){ix}DW zmHm1)6bu^6xDORJP<#9`7r1^s>|G|@bwcMb&kHl?K+jOPzDtHR>G#x^Z&auISJtt( z+LPpcHWsSu6~J+0K8&3IjcLwnBa84DruHkIve$K?zn3igXKq0LRZu~k0t5WmrSZD1vRY%ypygxi`^fM9V>%Jf1B7|^uEDkfLvOd*?dB9MN#lr|jv z#qu&=!N8h5+}<1ksnu7?vY8*aZ+?$$(aLa4m!~YL8I+{BR6tXwz}oG7_3<$ip(v$* zeI74E;djj0hgI|8&1EC5a4-x-?9}Pgl#k3UW+<727?RF`G^{C+1H%jpw(~(ce5{Pe z3AK6r?jLiZQt3as7Un>ik&jr-giyMJKk(e{sceoH$GNw*va^3gOebFR1e?Rzpx$#@ zaNcqdu)`e!<8QH9_mXJq&@k+s!sCy4J6Qkw408?{3NJn%XEK>RIP;V|3pV+M%AE;< z-tHgDzPk*AuDWCtCc^eJnmEK{b8OKhk%q;V`)SenXDC{iit1U)&@z4=e6&=+gt*qwda=lTb9XoEgM?z-en+=-@yLTR+oIm}lOi_c3DJ3~wJQ+;WywY0RAam_0TBjH8u~QSYHf zn*Qk-C%WbuUPw94B;EE7-?Yz=)wB z!y>4$9na!Mr3z@G6|m3w4!*RwOY?seV#;47Ht{+~ZhHdI#-;`@o3(Y$6^ zF0B0)fS0RESVq_EhTKG5dSnqniwgI^t>6(*)Z@V!nUyiM7Z$kw&IOW6W~|!Cm?bp- z;2QqDXBtzoAo;uju8cXwN6#O}=7!Z{c*_OeeMAVPm)f%FUE^u*_>YiscQbsj^Ja;@ zSE2M&H5XSSLl%HQ$8>3W5FV0~0!}NEo4XGUN#tuVKxa(aEx}K_B&$Ke`NaYAn*iy-#5lw?ti)X>Cr{$zHa~`Da z@qtrQ5ONKr;EcKtRL*QdyS8B35Nt(X^zGTio9D=F#z;IcCJX%=>gm5{H;hFe3;lfU z6PUHE2CJ&ySFQG6M~Sz>DR;pbGUk%^u0CE$^|1t+hr+@5@&T5)<2TNIQv{n%J;ktzO0eM3Li#=X z5boMzL}5iX^wuJd^-a-(1Dmh1LO)yB_USH|TQ1^yj2E#VIXw4wqdhG!`im{Uf3QPF z-PH77H^-)lfO>K~20k8&ior#k&FC>0(={1#VsBu8Fh7s&$mI85D&=-Px{qoZBOs`9 zBJi9nJ^osP3VTkl<&OB@SpiV0*-P4q}oJ)cY`3h8D)iT^R=L7YZRoPy^9-#nN4-_JGAT*coj-tSnY@o z+%Ce`S1CD}ykxIg=dX=WpO?t`#ogIKH%6^<)^cMXEiXU z?Nlm@dlbW7xE{s4h&0-NPm#54bcL61Zva}~!tS~}w)w9ZNqMnJd^Y;e!)FQLa?i`nCpL1KxL~(K*(@&gwa~?JVVaZ!RHabd4UK2 z#%>cRZ?UIFdjYF8*$WiUa^(0^nZ8dCrt!-^vbj^8VK96VTQj;Cw+XxcGNo>|s96@? z$fUr>ehapt%K>b6l~C%)JW|@DLVKHT!(hE3m;5}8y&K+y({GtlgnSuNBpl~* zUa)wXGWwoYZucl^K4MLRtLKs4!|eV zd$`}_7=L-I5}VO3$*-Akvp(6@obDG{^K<76p;cY0&|kF;Uxl@q%n@?blF?!ful4fT zK-5^0%R7%A{^&M&(|iAJJOGb@u5qw~vLeWXcLCY;%Ci zhaW(rOC^L1KI1SblY1Ls#d?3u#?I8c7@#3Ws@hW2mwN@aopFI_sS9aWRS4!z^nnHW zZK!oGyZ+Pob7XO?kw#PyPr&?DI{lC5tOL?Wimdy0>|8Kqw1O%T<)`h zre_u~Kk-PAdbOTz|E^)S55?)+S0DJ>IFigB?*XURo)lat554CvV?@&fEO0bKy8{o{ z^Ar0q^r{Ru*3$*r!XCo-t_bi8Uj#)eDwG;G5q7C8g#FvbfZaCn%aD;Z3I6>=cNqP$F>EZTpFdQb!D~%e&1n(pik5+03UFm}TBbUJ0 zqCR{(#hJd?F2+}JD)?|<6n%d_6(Z*ZQCUJ0IOQ8NP2o%|KR*EOyF0PsIXP$$bQ8UA zZ$Z!X8_1b0=AQ4|4X)C!*yJ~l@cPvVoH1%7%MK}GDqI{oOSFKdycDZy98M0a^uc18 zmT-3e!+iUc;DofGG~TF1rVHlabU$(U82y#I`8NfQtrMZ(dK>t5R*YY>=%Pv9@2RxF zs+BKPok?k1pV1NBFi51iC}#VaQ#aHk{gJk;upU{@fq3@Ss0&_M?4-8UPADhr?S6Vc zj(zynnF^Ok(UQSxuGh(qQlrN=yo^7|ZE{-7WD48x@W~~tTt7wo_5dBOL5&ol1N z=ektkv#%HZemNYLpK@lfvg*dt{#*#wIQ~&+M{1^CuaJ#& zaHF!(uKcwRJ4lyZA(#D$l$t)3eQ-2p8$UjxRJ*6lu~~S|_8h3U{F}+%1rCKNyO&eR zmmq4GKAyGzy+IEP4}+G92?V~^!H6;Y@V#CY+gJJubbR071yxsebi*w2+I$7yU96`! zwPv8a=mGi*+J<2nop?D}P>3!pC8NfPaQs~f)m$rpu7@wV05>(tus7ree-&c3qc{zu z_p{a4w$Y!v&!{%xHdf`XCH-%_x$Blq(DYy=WruaK<=z==l9s%s){g^p`AQsrCno{t zmc&4ZoHIn<-9)QAmQuKq1fASIf#x-xU?yh4);ufrK}-kdnykX6 z)NOcwYb@pd)djf(tUELuNeF#bJ-Cn# zK1_v-y+hdQpEV?IqYLAuu3_Ho^<;SR6wKAV03Yubu?HiE!KA(j_II9I!_=JL%&*W9 zEWC$Vn&&^lWzv6z^W`(du^3`4EUPkOZ6Eet1&a11^4=h~jtu zP~o!44g0rUL*MD<#2p-JdGf#kn(Jo>2JW%=5(V|iy>fC$v&KPtY3|qVzOaS2Oj)wy zZ(Q$M&F9S-OVzjMap|v)W3<2?h*TL0lir`hm#eq4oTgkV4bw*<5zfXc^JzK2suTyF$nX%&R#`vsNggl zhAQrXiz75}?&lhMalIOLSBBH)J@0XaeKTCYRRoI#tbDqnGE8@H=V$qz0~S6TUfRX+ z%2TXJ(TK2XpcuJNcHq9NhIftm&MxJJu}k}O&|Z2z8tKHa*hR}|mA^6_?m7mBrx+-Q zB|=QN3DiuMg8BhFO#Ud3DLVu;Q}I^1TIP=dx!o)`Tu$N&>!du<7&dgL1o=DM#=ARe zSiG?IrZ?PzwjZU)yUwI4#Q^3mCMBH5RM<_eC?Q8($dqT_hw4mX$s%JhU1m$cTA)* z220rDvet|J#91+8M!!jGDmdfG*4NIJ0lFDR9#Y6GaJPf?rMBNX@k$%-6 z=OewCo!oa2XDJ+j=RX#K`nR4jnM$?WBIuTnE}XD5hGkp-vizf^#A5bGLK=Opt%Nlnwb-;>q2$pjXx<;U zSzMYllwt?oGj+u$P$d5mS8grh_wFB{{eliReegUpXJIV8CXLU!FoqOHp64v)E}?gF z>)5BEmtfnI?R<*DT;{UT2!8fiv6MOe4}dfm)7J*Q`Kb1DYY4$wVTPZT@}U@iBj$O zr>xoZ0O&`}L@TilOvu`Szd8j*)7B&~PmiYES+mK}Z#>q`PeHpgn{l;AE4bdVp@`+H z0mNd^DOld}`ImU~dnKrJ;-=9fK_B_BVvs#PKNQM^GvT;LIh1}%4Ac(gFp;gxscge* z%A6ku+tUKsy^eIIt1U1Z!q%d}a076Zzs|3ECWlf2u6vV>6dP}J9-n{Eq!)gMOfG*F z7tsBL_j_`c|5rW}&O{gD+8+sE(pQX~ccQ?YWcxR|r(&Dq#3qU2gs76{M(shs7V8 zjwT6vpi_Td)*9|7Hy!G84}EkyT*P?YcS89+kjk}d6{vanA&qAHY(&W zta-sy z=_4?Qy6nI;Vwy!?@EZP|ix#-vLYAp0#ZPv+!e7TL5Lonvom0sMx%v0myVZA@#G;RQ z;KT}wSB}Bab0r!tRyGP}-y(LkbSmsI_W}>gCh7x0rC2_KEI0BNum7E+#FDe9V>K5v z4S)|=zYKmv53qfIY_TET5{E9H0pqvcq=RXduV@w*z?*zXxoU(g84>S`$YZIQ*IHd)L6o@{{q3q^2eEa5KO0@(FYSy1X$ zQFL`a{s2jsQZtc)oO*%Q`O^V66Zn*!NN2`}uqjjR>0Izh%73NDb=F9;F`LIhxVZ&O z+2xAz>7TfofLK&nsE*Qa*5YV|`P`=lb+Qtd0&l;)f;LSGU6wC|3#mdKASamicX*=2 z%)68|a~B))V;eOo7sAccr!2gqu9IrkLx>4e1esoU=q+nvJ!`!nEk6Z9yp!YeIbvPhk8Pne?_;DOFQA{&U$?QW4%aTBea6~R6%)d8D@AM5+>+d#eN11kQv461!x@XXugY-(*4`iZ{b zPb-h$t}Yuvw>t&N$ls0RIw2Ot1l~vW=^qvzv&7-Imm;jY6~l65gUEBd8bq4saylmA z(62fjHHNA%ZMO_&sN@I!>jL10(m~q3CasoMCkg`l%Iu!l# zQs_v;Tim5mVDWqRU-ohA35(^=R+3i45qcsi#ww4;qjq8e=sPyx?Ta$(@M1&C@U^6C z{5Cq*X$7J2nM}UaM#u<@!6=Jb_S<+pJlpb;EojQa7uL^Mk@r(*&QXIMf8E)hwz-rY zy$IwLSA)`d~n6a^&=LIGxBy zfq#}?G-C>}8cR86m%aE$45y~5` zWDpp^wAPCY_gDF}%IYXY3G89rgX^in&JY&0Z6?{ME9_KQ1oXc~3^iFq%k1o^Xhc8h ze&sFPU%2CT^?2CxS%=SB;SL20my$k9rdLy*H>eLOrdM8FocD_=ocOkmE$E4(y$hvi z=-whKlP(uf_;HZ(Yyxc1`-)pO3+sYz60B7S!WsW{GHX{4ke|~56Aoukfbnp){h}}a zZGXXrq+N&Nl^hM)1hMg*x$MB2QrO}2iP@Md0}k(kY1J82QZ^2PDvr_4(24aQ@AOmP zaxZq}zy;=AJspxh1vAC*S7_%j2P*jZq2A<6NZ8wuc)I@J2nA6Y`)8KNe#;5UEL#se zmfFA`$PD7V3|sPJ*(mi@5+b5Ln|dzO#i1`Rv8J>BEHuXf>gKGZs-$%0T5*{(E+5HK z?bA@Y;{bRbn!%)oe#J~_Med647HSmw(~*(N4XK+vNjEKxDrSel8{eyNX`L4PZv2Ja zTWJmx)Q@8K1S4oJ@W2~j#?-@-aoU$}EcTuz`*Ka9@pNhjtWUDEyKz1Y92-rteIIbg?;MDI zagFWt+(m1DuzuH?s>i$y{C7hqHN-iVm zCjy^+=Td6PACE={SFv6RpZZTfN{&MZ;=B_@Xkg&W^$C4=T4`X z@r)%Tx8gavfyDwldnJF0O%U`*)8+}xjarJzzd)eSpSK^MPAivMEr)vcctt0Zpk_2GxUKVU)~fIMLYzMI&aAFSF;iZx!x4 z*bzvuA4zIZL+k!3(90oiw8b$Rj|+-2A65u|Z4}VLNS)lqR*tffnyR};r zHn&@Y^|R9`B7X%Bm~Tbv9b%Lb4y5wzJTxiaq0?D^@OM=^ZgFmcX(0}5noSCZ=yg(i znjvqrO1LZ9H>PSrbQ@&UGDOtS4LSkKQB zdN;QPMx!s-3prPNj~vcveG(ZTT+NT$ZjYD$Zo@c{S?tyPyU=~@9NQLV49o8*poeg# zb+&zlhb?tzYQ;A^X|$bgIu{E)_&j`w7ubVTb#7(27=*XGL8w~^U6g;og5LL$Tcs zgrlEU1eSkH5R>Z7FayK`VjU7$xxT!CeCaB0eGxdz*he;!^~HG zY|f1&+^jLx;^?xwxICf}^bUoCuG3Zv{a7=I+ZzO_0}{OR)vs_nPLB2zSF$5BLxem~ z1DR|zr=lzKnJ5>>%Rts>WwHw}v|KxJBDj?U|83B-W|B4Os**9{t~XUHfw63%N3A=$6K7IBM% zU~!!;n5c;0#hXJQ?L#|VeX$N5_c@|cS%|)IDHKJ51ycm@gn8W3NP!7d&b@dY0=2_-(?wnazRj*;cNNM}-~S9MT_^z)HWNAo zUiYFo$6;_+DCA#LL#q3SC)TNxU6vQXoLII^cxJxeKLin%n%Ik3Su|OGAN=ZmgIms* zp?>gcnA%wdRg~Efw%&r9JtTuY8#@7)_2e|{Xghi^GqX?6~Re;9TCp2I* z7Y4TkfYoGg7~}sIZ6wZ8Y3>i$@K2akgxICB&n;db4~JjwfPHac79kpw=+vD?+WSQeGNulkHFa^RzhQTYJ@s_qc1}$Y*tg>; z-{dSUy0@If1#O|%R9D(k+QV$sM?lA<5R6QD#HvcC5?2^N!>gau{RV%WUf@PXcZZ?V z`m6w00`5UeZ$Sz6OU?jxcAT3yH0}NU?1aaA?g;npE%43iOV` zwe2Ceai%U8e&GoQ|lm24z44g8O_4mLouX%aU#xta4=Gss>4+`=v&D(7Uo zx50uKOj zr-_rLT{J&kt(Dmeo~T{Z)VQN@W;mwM5jun%jhXo@T+YwHX|I#%_E#IYYuLvmb9d5I zxsz<`v8B`#iZqxd@TWDV!R~)uc*JG{ziPWJ^Rnxtt5L%ADoEUXgLaI4M3<8yDLCOK zZ#zQ@`+|btVXiBbj?spaPoMdaDoGd^wiA7$_fY8Ex$yg60wr#~iwo;KVYhr%LsrT! zbpP9lA7*91ezECPcEy1nn;fFWX`*0PQ@}21Bl8l|W3 z;Sq6UpK*kYGF9QqUjr8Lrhu=H$;H#FTCg@)l}bc5)BGoqxKMm2Zamh>hBn57*>NwX zJnx>QMxh*AEhbCJ-gWdz@iEq0YC~XqBD>f)1u6@p8H&m^8f?B#u3=VGdU*+LnwyWy z=83{Oy%v<;DP#fu&V^$W=dojXrlcU{g2^5-Fi}aQq2{&-b>uzbyCSAR`QvC>I&}ws zZHfwx+Lcdc57gi{GlITWQLz5Fz2W$!g!nw>{4@xg=jM{_{#D$Wp0D6#;f37VnEH)}zBe3llY{CAA-`a2!3GL% z!-d*VF!)yv&1P4*vrose)EWN-1@?S)$on1JcI6UVuDOavE(j#mxfKmhAACBy$|{Vz z=z0Pbj303VypSJReS=1YwDUg$198Vv!Gl(yMR{vIz$j9gI%|W#SkIUq*VN&Uo@BNx z(1ZykuF3DJ_4wLKjh{7rO{<_UM6`n+((bq{gf4kD{o-3Fy7? z6vy-y3OSCMaOB)MxDg;@k@$IpWx2dEy`F0ezoO2Oo`ENrPYtJghm_dr!A}9vdBb# z4My~gA^!F)sB!zu4j!7tc3l&6dD?~WbMTFYjjknZkhn}97n$(LU90d)ZWr+@rf)xatc@+G^8H`4B@-Ldlt^m-M_D~g(J&ZV1mG_Qsr5Q zwFpxdF=7XYGg5x4Nj3LRg722!v~k{cT+*pUkDYYc%Afyh)1qIo(Hm1@hi9^MWQ8e*ws_73)ldD1^K z8*V~FF#X=70+lT#bba+py2+-&Jy&zQ6<>wghtq*+&V#E4HhgeJEZeE`9!JhA0k)h~Z9e6f?}ZZCt+;IKaMpgBh=?zmfRtkTg9*pnZL@|e+vfyML>OFb{f`5CH7b7vMdRSWyI19FWc7Am3Fol(LZ{IEr_z+ZeNbXHA2J?7u|3hcb6 zGa&wTGj0?e1NujV%u{9!TnZe5KeotV$*BnJSNp-G*GYg+PZ@no&4=|qEnMwzITrWS zfGaAS1Fri;U|4b+%Gn&|jYc0wY!ZQgBO@!5ZDu*Z=9qVtK9yxGXaMSe2p-bq}0 zyAf({vV=>*{l&98(I}p@i-sk>;k_movh#=b@<*nra7RAn^G}r2X~v5md_>?%lJLLL zuu}Cjt!!`yQ$t0z_vV-|aJ`2IA7;|4!Er2U(gOOF5d>+wR`a{O@8I(z@&r9mrXF45 z{JsgBF`(X!ZWJ7(jQiu@Y`Nenn%zvnDjn2`vtVXP4C!B82`Q8a ze=Vv0=0{)tV_^tg+%Od8UD-`1XA0egX;tvK*B`pI!(rmgW`3YGiPjw`hvddA-usOj zK6g=I(np3i8mkUv3L7Me2@Hf8{*`R>gr&^=wli$<4`2_^PbBqjH8MGPn-k$)lmCP~ z(wruV{pM%r@u*IA=8-6z-Xl$(m+s=M4@9$-M>Xo{aLma#4-}v8C4&fmDPetqaSJ{KInmav);GoZ8}2&1x!&?%>c zb+-x8YK?f7qjwq>{IKJ1i$yeiT0j^*B@CZiAL4F^4&bkeCeS;~pJ^tHr}5@m?2!Is zZkx8iM94D)T0gvjuzLeq3U8=Ciya`qB=%Jm4CW&ifm&X1)V z6Z7Z6kx7NLUVQ?}%oIAY_9Ge>hXq2Vln8BjZ3n74`LIIBPG3{9rBuU>tngF;Y_Z8A zNhQGx0k`pfi88AEE5>%uO!EJE33iU&O0T!Xf~NNn@|`}XVVt15)jy;OW27>1-{(N^ ziTy@C_l?=_v>N<*elm*Wn?uA3Ic}4ywcr$JsAm z77?X-+e2LW$1;}jc`1pQsgjMZ74%#8FsZIUtamw2GpkJ5)Jr?q^x`>8Q74e5w+d^H zy)}+;nE;(F?%X7+P^NopnC19LW0qkW!KU0k#Lo3T#=Bz$jr6pDx_lQak|_^E)3_cS z{r0E8VD?~>{RX)#*@tM~+YFP%Cxe~GcB~M*moXQzX)T>_TKOW*Eqm39_6v?(Jd}Ymx13>)wUZznb*0|J7lddwonWZ}D!p~&5`Wix)>W!LXI zf$WN#tlVq9;8W74*Zaq?bM7Z-vhrd&5?ukA?PsYh?JVE!)s)onMo000q1X>ql0Iy7cEKk<2Rp9I_9jF)hx=r0p96O~-s`gKr zlX@z>s#;8sb`S8M6NRk$hEg_OuAgqV?jh5cfJ>z3pp|_D83}%uKIH&> zYy!ui2@sYq%eJ4FMM;nA_$O%}89e8TM;%36|FWRrvXD?b3H~JB zLA~|Q`K!2s=4~t`?O{!Dc>g`L=sC+7dU4EdQ5?LJ6v4$s8W1iPfJ&9AEcenmh!sty zo5mWT^`eB;F-2Ut{0zL`upKSjv@K4?mx0`!!hqt0k?LC82a+bmDiQCb8dNTX^A{{S2bt-dK44_ z@@oZmh?5iyO{igqt_keFbaOg&v;>})Hwb;)O6V9ji(Oi>6k6`MV%)%A%E~%)6E-dG$m?tEsYrD|;3SaZ5^xslT6?x!7BhtTP|yLhfbn6rh1lAm3;d5`iT5QO3^ zuWB_|H+e%%_z7I+FAZIRuI%TwKiD7|$;P!4lV0J1y$idr$~FrOl51Fmia0FoSV@DU zSCY+z^H_7B2R~hXP5X3A;rVqFT6IGMGEPV`1HTh+c%vmdQEo*&zsBOZG&}U%bBx)p z35E0Q1x=5bDd?$J!tEF%5~XO~;PX^ksr-@?nLmXabgifMZ$oinOcd753~~4Ays#LTjZ0EZzBuGCcz+RW^a!ol%7`W#Uxu%mcz|6JUpKKZcAqql=}h z!6*LfPAZ6JLjA0#EkEv78k z!uY@DEO&b{{RodClc7&ZZc{J0oj*e6drIkAoGbj?Wd}wAZ|h=k0TcVVg-+{Mp^N%0 zJU%^=iuaG8W%U*8#0ph@)G*{7x|wPQb(c{@U3O#~)a2eV5Op?GEAZYYdc3R_KLphc+^&MBP)AE_`b zR8D5YJ5piAnPOi&0g31e+)1x0|)blYn5uo?pWnqveC(^RI90v}GcO9}9s}-8QQ2P5`ttGQacS z79P<*!LF9ILP@v{B?oO}i+wWi?O7wd;q=eED=({|$0;3OJu#r8yJpalfBhsWv!49; za*M}VdQetx$sE;&vTeJv;q6|ic^6=r1@h#zUF}6TLf?izwiFa?-GO6!Ek43&4b4 z=|Vd+AY(JsA(g_u`C|S~N^S z2R6b4_HbAs4c?zhg^MfU(zzZy*rp1b)+}aMH%x^Ul0)Iuk(pFC;smq|ZlEKMig2*? z4cBQs6{dRYBcCX+vr@{jL|{gXm$We*ODPiPZ-Hrb3w>6M5qKq84I5u&;LsoIz%l&_>WohEG7&h0n|BPq+N7M6wwqsEw7!kKd{q%JziEc4HTr`uk( zw*3_5zV(8dR|~+zb}!5re~%^IG$T}=32zS0qBy&BsB0?W<2NTU!#%s0d#yeceOL#T zwz3r1B=}>48hN|^X*m602}_tBN((pKhL_EnkTP--wi+B~kJqRiU;QT+ql~I)ue~q} z3ALr_@5^Y{nF`+fHI3OoYBy=;)??}W8klx-Hf&zF9M&u~CZlg_Xw8JBq_*${+EvW4=u_@# zFlaeRYuXOVx&iN>O<}yKcrveE^HJJI+3VZwZ!pw`Iz)TdN;q7-isj!5)$GejL$wH>9c9E;w z`Gaj_4p6u^jiRGP8snc8!c&KDRIfgU&h=}O%WnOu|H`)F`zZ#X=Bj{t`RU+3eFnM( zctf&|xm?$Zrp)9|D`J2Vc=)gO5~V&*SUuM9E9eVM@I$eDjnb zr#f}kWtEFRpIoEBaDNu~{s498SWtJ{akgrmCPcpp!Cg%&>->gXg8n%I$LqFiqr;LN zVB5t*yc44tipjXsN0(W70}eSP-0LfAk@lJET;$+#HlsNMSB}_6Z-YIU_PKfp9#DZ` zWjSb@I?D2ej3#qmypNt=jOL#zXw%O)X7N8u;HDWzV*96HtIm1sZJ2_xbI!4R z!R4yl_X~_a)xep9-2!X-3!C5W3&kq!l)BUk_Qmg|!ahm5@Ols@-m9gR+iybA?b$Tk z-5g4^j?&Vuyavawuj>;J9A*3t2U^`5AoO?!$Sd<93supgS(5fFR?i2FMX#XK@BM7q zzu<&0XGIdu}7QfsP%%$Il@j_&%pX z@M_9J(WwF&sp`*a?prPkjd6!+SgP_M6aVrHl4}H(FRS56 zBZ9wu!jQyU@3F(nquHiuO7wJcJ+u3m!Q?|{gKwt@gQc@^laU;K^UQ{cDm%bwJL0$z zMlc}o^6I-!!XA+|0((S>Df<<{P1V(KU^ zTVRxXo7QwJp(PzwQ0>Tp*P|%R)a+)<8$a_=Du%4iDT&qwe8!sJ3utluMQmKpGr8(A zhKD|&&h?|Ten~=@lJZl08yQdb6SN@5>j=yokweMZ$+Xq_I$QMv(N}n$Kl#ijDV0Jv zm6=EvH;IC`)C!oHIuF9cpY!#0BY-<11*(h6=c;(qMZ95W2-I>bDmmVs}*9IaZuSMU@qVs}3WV)()v@NlvNGmbvP zPRr>+rJoj7c#+u#TTEl#Fnq8qnhn#mhL)dtP;)Ab8!Mclx0oG8 zHBg2k3&9KGUkDqQmcqa1KQTcmf=$;OMH7Tv!v0NS^x)Y?i?N}ttl*h2t66IYk8LF| zTD&+^_K+5Sbhd=8H^hNAzCd;RSI~~c^(1{K5tb+IfH~8Yp-OZP3tB3?W7MZn!Pch` zFw>kCh4?{SQ7U%(EM{lc=ArQek;eCaW^mX$l!`|zq|?Iu%PDa)ygt#+id|GK=hf1NU1z#kj1Qd}J6rdb|oEUkaQEvjq?%WcCdX zd6Hh}6*l_NR5Drm&!YOjb~eqkncaEqK<}*u_drrJ(>J+H1@kY`zdKp@xI?Y#+lwZ) zt2tiSWp8DPL!&{$cL=Wkqk=`Z0@xRx8NRdC5Cic0Px`Eq_V+ zCw^m-`&AMGRqXn*aWKYV4S8J?1t;Ix^!084t*KO^$gN9g@mCuxjuyH`R%-M^)s?P% zzR#uGy@ZPJXY}IBT~g4RC3F)nz^Y%mR6IeR1-{;i5Birjd>B_q3&P9M@z^jFJKTps ztN*Yg*;X*-?+N;OPvEDl1|f5x2D>huf@$_I(ehFu#jaig@M#&@%nL$VkB27E^LrejX`MZWpLop%=AXkicUPdq0XazI%UR=t{m?pY8kKE- z&OTQN&%;YqXxW>GmaFbzSC>0eDM_XOM#?ZX%RRJi{!?z(M@J~@Yy)wf2~7F^^@bO3 zli2PhO0>~Ro`Qd_z*kqCVBxA^jrSd%)53Y)q-mePR80(Np!5xQzV0IHi9doj_Pqj; zG2U#`#u&1TD5EnbCBP@dz=ie|G$3zEzjry%5cNCU@1a>j-|zuSUjK>S=JRn$p8`B_ z7NI4Zr;+knMRxLP4^CWUMSEJ-3!R!>R1y7!>ntkbvbOrs%WPc><)jVl%>7Qx?>h%A z^*04~$XGIXr$Xxw%Tbg^1Y9&;hU~;Ou6N!^8rk?gY|jN3<~Z7q>fNwSg%B?rM!~3}oRGQJ{!qzWH~LuYR|^8)=;N%nMd-ntcZNR#7yh=zXJ{Cc3UQm|*%a40 zuJ~sX-E8XwJKH-L<>n6WHg@BT@n@Kw;F&J%8^;7nJE#T+QTkBnhy>uQuz>p z=j2qG%XDo^VXs;Ss4V%48?U}&nYq^H61f$$^N|ovD$^G7Y(LRGVIC7?a1dLdM>l1H zpjoln*V29UnX0`yA%W2;}2kdv7~ z)wGcAb*7`%jG5$p+z4bt598$-d9Yjj1B-4BgYbJk?95;^A69FLM&BoqmWm;`+*k#% z3Fn!dqBHXr`U+a1t#sBenZAyWVGr3@Q246HVwHzAYKhIDu4S6Mipyc=nap9Gx~Q~@~m1|8x%n?Ykw0TGnb{;TMOrbFR1mdhcli#5uCJr zLFx1r{?UyAW-=-izfJ1o4W2ya+nhh}$`xL4Yvm%A+pr3j|9ikr<~4%H);RXx;j1X} zHIrpr%&=h9AyBbAmTcAJ;konx{;RHJQdfq;qdpsE`&blCTCaff11DkW0XfUxbFFDj zjxxmBjfLkGc|>EgxQQb&1cq@E>s}T_)7syWO}P_{X}f|SR3u>c)-K#FuE%n0*Rn9L zebD-RA^I-Wp^~O^H11>*yp#WiUGeH59<9Z+0Scm`s`AepHFZt4kGNZKMpSWS*l89~I;h*TdLQcX&Gd8e9K1ibmoh7!z^`+UnDo zsbnNo&%evW7NoFkSDhh5UO{kTF9$yBI%wP|XQ3%8kY95hzbAE|#N@x6+wJX;(ln7g zMD5X9{~3EBnZ@?4QDS9B+t7Yu7j26+gCLKlh7|7{D)p`6N4~41M)^J9EbR&=Szb7* z)*s@u2H>s#eY8}cjlZ55aGKTaSY!VW^{0k_wA^aQ-PuY>^G#{~U=9f+W?E@`j<5O? z2e~?Pgp6V__G=fhf*TVWKG__iVzE|w^y3qEw&McWD_$kX_vfhmeFA+5$UzP|ZByZAQ-U`%e=g0dtCNiTA zE!?mFh<3iWWdqC^+%+^|;e-_f) zSXK6mWt>s799QZ>M<07(_N;7{H7$%eKU@r-TKrJ^e-xefBUbMl$F0aJWR@tUP*N!4 z+?UlvqELubM3II{OZJwLQAUxO2pJ8}xi3=uQlSa(USTHEQOE91wSX6BW1?@$|ZFd-SCN0Ch zM!x!i^{bF~{}R2ha0&RXN+LF6<#@o_lIh*H2BOBS$)vb$dVFsvcs&xqhz@P|xx1C= z6!9bdL+MoFbt^d7keS`$Y=O zl#HTjNzQOR zFk^%oTb5G4h1NJxgDS82o!%O6q^8bByW)*gka|y!%#RJlYXS z4aN$wxiSNjzXt$c@hnEcvxSNDvP`F!-QvL+3#-^tVQ7wkjJEaS}#*Q0*K;zYxFymZ0>%{)x-!auGv_)UU1 z91f*L;=VL=mY|7kUIlK|;9=*IlXS(&!!WIy^Mq=*kY{WN`5KbS=njX{M~Mc^@Xjfe z3H9dK3(54jP&+9W&^I1mzKiYf7QqkuY+=>`ZbodT&wJR@NhgAi&@*RLQDt%$M4W2G z1(Q{QR%@Y;w-`owE(Qyi2I{$FBVd9MxO|9(eLqD^1}9G8xhraH)W?lP;GZroIJX7o zPdR|gN`BC#6EdjPN9m-jO44B!N>*uEU|ru@Y&W=r>TdCHSzi)NzAc0=4z0#$pGFRc z*Moe@T$Fp1MV2dD!2U~Z#JHp!I`aImbjBxUnpY8B>BOBJ(^IIL>q2TfaTqqlUM9El z3z^rd0>ph>mxLA^0;_sAz_Q&WYL7H#?FuvQ)*WXP@8?m+!dfbGVlk=Nok|to&w$k{ zchTK1GLaeN_^2}Z&^vQEd12QCsf`o#`8Gkad24bSB`6^AYdDbry zVX+nWALn+E%% zVX@@x-sd#+`VtgZpJpN_z6A4jO~M^f+eviZNzx?SNQ^&!qx);*s6BUHU8u#eKqg;C zg$WD1TvANycbLNOZ7QUwOAG@aF2vD?4D7j*165QM#<;t%%n`yyxIYE$tzW@5>eX>ITbmRM23%2G)K( zhG#C%!)xERGB4-JGSW|#*v)QSmtz$XHCwTcR^4_(vCCo9r~MjUyd__Me19C>mJ&*5 z_cqe|5AsOHnrj#slZ5$yOF1Tf2R!P~1c$M|RJ!`Jv6zqwsxLl=CfD_m*Smx)Ygvpt z6}Qnb0Z}j;7lL2qCG2AH^Z4FwZ~f7)9_W~|6Bh3TlJkk{m|h;DcLT&})U`zzwxAdV zUzE_l-3wq;E(Q;%Z-4-k$<&WO9DimOqHDV@UO2yxzIdm~7#D|wMxg@kb1fyeHiuyN zXL~$$#us~4x4_4o5RPkB3w6`DF4N9DIDD&`&Z)l!rg;X~b?Fo8EKTEjwCY5py8##Q zYG6)&6KR>{M1E|Kr}cU!Sok&y%+==Or>SX_-p$|zzxE`28UxJzKl2g`#1O-_mG-efif=TWlX*miHkWxcwTZ6~zh5J9sSdB6Hu#4+no| zfbH#Z>Q*sfkifqmDguORwpkUx!>R8{kYEhD{0u{Dt}`>K(i*>MTL7P62#I!_MH_wu za$O23aMF;WrPeHpv>ZUzAOJ)CBk`eP5WVo}0gXBRo%kQQiGzEG=yA?#bZ~&4Sl+#RV+&Q9@9}AYRJL%oFC~A5B1VqUlV`Ua+5!@z7U+*}_ zmO6-&H3MJqh2A}4G)n^_E{u_7<+Wt%ktsxtZbS{=9Pk;jWE^TALBS6#V&Nr<(TapV zzCX^a)>6Wyc;ASBsXhH>c#V}R1n8A~#KNC2bnfHFlJkd2$PIw7u%)DKJOPX*4&jde zQ1Xv?4i$X5m{PWhP1C%NiS7!7+%`duU5_tiGr_G)3@1xbTDp7}bq&4=p_}{3%BVS1 zf<}Tnz)=$00wrirS4jGK_;H@Hd>H`M8oj@r28&^T&XRvj7Z5*i!q& z@!0tGyYUip8(coH7<@b|(I@^YT3zB9#k-4M=sHA zYmhgi^g$oqc~F37HAPTr z?rrAiFMjy+`Z?M4H3^=(Sdf|eeYoCG%q`@8}6 z#h;~J9sVTfq5;agEkKr^EcP z(MK4rFjMH^`#=iyXYv*Wd7y5bGkxulhQZ&~V(;j7T3J>?mRB3H&NmMev#@hmFmp0I z7dTA!o#ps$v5Ua)pC>wNT%&;xx-oNm4-+3C1fEfwaMoZ3O>lkBo``Y6$|7U3PNodb z8!hB5^52YN+KW+m{atu_PM-CA77Mw#SBUH0nUHf>k-ojX2RD?=frh6k##d);g_iJ5 zkQ33%TE#>XRi6}+7n8)=uSo^jwE_5%n4(>I9i+-FMc3CXJ3-Ardh>GV#I?laY!DVF zJf*{5)ZoFNY}ix28Z_=iGSh#xVNcQ=Y}=p;M_c9aDVGgY%>ND#ryZk91@ejBb9qef z?k2IeauD<^4ffvlBR?OE@(SCuId;w)Ixqc1webq^$B{G+cy{i_%*_D;# z<%9EV?4m{{IlL3QoqDNj);dh)=d16!83WaBTF{%FOQZXBI9}277>PBv>9&g9sPKmY zjSgu{_HiIS3nthjKfR%MM+64x+Cac%8Paw-0-HE~!p|kyR5#)Ue&Z8ku70njD%t_) z*q^-)jO3e`)chUj zX*vZ0Yd>*WJXh*j)I`B(08>~6?{Yn!9tQmHTg^J<`Z zWt^vtW0VEt*E3-QQdEB1Ewb~3A}-hcMPd}ha5w!(uJqJ1+dZ;r8n2bENx4LpjM&g` z11fMTI~*Fz5~zIdDOeKv)j0U#H*#A|0lpspVzj6>8>h>2`*p!}BsN`}Og7v?I3pos z>G;9s*Rr(nk3_wXVH6auw`a0i5+L}e5GZN{JdnpKr*q75U3pL(L#kr%1^oWsiO>B;njV@%tcV+3wKIaA9*t+~&s&1bo_^9f z?>D*BI0zk|GKuVRFU(({fNvfSP>-zzM10?O+BEWmG;sHgv0N9U_Q_@R7{`sD7k`5F z=!k=?8(Y9KCl8wbMB&e@KIZFK9#sC>hJKYP^oFe=ZOh!nN+~qZ%l8IR-}O1yLwQQ9 zY&XFjuK@hjZ%<-8#34g4lIapbqsa;KMB`r;rbs?zt|U%J?T&srI**_3JCqMkWD?-o zQh9LQtApJ|j?j5InEVLfn6Vk3sCTOI996&J&_ zU4e!+_U6wff)i&Uygif@-6#a($q)r~t%njq;Tj}$xx{9&gJH>XddY{)CjlpR6)ydB)LGX#Fx zf8f}9U+9Vrvmo*lKjS(xf^Ke1r#`hvW!Op3dgB~PxpSW`yRim`>y_}w+Z-ISKS@_> z+=Ds0M)CZE`(*!zE%1QLQ@ro}%@B9anPIUCMM*7b&YWN0X7iC*_9+shbXw@Xw@TPy zlR@bnA(&RY3V*fv#ctiT7=NUnq3)&1Sa-k zIso%W!_E67VPzpG&*biCb%7u?I|`fn zHo>*wjXdvtKIHhKWM=&D2An!NOmz>)5+Q8|c&V#{`e}cS|C=%ujiZ$z?e1i* zIKgo!^sd0c*e+u8TZg_%%p=P8<#7)8Y#WWuM)lLpRO>%c@>yw|O57G??z-)zV>_}S zM=uh3Uv`mWljafkObRZSjKC;5pL%dCNx`23yA{Mq_?a&_CIsDA{-Y;E1*A(X4 zHZE6IQw1ZVr&;}|BBuA{VRGn~7gV`$eqYlLV_&8iuA3Y@^h>JzCI z+=v6RA252a9d+727kS-*cywJq(b~zfVZp=9p+zF3{8%LketnxtdsdUz>>Ikmqz#n9 z9^&(g+3fa{n~n?UK5PmDs5CHTMI%6g4jL;q<48;&5CA#z00bPDD2(m?%0 z6+|@x`0V~`gB+7CB3=vwxfi&I68(o=9 zY!5?pT#`Xh^F6k8hhu5TEwWM24nDe+k)E+G)>mnOjLiyTS2%dX8|`JddRs3OaVeB^ ze^Dj6n_KC#KvgtizZ2g>$UJYAF}b2}3SYPHVwx^(gLn-S5`5Jg@(Kc!R+}ew62XN19`8g=oC{r zeNzoBT%mxA4>eJ_1}=9ugZtg&1|jFBq?bz;!%dY|GBvD$x`{r4Xyh>wmdH8Qt9-C~{n z-Y{#gnPErmecYfh2GK6+WI|;HdK9P7Rle~wX>ASh)=`0)^|i#s#-9fJhS0-ViI8~o z4#~bgkE&L=VEH>2?0&rt(?T*~z%vPbBd;e|0#)a6?juD;`WiRGKU_c`JDSi-?#IBbC?AhDE{3rI`v7y6uKgr59j zmObJ#i9EjzHzkLYuEyQuP@fRY{yQ7xO(&T9n(NtbDLJfc?PeIb`IT8P|0XOhcS7UY z|A?!r1ece(4zG=WGn-3Bm}OFSI6j&Lrg5L?VG}-5J|hTnH+o=dKn>&u_EWv@zp2&k z>qPR)Vdl%H3aZF`KO3_v$ewx${FV2R{Smf+eHp^_C$>k!g?m+`GKi3l2F;|3pgzofI;=hZ7%?g8Jl za4gv{13c|}Xn=DfGKMjbbwL4-#0ZnXI~$l1u1ocA*9k7K;0*yjrue651`0)}K~f6Plhrh0o6k)?Cf`M!oL50&BxnXz`&CIyrvdoP`>o>A}w~d8)J}0& z+W{)n*+EZj(F8v8YS_B)Gu=L=5c68=aO?UoTq1RwHMPDE@|(lK&Ey(cJbsnhnGCbH z_;!(1yZ(Aiw0*dCJ)ZyPi5?NFm4=Xe8(uDqch}nbSw>o>+!5)2obG{sB+muhc|M zzxi-JSqe54PoBtJp?Pu{5fjNKV=h5dcXK;A|K}Zdp}A+g z??5hn65dUdFHK?F%8emrLHVO&l2)$M}) zj=4m){uSpbJwR3V%p?BkOX%qCP(t5v_rmI6TqC&|n^bGi%t{so0xHSkqHnDFs$~4z zAPQ2`*MOM{$G*0?!vya?iSKXn8@orVV)=AISnYKfm&`53o^TJUeeXMS%Km^P=cG7S#RpWRb`*tLO;;L-&{eU2D z%^#%>T}2S{K9AA4bsSUebfLJUn~Gpfw_=v?gWF5eLOqbOH@sNbNk#~*ek3A)thWEY#%o_?)1kY7c*M_e4K=? zE1-`~2Ggd|ZR{5JQn=Zl$A0D?p}E8Y+S~Y`bDlIbmzaQPo+L!BjDY)BuN%KxoD0); z6RH;}0F^pBXs@CstmA3Hp_kt9@a++DSbixCAK!vof~Pae%U8k{hY!X|9P4g=LMCL( z-v9yO5&G5L1Nr~zF#+3UNz^ktRw57SO6AF*p=wHUk6wh3kObmaKQeib*a_%Q>!(@! zOX!!$aqOhuSD1M_t?9a5bx;*^h^~`0Aj$*L#J|P`&n*q1%12$$yze;Xc#F}pJbrRE z+>C6|b%uBMU*SnrM_B#F0fjoPaGCf%?C2Ck@hw$gy|$dH1iVDW;Is6}yD)sa^B}Yt zr4o;G4%Bt%E+k|f1NW+j?4X7Ys3o0c%@pj2+sAy6I?nmIXDlZ%{}RZn&U0Yav;hho zE;5#%23X1enqf**G0|Nqh4Vcv$n|fn>{7_5|1!s^SDYj{yPnG?S^j{3dxgMAGX_E{ zrqJ`gujqpFO*Gf?8l9pTL|S9RX_KKT*5|xo`%YCsu*D)w-7iiOkA7fR@nwL)3sE8# zupFh}0y9mxkqSs>Lb*@`p*kGfZ{-Z$%EVc4zI78eOF2TrGkXkEO~af`#bn-v6u8fX zpxvB(;2IVUSN!kNWnbjb>Yod02BlKTv@Epv$#LxSzf}hZu10>-gN**&T6*iBjLDMt zMrQ58g|NKv47h)lf`e#}dyDy?{y4{}f4+jw-E$kK)K;ULPa(d|drR+nD`L7@F&v#+ zO{0#VM7~V{P<6_MN?*Q7``fF@%iCT!l+r^5Tk0sEkf_PQ(*Y#TE}F(CcEF-%$MC~- zsrrZQ#x#9sE(jiEsY}KHbG?Bds>Ijv_7EfAP4VKU{O5jbeqGlqhOsP}dn{ukJ9G_#iTGCLV#s8kXO5s^mq`f?-fCwXZ4 zS*+g0dK1jedqj>G)R4yDI3ioJovnU+9JF;ZVfz_|&@)_zlzC0m~%d2s<=WPs@ zy#Rq@so*`Y8D<~0qd8-jY4Wp8B-wcf6>rI=R@sGg&MGaOFxSM0hAZUVqQ_{N-$7lA zc#J03Lp_-EjESnAOvJ4oQUj|;q}oFUe_yYHwXYX2tG_LVZ<(AU`-dT}nm$2(8crfr z$qq&akIW!Zyqc{nPeMCeE@#MP2Lu&PQQLwsblV%p`EDq$eoTk8(ltX3!2@)5vIRVs zRAfDzgV9k*72cQ^(Z+?ctmBVFYIo)|HJIf?x>O%fR;-6ipE*orWW+Fzn4)KbKj}MD`iLJ5@QZ)Ab+ou*iJ4Mw2n7Ay2M z%K=}#A+B~PlLn%>G9TY=n?&yKH6=5G z3ebB#H(R@C1JBz$F$a=x)LfgbSgB58qQ23{lQ-E1VyReQK#bN;DB&MX4cPBpNmt&N z!Tgo$VBg$xSizslIr>hL_I%seQ~v%Kl@yH^rsX03HU)U{D2KfKJdey)=6FSxN(k?^ zveNgf@qRodYcG3)^|pIh*E2#awH4sMl_l6Mb^+_0LSZak99Apj;>1cnRR8jb3A|B6 z7Okqsfci7crsp#l*@i>(kj^IL1-;~LY%3+^2`!A@G)q$V_a6Cu$RBrnYNxlR>eGk2 zB)EvWExj6ihJCasfvgR!!8s>agY1a{Y`VP*vd(em^FCA3+ja|*%T6)B%J0$Uy7f3J zWDa^TKzWNVQnw?;bhXR`d+gsm-hLuMe(nB9dpovpX9p|NDiDw3uB(_8QHG!*Q$};< zz5-T`V@mpbhPz7hpy=mn%$I5b)*wKy>_cdDY?a zD56kGUT}Mw)H_-rwIKuYuk;Z0yyxH&&N*o>2av{Y5%{u2mz1aokcvxfNaSOn$>l1H zxz~`%zC6&}AQO|b+M3=H_9ED)P3`mDX?jr<9_>s8bDs#z)d~ldi%CSCZzo#XdXPgL zAImU82y2fo!4!FIj1yHh4ya2c!BVGD%G;M%as1e{XkwmxOz8o9_1N$G-ZUWlyAVLvcV|IIY380D|0zPPmntygJyHM`9-%K&OfY) zzTDhmtAZ-rG%bZ}$5Om^J_mZ7#mW9V+)O5*4Cnl9ht+HUquXjLK(fPzeE%;LZp&PvQht$Ysvtf=%1_plXqSM^DPRkGw z#@)wB;5P5L@;oaS&_f0x(KEH~#nVloShclQNRx4rfxd;{C zkN~&#{DN73-qX6uMl@>X?z7h~QJ)n_>}bMnobMV8zjf-#Y;P63CVLNMO1jwGyAk-M zUDCvBl`|Y(+Dfj7ekG$dZ|K|M>m*ud37EX&7|v z@Q{OPxSi{DrDL0sJpH?)mic=olt?@}4p-#9;l(9NV-myfgrG z#nwXd^Gy)vGZUPyzMzj&6isTH&(qU)sxZ@{hlDCDs~60(!`!2(n5B801pi$Fb(O1V z178Vj_LszVuX<_@>h-5LoF+k`VEprJJO22VgeS9X$wNO%d~~~rbJ|U~aBK#w zP*?{4lu~h3Kp?$4Cl310>Z9d}t9V8#iWmDx0)FSOz+LCmK+4U5y7ZkTxy#4MvHb?v zv6>IerLIyjS5aPO@BS4q-e&Zfn6v+r(jh65yy z`HycGoqK&dD6HbgYZ2pYO!#%0_0W%TmT}+sQaIR|K{fTq14tm1M(k0tjRn zaPKoARJO}zzrFK=WqbcZ&-STsHAxB5y$%wUJ$jV9l{Z;7c#`Jc8zGU8AJLn8zA~S4 zkXAb`VRS5&aehD(9Tk3wrW|X_;n^&zIfassQ;Wg7yAmGUSp{z%gb~}DKqCuQ!|?Yt zI1#;tRZc#K|2+}|&%5E^@`v+8huo&8cgz;u{j{8ZW`RL_j_+WrNngbE62pHyx-(t^ zYy)@WHZf&zI>7jmqf zt)k#`c_%Zodf0x`#^IX~2nYK1j^CO)*H3UHMN392)1- zpZh;hb=NvHPQ6EMmYu@3mJ(*c6kl`*HQsnYrBc; zs}?w$J3@a?vZDj~b@+N=7jBwFO215LSWMO7UH8-h|)Kd;cu%CmduEu=H@9z(vyNf|BWzY zrw4I-q+#}^P6kt=+e@;gd|9(!Yq$>oPJ(j=QIOkJ3LQ#-$C6jroy9stZTTYneASLd zeJNwCGs38rc`jM6c8}|g1!M50Y*JI?#IZ{(bcV>t|fZIlZ?jabRWkKNWGEB znAVk|oOT)V>zWdql^#aB3tqGJMjTsrmmm%Whm#`39Li|@CF7?;>E5<~nExOiKWywm z4bsC1`mG=#rB~@Yqy&@Y$c-hNn2w-OY`nG~UDp?*jnjMZ`j7>F`o$xY_++13ZN+4m#_yuXCUqlCfzxjL@@umom$rxB?xCCqpz0e>zRLgKFjZ2#(~ zM9cIc7=C9^VQVCsKaF5(=AVMcKG!Ijsfb?kb>u``Bim_GL(jdhCtg=}P{|#+)PrND z=zUgUzig-^hIxHd+xj&$dh3Cuy$r`+&cGt05DwPbMCLvlq4$@L5wkstIDWz&Hz{jE zcIGPl*`WZZKNS+`llMUD^cptC=?I*c*JbSObkXLAFalMFjE-Ws`MwgzC-Oo6?F6qp z?l#-{L>Qo-kR=BTq4K{>oO^O71jP%}6Q`b$$2|nn4xERmkX$17*$_C|I3*|Nn!F1V zz?XVDb#I%riEQUnMt_zSsjh2;>kG^1?OZL;>1@IdlOxP+3x52e9t^GT&w{7eI%eaa zD7eMVUkqh4SpA_=;%N1o98(-&x?kQzXEkpovG^rpzPk=GK^@$E=ZY5m#dYz0hI=SINtfg3zO_X99nm}6g0nuNR0T!+(Nw&Lz#1$yF15j`a%0S)p$$Oc2B{@UiCL*E|dT4N9o86321)S%#tYM%Wv<9`kA)iOob4V>ss@ zd&B!NOkW<#N_3tA(U6NEmlX!u9fAORqF}Ae9oEr4m&Yrvq-4(@s$#VPdes)f;+jwl zi?Jj=)*C^)D)oAz>v@!3ZH=`Xq?yFbEA(^qR`gu^sajhulb314kXPBWA%&g?Pu)dC zrr;Fi7wm=0Q~69TU5hgoK0X5;s%W9zOL_G8E{_)8x?G2-nD};R&l~xwy4mSQiPUl;0%a#%rb^9y;9O`#JZw34 zJcN>iZU3o|92jOEyzAQ6|( zdr;?0>`$rF%9HU-V`D6xWyOz$N)t4M&zZe8$`2<5M#!?Gv&ohZkLY;&Yf^Eqn|W^c zj?mk~lvgx@r&{-c%3gIG;IiP&XZTE-rE_6+?L`Rk@?gI{5P@ks-Vy`%$FxPb0G4HB zlNK3cqEVj+WB*ao5soa2Mne()z9EcuC*6rcZ!@oXoDa+TchMW84@kv@vsj`saV_nWG2YrHM^%*u z>2ARslC+?dUDJ9FuN5c7&PdQ=O?53Ol^G+G%WXl+&>Ssr2Hk$ej26a z(k<*K#wTYHTuW@?d{GX}CT=c#wIKwb>_n1rUlXQB{-WklHt2tCE)Ghx(D;;nWX_!y zX2mpb5O6WYO*iNEu4icjv2um+u>1okkM1 za1eu)-p>KA2;kDF$uQMs8eUG<26N7fV4_})DbH^}v#A4azgP#fAr>6=r18Gjl##<( z{;);U59XnqiIznIOpd9d85S4F$)J96z;-77t{fn1gS^1cj$`SgGNOwm{Fmm2Wd?yT z_0|E9mbn9AUHNd!Y7X(c;0Vge)x=IC4RAhMZ$2itZJ%N3|0<;J8H#(%eAgr|t}4Tln;q2c zqXI3gRwv~*oQZFj*zXB15ibV8^UVYoHdVkH zl}U7L_6bHYHkWo;s&QRlF25r$hU)KB$t)p3lNAHv^;3VO5x<==;2W)l@!es##eF;7 zn7N?t!|!J_SEY(7`E8*$w_2cew+7s~YJ}4T^O9T=97LHIy$9; zTyzr5d&LJr>UU|xg+LVXT)_D8KL*cpPVg>2#&}eGDUoVS;r{-4v@L0;9%`-l(KzBw_XE7g|E0%ZJ(i)-UYu z7oONQa+(e$`(w7fUvJuI27Q^+c)VBYpF(tIn~x3Z@@0!E>t*)AypT>`2Z9FtiKg zHI|0by9ycD<4{iZehYyg*IRtCE*z#_sUsgA`QzE3%NQx#NZy~gK->e1$tL$zIPN|j z7S@H+ggL%=pwSI#N@qgGl5*yg%2nLnsS6S(xP4$7rBm{d&Erm@CAF30}` z`YEn}E_)%3&Q*!b-BAI2Bk&)+wab-bX1&L3D@BYw+Kx=LAbcHKO^@F_Z*<+}4gH>! zMMGa~B43|~;$oK$!ttviJ3a|t&zlU|{JUAlWFDP4V>w*j`i6*Iw!k^}vmwQS+Z`=5 z#4(Pe^+qj;ir7?PY-%U&yb}Q3Cs)$Xrl;|sS36}67UJLJ1`?i#>^nAqh2o!ozK{~pOOJg2NnyMK7+gxPCiZK4PA^^Py2-hH%`JxixucbR>Ef<_e_`QYZ3G1$tYmaAvTrS7+u+E1^llzU`_88HP!X`Ax?h1B2Z6mj?NU%Lw zo+u@#U?iay4&qbiV$S*ks&7QdpZP`DD_@CfwW3%lT@E5$AIYC59LKOQ8WLg*@oM%w zsFNJ#zB?K``E6wo;`)swv7VXsly7UGqvIREY5QZUCbbD&lr$J+&041Xn;|W_ahuM6_Jmxr(uaMA)2ZJfW&C)c zge-c~N7;k(;Du;BY~AvUe7O4>N5U_%!8Po&ua(KZw+ZGT~_szH$s#-6h_OA%&D*i!U+kORW3rFfC z`HD&uDq@V9Gd6h-qRR1p^g8B8g$HM&f68|BHqmq<}g> zhtbTg2ajD!qrXe+gCEXe@5HBJ# zlsZ_ykG1SuzXf14egoG`jInP&OX2jTa(H+BMpP;8#2=Gxz^a6B++!&N@6SB1YbZ;n z`$QLF)4FUBZ#hekjvs|31_JeWMWc~#i3uF+@n_~weFE*5TG{c{Yd~6d5*g0UhMk)} zQn|?z0A0e{Up(^v#~iP z0=7L`N^3gBV3%4gd7se-nhlP0d)xw)xn4oC)W%58gWKGUY^q7$2@kkxmWLrpb?mMN zFWPO;NykU0;qjx{w6gmxQF^tLym7IDCgu64qBD!0|J6)ZE1{^A&4{mz2uXxc_yYz9%Vx|CgK3nHIyu8V zU+>$II1Qb6cCoS>8Vc~S*H)In6s{{~l*8iSk*Cxy(*t)-I5C3r<8fW)5ztgUP05r9 z(!DK^zA%n~CoUTqskvdCJ2jFnIpKpFO`g#p(|hcbz+<48mQRJH>@mdk1O4yx5Lsk$ z5)aHx#k05t%BUW3-y@HkPgoJx9vRr{E(A9YNs_JoC!qe9A+~l{W4o>xX1*|kk@W`n zZczr;^*WB$-1*e`;uT0;#zL*?H99KvA05gvAe)sPuwUf@^yIHY-XlZ&)tgIoUOH5Ebt~KT?InB1 zB^)%Ft8idN7cW+YAACJl;L>qh^xa^C_TMjI?Bh}}J98FpdY$6|&JyrtO^dv!M}j|`t8{}=5gWSa$R*BSl!Zp~|1yIH z+<9QPa9wz{8U(-a;`)Y(aLB`(=effY_HJH5O6H$IaT5vBo+E(kH=EJE)iTx_B~ozU z%Pm^>BL&`bU7k;-q8KK-AK$B=SI7*5&sPx_oOTE1)(NC$3b& zr(D;l#KZ`1abEF?85ik}j3}J1djrm?2C!4b1nTaZe`BWZnF{W0+fZ@53|?fW)h#Ll2bh;qkA3s4=mIf198EM!&9taVZv*DKBX<~SGD{MFWNut)IQBP(E z=b`t(SBd|@(6>dHnl%d?Gmk)8peP*JyBi-Q77)SuK>B(b4~|4zfaf0rYcs8fJngmT znW73a*j_Y&bV??WjeF$Lc2NZm*msh^y#L7VbF1O5gLvJmoX0qNb2<67{U_V165Wer>SjN0$JLx;VcoMeV17de=NxM!^_Skm?6Y$qHqaWroH789 z7t7%F%tCJFIts77ROkt1ckDFOfE13cOlmJe@l*$rkY)$+Qcd6$^M_6${lx3&6x{dg z6sWo6(XwUZ)N6hb)UDmhoogfM*=6Zu$;f%Qc{?2Xj@r_M$8}7>74G@4A)d~j(#QJz z+JM=P+ISkgp-9@0?s;~b3NBnpafreyxeQo4vXkRb>|swp5H6g!2$Z&|fSuwZ zj80vRFMjNY*_8y&E!c^;r3pXGiN+~Ev*^;U(`aj)Nkun2rT%qW&}ze9JdkV3dC;O^ zZ|yG{@VXqb?@fZ?=25!eqJXGXm(rGtOIV@v5m^4Pg*?{N0n37$toyaEY<+B7Z0{*g ztpDkW`o9*_xpU%yKXD7HZp^|U{sYqXBAi_xnS-r9%h9rHCGqK6$&PaF@9ie>;GL2K z16*ft-haciTw@}fYwD#sra(emJt6h70AGHu5l;7>&u)?VL9cmD!o>@x!|}I*(0wt1 z24FO`LDWV*^aySUSi*^Ek=LnHJdY?3FWx@>^O&Ezzr^}um z2bC&wI{B|7kxdnY!0Ke2?r;wj^gF47vk|@;;h6HB-r&6S1x=Z#47mOz-W|-QX=l9v zwXc(Vbv_{V$AV+p6@npi9%F+y!m#fIs&r5qE`QR%;G7g}E!-+z>iN~meI9r1ua-pZ zhe0_1&@qgfbhvh?$CNs6ZX?-&^U0>ROvcvSAD7Er#4W+&pm$^nbott&(RD6|*fo)A z#J0kA*?6Y4Armx0q)<&r9VOaK=`Fq|S^WMht0Y-WMFgkRJx%LHHE&AKT}i@iH+Hl0 zW*SiSb?@jw<}@>Q#u7iePJz-PanPBtoEom~CgKsZ8Gi6#G#bt)nhn#?r*8_~6gG*Q z1(ZTjk`Yvld?d$?v z3Er0HQIjPJx4n!tqWGVZLR2-Dt91#?F=w3}$hc^^5JZt#5Ot?vmUvpt3O zUb5gVjh_kuvI?O5+6IqLHv;YcM4Zd*c7H8#Ba6RIsJhUuM=P6O(ReEXe5q3gFCXkD zm4YXT@smzEsNTg`e$&N-<)E;W*G}FbLUVQMDm6weN z|EOzcojP&vKP8m=VH)k9q^a6(Xr>H#N92QoOpAaG>6CH&xu#* zCihX|E_j44SW`{!oD;+?%eJ6@$z+JLUP0;2Sh9YNF1}otO21B<0fi}#U@MnR-f;IF z`C_z#)E3k-zrPRB`H>3N=OX5#!xxSjTt{F@Q8AT~iXy9ia`_~7C)!rLVCQF^$B{8B za8xeBg74*|0FKff0!knlG@G81I|27h?-6msX|%4t7847~rmj4;V!F#aZpBpmq!@ziU#k;^<_yB%_Fgxpsoa+^dW_ zGmFFqed3$CuVTOY-XyaYMw3rZ`{++YO?owp^SgwJ60I#f(uR+SSm_||+=&<{RL&vx zGSW=a2X&C~9>!#yqwxIeIN2L?h|ykA0X|9H5E#=&i;vsuOyGS$=Pnx$gRc=P-oosLOhjWl@U>_r$~mq4Nph3H(JbF;+w*uZZR#Xpt8e4viUYVj_#*1O zJw_Z2S8^P*8k)7#3y) z7ir8-x&dEiI)XXpgO_XbgPcRIaCfgZF^+qHA1t}twQwNLEiXn1!Rxg0Ukg0B)y0Nq z-^Mv#%5a113}kpQv_U?bo=nr>TzyaJ#U9Z*mt}4wpwtXXzDdF>>lIwpYym{Of22ca zcT&?Od#Hu$9iqMV46-{KN!5#~*!b4e>O%ho@Grj(zt69ww|)}z-q(VzE7kB5v4p3> z$MEWp#n5b9!!%892FD#OL~FYu1RMNg%Z%dSWl1;)p2@-?1+LDVhQoOip*ppU2vko23mYCZ-n&Ds6Hl=}%X=8V-z;@7HGwa$#lXDE zfQHB3;r?vrMUVSNqrJ;0o$F9fnq6k0_)>-*e`ta(8`)ejKtf&g@yo_1 zwBDqQ>z6Y)A;X_$Bs^mu^W5S2!*uv>F?WtUSOE)?c2bX3&xy(!C2&ZwBw8UXb9|#T z{u_|2>wo6UN{(gYNMk*HYad4*waL?KA2##CR-{ANjN|;VoW)SL=^>E_?V{^71)@X)96r9`{42*R(8zf{lyLXYA%i07IY$wVON3#g{bPLAv6yqIf1-ge z-dFB2;d74O24YuO$!3YJ2J4HB^zVb0q`e2&XUmL1_+SPV@koQ{_oZ}=X9BV#Z8#Wu zlx9y}jX|Mic*0>T&O0K2!M}9jVaP#x$ytrL^gNB8EYc^H8#1VKZ2)q>XZ|)~j!TNw z&}Xg+D$O&6*0cLyrUK{6c_~{rsr58_y`YzT(BgJvPjcze!gb(vg8|2PW>|mx4?VKS z1haVqw9A$yN`oy_a^7q*)LzRN&bELnT(Ntn9zqw}N|!x2jj~UCNb!R#YJ6=U{4l?Z z!bQEL>4_S*3s8n}ZZG(^uNhbaOK>mV1m5q`VL16JWZKu!1}QJPX4^kz_qj6s@clZ7 z7s}TOlA!*Jzmg@kcE*->vOo(ncBuFM~dqIqsBZ;omAXa-F@p0c{vTfZl zy!&VgB-AMYulFM9t*Hj3RnlNQww*o=JWRC1d&$D@ub76C7xZY97{bpFaV`^Om#{evFeoWTCcY%e@PVhGWBq-O` zu>%WBsN!nQcVBIb6MVbro4u~k`HIZngqD~Kub@hB8#h~`BUr5$e(%j@RYFFEsD49YPY+F2xF#Op5}E%jvLvZ6!bO&&ru-m=a*n;y_!uo1$5Iv!@c-j zeFjc-6u^}iUlM`nn~cF}fBf?tQ0w4C>&+|oK}EMMTKsVUgSQIg;J_Xje)$G-jva&6 zEm^$!+ZRw}P9FM}$is5m6e6o@2@Cu_u(Miv$;7vD>?4&#s$JK04k2#Rh8x9^0e~3GJF)O&{T}kLuFsK|P#XBz0zT&;uxiXVHbv4AfFLU^t zD(uMvt!22n&7H|Rz;$>UzmkE&$>ig=$uzpmLZdweGz zKbr?va?NOd?^SxgZVG$iycS*_Qo*^K8?mC0yYFqj!vD>RfT{iyoVZ8?_U@~Ig(u`0 z3k5CsVD<)zM*J}4nmDae)TWsehZ#SK+2qx4Njf5%4pqJ##O=^YvVUeXT^VOciq$pn zw)9$*x^@NKg3`G;W({e}e1`E+TyC9nDv3^rC8xOV&}Atz_!J?<@wV9!)57;W7Gk5+cj4z&xM_iUW$c6?8--Oz)StPE1J z_db}Zm%&P-LtwSXftXn}k$Laup@hSG>QZwGHrox74vC3aA!-MUlTWg*CvrQu6j6Fy z@&eiQO_y%n$(_C4mlDY-&nkb;T*DsTpUOxz_7IWkcQhhT1VzFevF)}j+(;I*o~6-7 zZ|Q{-jqV`Af1QJ`1~~4X4AL)YL8vqL6zY6@%4J~lz`ieDUn7eu zg_rQQ`#)HDvjHyF7CX~^~SPpQG~OXruJhpp46fc42-BJ|z{tfQrgnWPQuOHsgR-x%gp zU6eL`8~+>BX?1=sSg>uH_c?n z0E4<(YT*ClH@zURl_Zy*AyRhn@SIV_nZe31-PfJ=)kaV!tLd=+s1~R_JHUMUDg}k> zzvELYd;A@3O&3?!v4R)&a_(+PxEM157X&zh$umi+{5t~+3}f+`?na!tp@o!(mykyr zB2mWdFuwB^W6vHgqqm;lByI&JggIcxOX2bYzdRFRq1G{sD665$yuC#7Y%6*Mx3IxC zlHiR)9@(^86!ru+@+RzSpykdtA$|nN_Bq#y3e!iVimQn3%Qjl}{wQtYc-I^C8VK{x z2G(Ebq=C`tWPJQA7ca4uUpBoL&xC4jG%CT@9OM?dbG;1Q%P!-sg96&8qMbP1+8b-V2z``Y5zsW0$8F?#P_v!EJ*wd@;v6ptH zgGKNR&~z4nJMDRR%XB^d-$4um zn!{k#v?IKl74htP(L!i&I*sa00IH9}@k7P~X6~k!bd&3K+WaP-XFX>X4!NelnNvJy zm7Go|Z7sw#%L-_V{1QBLmCD4m(6DwTCbq`_qc(Qacp1c1+qT2f zE(zp*UkJ0%M(^2s&`P+F)xX+cwP{B&ncyc25dn%YU2-xU-m3uyb@A{*Dg)As?-4y4 zQK)wdf@yJ<*gGqV5zO5T9~YWZ?e%L&hj9%^+8n`$zu%DJWgIV6znf{*%7EvqKcXp< zgL{{jF%boPXzz|zZ`ImwFDh&O-V4nC7Q58&b$fp9As&QJ{bl7#u z6{GANG2C`4WJ!f`LAXfb;kg~T5;f*}hY-`M9q`J21}ewavl>ldT*rM3{@Zd9XEaTN zX*I?qJERK-kmK@{>S6IeGt!!{1m<KosWw-4olUwx!{~_fI2QYbiz${unBla>SBKq>8W!;Eae5y zPpp9K?7G2fZxn{yUtj6F)7m7P3e`zB`GE4vzf@eYmsoN=%X!C|AT(Nn`JR-9Q{7)u zxBf07GOviJUr8j@dHYdNVJ7k8D?{nLahhikMqerRvo~6Y$ics7*n{P*7|~P1?hFCy zm$C=quDC!<>>kh!v4+M^6;x8)5(0LW5ZvX9&j*j77UyE2r?o>xc4Ql-F5(WS8|=YzrV@GI}ZV0 zX#!1_z#2PScsa9|ssD2dhm;XEG8)*OyAaE57n5rsdkR$$EE z-Spw_0Q7YwydUo~(dMN$%?b&myX-#E9Zi08QsV_=%Kb@op2)<9r;}8(mxlG!!nihd`&Wj2id`_kJeb1=0T<29AnEg z6KT@1Lzq(;Og5zFlLF79IHjVL>|Sh)Era=_GTRCJ!#U^siUDG!Q_3XUd?at}OsMrX zU9cA2gdbAWXjjL3Dl;dBnE=S*h(T>|H{u93CFS;XOT z2z%Q1EPMWH4|g57(H56cMnaL}(7-=Z&>KZu1%qT7X7%;{N3RpYiXx?;k}>NvyDQAP44HxNqJT!pK*$4R_oG!^hVfCtw5 zqy7dizxS;OZ*Y6L;CyqaS*{7q3Z-~9MjaA*5}lgUR|57v4S|jOr@@+yStM_&0W~+=g+V73P%D`84l6W}8ifLS?4&U|>Mug; z?fP-&->pKg{UxZ-b{WLZG=k>%84Nt6$Ba3jghgkfQ21;f-ru2&5{<5K-btn|!&MEM zHWiW|3Rz(PbBrwSpN?U@Qt+<-JT|?}V-&gwbku8uaElWzFuI4`IjvO6G8-=4*v^)K zAvEZF0Iy4%j;6RlDz~dBH{1t;@&}3VMSx{4|CrJT+u`nWL2LO42I?M#gVz>8IxE@_ zAH*NPbQufl$976Ee)}IO4Vp)vo>V4xPCcgi+S>SN?s^iDxP*B6U*)>o!(`b~VYsfn z0#fv43A}D(yo;n^?WI7F&p(Z==jOuBWp>0qZY96-*KT-~W(55ZMn*p_g1r4PpyADe zfWt|cKhjV9bM;(P7qSmR6#muIHT*m*oJK&`6^u0j_&U0LeSvjh( zex(&VsqQ+~{&y7DZK)t>8fnbPT78_o+8kdRxZzVDS^8&n7Ti4Z975NNKu}@} zSZ7U!-bK5xbzLUP1c_rw(+T`JmX25HX}aogD|=G=E87sWftP8bQ2iiq87Tk{w?YF!>sv+BRs@5P{{y ziQ8W)xt)ekn#|hy2ZGPIEv^rkNwk(Ekv##q^x{8?#?!d{dQTg@BXj_6Ja?ndUgUtp z@N|6q<}_a*DT#RPwx&X(4eXR-d@|?oF-D|y5xw^`gPhfvLIX|5>A0~UUgBI^@dd(A zbZa{1E-a#|pNfgJjS75P`v_(%t%vn-Q{jSw0cf#2A|<$p%vhL+Z3xjAl(* zB2h#a{%9v7uU?Y9>w@UnJNUwN;9T< zCP2~0pY+Y^7T8pIk+N4`W1+%Y9P3B2HQ17Lt-eI#URU#Gy{1gcWj$n{2@!axg|mt| zxAjg_fTr8-;yUV=Z7tEKB>Qy$7qTGw7fEn5v(5fw$@J(dEix{?^{F)c*vf zA0;($ac=@7HII^yC40F4=}&T`Gy&{lt7%T-S}0U?!=^SR?pYa5=Z$h5lk0PcW?l;G zHE3`4V z{+_2|{-ynBcTc{~+BcS|FuX<BceKmsnZMj1Tx@GL?P%P|sddI%ry zISJD?ErXkV!^GlsF}d0A$uvs}TL(2Ecx~JQ>#}=b!(knKxq^XO+fb_gP7F5`dQ)$Z zw0^up$XZxHjDj)SUQb}%T4&D27 zgj%$#)H&?bfvN|;$my*gsCxQdI5_A}4YX}|Z22a-Uq}2wdB-1?um(Eq90`Jv<99jsa#|7|V(qe2|DTi_X?&!Le(3?_wY5eX0JpRFu zJtY=>&DAf1`B=OVFCA#+hYy(2$J)Ed!84=yO?Lxm4~ydtDb6KUeTOOCEyGLxIv<*> z2pmn6gMrM+a0_*zXkZCE9hU-&>tV!lZ0e-6_R&cSA3U*^n05zc2;19Md5=-6m7 zG>7lN+k^6KY2z*0+b9Q43xXiL;0S(`e?TKsPE&y+o#gHLOnjVs2pyDC$)=7_(5>oW z9lB4@MMWRPE+0yy3#uN_is(wT%Kgl+$u1bU%n|dnr$a(x5|RCN0T=dsCAHR-`1^5Z z?dqNK5VlaT?m=!Nxu{BL(&|1?Q2j=Zg}kG;^Nxb34G%+0ZHYx?D4ppQjk}A&;Hk3< zE?M`R?s=nLx6V=&lUhv4;a~IUPs?*m$)YKYj$%G}{ma`1n6J*AR&rp$s$ZTSGP36AKxF%xVj$YEcDHFcLchPOId z(ByHPkYzEXrYVa#7!nIxU$k(n#<^$}lE`^DU-BwX^pd?o6EVeta~b&N(250T!PD3m z7x8^A<4HXkUvYi@o)~POb%;+06PCWl2avN9^z!=fdpKAm&y8%AH&(j1dUY7(PqmpI)2-LC_T(XF`XI68>>d`MFW+Y zLV=+B_zm3y`Dl7h8bTK>gBOYmAT%QmBAq#xo9GDDzm^Jt>vE~LPzj12&m>I9CN>H; zSogR|k0v>o&_Y^^17g@L&sO`QRbj@K2@@APB z+`k}Pmz!$^pN@$EQ+^s$c1v^pn6s>HK?X19R4H8YeTwb`V2%C}SQY#VJ2+oypPL~3 zJ^F#f+FwME;ZHO^;1!W<+s8(nJOmGpURhE(xthw3X3M+im>5iiZsQKA?kX<+dx0~E%g}Nr; zc8PbibFmUsy3Pl&=VCZPCYB6GDRX(w)A(8{4_@*ODWkjqwL2<^`~679f0`)HdRj{B zKF)xuYtvCGEdugu?!fbm>y)?QG$}AziW}x-;qsF)WcAT7;wyd)Kqil>`PS0X3kI~z zR>nGDB$xWi2a>b>;jmtCFRkRsv~e>1@?H8oAdw9IRwVzH{-x9TUL@!&mycVEMB!jI)p)B8Jucgc$&w>DoZ`#4 z@KrED>?I@X>P{4rW)hOyMvMYC;^>+lMx!%=sI&@mJ_aj_{~hD{`NQNtI09FbUQ+kA z1e~QE$=0k9gZL?f5Ir^zTbj(+sLN@j`%)$`Nb818|KdQrQwqzC)`0Sllc1U44&50N zu;)@VBwslU6VgH%9eZ^sR69l&&C4WyKLC9?_R(omrdUgA&ZfRChdEZX6y%l;5^*<8 z9BbbPT1)mYo8S9E-$pISknY9L%Z0?RB_G91`_rI#dImOc6t9!mG>&rqNgS*BEWCTz z0eKz!FztaadvCuU+IA{K?|cPo`RaMZ&DjsP9{1GzpuvPJdVlqLSfkJd zJl7s#xiOpY&3D6{OktQEV#=QVn}Bx0T&_8#oqWvKq1zX1LiIO%s^NNx-n~+W_UBii z-^LM+&wLnr-6!B>4~eSgm8VG30ZB-Cnuo1%I@r1UEuB{v4Do|vc^azr6ROnN-50~@-S!4L=b<9Kv1Kwj z`rHGgzsH~yuYqKI6~{B@8u|a+*Mf)78c>;6h?)PD@&+cK#lG1r@t7OM45s(g!m*!p z*}fh+{K$*;Y>TIhOb2MVAwnOm|Te|BN66Pt(%pT z*)PUVFhcVTxR(pm_4-|>9{p3GZRQnJGFyaqt=7?m^S7y=XEyWCE*8$UdqZKZ81>K9 zgsPO;V7cooW9)sDT^F|*o$P#Il7a`Rh+hO>Hf)3`HRaUQ=>y40ucP9H=iujxyA~%3JSTpUm$*y(GpioD2KzME;7*q?6pEPvDfazXuT}^erv~Wl zQcmq2tOP~g+|U1#+~#}XuYPGz?9|~62fv0uHIyuJL1Y;>AWLg(C}D+U6|q911qF3!)3LvYgNz zTm_|ot2bBB-8eSB33PHA$RmMN9FZJicCziHV_+v6>T?lzE}2L+@4=K?Vs*3ZD`+%- zAv{stL>C^Mjdo+HwVl!zKu~xScJ?IWMW?rH#v)&Qym=*R-70;BK)Cz_?F}+|gJ{n{r>%KH-_vwUfK;jr5ZEEz#s*zS`f{AlIbk6D|4svIx=I(A1O-;ced<{BzMBv_=>p`pP z33OyA;VQkebkWD7czkv$R>?Z!z)1sGry|IWTqH2ox&z-1j#qx?u3_hBH|vhv4E{~n@uY7sRqI*KB0(pal0kMdWak=ss4ldUJw2kwG!>{T|( zOwh)_%_dB`dm&lwEruronqg|12H&WT)4fkgA}Se+$cYFsShY6}-1IrKiC-EW|9X** zwe5iA^*tQER2B}*Zi1x8t8nj{rKG*DfQaoWq&89;h(f+6%+L7&#t~7N>g!34KR(Ds zCzg?g>!ZPMEAh&|%G#P8w_VzNS)ofuUM{mT!+Vs|-APwQe6H^)=c zhOI=}_ys5mULg^|-e7QkDVBc=z`9+I5MZbU8jt4To9Izup}BxWH@HDzpAhh_&cGi+ zDJc0vkwY_fLtTgg79Wmdr-WqUj5W1%k?|3j<6sZc?aHFNLr7-gP3AUTK;L<2@JR7WutCITJ#Eg6>v)91XI1zYKBneL2 z6UkkzSuiu$o-sd~jtl-XAfN2BLB_U>cJMePr))H?*gYG*wC9tp9V(|er;Vw3#7~rxyFpd&O~Ln| zWL@B;432(&xcN;DX)tagKTceSWy>yrOt=%>_-l}46sbcx5u^=M3_--u7z<`iN8yq@ zxc}}GgxnXjPIL}IgD)AeSxtN*H?^Jw*VQp1*ENWR#~S=E!yf}L)Wfrc?Fa*EUk6Dn|LkPiXt)gp)OmQ`~Xv7f3yK3;ecq;~cc@2j5oR~ds z=TSMqg)Us=OxG#r6Q8}DX5#x5a2XmVu`g1fchCy|oN>fc{z_czJ__<5>(UxG5p<8p z#VbZX*vlaXbp4^lILSwZSuj+_uwL^m&+-COc^hrsI_1E09|e- zODrh?C-J@g;li?^pdr^`NdkjT%rMCkEt`g?E!ny*_)T&$g7c~vvb zZqdhQjp;a=6HXi_xAHd$pQ0n}*|h3}8U!|`abq1ZxU6;>G9(j8@%IRnM6>AI$xzW$8_c#;BopOE z`9r?T@#TMK8DBIe6%o}|T_#(|mm}%qZuc2>MqV#`T;N57Og6&&f_iKnx=S~kSHr$U zUpT6mhVw+_;DBogJo3@N`^;6GHjqb3&Z$FVa4hawAPTwM_aayNhyItP%g+6*h>A~E zfwa^=+||1P+O=%x!xaapZ;qH%Q?ZFv63nHA6z zB10L8Or)zObhUo9vdi1!q_8U_C#p z6NeoO8Oh7Hz-)>YIeSBaPQK8@b8|e(Sn7pAboT-p%#Gom9}=$H5jKonj) zE{6!+n*`qz8rbgk$%v{Ftl{T&O+zLi3BcQ0JnS@5JgfsOu zw0&|iRll|r@2=#I)nD#lt?X;oL&};FJhTV$yc6-=yY5(?tr5NuQ-HO1%Q3a)8L8T7 zglE)NQ-{tB_$wW}rpa%}(YWcT8W#oo?kHf1p%+Rmk-I@f^8L+u>C*R6ZHAh4CnEPjKU{1j?896Uug`pw(jZt#807MFA$0;r_K2r|NoAv+Cr>3b&`Hz zH*4PakW_WmLSAYg&E6G+u1=h~YsMIH45jo0%|-14<@oSmJbrmE4vk^v^u1*;+$kTX z)KB!m)HfD~n!eTQS{5lhkTi^j;4ns8&9 z1u4&a!Tmn9Wa|n(_?pasy<6VV0y3FAezKB-q&#MQ&)s9~>o4KRa51Niioxp~mPD@a zGRbKc!Sk0)F=E4hJWiJ2ytM(4{NH*yF3Z*UqZM&bKaZ|xPlwT(MU-m-!S|R#8uhIk zZ53aVocr&o#NqY$ec~OMZyv(c+#gf7CpkDMsS76sC&T^FH1b{l0@2GKr8Y7Dz`;5l zqr<;b1+GrDHPal8tTeIR%N0JZHivoRQ}EBB6taW44D0-4NX_JjgzTcvly)Cy8sclLdf?tS=_HBjSh#ziQ=#>@i04pS1$mV$i3suxpW6eQ3B4;taaV>gW9#>$LRB+!@_U=tWZ#{N8|f0CMU;+he9aGe51 ze2&7iuRc-3{8XZ5<%FW0s>H;d51&hdaevo!A|}yJGzEsxm3#NumMDp%lMYjfg%tku zB8@!LfVDS;(KMAX7i#6L%VM7Jx>U-U0TX>Fov{Sw_#3kae%;3XVZTB8zB{@^2D8xd zi46D!;P~;U>_rYatJhsl8&B*eLe7t{o(MrJS6%zIb}>dvo`B)LXJo3=1C+0ghUqO2 zXs%if-GBTSn-F9SZ41s>6(_}_{hlcL*yjnFITTQ#J$zVy^E_!tyhta@r1Aeq)?nAC ztMK_&8eKU|sMLyk_?N@Q4@*bmIaPJ|X|s@*P&y6o$mu|Cs}Rhbe3`A2O`%t}$dae- zaWu5k8)sNvhTjL@fqKSNh`EtXVk5#~{=5`8w3y;YPMdco{0IAN>NzUsG6A3M)< zt@D@X;g{0s@M*&VwsqVE*O`4Mkn)F=J-g0d{5FY%ZdHStYsn-m*aZu07E@Q-B0P2T zCGptwj%vNt#dEypaDlr|p1dDM{Vv4g$SVQ5vaSHToV1{Un~lu7Y=>USZ8%}qO6HJZ z2Q#Uop61J+z&|02;KQF=`02zNv>dP{X1s5h-XjNFG%V<3^<+?D?~tvMR%E*1QGAzB z1p#VX;B-?04*i@31)PE+XZcc;_i%z9uBPOmbB0>{Ov2(%8z^6H8obE&f|!eDB=%xv ztkkg>%=q?$9#6PIXQpHkZ_RK{wWf(-o>e3wBoX=1i)ppbGF)uIDY{aB^3T|$;(1|j z_@lQJ;uasJ?bRERYBAWnDTPiA+Yf>h8ES;jNy%vg+NP|5j(!=`z-=O03H%{LKHuql zhcl#T`dgaGL_t{SdV0(082wUshE4Mi!tEcpRsQ>_aQJr$K7U+DURUSCa-SyRr(pvN z;5m6!atoA~=ED_B1v(+2giM;439_dcm>%cKD%R(Za?CliL`uvwZx<5cX-;}|0w89;Jft)&Yn2f?cv9zEyJY5etIKO)? zMsn|bxw*fo&SW)gspTS(pCj>;?I_)zBu3Z~c`UoJ9(cDEIo=u!F26sF+p{F2>>Z_Hlh1SBsL)nO6bTItl|=QWVHR3shpe(C3h|sb zGTVb94Kz?_XqVLQ`TYT|>vK8hdd@lT`@UcID>jx61+B#Eu`w{CVNB#!{OgFLy5)ld};rZ2b(D6Y8Z{HC{Ri&e7WT6kiljLB)Pwa}L zRs$o>S#w5&W$MqB_Y=+M17y}i7xX{RS(ElCTP+g_C2nPDFnr<{N{x0w&MzO#S!xP# zf=6NVzq91dZ3R#Xn1+1)2hlaTk_LX82X&?u#5&jrx1IE7@5pj9mCFHmp^ig{ZmxlH zA6Ibx^ND%(ViG7uR`L!^OGmQ|XEJH|I$X;Ehw~aeNKKI;+aoZa%%1Lne_Cb`8SV{q zz(^L8mMtS?4|G7YTmc?T2|{CT->N>4hlX>{X@ZeF{=Li@dBnN8KB0N=)>sfiojke9 zEq)Fc6hx(Z>mhNk0ew*X02M2};l|d*py|NUbBlPixKNsU=WIdUzJE}1h$ZT$&cJs~ zBkmp~T5laTh->F&F*nl%Ah6I4S3goAl7ELdTT?pDKU+hF9*&cmoewNBnmn=lYBE-I z?Z>FgkLW9C|a`^GBl9}}BC~jT00D>xI&|>HyMx9%T=bJuoNGL_{5vzdl z5^uP^T${I0yMsqIw^2cNMfROa7$-G%!u!HcR5g4qsBN`_rMj1J@qJUcJo<)BeKZOD zRWixS=T?NBx&Xe#>cYTqFLqA(LoM=4$jxn2i2WLA@^7{?yI4@ zM2}G`N$w6EtAgiGBtYwr5|lbF32X1($H&64c=e4D%eN#PX9%R@6kZH1*sX>dQ30?! zIui_dJ=Ff6Fba)EU|85by3KGoFrk|;!s0Ah<@5k${6v9%OdvOxZ02wu{;)VT5_kE{ zCzR2_!2SBTyw@7_UnoF$z9nq5J4u~XE@G9SIhn*sy{|3fpy#_5bsjwlb2f{!iGIH@ z(IN)NZoa_A$Lk^Uz)$LyD+&@Gesu{8ESaH-KwRE%tj=J(96Dd0p-t9h|t5ySU|TiLsNwcxU00n__X zmx^4lC%YbU^$?GXup_&TeKfC>iOu;yM9b5lLCyk-PM893Gyq>8)55sC5LC>FBvUqp(*u$R&~1MaXuB&>m(m=#a+{Lhi}hgc z8gDvx<3g@_DU7pIC9vuvYSgByi0BU?+50OJ3LdYZzj7;K?kHDhJ*AjTvM6BlsySR@ zcT%0|>Mj}|s?5!O?$d`Qk#y|K4fy2p7)um7XuSI?nk;mQ`sn{4U-)~_gEvBF|5YcB zIzvppjUm&M{gOH-htq!htMDLt3Z3^e8t+ z?W=(M@mWlI1Ru|a7!}8Uk-eLi5!djrx@g@r+_r5UJ}3`oysN)} zPSYFe$#p25@~$F1$+cus)mHkSei2=JZCFfvWgzmZSHh=v5%iDsdN7uhBT+$#=yN%l z96VfsVzHKZ;NHDDi-S!xzON8`U2U0|uXkw6c2Af!N0+l{pNBmkSorW#5)Kv`!NFx~ zabMG1tl>L^;fV(H_n0r_tSW-J+V60+v0Qx*hYI3h3m&qs@n&l7xZd@LW&^ zx3wRjlJ{CIFa8px*=}lh=(GUM)n37U-&W#wE^_tz_H6L~aGT1N#L|V^^685yNpy?M zA}SJf3oZpdAdl~Raq-Jr$gjQ%G+pH|ugDG7nsL!mNnbdy%McewdJ|3t!k>AciIw|8 z$cm|UI`G^P`wjgv9!4-ellN<{JRU(5fF zKP2CMlkmBrG7iaP(dL&^;gHp5GJNz8Neqvrt#hst*SMp2@thUjm(F5SQ3f<6n?WW2 zFU_$nCB1=rNS~AyW@dh;JLRtTb97K#{VE&Y7}6YZyYZ9ydZ-O7hrb| z52owK!@=uZon+Gv=2_J_d}2)+Y9p#KBAAS!{( zOfNvOJx%yVZj}5fTgf{@EMV6jLlT(dLU!w&rL9*es0a7c*ror-+U--p#o`I7PV**r zM9#n-)e2l_Tu*k_wJ>q}`7kgFkX@`r7xahGHt8@(IWUilfXSo!VR=S#`#gwupNIb) z@g#B+#faKbv~hX@h)=V?$xA(OlDih3>dGT^jRH7Fz5qlb!YQf+5(AtdJKe`=!o+SE zp0a_2Ok_~o9Yye3J&CSA+eU;Y%kU1&8=`;hmr-BU9H<1_MG zQM{{6NmD(%ku$*TfK=ihYX}z>m*N$pFPxR55I@-Y*0E{R&@Syb+1gTNnSE&*dc5)i zD~Z=6DsC}6$<)9#d$e(Bh%62q;%b#NQmIvqGuqY$fB=Uydt-eC#}@5lvX=9RL*;bh z`(r*S`n3;}My;4Q?`o7E%3v&A8NEnV0q3L5=_ATAIBXGc@GHIlH>Icf$c zq{~V0?NHMB3h9hY2j*?M6vnxfPy^3M@@Q@wPfzRzE0naKHYjIN?Vy$LKz@`&--v>x zZd2=xqk{43STB7Smjjv=zD#4;U7Q62L;wy#j`iP#?T=XW>7- z3|7-E8f~(g*_4ObG-aU(J(gpELvici+n50Q4~PN^IkEv&{Ph`Y!^p_wJ@mDX9|)V+ z;9IvNOptakn7*CN)jaH{Kh{Oi$9seDiApXt$}I= zT_Ls3TvhVzCfLTs-Q(hQ(O|`5y0^Q69{A9R{fEsk)~u2_YIX*{+dKx&K}R-7l>q5b zhyB0f=u$UFbh_b6I|k;mW_Qg&dT%WG!C4LGlUPVTwikcwd&C;w8Ye>LNpR$NK0W^9 zCCXiMpbNDO@qnrxtX(Zjuf34QUjbd@f^9M=9`3JI5tAo8(I)(N<`r-jCNvm%GMJ$8bL;0++ZBn(AF zj^OStZRT`BCw=)QjF;IxgA*ajhs8x| zb$wE5G%X_rl{Qt-#@;dVGlSo1n&Cy-)3%fBO{~INtiqelmmqIhAG@wC0T1P z8%6pvwSDeSTVgDU*iCh-CyT$ZfkM}b^g2^&chm{jac|~*+vn8nc>b4s!8&+es*jf% zqES3O2ygw0WE1=RNQ$~9*7K#p@BGJPR9hUpR-0fs_hzg&n8`TX9l$f!Rp9+PFE~Fd zflAuckmUW_QETxPGIQ${pq-oGmE+|48)i@7>~J`S#6BgaoMrpwz6JGye@3WZ~QZf;s{n`wNPngky%?pT~+*WW$L82|J%?Q8z zMz7s@M%}a8NJnTlShN&V-i2?BM0NzWIcw8=lL>NPNS%#aElu6T6X63!VDp4MC^UUz z+XXkUllI5a+h26*$0`)b%1S32si|%X3aEBWolr}U$ zy5J>Ra7+mjx7#ya^Pk~NU2pQMNCg(wHB+a?$@JyJDl+!_HJy5pv&|jPqbC1VV&=&; zaBX2QFLz5KmfrWmf!HI|W_A*>;VNKijV$2Si!>sTwwca)kx4`SbMTGbP0j{r2%qlP zpvtTLpl~#eNZ-^UPMreGaGVwm`FR|EzHEo`u{&_#%^9eicuStGOQnVTgrI8ki8}Sw zr%2V$T*&mF2`BUCqc_W6{~(7i)*!8s`tB?QKb;tu8JJGnE4LAwGf`ATc?v|j*yH2Q zf9#EdCY0-a3RTW#u&G%OIP+FrRMr&G{W2StT{(s=Tjh!25w1q%pFJy^T8s(4*SNXn zD$MV_#<(2Ts#lSZ0P6$QRNiMU%}%!_7a~5;6AGMFxp^H?Ti-`g3mVAu>{x2ODjU`s zsnLBu3n{&?kD{@Kv@g4nzHV!vR&BlHDyNyx>+a=wdvLf%_c&s@S^{ON9ie?$5Jql4 zjb9dQ!fA@xM6f%J3dBFBwPQAzzlhsQWVI-tI(Dd_me zlQq+S*M80ZXF2uSeu|q*A+*#SzL|Ky@RAZT#XAyS@;rgwJ%_t{Dv0Tj0(1?lP>0(I zOqp^b+&g#`eJfW&VPi7fUbmNohij6Xe=9LxGLq&t50Ll^VVo~xki6&#MzQB+#D2kS zIB|UjdAV4OYV1iCSNS7?N|#*m~vbWU=?&eU>H+t-X?hkUUp zCX`Hl`InhjtbpVD#OvPw#&<8^->X4KeR9ftQ~Q(PvX3Y$3_GNb5Tt zsWd|OnV~$BO`JCTRUIt0m5?_J&0)?tbGqQvDSEG+kjw8vNl3RczJHK}GtwvM*^7e2 zX;~3m`%?%J=6kSu<_vP?^EYO@uQ<{9orPD&RnT2)0eX7HgTjS0(*Gp{rWrEiXs;Un zxL3lH7mJ{`GMllmMUY+=sf3@NUm;#;7s}r>rAy?-=r=!e&Uf&htSgzI3c-ibZBjgr z%k$NTe?D8eJz^%CwD}nQp(t&8%8!>b_-N3lV)9>}30FmV7G@_nvC;Rq*y8uy zTqGn^KI^zxIuUve*~i)0H01tXaQsuuOgy=V`j(F%{-ZG6B+T9EZ))M}j0>=K zO(Iq~z5&Y{x!7??kPOFFfc?$0%wiGBjQlXixPM6)?JtkPEaz!(zk{1LE`U>(0=PZ- zDSY0vhsn#6VNYHB!n`?eLkxOtQGMG4^g!0`KO6Md-hlmQ z3rR`N2}mvzg$d8|ShBJJjpwF-?T9q39^wIMdd`fsU!9-I{zF?udrbZFp4gby)4|^{ zxNFx{swc1?htJJq$EA+L`cqu&r9v3Jg$kik5{cuGH~e*(PICDPwQ@K>y2~QT_HD8t zcl9xxcyyJg3^^yFNAozW#=K+)^48BCcdy?Fdulku z^>iOBzAua^g~jyd_C7izGJshr-@+~$dB7g^x5E&>BAPr9NiQ#10RIGYi1odVu&SX2 zW(Q@ko!^d-4V!A{huurTP*(`w8gu`*&A-W95os$`m&Ihc*m>gJm509%4#otAz9cuF zWrA?0Ea0P{aJOOODnd`u$U*u}Zec;V{zNrw#C5CbvIDa4^oT7il4xgibdEzwmL5#{X~^_C&H}kx zZQ!;_2$Je1qr{s#942G~+{qOJfg>Ksc30xyEKfR?`WD0E3rY_&~bZ>O=W!RrAcEPes%?)8fqM#O?}l_MFlwWftW znrPx2&-yM2yyh*=dFs><${$^)daEB%2U#I3H?+moYlKPY{1#-j@<6V~1LnH-u$6l> zQ0BQQ4qovD*%>qIBSWR|$)9TWj=BxB8$B9Zb-@m>KnJ5Aubkg*?Eqf z=IjuGO5>x%qxdAguvdb=JKxe4r(!(O$47LIT_?k)JK%iGdeS3)8T^;rpsyQF5;ct; z-p(FE1^A}JDJMdw`Aq?g=>vPEi}?O@I^9&R0piyr*=D;en&u5$eDE>+*lmC-7*CR{ z^q4m9jsuO4T5Lr^CK;|>0#_Z=Nk(@Yn*69FUDsp)2d9wDUH^EJZk_a`Knd=-_m;Yx z69s`*RR~woz(!^v_zQnwlHM24K2BGz)fQvQhvZOf^<`>P{U0zV5RT1I!6gm-bYWF3 zY|^vAQ2lE(C$$zHwPl@2NnQL^yQZ!VqwJ=0d`j3(#9XEl_@mJvjb#fL0f23>92}*V1M1 z$;m%-xswlZK9xm>xEkE>Cb*kc8MgXg2BS7VA7Q6po9LqjF#3+iWqx-Z+w0561)k-j8j}&*2 zyYC9&;hsnI-$o_!L+KRk-Ot79)sExgJ633)){G8L{mjRG?tM0C3hMB-TpNC$6O{ZRsq7EuVZYxqcJTc3Vz>< z!8w}p#72R^=hkB6NbzgN$1NVl4U6HtaWd=Zc?4F6ePO28Q~0)g89j9R8lz`)5I!!R zjDr5N$%RD(<-L@!dE0devAj#)oR&jN<+*f8Y6gd?KMl{ai)qi_t)%R`2hRPIf-@)H zFl(pCf#j2;bd{AqJaQ>Q$#((tSgHzk1g?dn3l?CiZwBaeUZN4dV!uNlAd@aarpGV?tv*mH;iRQm0xt)1)dik^s8iS! zdi8Vy7gI=yxi0gAItH>-yyzeedUcl+suq{#qCo=_ew$`N^w32S8Ve-)>ha7)% zlo(unMD6~&M(P(|BUckeK>xTps2DHCb%!0G%x((&F$%-g)>knT7T}+sYD`TrKdPjj zhGM~6#JJra?!0+GZy3I#5%XTrKTSgx&R6tcw%s2hFR__w^RK$t05}I}$ZdKNxv&9ey4Q z24+e$7E2kzMmc+0c1WGnP1tdm&sW6cXbX|mm`_*sv+&^18Wee)1Ai3zh>bB1ze@?g z02p(V zF1z0z(xiZCHzjC$JQq#Vt>e%_TG+elIM~UY#XRdtaLF|TjXlpmPWxLL z_H!G0dT_{v-3gSnJ&wK$m0)yO76cy#lG9%lIHY$Tn4UHUeuD*=OAp`&u@>BDuM5XM zwZqoKlfWg$m{mS~8E5r4pn*p>=@~8{X1?FZJ+6|z(sd4v`Mnpeb#7#at+*&4_x>B$ zI|~F|J;A4a5S4C5qQ@V~)1NA7RVVGu;f~(1yP*SKj3%?nj>zgRZGo>-pW`)lX#hbBh#N|2>fZ_&FP5_!C} z6efm*F+yx34(f}5JWmwXRc7Ibcx3E-r(vT`5aVsW0}oEQ2$mYFAxV0{@Ts2`N@Y!9%^QU~s`2~eI z;WGogZrlL3j34ZSY8x_f(ht6g$`gSN*CA0N5d%h5@$9#auyHcrRiTS`d3-DCr?rDw zxf-Z^mV#`%Fj8$VMtpaa6WPIqILLWUPrddcdjAPpwFKP&0UhqHdR7Hn9tWdFL>!s+ z^#WLgh2oot0`PIZOEy?&;V0u@lzb?QejiTIA70r+^gbUfz8V7j9gpZovG45P;W zzMz%qXE_qt@`6?QKCLdi>kSo8X3#0Egq(q3y76Whz3*U3%>G>4Pc0I2q4& z>z0Degbeu>?Mu!@1Ykt95Zm0&ZN9Z&*#7D_lSK0=F~syGCi3ElOu3KoqFQr9d8 zY#RDVoNd%WBDWufUbf?gQ&F&UpCk2J{+B%zdl>JV3d828DKymFl&-Tj#C-oOX3evF zc=#a-3qz8i@!bn1@QgAVuRf1P1(#sk`LpC~P%AzYks@lrp451vky%5-U{7oV*(bu) z-n^fOg&gAT=1FJLyHc9|2dYHRqbKHhsRsJBS7BJA3pCz)LZ*tQp~Ua2xMWi-*|JcM zv<}OYd9hI#xKjwGeQU%HF^js1z8I<=eHZRcUqMaNd#IFGC`4u_Gc&eZQ_VhZE-Q79 ze2F?hF1xj}-va!p)@mJcp3|4cBDx5eY=ZS6?)bBg-)jBhP-bCj8s53li6dKHkd`^~ zKw&`!nJsYyPT$oeGLzkj=`SH;a zqSxB~)jexh0OiPsD4S{pdyifrucn!hprF~vH<#OoP9J#2^EA0y1Sxbj5`eXvYsp=% zn(Xq$R2cG3Am?JUV4LcGXg!=l#kH=%me^ac)kgv+Qti?DbS~|+69nh_1k&KX0Ct-n zpq>6dsng=qaR0%2vh+?b{j8yff6);)sfb(U*Db@n?q^|izaX7-imOHFnL-*iM8kZi zjrjigNpi`pk;u3TqH>Ib_)02Aq~>mepx=*4p0pnDk6NIo-dd`0_ARwOgJk)ti1`yg z&fuTL9@twD%y@qmuxeg9%AVN%i@rE41}_cHvriW0(s_Hgpl2zEoW6D&<=bT*m{TN9&u|-em+Kl(4A=qzPt7pJ!WnhU7sG|C zVi-|+jtu>4rj@@YVNsbRKAQMUz3Lj6S(inis&PGxNxnj6?LJS`6!kF7LKpweYob3suUY>5}Qn!}Yqel+eq#S;}*hvSwjKy6GH3@*)v zRrYeYVwxEFR1!}GwYOtxt4{rj6OnaZ`ywE?r3X!NIlP^CJ<*=Imfc~rgig1L!@Pg- zn7+QKwl!4#ioQrH#9pw4?*j_7tAB`Dq<01~e{;ISP!^axj{)Ig4bqXclrXc_o%l{R!y+kj+Zw0aV}e7PJeq<68w22L=hUP_jfULgv!n(~$K zv#-_$<0F1P{K4q}7MDM<0quiuC9x3$j!Dt)l{)CA;0c?p2k1xSkh_O6X^NN_u|F_@ zzt!{M6nECnFjXg)WuB71u$Ub#63D`uX?{DwHFeY6kmc@2@eLS1 z=hTyf(g$UvTPqxdl)YFxwMOXO&{}u5#2iNawLnqMoHmUT4nMC-j){5U(84?9 zyyz?#(boaxWoyaF!3lD0l|3>)=0QVaKfAH+7%DEgi@KlN>CuN2+JaAz0h29^#JD0+ zn;i}@)04^jHc4#en*l4*^w8$6E3CQ{37T1x&_k8bojL0C>5d4rX?I}5vTl?2MvvL| z#^&f~?MpN#s&HX%Dp!Ni$P8*a<8K9bP+E~pZfjX%%msN%8<#Y?pzkHnp%Y@D4s>s|MTtQSec!}hB9{)HP%9GVO>PKclY za|O1=N>~j|XhQ5hE66@;NN?uMXTRtPGhCi^t7!6zNAERuxq z*^OlO<9(?7E|{8@aC6~~tt9NydUE-X2c{dSVw!^^C=a?a2UVAm9gh2G(3VIJnRX6B zz0T75H^N|}Q;c)lmQ&dkfsEy~8*EK?36U;P;m{>|@Mgzp$kvM?$rlsgui{G%!|O$+ zD-V&e#Ap&zQ-tdSW649y?ch)w3CTM-|I*VclH8PpMeqO6E+0KObzc=kUiab^2ZR5=2`sz-1x}@cWTH$Y{nwLO37t zok}AYnv~$5|2eGx<_3GU=92g2?vT#y#{R^IDKn^Iy~9JX|$7QWN9j!+Y}9ow^|@! zQVg8;yiCr>gySz;Nzj-Zh2~C<S9~D%;;3DjnwC|KH0~4tnZ8_u)WQ4Xz)Q8l3oeJY~yDrVUtIm zRUgH;;NRq^Mib$6orT3y9eGFI783aE%jJQeld^sBxJt?&V@n3er!89{?|m5!bUKJL zzROeldzVOOZ49n>>rEzBgyXRrRd^%63y=78R@RM=GK<Pl7IACaA3CGfwq-3cy-D(RPnQm3LYD@BNP zw)##!tr%yMIp3-I$Z7JpJ|485N26P0H)$QQ$L95?*$1b`$$8`aIvrPabhEQYr&ZaQ zrj*F7VGvY5U5|cAM{sNF3zG7;lGNmlk!4kK*#71>oR~ZV&#K#l^Gs1D>S!5U(}{%5 z39lg1*N0R%4uIDF1GPUsyFkL0&uos_Q)*uFfaV9uGtW3AwM=mw#@D|kO6hwrfBsp> z8!5!E^e*$cSr@;Knv$XNbr5+Q*{RRmK-b@lwijoTx2D46Z+aO>$7zA-Vo_ANTm-q? zce-ZjQ|NAxrTorc*bbHDnDOug1XUYA$eBr~SdvF|=4Y{XV&8Ce%`^x!T*rBjzcVV^ z-$ELPab7;$PJTRofXDI;ustQ6-1i+o4{IY3-@ymVo;abnq$0$;TLaBsPZDPX0qXrx zng%_;4GUVo(+@t^dHf%*;&w9&ChTB2_UCfdul|a>l()|Cebyj~5O z`N8O_TTTxxT7d=ML?GBwgX}$!LE4p(bfjGgZi!wR%up zITtQ-_ovt#f_dkLsiANl%pSi)1RroJ;Ke+8`MnaZD38O)ARrY*JMq<)-R#%6G`PTF zSgL{#Q_J{tru!G*?AlzWcTFGNK4U9c%)N!*U)})RPF9I(y@qY(!3-t5Pu*$w31duJGxx`8#^eA|Cc4lbRn z%JpVzY9INTeM^$9LybZf=Ko~xlt;dT^T#eHC zb7;EaE<5_-5p#6rUJxBDpjme2m=K!6Y>Y^TDOEP;k#%MnXy*`b!yrm`-U~VntP44DM%qPSq!b! zilhtob0#`XJxEl&2-7NjAjqA|2uK$Y!-+K5xOzKun4boZT|p31?h5)GLezs_8|=hB zlID78oI9Ue*G(qy@#RyPXFea-wCPe&*BkWSbx~M;Tmr*xc)=aTd^GHmq21@=SsHIn zT~i~)y(tf;9TuTS3_lR73v=N7Tzm9udx6svb>O91Alof88D`G1!E1l7W5t9S&Vh88 zHZVkU=Hx?P*+nSs{zoRi%;#$Al-Z|iI;pBxJUtlLjUW1ZaLOYs^r%v%>Tf2qlV4Ti zp?abEq!S9bVYm~ITYTl}b}m6Shr^q0u1)3b_F`dX2#6a2?Z2dlwx3(rKUGe+@5yIW zmfcD#vhzS*w2tH(%fhBOXZq<&2YqBe3$^a|Qk(giuxD*Lo|(d7K36M~o$t7rpVkoh zlkN^nvMw`n0XyJHY6+{xmx2o{-;ozGGqL7+1lk>a3y#+6c*XeuO?%t1B4xVL4Z8Du!~Xpsy57yM@)TEK(Y^7Mx*=F9I|5o(0VxXXx`x z1z5{3!sUKjnd8D2$%)Ojtn%%@yuUUz>=Ew)v?~_CRr+B()tqpwDO&_SgW|Z>q!OEw zHNa2y4zn*U8oNFBGLKhT;m7(1MD6YgPA5=is+BL{y$DJA>(Bre9GAgwiwTLH)UWn{-fXvz|7?qT_@~{uZ?*5BheWL@3tTjXSs4|6RtLTyo z$cmL*#D5y%u>VOfb^kPtb>Y_g*xmoAn7ji(tuA8!KRol%DMmnPG+ zYwHT4S@h015B2t=Ow(6+*rs_4G`k9T(!Iu@zfc23qi)TQ;CKg?CtGoc&Lt>Yq6KFK z-?L6Pn#dG0jv>Jp2iJG+rOulqz<~3DZho`~{gfu-ZPQEW%KwFg!(;ZDYy!-up0rUT zhQ0`22OD=jfJ<)$so2*jy6^ZVJU`HkCXtb(HN}Zn+&2HtamkBaGwUUqR%)Fb~+tXHlHOf)fzH>B12@JJTZda{!A}YHz-v9`QrHSm4n9rHrS!nBAf4@>?uM0$ zUTC`E8to0RB-cue(fhq4O#Anabc(59r`-lDf0G z33vZX1iRy5__8qprG*3VwQ3W6bZ!Gq;%Xz#XoP@@auKK&>?6YOETO!XLo%mj;ya6( zY>mAFP3nBb{EUpj)p2{-U&5vMZMQgAIpWX6PIU*n!C4SiJp=Q9SYgU+A2@Gz0GKUq zu>6rRQOZ2ZoXi<#B}Zn!3A+iJqOyU}@ZE!-_fBW9(-7Ga4=|RQP7lqEAd~kJ z-YGs<_nmWp-FUqm1ZOsI?13O08R{dwDtk!3=5?wR|Af&Eub?GoePCs=X>G%}1kUpN zN8at%BD>Q!6U*Rc5FC&rHh&-UP8yjq4ZCchZ}n}M`fVzl3bq3IuHQVHUlGt6U{B{v zWJ7x;kv6iX!&CuN~cO&{jw0lO+5*`!w?OcS1B>Hyg{h4126vVeD;XKN2^0oFJh^14P!HYo*wDa1V67J>WZ^4jXg>s`CiD}bv?G*M%-?_b1wo0g*I^i;MrHWrR9T!hjUP8d`FwJz1Qkj}N1g=?Rt zS-spH0ZCDVz>6^?^8@FQRXKWC(jm`aOBTs<-=iAIJK2>n8uH!td`gJD< zdwJbcPm-#QJ4nfVK~x)dV6Ph1(e!{U)^bY`)|Vfn8ahTyA~%O$c{YQ550)f~^Aj;f zY!6+Vqyi7jr=U#waVj$UnkVMjgo?)E^i$$wqVt`_Jb|fbY?^>(>vzK?zd@38N(GHy zX5g6a8Fq9;sQ&b&O!7=s2RptrGAla!$$;%c(ouR7H*}sQp^M$ox55tI7nIP`Er@Nm z!b#n!cGh`tl$KtMgRY@GI?%Dr>xzMIduszU?Z;O_;sjfU{tx0n}T7!5YJJiuq; z&ANbXQe;-_H5%Kzfo(3H0wwGd>Z9$=VLW^Ahv-$R8smiOD*fQBr$?r1&!^<*4VwGYIhL5fWEfcZmfo4Cz%w*xAZ;rzLFI|<>t{A`^X%+ z?GIuLA{ko`8^(tF4IMM9czOY^X!=VYb>f-f*p%<|^ZhEK)?o!vmccY6L>n4< zbg%&{<+%(<3R#}44A^-I#;?x8CI2nPp^^C5dwBw zN2$X_CnS0?mokX=h32)ef z&a@8RR6m32lYVt)jwR9dN4LSEsuFyUs-s}>OxUQSPjo8hqCNj(bkR%1j7!0E&1pTH z4op<@Vc>vem)n*+W0)Zg4B*$BcJEPa*uYpN8e!GONQ00g7N?A~K zsu(xNNpW=?Gg;Z+DZpnNgGG7wnZ8fUFew-_*NykTgalHK;=Le)DH5zHSRhto#jYEOt{feJi?D`7=2g zz880_JAnq?t4LdWDax26g1VDBwyZdgf&XlwEw+dJvi3u_d6|q-&{tyo^bl-$_Jg{$ z@xf{32ed5J943`hl6UbEsGiiKvfpf>sF)9ROg=Lk{4%MSH;-LXu8FMwTlj0a5T@vS zBpLgJu{11$-8jgwZEp|YmGNbmC0c}2PJE@&ML#ObqwE;74{GF;SvJmEF9+{lr%~RO zK(@%-4X)j{0vo>p`s$Gao0Q&&g}xsxBU9zk-uVpsTtJk~zb=5cY^vz&yL{Bh`Y`4@ zg;2L;w@C+yf~55g=w-vzt)BVC&NKVLtg7}UCqGStM{}0ooQ1>0}`u>nIAuiZ$^N^h< zvkjgHBtcKxn>roYTEgF>hyl*oXgabU$>00zyR!`-IeWBr-90`W^r<7vj3vZX$`U2` zAHc+&57e%ymhMX6&bU*V%-L-skinP6yOC~AzBN7O^6JHSV1prkU3HY$(Rgy?Ng(F$ zUPX+XYbo<#7c9P^Ol;(0v8!b&$}J>Tw}%ds2r+$XRUlvA@iG;@{OlqL5g~AhjVC*L zeW2&vdfFVLhm&>Yl0}X;=o9pZfyF=#gRddc+6*e0W=PW-fIoel{%+ycCO026XFX=> zmKlK&SMTT_5=Ztu*$MlnULo#|A86F(Tp0Pn%@P)z!_GJ1@T1cTKAy6qXN#v`UCLYd z9J>^koO^*o-Ob=SH3=1FN`f6X+YLTX*@F2a)WflpUd+s38i)*xXiAa#!ke(++dO(r zB!FJaxyT+cnGCk&+Gu<^l`P|nz=a$S$eOFwDStJSCe4YbTbA!4-v13ysSrK1eB}V~ zG!m)D7$pxB;pLuNQ$~{@A?+nx>X8x*$=Hl{CwQTy> zt2#^0k5MDlg3lkd$AsL-MtNleT&Hl0+?2WwrtJ)5J_~1DpBzJt1>5oY&|8}K>kRhU zUjf|?ajU$JB)W>*yMJ$bz=HNtYBc*7{{9*VhjPuJS;LQv+PtpglXyqYtMHiXUUulW zb0J$IE)4@8?=spQlR;qS4XQCSjEF{Tgep$&$}M%o)S!F#!CC|gQmlX{kcg+tITpn4 zQYhEDNxDXMq5jqsn*aAUu37CuEYuZo_S70UVPp-7sZku~>?Ij${zsnYr4Z$Iep>mi zis<|4qL9KOSoP)%sr$~6$zJb79dBJ4yZ0^@a(B{lXFl5gI+eP2HIVSG&t%e`dys7M znjCkqMISL+SpNGU+^W#R%Cu&p?EI7deYBG7*LOi_lN2E8Q*olm0Qo*`goU~?aN_kR z)Cg&%b0gNmm*g<^EmJ`5I0(RQwKFhWvK0sX?h&63O&mJmhazh8Xy>*6(E36H9K9-y zbz>7^ojLtPxrN4UGl zs=rI&M3+xpQLg|xv9i=~fiKY$e2vVxH0JkJNw_n28u|_8(-4U<)@jg{;}>d^$J4%I zher;l`8=YP!~5x{1ES34q>r@COB4jX4r8f;GFBU&0+ESSXxZnEBDLyJ8Scp5mA*>c zjaQOqmY*4+<@IE1<$3nX)(uc)`iMrUae7HdDhf>2#s%vl!N1U+l@$I-KBWnP)cIW6 zby){`o+b3EtV_jUjO zzn=)*c}0?JNOpsn*B>%`7cS$Ez5)^()CT$5Mi6~B7r(Tm(&!B1j#%;gj8S(u1S;NR zKVHmcKXd%V9U~63=-Pd5ZXAPZX&Tg<=Q|DFegQo0o}itN%!%)R%ej5WY|>`4n~uEU z(CH5P9pjoZcsVl_ZCi&Lvz5w`9Sg*jH%dvJix9A*Yv>DymE`t|J5bsEln%y>n3!5F zC(_cd*bmF?Q2+Z%9MOA8&h{3=))GN_8-(DTODrt^>xt3aEZXa498{N-F+th~;H}mK ztzUnQ9NKu4ev9)Y(b{4};nyq(Pfej--JLXsn=k39e)+A5guyatN{kqPSkY$S^DFJ77;srjMnj1KvU8f5ehsjDk+eSUj_o!ftZFB(0p|lEKi*WW0EV-d0GZjyLgyKt`;?8STXu}DsT@U`n zMH9O)N&J`do4syghBvJ_ty3UB;gxd06r(DLUAYQgRC$2X`<+bXocBZ`bUmuyup*ta zD#=ZCN%AmnIh5FVqmX$dE&iqqZ%6Lqq3C+*dOQ=n9Da~SeP??8@*uS_oDGFn33GF5 z4zPPtY0~xqldFsO(4o#+!q*qexP7)j#ge($X*VAlzh%OMrK?GPn=8W=-639wo8Jsc zf%U~A(rERB8ibuChl5gx*NaOi)l*0k=OU@(8E3g=3ahqMn!Bbe$npq7nrAU3Rr$a) zEH$WxhrBaE<){e#Albuwl37MtmF|Ih_5sw;9>jV7!Z0As3SLhirQ4-vK}Y{#{9;i` z&q*GkOXkmo@VCZLvh^BxU#-K3f}8@;ln-{s>0{NOd9dcrG0ZXi4_7c}X~KFjxE`lZ zjSIs`a$7Rp5ReFuzV@*f-mHLy^_6gPuK;LhO~dl_8E9PK2k{&)?tSn@_Sqj#s4v+9 zElXzt-%u~vx_TpN$li)p1+`o^@g9-hsfndSQ*geYJj&XKpsnx}dcyn~8Ghe}s!j5U zoj2g?oHf*=y9mpV9bk6Bh2u+7;A)FfEjvDEh zD94Kf$?UI)S~#FM!K}9#z`z1YV*PFdo!B{-rXK6V!_mU{EMbb!{FL2fq5nhT`^^gc zZa6{88?FOt+>N4@(j152HT~8b3*uX>5RR6$izu-uDf^G@kvAg^iUDj!buT0v53|!< zElIB26E^>kJ!+H{qFe!kc6+CH^Jmn(7W2 znMre5?=x;{us}K-%cvsUQ~1lC<{yTd)++Kms2sQJWMTQ-)li!m1}9t-Nzqh4a5yN6 ze2E9zZzMXR@9J52LHPl!75I(YW-5Z7eKmN-6yo?Iu2;`{we@@Y2_ipjJ<+kqq;jzYnB zW&5&y$hNBVkiAF5;VN^1xa(Ikqvx+d$>MzOEJ}`gjXa?DvL)EOd5O4oryNRi>)tv1 zbGh7bEcT{8BPD0LNMPj+?DxpQ6kk2ua?l4--AWq=Bn;?@UE=uHBNoTs83JBDLN>k$ zgl~l>;ZSBQ4UZ|LIHn4fk1Mf(%_LWSIKCsFEi524rhk)8(d}Ebak_InBmSg^PCYIJ zwil&9ZoMF^Kf8<`Ja!BpMl`Xi>7h?-3?~=hZXlcT#m{!by;&|0kccHbjjX^V^rfu2-xe4(?YK=^udj- zNa)~|gp3~U_-l1GDzixd(T{?J_C+R%(ut!%jrOb(D$6T^P`Q4%eIWw09+L!?i`~ z#527ZZ~OhFD=mz1T<0J$%hI3{A12uhk3Fz(R36d<{-YYI-B@yX5tLpUC%!U5wBovy z*;mPH;PIjy^b-~D>ve517 zGIJC0f9r>K$16yI>IjLBq$I&%X8Y|gu{6l0ip*8siNmY!v`2o>CYu}LaPT#^K31>5 z(_@Z|#ftkYyppKu^atFOWNX{qg3K5r&6!OPnVe)r)g#cLSpy#b>* zX8rBzGq|yPD{OT$!u4y+naT8hFruh~>W14`sW)Ea6#rYgBVG{)av!nDoHF*RGMA6Z zPea?>AlmicKkBA=049=tQ{HGZ9BMyBtla9L^0YBJY)pj#19@=l7laG#4@gx?C(@4| z(6V41*sPm^uD_!=kZv-YT+DS#o|wSO4o*F)xDc<}w~>YE&!OvcA!9i9jFg{VfFFNn z;Vdrqubw-D_4}U57EZs$?%D4I|J*&8DD^$;o3%Eu*KH}5H>iSuj24{8G9g6Yl5zIx zXUl(_W8~R&pgZR@%sK7^TQj^kK9wf(d+lptk)#D8U8kXa9VLc+vp{V5C)yGn0|G8* zfK^XK3D-4vkGG5Pm-c{~;ThzUzeUsvlu=~-x@n%`9%jP1nEcrJ4gCy)@YkYo_D@I@ zI!v1snr);HPK!5_cbsys?RG}Hf4vkgYso=db?*Ay;O*$1y@(2LGbCs0+)(~p9xaw2 zFuLwFtml?vjHBIMhtPKYCDguK#O1<6X=GwM!&8@s zOL%Otojhjz$vmteN?09!2Tu3v5*c!dL{@8K9M2GVD=dfh=>zO$sXI`3C=II(KT>As zBQv^sCS*-of=1SF()K-(Y%A}kuLYK}0R#ESQ!Qe4U$PoqcjR)civ*%FagHXulAyOs zIKBOs*%*B&-Sj=*eP&&4Css8Wk_RrsbZ+w{^mr5mV<otlkfZN8AXw2&(A*~J<^XrwQX zm0)ZK4{E85aQ7E&2vg#?*zZ%Yeya)=$Yy}p;W27)jhm$?EWrz`p2noP+!x&I8)WifoZk^*a0w=TY;sCEck#(JpHokx9OMw;Or24ll zG*Sl(inY)sRRzz@-b*b+4RDFT#FA~{lQeC`I?`#XgoObgN$j>RP+Rek?AZB_xW~`I z!o^wS56^xm;GK?ZTD#kw|MSMdR1v(RY|CQy0cy8P3Ov4C#?OXXu)t<5Z1{PIiuPY5 z@wILs(z_4BT)l~N{VCY}vI)vJ5j5FZj=b$zgeIpEs^&s>O2?AH>3pz6G`sD$P!zmV z%z{_Z$MJgOX`Ij+01eYFBI1!nTb_>4jcYD2FU$;}*C-DHvd73!_9r@jy8sEQY{1Qa z%P~%!7Xsh-ppNt<_@pomhBU5|haX~5RU(iW?AAcV4qi+>CrAI$I9Ox6jHFV2 z@X1JoBKbhL_3<#&$Rrb!d)#?##wsXLKS66IiqN@cH~#pqh~?8NBYas_bjw&9jncLU zE&EvFB@_k*nTX4Uj+-8yzk@bvx|7Qe#pK4X%~-NEjq5_cCPA}yz@l%{V4~fb${LEA z6?{BK+6SrWhp1M3eKnj^9J>i=okb|^CV^rR;^;n)Vcwqk({7)h{A27;DC+0f9d-(wBy0`A`mRF3peQJK{j6=tu(bj`yO|E-|&stm-yg}pH)=V-j=RE6#_2`FA{VA zJeos_@V@*;E|*ouPKkU=K8tjN`nUOzaw!44(+!||t~IGxtPg9~oF^kQ?eKyh$Fxnp z#<2v2!6(cHa_Y3;om)Qnnd}Z~va#)r|CTam3gbXEU_UPFvI6mxv+!`h9GfrJu^tUc z=sfo@k*rOkYGx%kvaf_}7q187mdaEw-Tlb(FavV>hN<(7Ptf$LhURiJkY!Vkg8$71 z)H-t=!z)EvJvZ$%>AWfDLz{F{%$^IaJymR|K6^MwZSZ{JBm|XrhaNCi@8-~<-EK^r{arGRHKA$G0`b$-aL^JMgVc)4 zXfpVY@JnQnZ#0-_U%yFHM^Y)|M&LYo2VA+w5#MG7!rBlntNPstgDm{n-+noC!O>ng zUCN6y3M9aQ8;xf3By(!f7?>5PXomeJoL zs=x}q#@#$=jQ-nA!gb4Cpe0Wq9=^Ir-!F;B{Bd0_BX|&}y%Zu7OUm)V5=)FZcat^+ zB*QC47y=9rkhS%>aB_ARHU zx{H#z9{d74#MF?zmi=Tyqc5yXSBKNzZOG!!lMG+<0u;VWI;@6^AfdMbBi9>JREWWE zobH_D3xR^!5#Vb$hL63DgGyyKbneumpRI>T*_#P+{^d*K#g+!Rztf0}{J2QhJ^I9` zzs`Wo@+{fB!vZ8Yjnk^Tb>I{fVyf)hhhQU23VFieWkoKTO|n2RzJTLBaUCM7Y-o%Jv*2nEqzABMep??Z4z-(peQA#EIFlA_?ln18uoUNi*ML=h z{f$fi3!)wmUJ(V&{kWjem(9v7;5hu9^o-SZ(3CgFY2GL4t50j`Z_i>3f0%()CE|>X zfB{DNRx`=|-DKa--6)%rOn3T@VP6}^y0GD65;?xXGOc{PJbahle(rV zG{n0v?@;l{&Dd4=g1t9lN>mEhgIn<`v@y{n0ld>;%lKzHbkz_){H=$6{=b+Qdw_Jj z{tmm!jnQ7ll~LO72+sp!IQADy-*bHC8f{022)E$&1yefYg0C}r^;NV_Ll7+s1k9$g zp>XrTQ|4#0D;Q-NgMND@d;g; z?tWzIl*jy$IERPMejr1mi-?T&7D}UMQ?^Mmov6r(p<^;jp9It~VwOXu9qzMm?%-m^Wo9sKbv&TK(i6&p&8v z;ROq!6SP6&IGl>BfW7IdXr6cqq&-rZl$<^?vUw`b&@88xTY?bsqHwy+ViI&sh^)L( zz|`it;OLV~7)W?PG$NGYg+~i6(RQZiSO2A#;=?d5*c1LGSwPD3+2q#LFQh`&32$fh zGjEK`*}TSKVxvpw$N_7j@N8V^D9EOAD2O&;j9oVsaX0wW|VAGNe9PT?p1%(g7 z#I-!)r?{H?K5`u%mD#2O*A-}L^FEvixC{=X?!qA_o7f6JX|lu77b8EMf-Qx9q-yLb z)}P+U^<$z@bYz&U-v1IeP|9(hhwx)iEGZQ(BmDoVK<|-w7$weh#l0^i{(}khf4fNK z#!4tI_`-EYrsLi(emKW_1G$#VD41c;Oir;ob9lft`qu-kIpv00h zyR=}`=Q6!HU5-8}*+S*V5@7S@Buv+>$6&n*V$%@}%iXeI?vqS%D9V6X_ozU=o<3UK zw!mC*1K^k5jh)a>O6la?`}xcogm z9&3qd>og!XbvG`vFv9#_t&L%N9^g3XL_9B?#d}YuQ^NoYXfe!&-W|_j+M_EZgy$No zU!sp!8fIgiekJabiz5Z1a=3Ry2NL&4;J5XYG}^A><+%G}@l~cb57&~aJ3Zhf zmPD7;T7u)?6k@(jkGeGI;P>O9XsMBeORm4fXI1I&r&t=3pOiw|>J6l@ZDG3trzoA8 zoK6Og7~%8jJjBtvh<&D2!noF4Bq!{WFg(s2R;u(+xmWSvCd|!uJL1Xqb9~U|*~Xe1 zoF?V>7J!uCSqwA03V$B=ftEE7>}rppD=yoSt7*k>B_$RX7|tdk(TkwZ$mP8>Qy-CdvTqo{xp0wX* z#@NUY(^2H38J5^}(A3 zy+#JwlpvLNgoKHoAc@jTAu`yXEONL=H!{bFOQ|+qnCpu5pA+%LDtoLw9zcvcG}!VJ zYN?6Y(P+x?SA@A<(l_@4h zG<+-*5*PTfi=Qt8J}x6Q{zZf&oV-E<_nly(LK|^q=mPduTPT{;7kuEBtOLMk@`KAXD!OIfydts2?xPxP+Re{+qVUs_lC&}C6 zc5L0z9h8`B!v@P^Q26&QIjMaKtJ`W|SJ+N?wu#$Ia(nBmpQ4Go=`i)boreL|i|Dn? zXl8w}D%N$>lchSBh-iueap}y!ioY7@?HNFCavc?`d>}3ZMsVwQ4PA3Pg5G$S39ZYF zVZhrBu58*#yZ1J>WtNQ4HxEK#>(p2f{3Zf36nv>g!X`GoOOk!x-AN)sZ;}@m{!vq3 zJ>u(n5IXYW(DV5RD)XhA{r+_a-tE?f1K(m{v6bh~FPZpJ)xiReHJ8EecOw=-opZezyuEUfJ{#w0HhII2*^ zE+mQ=&`PhEc@vR zt_fm9z$1-@iTpsBqoeJ?^0{#C+iHkT13J=_1)}`3F_cdnidN{++nbcwir2-oXKn-d z+{u9HJLcfaCHW{+e3_ZCU^C@CnnfSQzNR+k$}u2z3)&u9*i@|9!eq?)YC2;Z;>7Gj zAo^qm*Fk^EsK`vf?tsJeQV8cjc`Zyo{VC-<5?b(6H3Qq?^w~t`QhNPHA=!83Fs?Nd zMjK0R{;^CGU8#b(Dj#_YAysLlbt#>EPQU z1<)P41Zg>`^hT5qguUc4ree=%L&#fGO?g+Cp)&~|S`tX2{Xw|ADU;c5DT3>{44J3+ z47d=vnTFp(P*c1~wE4<8x63O0v(SpFg!GvxMh20UpAcRQEQh!S^;|ws82Wy2`J{ht zkQG-!+z-n^6U)s&Ol0xWo)-2|)pe19ZO9YkG;nw}edeDxA-H4>xs>B;o*!Kc)+r;B(z z-3@GL9V+;$U|ohRnXtY`rd=}K+_lAq%(j}Il^2-#~Rdb${%X6rEM?E|1%v^G8M=Az( z>p)JjI^FW2pIQ~faVq!osU{z6(V%|+I)2^)fQu}a6Y)1nSG`|A8o>A&1-2{lMwK;uGqfD z49ht-r_oCVGz_)J_0M;}m~J=BUw@09{I3I-`E7!Aei~5u;5bBZbLQ3}mUY$rOrI9c zAWxp1qpkvSC}4FCHGUdVmm2}7wt-{Lij32U;DzwBO8{+yQpt_SJ*2du8t)ohAWrKJ zlY`lOutmrX@p>>_93Mc(bNPvh>O(5g5D2rbG%$lNdr_R~(j~fISpG>B5-j|XT1WFr;p#&0iu;oa_OAlh~h=Wy#JQ|B|dqidAB zPpgH@uuz)Eh~TX^NwDF!ID9I*!n}JS&8+6P#jK;ebgJGf+Vt=n(=sd$XRK4{l#jl6 zz2-6=z)Cb$zs`(U4N*7OWa7m8lRVrz#N4jl0h4bN@i)CroRu8e^@}s`)Ukzd==da0d)HjEYcRZ&B z8xz@zZ5hyzUID$4$t2t;4A=e{#QkgM5YGq;qW-J^ef@G_Pq054otsOZ?>PcNcbjla z#%dh!&PTVC26XkxMCee9q@P@$v#0M}ryY^0pwu4@cfQOt)6_`<0WA%)&yrn4^AwNi zJL6_%VQ)HW-C2NpKYpa|H*p*w17CC#;?8TIo+c#|ACRwD2}HM*V4=W5T#?>LUcUL; z*cqk3Or2+q5AFBiv1zf!TVJ`8@wMwn3Rwv!zQ%*BYCf|jYbzBCdqAH$Ym)`{8&M>+ zf}EW!qEQpQbZ?Oe9%VUB8OIK+av#9L`IBTzP$}@m1fi6zC`<{uPsP3qFXPM+yY`BL zc*Gfe7?PZLmf6-En(Q}q1XGJG}l0u#_2Leg$bLBCQUf|82J zL1Q4dc1lO*7Z-B2=O*_|uqD5$=Yju#CKWr{3gYLSxWAKtuo*h-24`B~;UXuzI+GTF2?4iy~~?k%i!Tnh*D>9wNJbE&|n>QR>>3M%%sG$dp}6Id^O)f=$5fc?l8NluwjGda;VXk?YchK zlboZXuDq7|7#QK2#D1EqcORF0KfvAhQZQK93Ck2SVT;}Ys{XE-^o<|K-~axRr@|B+ zmnPGXs6?ATt@4_;Rd$&bP)cWoXG}Rh$`YL4N$>*f-67G$3gYO`AxDi#9$mCsYHjwNo;! zUkgUWim1#})WGMBa3e~J&tMAb{W)BFZ$2v~^|rN?0CZYT*^G6zgI z7t*lc6w}Aw|ANWIO=u~TiRINTbmDJ3+V3ca2bW{Pc7Wp;`y`RXk==Mo>Lxk%MiI8G z)Wi=OBT(9Thfb|N3b}y`prBPA2Ij`Vx9?SO`z%_l9bnPuEwbVz z=U$!qioEVv3e2Byve3qyeiXkAuYOZ|@j}|~ zk{>qqFI>0|j=z=Zb;mbs z^^9bC^Hd402YV1-txe6E{a_%D&Xj+o1N8&v3O`x0yy zGQz_rIQCpKHpb-J;zTu!BLcz(fnWq&ExanL2Z9ux#4Ab}9L;s)yE@vaon%6^?2i zXnR$x#K>D0aa@%;dh(J8*R$88; zke;pOqe~y%q+XdPaPp%Xs_Aq4Px#Cn2w#Wgo0X_2I|W{OSU}l=aZnVK?O3&01O`;* zv1=-A;i$!2PPXZYow zY1JZV?BLFF_-*NMJ&PVY?P0H;JidNd1BTr5KwoD!Ge58m+|+mB{zwHh*FQ$S|4D__ z@zY_~yj;dp*%|7Ui^0QS2cGq9Cl|){gA?@8o?nN-fB!scQQc&EYQrz;xLgtD9XUlW z)l8s{Vj9T)I!U?{l}W|QKh)Jb6Q#>}VD6E%nE7f8<|}2x&$M@xhwl@;_}!L(sVuH( zKE>>JT1NJzPX)nH9}=d>55V!egMTPc0j|e;ruGEpa_e_vi723FTJeVCUi5wEgcr{^ zunUBuA=3F8S=aE0Y~*pBApz){xqo~?U zmI~@(Yv%wd&5*@L%W&9xWDj=o`T%{pipcxNqPkoxOuO}v`V>s1Rc|?mx$jgI7quY) z&gIa@*9SIx52D{cX$TRBhS$NOboQ$_q&uVVVnG@1OFD^mH!M;$MvL&H`^mP7{%$DL zJq_LN{&bBAL)9k^kj`gDc=XBx6z;i4mBZ6%OwD6jnDvqj-3fv1Mj2pYp@`o%WWw=T z=SbS63Ti8Cf&KkfcvN{l{hY3dmygAw?0qH5Y>P%+cU`<=uLs#Bc5tv`Evpl1gkK-? znpxbwLc|YsLCo)YkT~lsBtDNu{+_3_EL)t4Zixd$*>sHggk&LU#(Exoc*dP6if@gA zZR8cbX6p_ns+J(IMGNCnBk7lYX3&{U*rrBLy3#}(Jm<}Xq8t0^pzk95b~U+ua$qr= zZfS&uA)f35Y(={tp2Uip(MBB&_@LZ~%Lbvka zk2wrna2x0Lb5RhgC;@m#3#9BNASHR6YEFpa?Y0PTwQPr9Uft|3Q?3JMH;a|3SxY)? zuF@^n4auARO(<%k03Q?|bN$*d9Dcu3@UKD()zFNgHM$_*nE)MYn=z_-FXaeR4I(*&L?;GZ-$A3 z{!FQZDgIJ;Pt?2W>2Tj{TqQgQBRo6FxiTJR(by^ws^%y4NxP{nH}ksB-%eHct$=L> z6tBsygxe-f;U(^e==0{q%*FtUG{j=XRoFQWSGyM1U;gzQ>1n zil}6>Fg&Bq@Hop0UTZ63e`GJSjO%8e9@qj0CY{0G*$V~v7tnK~Sw#16B2E9a2ZbMV z^XnW9G@WjbF_foCGcuSqs${UgnmM*$3&)7*n;^$eR?&T7^Qm>BHRw-E0QRgKc)wXi zN5VLs9p{66sJ0RU>v}1V!(uvTu>z(K-zQhw=7QKRd+ay5Mk0T-!lnPb=%ND#$S>MO zwcSKfqm;p!FBDlpd44*)uZQeb=jK7vMQ~lmW;VS377AUrqm?ITfiLHY`OWbnUSvIH zGmUjHqTZU`@GT)Z`j3g{DH~>rju%-maRnc(UxQ1zHTTEbF8DaJ8Nap~;DcwW)MBwC zZFiQ#O-l#hRn9c_)0d}A?3Qk(MZ=cXST4uvEvYnrLj(>z5rttFM^I^f#O$qP@ST?w zGi&}0QjuQ7B~**3o|_io*K)_i)$^d8nWX7&6mi2+5wm^9v#?)61P+y09iX-NYgMHvQFm8j{Nl=q3=iLK|0aJ@kmc{O|ypFN(BY340> zxcVy<@l(b4c2^j6e9lyqeMQd$GpLnECG2SwMANrOHtsq}BQ~UwxAO(5U2byfv>V^? zJiCWky)Op(A8O-nyBG|EIEWh(gb}F~$ZIvD{WpJj7``5Bj!waXuf=5iejzEI zbjH-7YFZzwNTiQQcC5<0NBa5|sm@CSy00pUR^HI28ZT#)&u3o}c49I z&wn)$=5mY48BbuT_jM}xxs^!XmlrK;X$i8{O$Ch2mV{ZMn;5TZ9UgJ@8Kc2>4})x zoC_;|E(IM;2E-rzrt&h!;imFjxa5BlzrG4@Zn{VJm=e7k zlLTun=AwqQA-oHGLUbb4@Q>1E8YgfO<|-sWbNM>l6{QJ6%e}x|qKj^Ql12viCxU6u zRhr_50iwHoZfG>zK{K_eLS)hx}KX^KS!5$}Gev4bJT+f!zG~EDmjnLKo4E z_~4f)cm$Qf?0cqU*6Vx_tGGcFRMtR=M+>>vXAfAVi#1=4Ld-H}E;4Kf-o^hB|7CJm z5mCmT-Y$c~$tG0)%`q(b5sy2+s52%`E$GfO(zt3%3&$#JK<3JHC=GPRN2StG*7chd zFBCL$`@4q;Rk4JLgoCj7$TT7!$_E=|x!lQrO+>DZV=wV-r%LU)__OK&RjA)bEVVeM zj`=~t=eP?bPb!lhkve3B@6xPWVrUkh31b}F$&{PB&f5Hr==Cf>2VsW($c_Zh&@onS zlO!v)ijXrG^`XV9oGhB(Kn*T@MwjGQSokJ^nRh`F)uy^oyYt;NDd>26|NBIo^O@ts z8NDLQwWoCKWsR_G`6lvtb1xCFxJw-OAE44>smt@Qf*;Sxb}K)``Y} z_K#O|dcy{2aCbn#E8LoF-YWW`fa`SJcjYpR)^I%D8Wr}|k#DlPq;{x-K6#%{l@kc` z?iD7ErB|?l+c$=eTyKvud%;RnilV;HKf<=hk&@iG#4s*_=>A=UNduYiL+T(k&e?*C zvVB>>@(vokWFH!xG9VX2lW6h>Uplo(8 z9(75hr?diht$j&Xe-dnzlO!W?wph4*0)9WJqJzI5a8k9WrW(%;>AR8|(zk4c%#gl_ zauMHY{J~S$7B7q2;wy1jAeG!dSx1DIiLz&>#lUN&cjTDJRI^oPyli#69dz)VCOgwd z@u8&@*ku&bTd%t*Pk$lN{Iwik9LR^2CC+fVw-UUk$AH1^)v%^o1dhFY4I8)Cl9iR0 z*}sqY&_dz^{8f@OTQvHRb<6)lrNtYdT5lV7#!>@66gQIOP#^sLMHhUY{zn#E4m3Su zh(zI>CTd)i1{w1KVv*eo%^l{*4#d$^?-`_t=M7!3Sr(>k62!i*r)lULF2l~#3)d6U z*#{ri3y*Ugm%2}790UIveRT39{k*XfN4o#RsHRMal(E3TVKsOs`WBi_N8zuu5^`_% z5tyR?jJ(MFYhpXiiOf8v&H1RzK+yFbJ$^15*T0r(UvTy|vsY&jGh~wJY#x3ZAFc@M ztrK))2|*Y0Ts&1U3zeoHrY~ALpeJ`1_(@5^f{sD_6?g^yR5;+`)_QXE$}X6JQn+T! z9g7`*QByM`Q}sbz;`m?+40nc6p=(w2l9L7T3-hGQgZUVjQyh_i^Us8TSd5QLddb5p z(jamz2KIF3Aw7&_#Zwzo!#8GlJjdQXXIs%tH^=02Z&`?&7-ZD(q8vLD}URiSs<{L%f#l%{9C zz2w-sRJaCt#o-Y%h`v(DgB(NSiPQyLL^7KauAGdq;-bFR31v*@8)Z)w<&9c=hB z6-3T%!_D(6@mzKZPT!G>*DVX_?$A_tbd?95Hw=>%QAc5LjN?W8u>c>-^X(Y|*NJku zHQl7qP51t>#hVp%Sa7q8Ox9c?b*mI1TQCV19hHP{e#NxbZU%hNjbl#Tj5pPbokq=4 zZjoTItstOs8ToOK$Im0V1{Updi&odRv@X2a-Iq>Yxdy-ECn8my9=1)YNjVL?#| z#yvYoZ2QdGLlt5%^y(MlvZ4T@ABb={8!iWRqlC!BaQ-2)E2M0A2lBr0!xh(hnDhOU z#Km?B{GHjuPU+J^Cf^F*7}rx1{{hBGeF=I-uAt{RCjTadWE%OAB?C8HG4#Po*k*Nx z96ugU|DJu1tKJ{s^rBvW-yS5w@Qf;Pn5xa58$yIbqu0LK-5l(&4VvM3M{K%kzHbk7_ zgGEo0a6^Cqc5!Y-?Ts0CSM$Q+jLXzzkq=S7NQBx_F~C$W`8f+&evFdijLT3qa; z%fc4owh&Epwr~W;USCWqGsEtw-KM*qab7{UzbPjTxZkx$>FvU$5csNz>J>O}KGt#^ zikAg1mSf{T&V?wFM9q!;^Vn1z)sAg4loIi6B9+uG$wzHTxBlS>JNFR7;e z%qI%_J}c9nU9uhPUMIE zhxD#X6)l~*0>*tW(Ir22(Vw#~LyeLzsw*2(%a?)ZXd;Cikc*_8zD3hh_~`sDO^ls! z5*70wK*P5hIJnP;mge1os+rC0JeAkksNoper^jXS@9|SHr7iekSrf-oh+{@`(&&3V z3utVzW{+znn1gyZ7UDm|JI2f;Pl7_`0#Z+c?B-JfkhGG|TX^MFP`L!vE zoz8i^uSjH)tERJI#A>DEDA znPco&W+eG?kn?r7`4iDS@nAgbEwg?!6dKjuK= zP!hF?8CcQ`pKqFRtg_kg^6U^@ary=B_LGDjg)rJWD~97q7SktJo`aLb8szn)vwF_T z*r45ndD@(>0lQ)DrWEjhS4u2?DA5V-IsCQp9y$N}5^b-FrtY?xtb~CvRL_^A4o{}j zBMZm48GIDpo8m<*`Z;HP+5~%Nk`C~{(8J6EOX6m34NrP(p^LU6%;Ok3nMmCfm_iY4Zmu#wlx|)Ex0OENJ2ecp=FH$7+Ga~;6-YsQb~0$E ztDw%>wfJVA9-d|&lDDyjm^-$fmbST~^V+5sA4%=qno0JqB@n2PqSv=+LE_hHayYS{+)C9)>uDFE^P@FA zx77ywj_J|g9LIH6MkZc&A7v_Mt)RF;4yC#NfoHld(cG{TvU0fZl2$_)xNZW~Kf~GH zTw`<*>BOeUVwxo9g^qgHL@Uf|>ELiYJ=Ar8`94bmbgX~!N)}D4H?;aq4;TjH2L(#> zHmEQG;!+6n;;G8;Y4WL(+XwFOfZGG}+1mp~u&i_eq|C9w#TTjvEyONZlZ#za=Dcs!Z_R%njf3eX4zNQdhL`W!gGb~G*?X$F==9NrEG)Cc zcL(mWGmiG<+3)~Z3P|_w#MHXp|~S&FK>q85$3SnB8U!4B%;HG)NlQIdWy?lS{}84>RuU4T6Y28 z>11GT7a`Zj9#_^n>Jj-R<?*UJzUFc^p9rAn|ITq#*c$FN8`?YiE9QQzcoX|u4hT8Go zR5`L=G>&5eQO;fBLw5T}k>zWr;+KIm=+%2k!WVfk8P?ZGQR~!~Z1?=bjJ1{#D8_Zd>LaOCxKRgwAGt<% zZ{yx|V&^dE&qX3wXbTmm_tAOgUtzCMFm1UPjy@~4Q^^uea$;0Mb)$s|iN4IC*YUahAM5{dCI)p+!ARp!(DCb{u0HZNePRG-sgk^K>}XS4r)vB2Xo54J^{|g1!wckQya~Li#Es@hKk>+!;d4Q#n`E ztu|KjMj~B!bu-O+^`1Q1_=KMP-a=0731N~lmEldsa_nDno|2JDYTIl|Hb)l_-ceZy zU*b%9_z&TnfI6sXD`)CXe&wwxTLPJ!H_^kSl&)D}3eQEp(1dk6$*P^(;Mx2*;$`j% zW#ZYSx82e_qsIhw|5b6!{5Q-E``>g&WDI$FJ&@i>%K>QRdYlEf$;?TPQNgyFIAcp8 zSyp$DOGW zuO;H7C9@W?(|(S~z^&3^yvzz%||H$g`$aSiR&N>9f=~ z=c}kj?+ZfpTPLPKmdGajtNV-O`kq2zF1r!CZg1VnU=O@Jrd9`gLwj4|ic z7n#$Ic2F$*2*md$;Kn--$a#AftDa7fKbyQT>GC$z;X1qdJ=!Sdna!#*Jc#c51{X#L z=$!2h)Hq5Ux>wobmc*AFyKe>6xN-(&JDx?)!21x=CW(CRv+11=XHm8OJz1SP7xkO- za4eS(#+x?dt+}n#!8ig}_MT<)!c=*3`U#-oS49oy#1or|WYO4%hl!!}S~#I)3--Gz z$k*xB$fuzR55FYx`g>jJ>EaHoiQkA4so!WrJAnuLL|~^yFeB->pIBU4NvC$QAfuyA zRF5i=l z=$uQXo8B`^e9w?9kze@AXet!XyM$uPQ|RzR53mf^hGmm(VNO~hGyeuZg!r8!557sk z!xu}i+~5sy%_^ZSb|duPzAPfw?gH+eT)$p-ImBvJ!1uF8ME=*Ky3~KqKtbynJ(-<^ zPWEYJU~d*CXmRJKqc5R+i#%~&JQwd|SYeQ&2HsD)#4)!D$-@+m-L>f!1BpqnT|*UT z`LH;snFu3uhhV|oBAD!V2##!?Ooa^H!0@;;JZWx(p@m0K$4ZIFTRkQGA>mAveIXio zq~OHKAjmzggW|Xz8u$#rb>%7;VE^*O1SG4(MsJfb?t9|h+rwxxvYj_gae_UyfP43R z)1cB)UxRddJ$hX0z{Hcj3o!Hym8Lic#RYQ~!$p;laZ^Hlrg4$>(iwF7GvuXXcIOGwzxS z`#3?&=yViW#8)qV`~`8H{*mU~o}kNhG;y7QB+Q&_4Kof))5d&dtf*KIQzrjMCH_#- z@?8bygu7wYwh9#3@PYO()~0xOGV)8-fLz&Svd3*3MB0c`&zV0__Q!GhKI<~vs#!o< z26=So+8Zi-Pu0R9K$sr+YXJwjJ^mH`X8P#GRkCPQ9%f`cVaIC1G49w-(%%q6r_2b$ zppsymIFwDsZg(Jm*(vB?YvKM$ar7RY18@BUXsSavz3FiV$3BmdKl~9yZdWgPIJgJo z%#w|q5a4)Q>tw`W=o4@~#0WUqeu{a^k0|Oq zL<*XmSt$=0q!A8eR5KZ~MDv(Q&uVE=lml5Jc#E3_iqd;JJBdPSJW+P4!z)#bS>dV# ze7AHX3eRvjDq};=$FRRT$KhY@HEbSl zr=G~fRO^z3jPJc%iyH+=V!k7a4Z;^h)*bNvIcW&<&4^dCK=d<-vK_{&z5tOxut zNN)}(lR9Sxlq&*RLzSg))a)!Io$Mf&#n+Ba3A;=KVdHKwaV?T+&C2d@0=mCtv1J>@~U-EI&6^cvC8NT(WLI39f zJ*KFLLU%f_Y-D59ecd1wY5qf0wyb2fj`WfAh)`_IE2KRsx=@t(7=uC+Fa=^UBPNJr zf!EWK(Q$TkVFtW!^n)XRG-0`j6&JDBfcr=5%r9H=;qC4R#BRSD#_x0DwR+S*V9gM^ z$zEhddy}xw;VBrr;sc#E-{^+1S)jQf9de}3g1tsPb>p(cwp?%R#diVH%BPAe{<(pC zoDEDYe1ozRDP-rtM|A(!QPLuH8yTHbpuma8|8C<^$Ed^1?XVYgYpM^32)v*l7Vy>2 zsPH8EB|}tn_jzm&Q=psHzoNHhWRNH09POrRK8ZEFL}X=+p{d+}3~~Ef?YW$vw!{na zx@Dl^Ts^t+oCmD`YI6JCWNPJojXUS*;G+~*s8^f~OV4YO6|#%5cp7V`RVm2b5o&U#GJ!2k*ILlBxSRXVB~=(D|9K-hJVu z`u)0AR8aQ@b~ZjDuaeJGqkm~+SHujMAAg4SE#tFrd{qbY+N4;vEdloaP@^rs{OPhv z2InvWw4||;IoBsdJ%c6aU&}r`!HA<(ZxosQse>%54kk}u1%rrG2)UKJ z&RB@g&gv4wU)NZ-+Yzj?pe3%6yc8aoJAdW_pFfUtd{P_RebWFW zwAzS~coT7NGDD-zc=X$Lov44{!))t7oVdA_JY4aGyuGW8>bm`;x!C~2BSXlzzaDUj zd^p+|j~ib{Q2&id=&deIW17=y4Ykj~M@E@^{<{N?P0mE?IcFh(Cj^^!wDYD0Pi54v z?1GGQT~PC)0vTZ;I3lS@3oVUc%8g>$do_f*KQe+zJB@fw_ovWn5f<1jn@pRYo1xH0 zYj9JyfflE!uT|`?_lQ~h-L(~ zi`HL$p$10nR^-xyaOfO(02_Te+0QdWU}OFx^HIYg>hQ;({5Fll-Tt}Y(Unhz8_$u3 zX<6VgZHQ^m+yPZ-8Dp0NJ@H{&4GdH-v<_V)GM{gNzw<*-`XZ>B7DBja9`32TPXC=- zLUz==#T#b3IkvU{N}UjaarqSbIh+qzqJ@+amzGS9o*zzCw5v=0Yzt&xChdJ&9wm7dL-rVV$fsmQOEZAM88E`B{6pe4{38 zZ@Wd*(h?aNm9MOC=oR`R#tPfyEAh&Ie`uUq3%J_6qxm-@N0J1d9^0gEA@UzKBWox|qQ z*_8b{hw^UaoBLis1o10qHETVtI_`+c`Yg#CIRj_3Tw$L_6B&uT%L?tO22X)5I`zB} zPQG)Q8Ede_V`@Ek_UI#;GZDvlmrcU&Gdy5_>Tsc0 z$l}Z1DR^?uGE!3IjGBigb6p=bI$I@&PRuHSn)qNUq#I3i9t@LU#vWX2TuJ5fNM6YF zY4vj9`K;Dpe`VK=A&z?{fC^tSaQ+Tq=r>9=cla92WVy}-KT~5|dU*q+w(nvGmU3Cl zF=Z+k?~NwG<>bO`M-X`|&qgS?;0N*V<_|9<5^ZmO8uwKmCFi@sp0r|oa$NvJyb_2{ z&rXi1EzNl=qp)J~RGc$S8J;HwLs!`eytY1q>{wca1zWCxVQd#MbU(*i=~Ih7YJ#N0 zs|G8iu5$a_c~t2fH=7xmNuHk!=bqKiakt}5SokN9N|gF=S(`+>r!|1WOTx*~3|k&X zNuqGGDlHcjf%VK8GVAf(jkQ$lkLA**Z(`(W$|)9DXA5<{*UsUJm+tUF`7>WuTi8LlU!f zL9gftOt8<1tV$jZAO8>1Pkv%LR~1t;XJ2^jBLI^FwaKoZe)#TvH~CVx4mGwf;W@=K zSn485?z}4`cWmF#Bd_AH{7yL(zFtUU-n}G|GLiJ@2QNHO`Hv)r&LP!jMe+2?E6niJ zND{2}82Ii_qaVfF@#3FS5W8zice=;XLmKj2j<*f`Z>q!2UXl9WyBEQMRACf(+RJN@ zUqW1FX~6mOpXlpd9<21|)l5&HE@XvRBA;ag7+h56{7&&OP}fH9>sp{+_9Ez;GK;2} zeWgJ%9EbQlfb2*$+I)KoLv{+x{YTU4n}&DN!KZ(y>IpA2VV={49^A}TSPu+G&!Vio z61xgwD&{X+*Z?RYyhEl{Lufl zmVC66hL46b$-nx1n5-;K3SJ+=H7oLYL$R2TbSmzI9iiOsIr|@(+xDIea~wUv zk~XaOxaqm7~2 zI*?vq3ATF&AUgI2V=T;9e=ySqd`4WzOs7ub!nu~U7K_w(C&$C{;}DE1N0GWN z=lBw9_rr>X4fKlr2K3O4fa2Ot9{+$WW+$B{SvC`7@q=A({+R}`dYTGXIR2Hf4snwyr)iV3AvG)--*x4g_nk`x(_}f|+g545Oh=8Ln5s)AoTJIzxtVZu zt}aaq(}GORohbNt8r5B3hKi+I*`#(^BA&H}wa>UiZyyjQkEI>NM%|Z!L$750wy>#m zv+5)AZ+8$+Z%F-V_=wj2*aBF75lTI8Taq7m&19bt2pFUiW(XSVH*z|+!Ckav!-MRog`+F z2H(tE#yLKaRTXW%%#c-D&E)4(J$Pa~%Y5|3Z?bfJA3pUTCeWdaliKVt@BSv(H@XNW z-SdK1RcGgY{!9;xUpql*ms2Z;Sd_*VisnSqd1b0L(o5{uAp&O)@V*i_`ba2EEJSrq$MVmaX z%i|}P-U4y_pbbtT>6p&($uBzQ;0b%7`hgdFh^k>vs-Q-91JS^WzH{dG>_K8afJY z@{>@~B!D$vA&PIJ65+zgCR`3Vr2mo`?J%vS3IENqcynG2?{Mdp<9qqh^U(lFUl@&6 z=1(E~a~)l~-h%dTNuZj={$QQ&4YJSpnQf2ckp1(6-ksxvq4teLM!*RstAs+8ZUVFN zOD_oO2tanxci5fh2vZm6qkljXBNUII^l1TTEGk7U&da^FFbWRc3_!QSBnYngMWpT0 zn8xY(B*L2GIeIOF54bfc%-fx%(6>xtVEKm zjv{zHMjc<734x+qBW>b;#Tph_(zN(E*wLg0PTY6QG~Q}lVOWZ(2JG>Q!&TbbiQ>Q0bckbOFFtk)e`xSQf3vgs#e=aO2sGBq6;xp>eGkcR3{ zi_uK6x^AD!3@!q8j@c5}L45095%I~n=2un}vBKvkvs%!GbERTvs8b~IQEF%9P46+Y zA3cIo=NcGUpQ}_fXck1&1i^lVIe2?e1P@21fS}hp;+A>|kK9&+R9!2OPY}U^H9;i2 z!UDbWlF;iw7rH-)j{LZS0@NP6nY#v5Ltxz85jRelDyaK7r}`$X~h z?oV{&>3aA=e$IC%-?hffFjKZ5Y{T{zuht$=Iy zJR|*e(u}WdIWczL4nOV*)awmSh8=AK(5xjjnX{kHIWvN8zylw$<+H51tRa}Pm>vZw{oJ`2Rs1E+G zDXy*g&KOt(5vjwvMEyo4EDa9Bg6r!*bEFpi<%h|i`@yt*^&X`1mT1ZzL6xKbXb7tW z(tS@wFKb;hS3WWm-#zpMA>WlSv%!Yr-X^2el8xvuM{vAsB`o@*KnwTiL0&16@>j;N zt=xo$yPPD)YO1M7+znpUq+(c6+eh9Hl#yo((lF;q9X+!nA7S%yjGXIxUETW-!cHE=GQwk1wgX#jfM0$?~Q3tp3fH&oG=&;};sR}%Y zhW1xk%TsmCutGJgJ7`30G+)!OuX(iTix!A#uOPPX2C0q8XN*|hfpe9VY3-ILl|Lft z>G-raWM6$7ogdUi)=xbKdlhn^+%_BfH@=|{BQojCzAQM`(8&Ew3o&}~P9snJWFJef{?$U;(nyTop8#g@Lg;k8 zlRn;e15`^5>F-fvjDrYzLBF21yo-mkTR0ZQjNjytlqkFikSC_TPVCg`1$c4YU!Lr5 z9m>7h*&PQ?VS%FzSZl`iM#YL($(Lt1NKBAAs6+vpvay;N-MbwQ&EF3SjqrN^%y=qkIEqm6ZPFXERD2K!L zvfs>y#U7yHe+KpnzJrs+3rKKw5|!XGL3+jpn8b1AlRBKSy+{&HCVElhUV9ojbLX`a zkN>iPUw4D@z542PYeT7dUkrH?YKW4ndK93&z0NE`Kf9mRY)(*yzotwe?24s0Vu;m#)oi^7mf zK(135pI(4%s>^ZV=_0hdql9#5J{+%GLKbT#)1{)*U`N3$OxY<00v(CaDs`2352?Vp zJ+olziwDGnxkP0XxMp*daDBZX&8t;G5CZAh^VSvj+{#wqfY9YfW?Rz9k%2S_AG}HHA{4cpAGYm>AdZr9^WsZfmqdZ>E9=-Ny+amW>y zjyxlt?f^M2s~Lw4#*ifOhNx#hql#@)&|7Bv|-M{9WrWH7*mExKeW_Acrhc{(@=!yKowi_Qj5lzdvkwMQlv?@yA+e1 zC+^Xs z>WuvtlHk7UUp7+vJN29`1rPq?N10U|@AzpxS>1e?=c^LKnAxSmc=mcs=5k{rvz{^z zfwk=Q4L0OTZXoVjXAXlSS83GFa@5bs1CN=fAQ6vH*ToybM5v$a@rtI!iX}wgv^YLi zd4p-`&0roVSU;uvD?av)gZ}NC;H&9T+B>`r)hC(3=J9uM?S&Iu9zD)(y%J3&?h$xW zTnwZA*&tJ4NL=?PkbP-3<`<_J1^Oxy(PhgPIP}Av%-H#kylvl% z;^UmJJ8=+|`__|riyiP}pB3%yk7d6&s^dFe9JZ(DBRd7$JyftNwjAT6Oz@hs1Vol4LcT#boZaCGcUw<^{@SbL zPognXV$nu3)ZIv1LMPd9`WEIp1mkMoDcC6-&*T(5L+@H`Q2%!TIM`^PAu9?(Ax2LN&N(G=?xJ84Ha#D0 zM-Q@^0VTA?buzb`NM!H4UJ8ZoZn%C$5l)1Nz}T5U5+ik)etqzntWH(|F~>&8`&ULI zas}a>cL$k%tOGB$1!39e92k2q%H^-`!09h;@G>leyw!=p%Ftt^>%=)=S~PHur9izg z|5h~HR>}Jkx*t?6Ka&d4ORU4F7aExDL@NbT=;c^kX;1q<4#W1{syOLAADj7n35K70jBzg|@xjA`RNAnUwL08Puw@pM6??;iw8zxg zQv&-PF91_l&#^wFVC-xiwGG-!8U*<`M(8qJ#a9jawo{-bEe-V*cJLw^RuETim*=on z9aaSgLS|jpM@b~~5B_IZ$zCR*sebz(HpD85LHG}v@hB4QxG`RiXGB{AMglG~8 zoba%PYwyi4z;m3jI2;30auaFP-D^zti+yCJMKnA-BnumU$l|+MQ?Nc|HN0N!8nvfg z3G=yKiT^iQ`t9T_CT`3Boph6NGDv}#mkCZhdu^_6eVncGx<&u`hY@!2E3(t01(S4S zsQnaG;CJ{zjFPv(uQ0&Dom2)Wuis|ABs6CmZl(F5T@qgIPAc4N}8j zpwN;wS{f_>oz+4_HG7aYlwF20?wM}-TZ2m6a>c*d5?CXsNM()6=*n-aF>LlEGEao- zB77Gj@20lUXC{6y(EO3feVI)!z%7t{E`%+sW5E1IIOw03sy97*nvql7MKz}X#1{%& z&d1mUf{(n1g|DNLPbr1GG(8EesEO}Zdc!@t7^~d5?^5WGTyt8_UthgNy ze$Kk6w=k6YYdGMQ-cAzQ_=nnfoCn`+`%q+83mW)Yp-lmzWM7e}cC|J+G&oH9jqgE& zgE?A{cTy%;8+SD?p|)O|k$t#`rkow5cJ-HuGwvlT&Yl2!VIMm8L-!)bYo;h zgvs-lb7`kU5=q|ahOc+#k@wO<^_jw}XvrNzS~W!-rok(8^yIIXTfY*Fe`sUbs~FnQ z8415t^l+7ZCA^4G;5d?xX#HVtaw^~%8?Y{vh&gFtup#1EZBIxR4~GFuMTlON1#$3) z*<1gS`Z)#DV>$1T`~CpU8^L&VohkhNZ!u}f>chQYcvmR~w2rc1mte%k zk5mx#DS5|Fp zi6qnBZ(yWC=b>l8Gng{oLKR{zl4VAHlovF{RNRlC_YOT|i?$>o&mkKvZ5|M9Q)@bD z$PzAm3a1XQ!fEFF7#wn53wy72ke4&%a3ZpitmC*S`>%Yb-siX;T5A@VNS+7HtO)R{ zeGS8M+ad8^9(-IX3u)myiF~>e=gH=HI||9Lv+^`83t3EG@_HE8WtLR_nJG9*I+0K1 zg-oCCIhbd@h_Kd0p!QmTe6x$Dsi0O5?0g1Jxj#w#70!oqXaVf>GXrV)PDuNg zgTlHS;k6bI+G>l@k;{_&d8mdi6Eo0vQWEv}p~$GD*y1ApR3_l!9tC)q>C zwd>hu@4eAJnGff#uVRnh;*qy}VlaHNlI!~KqkJd@%Zqx+rnSkqJ=+0ZWW0bOA9Z5Z z(o0Q`rqQ1L(Rg!;8q{bYv5)>$jBrR0$)4> z?He`G=XkhT867a8X?DM75vPdZ4th18|t02B1Lnyg7k;Sw0%to7GJ%M&HGls zsz`$Bk8cxow{AN0IFu}N@rK61{j}tEH`TxKzApTGFB|6;MUQw1!H;nltkvC!yGM?p zan@nX+Mq;3hGZZxOas*w3G$lf5GfreNX~bof?=n?^Ts+b{<;X#c>%;%un->yhJj$U zIrB0-j+E?)0Uwum8npBrP~&j;^oZjyKI^8M2hTvgh%^k(b4IsWYUJPNhh)FfVto5l zpPoH18?SJC=HpHt*xb$sCW*%2@46p5`(^NXDVMFidX>5SSOoGOL{S&}FXZ1!b*k%j zo%VeW16ebEi^APapr?`ue7jSqh|3$c+j)o{aS*jw{8)h0I>(T~Rt1pNngwnn9;n_~ zOl|h$(^LE9VOS*swm#IrpT>&7E*|u0pFXEJXtEq)l4$gg` zgdNyS_m$<7v1jh&>@+FTHW5Y7bOeF8DIaY=zk)r#SG2z8u6q5oTS2HdCRC+6Ujuiw zH_-*7#W>pG0&lq7=%I{TOnIdPX!%`$#JE0I_Rnf^+3*xzvyx(jp^nIhwX-dYx%`p0 zEfI-x!GEhJqjIJPK2Gm7*MEE-1pB)P`=pB6=*1GXsbA?`$wsQ0YQ+9Nbb?H73_ zaZHxJC)vMNk-lsmCSB*WG1|6){FRoW^DhKoI@jL}_;<-H^imnFxl_iBf0%`(gUK{n z;y&q&-Nh_0w83*X#72mdk{YY^ zDViiYF2PNbC1m!NWa=XO0(M!-fWe1qF8>jXl`&J;3I1BLzxO-6H#$KKen#W*{Cnii zv&-cB$tKwBE)5T)?$PJ{T!*(P3)P4HLC$m%)(Qxb4Vy;jh0<`y?yF!=dv=rTbD=n6 zLkRn>@(kr!yOPA@6Y$09B9)q5$nnyBuz5!;OgaEKyCaH>_jl4O=PXcb{X%l){pG61 zZJnUyaf{U5nn})Y4dU1zc6h!tlXTrGfY_KVSfOcxPcC_&yPr4J41Gxdds9vXXA&&) zk0lS*I0IdE6;^ON&MMOa?w+5CJ}-lC&DjAWq}k6q5mHUOUHo}hA}@g1fHho*Q6v*( zjfAVTW5>g2$eI)f4ZF(l)Su^^d*}c*RTi^fqgLUo-7X+B#&PtOFOi_P<8;H+3j8UU zN)$q~nQksOyf?C%+VJ0GZXb)oIij=S+u;uOneSh=y(NW_@sI#F1%9geNSN9cPbb}v zxE}KRMX=pEg&vaPGU`R+#6gy0q5gD$57_|_{<;Pa#5)n!i8ieM^~qfMb1}!zY=Z60 zd(dg7GuCH#AX6tnbdhi;imaT28hZt^re% zd=gu9fcAAcz`~8!+5C6z5b&;>^&bDmT(X^SaqDL(+bbCZ4rd$8jxL6k}oJYISD6_Khd{Pg(NY5)LOZPT)CkSaWa;)bnP58xjY?SPqo8o&DpT1 zUxJdje7vA-28(ZNL-Mdxy~yP?v_d%z4h0HXymUE>gGpSj=E^cmD839QpH;yJFL8@` zYX@kj>0|O~e-c$!Y=$4*4=^WA2KMcJK`-P>%4@D2}6*-SqhcmywM!BL9iTa(s&SBr{nUpY@(2zdfb!sycuD-R9*m zs>$VegyT?@7e`K*L_8aNXV4dkEx?%lNygR;wdoM=->zYre zx`jZ1wihS~2NJcY|A>=H95T^Sv|c$2nTywmd$j|`DSQIH!**<%>Q!=P>>+(8{*YD~ zDG<+noZHB!29~NNU?ndUo_M|?ozI%c?lm!xyW$f~-EaiDBqWheIu0%p*7$^thv^a; z)W`NVw2e#CZpWFl{y-_So$Ks`ONnC%hj7~9e}{bJxGTMn)4=hr9A2uC2Jclu)ar;n z<^4X76Wu2;eg7@?KQFzyPk;>yjUMqX0ShCH~vycL%m{LC0HXoDLi{Pp^)H_(IL>NWSy5j*qjV#XB(gXfQE3sT3um z*FbL`OoWVC9RJTE4upr)@s`6iQkE)3MGo#q|GFyt@6L5hx;C8{F7Bg0>*L4=7dc!f zu?u{-`()m$)6nC)818tKP*u?(j?1b=le3j@?TSniw>=COUG62mj=|J&VuZ~Y@y7KF z3L(Se4pka?OY4n0V46k@Z80<^{j&u~=@ucdFEhaR8>Yb1YD4yyY&p|zeFHD0EGKb> z=2XwIl<56E3VF;{kZbECv)$C;w@eZoYEMTGwuiox*@T;JccEHv5>dV_Li}}X@oVlo zvcBjw-6zJK>vVePF-stOe&>LJr7LN7Ie>>7T;NGIOCH?_BYX2KQ8X}xgf}fFMn5>W z4>wzN4x3Kh*Jz<-iXIGo$bp#FVzzjy6Smrx!$0#pp0Rg2^?6dpE?&v8iKk8`PON~% z^@LztG=71|@9V;-VJ=gDAQKO|NkhZY0vz0QnzYUnfYWzX=!xmtqO}*5ImXuxR1zGA z>@_Oz(A)q|T&%-4BO#b%EDS=%xiC*7oh-c>iAM{BFx*$tLdrUdWZcq)nD545BxnI9 zc6ZQB;0#XTkE6E!r6lcfK2_~jtv`_6LQ}dEn3svNbl6uM4Bp)4xB zKr1?rw56AjmwCp}rO-)AWsIT3?iy+CQUp!i6KJt<6S3<)2=g``19!y$wEv_BTQBbi z!^TLA@_9&w{BF`ruRPTI-Uql{gkzVbU_ehG#9s>olbddM;$x=yg$0^)@xgUqBjbSe zW?Pt^$}}=|cOKbs2hilyI_&z_Q<;9`IIFEC3bAF^afxmuna#1w zE-S0SwuwZLBr#yS^$&aaCW7~yaO{_EGT;9onXWmm1lxucsI-j(q=~;E9!fl#c787n z9=pKK)|o}kDs6ZY5oWmg^m{sTW*_@TSPG*TU#HXUXW&f%9o&<2iM($L*}L8%DN@lt{+8FRPS_hG?ruw4`qmN+Gh9Y?)PQIOFaWg4Mw)mgss#fb5_v(NuL5A{URc z?h^y_$*guBzxQ1V)>oL79PdZ{RfqYG$6Ha}$q)>cd$}p^OGdcm1yR*{1er1enC#t3 zhOE-4=%;XM=+H${yld#!>&l>_sg4!ydQiJZ72B?eL5BGzrcYV}489OJQ@;qdCfi_! zZZ$;RyGUKeR94V5A zYpXOlwr3VQWg-^Rj;50vRp~@ER)JOD&Ydy#2!Z|dI$9ms!H!H{!h|=Ufh!f2?5Y4C zay&AHNU!18l4EyZ(;6LUR=+^c0v{M?_+W~=3mhX_lq+wNlC}V7yQ2x8j<-;YHYq3x zZ6)t5?h*3vKe)2wA^9-3gU0?%LTu{7`%}-O!Mmpm@wd_$I&$d%s9%w$tLv1o!Frs?=UD@D^#-^JpN8rvT`1YH97Z#v z$xxLxZmH46h<|;kyKxW4b;*GZZr7M(k7syLx-)9ITPG%dna|!|83Nz_8o{Ftk*Fw@ zf##a+L}*eBO-Rb%-uWDSG5-o|8EdBeb%`KjYX?TXzrj$_k}9}l|0XIFvV(IUj${RwA8cEp&XK2`@#8z}s5`d4VFW>{Y;oC(^xZk4nh#cuXaT5)_nwf?Jf6f2B%iuaITVY`2JuM$Ni)Axa z5WUXQUlm%^&_5LdRHqRC!g-jQ*2CV&CuB(Z8zE-%ke!}D`|38c1wB%*WM?+_uMuTse+L|8NuTcTtJd^RoURktr3t@fKJ!tOD zRnW7Hh4-^}1CQh4Ec}v07yjZCTl86!9#1yGPCi2-@j{Dk<~WX4K3_@7o>KD%24d*- zN0HlA1fXk<4{DYULTj^W^^SuY@Z@Vf(O*{zUpT&$MP?&D*>JjQ_fsYGeG^L##MeSh zNdR@6@!4EYZ3uY|BUH9vGi|G`qvy1uak14PG>D*PnzEyw@%YwBL*`w9mS0-%P9TF!=3LuTQi|}&Vm~aL z!ksr?y+^ev97~1ku7^Y$f~B7uoo&Dmy$ymGE3}y$(LPM4rN_d;(iA9}GDN)Au0ZC& z31;p25z0K`C;aOkvJ)pM4OL5o>FjCZyF;@&wEiHMwUvggd5%zde=#)crb6%3C=%MA z%k3stao!-V-%-_v`{Lc$b>3&__gqVJ6Z3YuzDJ6D8RdKs+-F$)Z$2j9>Lpo%ld8k7 zRpYJFyI8tF>uSgNLRKI^3E$Ot^@t~(6Of?T9J^y|oU-|$e_FD^A-ug*ZR!5LK-b+w# z`6RS_Wrg=Wv*@k)sbtoBTk<2{2Qp@#$A>*n@X7RDWSYk$I3`y}MW5-y8^d?>TjniB z%|81Hghw^J9O*hO0wwAW439=AU${cJ8|%`CyTXwN$xcQ zy2eY$fnzI>eYBKpo$rYJM$1Ub(gId^PaK55yg}a0_(+_FWkGa*0l0igXZOxu$-e%0 z2mVaT!iI2dde|@&hs#sRMo(E<)u9g+@R{^fzDDEix5(m(BXmlC9!|`uCuUwjP`q+6 z`Us~&+K>~L{kux@RxTjJ7M)z?;2P1;o{A5de&S-8KquEt0hNFW#(6X49a?i0D!Z10 z(r;IoTY7_PztlsAJ!0@=tdR*B3NM0zZzz+5i6<)KCaX%r>lq5LA z%&Jjx$?G$_)Q?yZy;xO@ zKl6n-eeoRo-TV(L+))W<6S^3^|3cUgQ8VcC6GyRKW`uMf1Cra?@>0{SDuJ}l#WrIi)+Y0{{g7qq=)7|?Ronv4T!g?0(mEOg(-c1 z5vMJ>&9r=)f~!+JaMk?_Z0^|vXuX#Q;o=Y3I7Sb4O#4g)MKLqHe+SI?s|RkP znQ*ix3KT6wjC4;gJJycYpU4e!1B+Qu*gz+s zr8q=h!;rmJVwelaRaycJKl8_`e!J<$a#JvlBg)~Xg|CQe_6G%%NAL{+wU>l417B*T`7bON4J3B_hV$5W)iIPz0ZtQ zo(Fr&Zo>6Bh}hpMxXVk&#;;A}8x;pP&RcnE&jHBI_|E12)HsIlUKn3eNdBoEVI;rI zph7=6KbrAvp7XmX;`jXrBu8jrnA`_eI!GI(WVG?Gl%3f7*pGC^96e^Xb|cZ+R8Lep zBk_E(5lQSSgGrx@SoTRKZNBmr-yB#&JZ3IM)hP+Ec7qh@KOI&TWuONdXMPY5{y{3z z!dKmO`7v%*3c>O72Wj@zaq_rJgV9?VPN|$YWF2nAo5RUC`RXOMEYK7hOhPHUPZ|v# z#IxnspVOQ*-K^S<3)sgoW4FExAgc!@$YJhIpKVOh^j|ppbeTSeYQAK)HcZ3c2b+ke zN)ml!JeMxydUt!3=0oALB>E<01EiXy<5h8aSa>xGhBG2SVL*hml^?{J-_PJC>D!FO z`pK|hdMs$o=CWw3ro(7X02E0r!}lYBxU2Ij>P1b3SI>@;Ct)wyGTeaA-sw{#shwnl zLlBS8)e-KlN9NvAPwsbv_XP z_6XR~lmN#~bg=7C1x)9hcc z_DkPnD-ol%V4P$CW=Rf^f6r~va#0u^AAU{$M2CTWg*l}%dk~kibW6P-)Vr;QYlbqg zDf%f@k;|k*d*^{X#~a&R^N_4rX2sprQ^DOg6Xx8of~z${WTC78Je*bum6a*X*6qb0 zc5i?>ud=~oMr&}js|3_quEWREx0CqdRj@2+68?GS2iYBw%=3>1jDxQhadpgKoRWnw z=EiiGeKQK~7&KwUQwH9R=7Pl2lc;>a4i45EhzP4qNAxu!bLO6b2FWkXt2g%GGpm`A z|8@&BC&!|8-bE@Mq=337WRJ&?_4d|~b-NYeaqM;a zb@VRg70e|!4yd5>->rCPZ4&x?TTRMU<9We}4&00+mDVS^5JECM`5;%?lDw);CO=DrXog-J$8#5?V&1m&z@|zj zZAk|DsPbE=^|w%`dNG3ER=}MSSqt`uD;f#)uuFTCNku~+>m-uPT>5nk6e}BO*ye2f zJ2{*-4=PuWuLqnLX-i*AmV#(oFwgGLeYQhZfz0SYc$$9-*}*KdS$dJouT6o666F}_ zahaK6B!R~ax0A{H3z+DZ5N6BOG*Y`qmK;{N0#`rW#Y5I*blT7Y+I>S{se6}nnOU=Dp)aN5Fd*(sGS%?6g4Jen8^}+u`C$}o_N9^ zt{>ocKm(kF*1{T{csM+@kiI!L8|NCflHyr`sGD{k+eRBmgJveE8Jr}Fky$v^#Rhq0 zm)R#OlWEXZVVtS=ndDS&p^|bnboTQerf^{@+-;l=)}NywZ#;`GZ3zR@sdFIfdMMR8 zkq7_uu3>!&$A8#5pZ0fL;GQYFfbO@UN;d>?fxu5>V@Vo~ zH9-AnBsfa4L}>O8vdcpOe{y-r@kKT;TQULff0h8>p8|0C)hb#amIW&}iIS+B!4w*n z!^7L&%-g9&V59q(#OzOJ4|4tEqD>~yrEm&tIeyx?JHepbG*0}ooOwUyYCu-$0xUhE zj7`NQw7_x)Xm7s;e@os_{bn6n-Ik6;7jwv@yO*iI3FlWhSpYX4?_`92y1;0mCuLm2 z#dd$@7}2X-=wBm_o!Xg6m2M@Vw%9r}uxB9RY5~!i)<#QDP0(+_dbsWu$BO%I`@xIUtNTplccUlEpn8E0ic z-f0@tym5d@eMB>`_3pP?twSyX1MKqF70~Fb#s4YlP1w-jM^kkHr=yiN)6`F(4A7? zvaf`d8n{fur~83Q?-z1ezy^Xoore{>rm%H4^04xK95nLI&?#0ju!D2UsB)i$VEk@6 z`cIL5`{#>$axKA3HwFF_I78~PWSDzjAByCs;TiWaMr)=Cd?{70w#gl(8h!k@_Gdf_ zD4xW~e*qZY>j&%aQ|kQC3f}K0xP5|RieV;kow1!ZpMObjykns$)1K>{^I`A41*j}< zgdaD2Gk<1S0FrO5;oF85IKft8u*@=Wd=u+^^(E+bbr(sup z0o=F0O*h<~iKc;-=HGUHqq@fh@WU{pg6&A1N5K4;N+ z!%^s2DgYz<(%H<}{B#}1p=@m429acdZh9Nci1>t)PFq3jo~BCuyuILvv=JP(tsq7f z(l~gS@>JX7VQl9zWYnLNwf*yGM$JrIJg=8#iT)yO3p1(SfA7gyJHZWoS81JFH1YaV zNQA%NHMwdo#l9=v4rQSN?B|>^!q+ZeeR8!rZY`1maUXY>l$HnL?{1UMNk<@ONgFi9 zjN;#^T%TihAHDELlSzs6$9*9yKx(0~g@fue@_2^=mywk~86!Sga=(JK3@Op6VY(!F zvn|dtjKqv}w`tju2zEw^3rTEyK~t;#(Hl1gNvUWAjpNvdF^P@z+188noA`Oql@hDY ze_KHvn=jJg>O$OiAPklHdwEjzt#IdpCAp)L7L}QK8-<_f5rdGK_}_wu5Hj$a5!exi z4X5^j+qk#FdX6leJ3+Sqr2`4v?)&$4{P-veTV~FsDfYAJ+xMO{&{vSw$=Sf3wey%Y z;>iF{2jQB`D{y%vh!az+=?~jXGx4t_bmzn+Zmzc!E}$ag+Goc3vfHRBUkavZZl$Ej z1Cq4oz*{AK@Xw;?&p#cGF5ivI4l+b7@ELQ#zZRt(&XEH)#@LlA10#yjDEZwSN9HDh z8(mk3*sd2x=l zp8JvO7(HgY+YZ3^xmG$Yx|1FLB8J<}90seS9D{l^hxim%k!{H3Mnl}lA6ZLqap&;T>&vc+KgBr=_`L_7s zv^8nID+}at6g|Jtg$nvUX7ct5V+!X@QBMe^gC3Ka>Z9SrUUxTa;#e$Gm*{{&{2S7= zK>>a)vcaOP2GV3$i7q3{&}m*Qyf$JPvFv2rrgWSBtK7-1l4&D1(=Ic7Uh<@MQwYvG zqzwlX^FihGFA_M(5>I;Hq`9EWu@nr+;ru;B|Ku5RYvC|895f>*OvQ2cHz9mfUW9g` zf%yCbcSaU@QaSbhOUx>_gemK~v2rYhY2Q{#E`M4D|8;Gr*K^J?`hwLoGRTjf*mVnE z1V+=26EiS=ALqi%2*Rmc_P_n8FrMO=Ro9X;kfeoE%?;^vm3tPu`siL%7qO<%N^%%! zFN@DB)!_K4ezv2?A52Of(q$4qNOq7K)I6w0XPZoDnPdW^;oLc-DS}$Mu&klPY;CXnxY5SX?b&%T%>-e zr|_MK&)$TkQ#OO)GzY>wG6K1tU^IS^gIjIxnCt(y9CFSGpswy6D0NO^w0IZDjjdLY z%C{JUZQLEx(ng;{WEae)_bu0+8zcx)p63iMCMXfB~DCQ2>*F;jHjBfpb;5?3cm~R zozP;4ykic%OOL|V9fm}Bn!LrNP-S|1k}MTlcZ%N5-iIkZc~I(_&+M~1MR)NoLD$0^ z=x|p7?|P0lpu*A*bz>y;q#4>eJfZAUFR&UpM^0Z*tDZe*j&_$Mh;y+rD!U$~fi?bb$(oL2wr)!xs3zoL z!0b5k+(IAIeqX`jCw8#o@peo}{6su_4$wIP67*ihQVfk*0NYd75@&W1IV7ZtSvE*6 zo!5qvA_lXjYvFhk2gWMD44U>Db)E<~OJ4)92pe(IYnBY0tzJ z>>bp^?RSoYz(+HvllG$byttj^%Q7P0!s3Q~68P$|3<-aJ8s$BVFk+Syn*R9+(gsQp z&iGJc({Ac6Uc|)voyDXeifwKxaOxZ9?acd+s26$B9Yys}*yoNX7V6>)d2xL3po+L` zScY%55hm>3S?1smernI#1y9ZkgZpGri_;#%IM2DA3UFOB_XiI^xoSCFpL_wjBMoVI z+3_kB>j4~HeAm2rwlN5rxWtESU+f z^vy|_a3fs4{E9qlG6J_-t1-!coS3XzihXI4r1MC%neco*oPOsG?vBod)WGvF(Gv|G z#x)>cR08cTF_=2NoW2~+LBr-^(612z$*xvhRP>iBsGJmAdxG<*+Nz;-iW-S9^1=cc z5nR7JoG4kh(<;$iCPa;gJ5x?#TpH-4fWPO zih9?LL2e=ewYKKb2eUm{wOhxj(6a~B?pPnQ%*Po2yOT*?-G4|;Y(A1-O*Qn>vT{1~ zcs_p4W$7==0-W@|hC1(QBA;c4NK6d3d*ZknfOGF^l6npHDJuoOpW|!8{F)k08(w$B+wmViuZ{l*`#p9$UEQxIRnv5y|+GN+W zlbCZl7QQ5kg&sSttNCuxAo!f8a`(I3k~%V24EF=XEC#cOe4pm0|R zy_OwhA8`9dY0-sDhBZs~vPQ7@L@!%&JQk&X>Cg%BFf}@wRcY- zn*v#~tL7`+vJk0p9hc*Cip3d6t=QhoA7n)F1d(iVkfIR|p;6UprK3ymXZfT40rpN^GYM2TR5v@q)sNXlyG+|*H`eXqgLi! z=+Yd*bH8|%opaS1(lxhHbFbA@&twBmddxt|&21ogC=7WZMuPl$@$zRE5ZRDH&L(-X zBmW3ieHkH+g}Zqh`kJWwQU!cjC`*K$4nzO4NGOk3bygHop&WG$CFM(e#Y+-Ty z6uK%!GJ2w;5gWWWki=L!Y!|&!WjEwQUkARYV=1BVJj)m4HOx@vnKF5k63qJL?!}mh zAMEJg2z-9)F^neF)BX&T>gnq&$^Gx=!NOn$MzbrhFX%}~Qlpc+daeWioi~6WS1T%c@F$37g%PW!EV9?k z7UY)1v0~vJI98ZTRo9p@T~_>LtNTT&@F)n3ow+QX*)m)f;?GKUd()Mnwz%wu6uwmi ztk@9G`6h00fAb8m(9^}u^c%Bc>m6d@91V}mM`(G_OOT0tMei-!#7Vd>kc;|0Vtc=i zvOB3Udivhs3feG!{}wLZQo!05Dq~XWRP1jvfTxldJ6kxNzzrdi+`yH#?U_@6r-tIU|`4K8>Rgqm2FPz94Is z1`?-UGJ#yL+eq|>d7h~bT#Vy9u9oxg+4rk3O`h@^+QaaxZZ2dkDuqYRR^VA!0#W8N zAiCipwh8INx10{T!#jaIGPyuLMjfP*Jq5&OMgem~U_a-f%z?-c=g8S~z(2cBVJEeR zixx(>xpJJFwduieok6m)U4l)sl;hs@&iF?Ay?KY*dVFnGSowHs6p{Zl+kC8lJ*w}# z!YIw@CJ7dtTklaaqiwtm%(z5M`J<({T)dNB9reV)q+8VKpFCO{2~bvTH1&r+sCfN6 zozj|(W?t#Ie(hw^?`i{S1afZQ1I{! zOzJ%h2_K%)FK0a9=*?R=aqA%6G%QKp9(>2%4?9iEi_75q#6;@k0=jWh0d*EsVh;K& zzzuav!1nSU^1FO5EQ+fqq3L@l>s@Ld_bU$GHfn<7x*YSA`ln>QO(K1ZnS}is1}-t{ zSTp`6lyOdHVnaCO_kkWH`LSr5XaRFYuM=mjZ)8PeG|DeaCohuTFgp|0z^9-4NbraU zq<39n%cq9G)r;?G+`?c`KXrt8`tSvPd|)p95VaU<9$%ny+!Cm!djUD~eRuTdyzEl^uy_;n7b5O3bkHbt>Cq z!L8!_ijc47EUNp)VTW5Lg!IPK*RK>|CFw>hJ|8lNTURQUauM0P3z&m?>YO@sA!c>P zlV5&+sbr@jbAS8_{#O1?a~J9WW9R~M%S*{{(<}^n+fSR_U zT|4th=+z!j=+H#Ga+W6$dZV5hlhMr81B&NgATfIn5U3s{d75d^ zC@+HV4i>|qjojK;`x!mJjTv4Df1v^N6z!F;C3-E5Xy4-sEzkR@NmL!t{dS67@ky7L z5cq>!^xFl=3X4hc>Dh2&{d1UzzXor!j>4;9U#Qu_VaZS1<3*8rOyE|-%js{oV@1Re*ct zFODGH@vA^PJ_&>_I*d`g6AzVoxbgklI zTOK%j_DWKo?nx6q6u`y=RkAd#g&D9u0+mvE=Kjl{5!Iel_UVf{^4#MnhzVwFzz<8w@$0l))l<1~?|It#&Ah#N%Hhh)CEpSSJtx^FPFKtIMyX z^6Y7%`?HKJKKlyzeA}^bSOF%^y<)w2eDuOR5mtk%KmNHGVFdCA-#TMl8m$H{$*&Dbpck9AP$ zfRZaL`;)8UDP{-Z@ZA#jb6!3pTzwR8Ck2sAK0c!2euEDFSVmUgddX>ngu$WQ4xb+x z!Sx)ueidmX!`}JeuWko_kIu!)IrrJd;b2;7H?#Utpd&Fc`brL&t;L$BXEESd7PNJp zK&M_F{So9w&*Yn-_}pO3n`oxjHt}OBr)s#IC5<5)S2G=f8!>m88LP#&9$mTys1Vc2 z?8+Nw-!!GsjjLklhL8V9-JI1Kf&dUtSb5?N?phpCD>ET{Z(^qsU**wZm z3$U|*ItYhe{wq1;lVi$t~yKZXon)yCm6H`qU-h{Hlj7$$ZIGAvhshJYHUG^-+W`g=_B3T`5nX?7E`Gdzi7vKVN%jsourV<$tW+SU{E&V@ z?RAjU3UDjnu}^H=KOW?A2x4CAa&(J*#Q3HZ;v!BLAok`C=&vs$5{79gv~@-#|A}rY zn&Xa>72c4c@1d}?R1CKsAEQpw4Cs7WZ_W_#hd!9s#eRGjM<)5^lkH0`!0nIGjLXgj zI9=q9<3ZWD@E$+uzr%;u^w#3wxg@kc+Cn7Vh1g$;Vc4^?7Ue3uU{7@cy|^(IYdm;t z*-{OtZxN^-s;;F0FI~ynWPU7*ZYI?15WVla6J&Cg(4p@UH)d)jU7geMN%}kT=2r{N zde33p&62<`jvF6j?un(Cg8#!sc6N%y5$*w&K_i<*8BzL343{JEQKJUB1PUR4+OpMFdaCaneS z&r8t%h&IleHcUU{0sIAd{9#**(M6Z&g`a}eJKJ1{$6{?*CG`hCduI|w#{(pBb0x{H zOd>KL$FR6|0hB1er8eKQ(YtOVgeoR5Pi*EvQxi+0qfQdCW-Yn8ek=acxM#A(TojZ> zlQ4NlEF1G+Ivz@Bz()Hij9YgXm5NZsqU(=gFUYd@X$^Uiq5v8imzb3Af5?3)Hw<9h zAVqr}NR8OSjb9J&UQ9L>>Ntef`?v^%k|*k>ZsFEbvuIjl4*Aq20uATR0pG0TTUS;T z!L3v?Y6CVP@LxPIm*uegpC6MZ*9jVkfR%l1FQ8Q_HPq+z-RCwQ_iA$sv@eHUW`E9`r+RE+`Id!1TA< z;oa|lY=vJtv*oiI=$%|iT7~+U8}ov(?ww=(*}Xwask@oD)hE)@DMQ=NK%(_2%w2LShDOK`I5z*x0Nz+r_NnYANh~RZP##P$O8I0 zQx-;oYJl(5T(Z|j8)~Ik>Smfo#pd=gy!t7O*79I_+MqBV*1t;YZBF5s z&lK7_>mmO2Nv3taUF5f^Ed=f1>NrNJ)am$3ru>Q?R4yE0&+V)wmle{8VcS1SUaa71 zRjYA8bUWBaS5v)+Etui>l{^>eBsZEi!Q#26$twIoeC}z2uKhXqd-o2k+-SmKP%>ap zO$h>19ze++4`QGxMhoP4V7vRJgYnno;1!fJCb;_j=9R$DW0!M(YRiFzoC4BsHxF5hWeZ7E|CAcM;$ zLNTOl4;o|h0LOU7R_z>K-t(#uNJB6UCQxa3SQQnj|VZ3x;H5~HV$^D&E z=*HkWy61-_z3OBPJGB7FdVA>eT7uf&8`w?Ov!Op}92WXs1kk#x< zvduaTBA2Rx&BYF~@umW_47buA&l&iuB^*0;N05k{lF)zdJ+ZpBk@;Zig)ep*QFgGE zzU?T36<#OJx9M{uc#%xrw1%5`86OE7qaHe$udDHR-yFG)WcK4saH)UH;y_eyuv22Wx>)>^( zM$s{S2OQ6_#BZU=KzwF^-NRh)E4K#UKo%ZmchQ~l;WT^J6xhVCz>598z(wH4>0?I@ z-=kSb*MC-~yO+e`tgjtlE+$6AOzyDTyY9kINrq%ca?0!vB4n!1N{HOwOdDzh;LRIj zm{&`QXT=R#`tCORS38j1t6$+2@f>38ewImQ#ld>`79E!#U=nV4;Xp<{t&FgtC-NIe zXuvwKKG(r|I!Dpfjx3!X#0Tm(_#rCoJDqiC6J4}j7mSZQ<077f?sK$)M`gimz_u8o z?4JdZFQ=~I)Z;{-&EBmh*_*!5dd;Q6EsRlj|?y9qOoFE=}!= z;;ep?K22Ic6H|YH)3FNfJ{Eu~UEF#{DjR0%Pey(+N?)cLGfroYq2nG7D>LQ_8q)oY zgBXWMIvB;q2cARs*WGkumm0>+mt-%r&xD&K4S(vNp@K8QnIHRI(2rjbmY=mIucvCm zG{-CO*0YVdX?5;a)SYVc?M^G1gY(8{@Uwnyb#W5D2Dx)M5-vjj;H$C{s_gc_$rh|I=P zLH+8A4PWs=dwJ!Z!}hGSZV3DGp$Q&;(?%CA86|5yt-1H$7CfNP3sdXmVeTcee&FNiV1U$g!Tkp{2A6(?h13& zBZpoIv!aWuKhZYR7WCP1f)@LQgQSEiIhi6t&&uTDvr$g9db`|Ar{yere|tV%WR_=M zaJh>2+Da4Nsft6hO*-1A9Rz*bO#JL34ZX+yu~F~+=?+%s~{hegG|x5%vStGo>Ni>TaklP1gP;Jceb z=)5i%bN6!C-s0OJT)cv{(w@bw;v})=))~}0|Co6E$fPr`rNhR}N#t~M9la-9POCP2 zWk+0AP_app`15o)41Rx4>-&sx*pUx@9$8ISnM}iJn?-TX4K`hUNHTi7kwPJlaUpigY{F4aOj;M zDcL7r@yE@Wj!&9|!^~Nz(U-$U4?)oRv=L*K=Yx@c1@p?~J;|#r#|5u$GoQYwllL6L z=G$2$OA2~uj`bKR7A`gu>!nZZD;N{MT z@8_K(YYH2If1xU4ANh{%m~Kg$#-b?S8#yc(ScQK)g3Z=&8Ux)ohG@NcJ?)bNe2TvcL z#evOB2=8M9@t4{Ptnmxlemon@Y7FqWyDGjj^unYD%DgRcz+?_@VexGloYv^>89gngR8n*fBalZzguZ6(c1&$9W~H4$lI`^;2PWLMtBT5~v$5JSKj@K4jf$ zb6l4>0C&!Gvpbpd&{6ZA98_22V$7CMd9oF>;@r$d(tPO@{rxmQQbr_kNC%AXOW~2C ze3-WO4fA2plFsN?sHSR5(b(`2*{P7g@=oYsdsr#^f}3?t9IU508=j*3fCSv?h=-MH zi^+#*6*4tvn7**eBVpq!a5H9u+m|V9S!5>WpO1zvwMkgKLDHi2sylU6y+q+z3@S;U zgYU6Z$WBS_4EX6M^;n>QV+S=YqW>KPnM)buksXISl6VFh)fViQveRI|^ijJ@=b4&O zANwwA< zMUaqYLv#BWW0^-xgSAN2)bcL+fPSNux>nr1e=;52w-l61KT)O3Mso3z75TNymbU$? zN4?O4pq3qsYfHu8)Q3#)K5k0>YBam0!Vo_-0?X1GsQ%vh z#OsbYew_A?=H++8n#d4ZAx!A@9vh}I?-a&__n~lVS!ExVfk&ekrUoS7&WEQ-^r_QC zh`*683_Z%Brqt;(;!W0CCW7BTW2OK#0fhOsuk{!<%lTCKD<_1ybtk9|D^h4_t zs#Ot+OJ^9vv0yDYw#^$up~V#s?f+^P{bmpji##UH9?j$_hp!CNn*lMqN9o%7`(R+F!A3Rc zV@XIXiF1hJaJ%R5`prgY$r)sQ1Fq3e3+jlacQiIsT%{TMow)5_E&j~^M|;!VLgYr46wk?68=@R; z(WvA%h}+}A_PKcKhYvW6>|>MLb7}CpFKkcLQ|7X_7e31gHMX#8!oB}4^bd$tE7;qU zftv%|d0=3@Yt0c-3QbJ%*ocZ4p=H4p~a0O%AFi+UeB<}Bq zzz0Qa@$>gwa;P<)^2lPobCpTm;R5-ouE3#n#NE{rM!CYAw9L*4^6wvp6^cUS@J$4` z-c@3D52xZpiEbAAUd#~iNP-%UF zGs`h2tqBYt?55#bxlnhplpB(oP8F|H*{{ebR8|l{Ly{NzyR9LK^dC6zIvn2dC+KnV zCQ5o9jb%P&WHiy6!q%wZ!|HuPj4FVtiU#BJnnX+wJ5CBmt!c$5e=2AOlW?-DEosw=?D0{q*d@eD=A078aH*rlijg;J?kuu&yN! zeHuM5R`DtPI``1(M%HEM=Z~O<_#qSpT!D=EOBj}Ii2<44(Qw6RdfHKkONQ&ARf-h% zb=(RzUqET?4=D!6JLB=*jas&MgCl-zUcnCW2WjR$dm(E05!r=IblECr9r&*i7T8vU zj@UKOY!yMD<9$e4XMu;#H}THLR*+On3_Wt8xb$8x9Q5@rx64GR!Ot2^k788V`K&k! zHxRJqp@rm96hme%Y2@+PoI<4DaF&~6Fy-HQdh%F>#tiC!QTeY$!zT!+D*e;KylEt; z4%Ct776xwW8%fJ}4tXW#p!@*O54BOk-FE(Txpf~qGMR^Ke=-Ek-*GbS6S5ROCUiSs z5e*$X52tK9fcCn6kQB90P|NJc;_;6~Kjo8A`RYn|YTgUU?;cWNgo5?b%^l2hZZ1as zjwjFUJJ@zT33%01b*i*=U1iZx89=sXvFqk(;YJPQ@X zV=z%%z_#8u~$zlEb&lb5P>l3g#m9r_Gru6z%BhpxcL zj}81$x9L>qlFQs;XVUkr^}^qLzM@4u0V3C%p>F?kK`WHTbXJ#&>dRih*Kf}B`f3zD zUB4NWho6EUf&wEX{U+Ocu#{XLwgPXmhLQzEoPgtGF>A(RON$F=(qfW2(82Qhv%vYM zfE5aMCxyTJ;6%D14${tNq91peu>#;L{wP1#E(?k^*3*!6Pe`di&?oJQ!)1po=%iKouPp4!C_yzliBtC34DZ_NAhc4J{(Is` z7gy`j!u=hP+)|0ZQ%>R>mECN$tDs#@dP}qC9K+hD6(WuOdSG2#4AMISS^ETY3Vr?$ z&TOl}$~l6zd+ITmZdL*1QKzUNQ@OhHN)nCiT!?WZj^!`^#u*k1k=3CR6n}(Aqa#M( zo?wlaRx7bFpCv$Pp$blOZf3hpze3*FW1`H8;pkl?&Lk83DYPse-#CTSWS=qg({D2B z2QS8qJ>Kl|8XbK3jj=HOWE!;P9o*`Xux{RQNKhc3rQ(l4qGMh0U^oZalDEO+5fTHV zGDnlI^kj^hJeKa)rm=atPIO^l6K$Bi6vrkO<8|*IIK68yZreYV7RK3=+#e+hax3R5 zglb}dkdv4@r;z&+?T9bs&oKvMpeIdY7%n9Se=WYyN#DPyJe}E<$4sy>c56hRoN`yt`h|NBtm_vt$;6& z7TuATW~JOzrZjXi<%JmHFgQ!2&P>DJ&7HsrDjUzdDOe)e44u{&nCgLaw$|De;-1*j zPXQtEBsTy<#;lMh-?ME@DIi2LHa+tNn7P>4vmpA!I(pI-e;5OEk4A+ITyOA3c zRGpNqvzJ?=f`9_fei21=e@?R1=8|yCQi!J8MY87Uo5|Du63=&up+kiMZoCk{%KD7x zzT+c!`@D~deG!nT&#SFc<9spfXBixk>;?CiE3L$o_F*&{V;-}`$;k7Uw{50+VJ4GM zc8zAc>42BkZsBk0CT+U_0i#t$3r4-;?xr5Zu@_P>Jmnw6eRl=X85!QW)f<26oMGbk z53@%P?Xa~-4Y#ZhLJwcYt+#feo@Q6P=(Y}J6i$M1VHL|ftyf+9ErdpjZ3b~IJ(OR0 zi+lGei)9vcLEAY8KBzMbZ*3Zb1p|Ajq<;}B8S8|vo(sr}r;G6FprzRH*AXA98PLi? z1-el`9}n+(1PZr;@aSeiQM}HU4Vw0V^;b;8@g5s-p0tLwV~_(IBOyZPk88lU^#Xm0 zAI=sUOTo>W$>_T)mzFzbShYS^qN;d~3Xj^OzMvOqJFtK@=ssc9OKjqwm&sGNorcw( z24w5Kiv;bcJ*iF$pnd*7s9BO@KOL4a3;k{A;V~UXO;y4O%N;P_dK_{%0~Y6$B?{YL z!8~(P+2xH+V)_yzt*rSKD7{?TGdM^G{uN2LQ= zn4`>CfZJ}e88K!et4pX;<^EzWc!(O|iIIhbD>-X=W z?qjdmn&I+%f7KRr39`IwU&OKK$vSaeBm2yvRmJqRYeX-VAtzbS&O*pw3Je$b-l8s3|CpOH4{4XuW@qwuVRy-R1 zq#E#VH9tXK$0?TPFqfQk?!pQy8FH^(OxF#}S%2qQ=G>GZa$G$O4_s{lmGPlgPUf)~ z`cTC6)eeWgxq+ltEnofNi7Po)HbS)TD7g4D6wl;? zCIxP;-+?h-GO5&>c18He6IbHR1D43=d` zb6xh6v15n=v(9#>r1Co?uP^LGk58jX%R?~yej_w&En-za#rekuLKUlJJ6pCjhHPZ- z;rGa|!v6aO{k8VN&b5{7^T+9!xt;S#K{rzw>|}Yj)f!Tk|FAST$ga?P!c~+kz_-_Tk!J%EGM34c5H>!t3Xar%b!$ z+>2Okdiy4g$&K5F(*_FQ>SZy0xo#WWXi&s|XS8V3hAuEWxQdVPP-oj?M=xdrLwSPRQKnjmj*jyVHhUb}oXoQm4_;{3Y8zbu(RcEuxvmL&;@# zDgRoc8zzlU$B;=zY(~={`jD-QKc8vS>zqJxXn)R(mOo~*L;UCCN~o4Khm z0JRmiv{^wPzX)})cdOIczR+YVNtb&76`2&PEmU&<%jXvd?IjlZ1OnCbnf8Mo@Skc* zqhobx&Z*P*&fz7yu*Q_;{~Sb1*Palb4S%?sim}{xmn0nOBLfbB*KyNv#vOXA&fX5= zXpo}=Ma~hg7LT3^*)@@15#4R2wfH1E_E{1x?V5(ygU8ac-gs0QDaG_g{NP`geBg7N zI#>{^KW{SHGp z5>0f>;MR1@~GdqAiD&U^ZUxa-{Px!#RcygW0v3qg< z;M7eW=JjWQnH9&0ns+6za#Jz%HB*O&f8Ow`2eLWm9c#&GW(v~L8}Pf_4-||hpn=mq z*3&TxgS}n&1^3T$_soZ*ZSpl1S1`)jM#6yBrL4ozbw}C#*|DT}Jd{#B_CwdYI-0UR zo;z^P8~KeIczeVoTz3cY$d?vY;xY?v&FAUIym@F}I|(|!O5qZNXcj$2f;06vz-9To z2dBLvAs3yAW&cg1$GPv}>9=#jCHgP(cw`2pFa2Rgz+N=7Oh$7PMSNvG4W~41gSH7B z$R4(^jM6&(#@Z{mpyz_{eJdu(Zz*itf(f9R8xKl9W$Dw)+006H2hFlhB5z?&ZO~O) z@#nx`D)rRB5{q6|5s^8@GM$8opAM|ag^BM#Y!d1*xg5?*dqmZnwFjln@Uwh zl3uEmFFp|+TJEBT*B!`QJ&lcsZQ|;K6T!W)m_n)pSm|`)#~Nk8Y`QI|dCHjN#%}l* zcA6SyEW|)bbJYG5ffdsKSdzqX=KQl8{67n-L-9D!3Jzd{FIaHPDBJc&S`9)}y1u%I>{lmI6xgwe~JLB&&z1`cimpO9XfHmy+! z)nUtS6Q*6$#_L;^*oU8?RN0Y)mu#~5ci2Rsb33W$Z8gL#eG3x7zuDzl0nHCGSWtTx z*FUYKsKvINrTtY3ohImxT1LYdizZ54J&et{nMju^W?+zj9bJ1z6<C(Jl80pE0lVb|O;CGb5A*geaqE2v5)#}^muuzYmN6HXpAPNKer&o1D*VOJqBHauQ4wnC#-Vk7FMXg zVUOJ^g?rah)KC7vot+_|s8`imyqEvLwLUjsotNIme`=Pk{u&`cXOnm0gQZ$HL(pEW z3%&>4k`1u%$u}C8UqgCr6}bHGGnl*lH2$4Dk=n2a)DAsKGhWT3JBN2s z+QL>g#)hMB&-1}L=m9@9Km@jZd30vlA?%u?!?&#SMe9CIL1#K2b!)GHlb|yEy2TVn z|7>SN4i|GDZ>T}CcMo&flt+_3451|r_2{^?iFYgWtW{%lMJL&ce(da;-< z8a5hd);F`r)mPc|a1R{fc^=jO)SyJJ9G?D~Ooz(k@$MmQl=U;fV_}Z~-NUFr*yA?K zS>TsB4+W&8A6aOwpte?jY?2qlW#SF6-c<|rXQja@H%*)+H;?7%)m>h{Is_JV4W>CI zI&@o34re7T!pht$tiF3T9=bM)BIu;l({qbRCMh4j7+&GFyx336ZZ2WkV)xj1pW{$u zXh$c5&B&(lB%P40qK=QFxNV=VGyB*F5Oj7PZC`hf9oLJ%7vAr|=T8ST$vVhfDuxq#&Vqp*jy3t6f|htmZ$_c89n0uStO-$)h>2f?E8 zG-C!yD4Wtj3kCFq0qi1w9SPx%9ss9|YC*$S65qHNz=5=3_|;_uyYgQ*{26?MAISIy zUNO^1X~}eqj1u}aK8K;aY6*XHp9QU#sAQkAcfqbhH}Lewk=4TWtNKA=G40;<9pjCv z@J8PzRQalkxF8J92aaHaH)vq9VkbCV`~bcV;`Cl`0UD(Jo9CaJ9RtNdDU?A)k{4`RkkUT+MVCC%mF{-k!`4 z4tU88*gL`D>7A_Drj6am$gt{P<4%Q7D){XM@A-oGNm%uAyQt>>Ir@AV7;fo@7mKgs zr)ydCWUYYw+-6N7%Ko_Ht0IdWc@P8TcTm@(I^LrSXvl>UlDg?gVdHdgcI_TvPMJt; z?JAV&Ox!~g0p-7?O~_k6C58A>-r~?5_VcAI&i*hRuP9$)Iq@yr;%!5)x_TNoP986+ zJ#mChh#pSzUuKc&#gW{3=OnKA+G=R~CyQ|_*1#Wjj9qI^f*+#?VN6UsN;>D0cG(2$ zoQEUGz%yDTEg%ld5B#M!>dnkxTphFZl%?R2eSFD?qhPyHhTZqtN)d7fSQB2rTjZz< z%6m_oamWm356x!D7HO!K+09=6w;Shm?G-%K+K`$bSLo;L0R9qYL_v)a; z@}Vf9Rs=uHN@=A*0X&nA!u_cs$WLvTTCv+ zXGqcYiq)GWFR)+ci~6_j@V#J6!onG;^j;#KQ&0T_wK@pK9((9%%pMHUJx6(czTD}cL-ZkiI4J8x z@G3dlT-UmMC=4g|PpSu<(q)i)a+HrQU(8%aNmna9PzNdT!RWQYfX;Z7!_P?@@aP{c ztcbe{XNGM=+qRdW{5u6xwbCg0>pOVW=70ei9%OgAfxi6KLdlvbyyZg=QS*aKuqz}P zwj5uES43SD zV4TNs>}kFRPom1LhSFCyd+%sGVp&fYQ=jrT^Ro#&4p`N${RN&ggqrW0Xk7RpjcuxK zwOVC89+&>$d8Iq5EHZf<#P5BK0kN(pbdSJMm~eZw6;^N?FJ&ZDw2RbgM5` zN-^ngr&(%e0!rIXAq^!7EIFXUC4M)gDbeC!+4u#*G=lJPgA09}Swzo!v_LvQ6H?5N zb5o04*xRpx%zm*ZIGcswjxEV}E?iKGNH>G+5+RdlaUN~QW}Pc#N49c67E@m#=SO?;=?g~yr}Qs=!o?tE7h9MC2> z@^&uTM$blV3tb_vl1YPI&B1wGf`HMLu{O6*#(M(dBk<;AuynPg$)`=_T!z;30hiu@ z`tDd%Dz|1AjyBN?y<3$1>p#Z%M?jY91=x@~p4TXuNcskbob8}}SoE%%Dx*e2d_Vyl zu8+pi_4DY`*`G7T+cv|+iagTz z;yLVw!5s{36=R9R&+$9vYT%#NMk5QNU zubl!NpXSrPPpVA!ssi@Ey$?GNY=rD_(j;$~#s^+s3ZiNb7w>(-qTXF%33p@QRlL9G zhqQUM*?&qS;rgqM!e=j$;%w$o(nDYD9+D&y z_f4=Nfe{KHZ#llFL^{`_xzPB~DE z(E0jvN?gE{r?Ms6hFEJoddbYCHskspeZ0LWni}uSCo`#8IO&@dnU{SMGDdmq+j!!} ztjOfF!%9g51OEbw_+%}QP_;wr8Q`4;0)A%kMi>KdZK z+9(!Y)%LI%cO0yi&njpz>oG>qSZd9>F1tpcv=0iDD*`j zZC$kTx> z(A=gUe6{sSoECAFN^&+~{qc3-b1ZJn#bTU*H)4)w1B{f0=B6|0?T`w zVg59KZg`@Q!?w(z-7#Uf_INa_={v+S&zix$=eM9lIP}|Ejq&D|S}V`*)$n)R70}Nc zfI-(9x%U%0A!U#-L>%NnXJ5|5?dkd?Jwyq12FGFHt$I!+?>YN-CYxo}M(_EURy;q?MTcekTsr_=<1yhp`^Xcd%AwG%NRCN$I*@;8j8+%Xg^<>EX4oH)*t>gFC{$ zo87=C%datGpHdppW{#h84ze>_GFhiENqzp$k<`i)C}nm%wJkBEr#~{Rj&}{=JjEZt z$>>P*{iaMU?2POPpB<t7nw=Z&&t?5krh7eaLCRq!ttxF}SA;X+ z_Vr6JK|m~|iH$?2?SCNfxhtBx`qyq1gw~Q8Mb2-CH(h`8}Uhz z27OSWTfe>%t_jBTgEXjY{201*>>vgwo&pD(WjNH}JZ>x4#+tM5vJ1XrXs^&|I`Pqu zGPhr?P<^lqC%hYtlN`2y&uvZN{X350hQ5Txf2oL9rc%-8ldxl3B0Q*y=S#EtX!r|5 z82BdS%ZjzAKFO4wD&uIE$wYK9dcvtIX3=XGCmQCmlk!JRVkO#dt^T=BBkO7{^4ap1 zUDY%O7M*~#G126|RX|ET>V$^}PqND=W65;cM=+gdkMS3Rv3>qn`jL^v4fjV%jNM2F zFRBZ7@MtPO@>Z0&kJy<-(ioVg2t#vQ*|OcgXhRK9(xou+Yz)BO)Ba#&nuTJkByl}I zm>vpe@u-3+bO=8}r&9r&>O2g=Q4hym%YkO8e-QL34L9{pCz;M;obpAXf1Y=m20Kni z{=zL1TcJwnzWv;;Pjg8;_8qIqH->3<_1V>b&3sCvG?)!frQNNn)=j@>gR9{wj5^Mk z($aYBG2KFc1}Cr?(}&{Bm`f!0A(}1TYDw3kPT;EaAghJ4;gmgoFqm~qTNw^5#nEHM zKwG_nIU3((;bTH+kmPJK$;~Izxdw2jG7GH~sv%$g97r6j1)C{xc%!wRHr~1dZ#KRH zvxn7~@$(KuOa21qd~uwU5rN-VCIBbt!gQw9u$t#%C~aa1)9W~oiVJ44C0a4~^gulB zc9;)SJWhhQ?i0RR*$azj%L^Klx7hGE4|B7k$fC=U=X846SnnpbulFGC__USJ+m??N ze|o`>j*veg$|cVkS5JFK`lTvRrB7_)j`C}gFb zaQ<>XdVAzB3=4^bz!^0Z8ZaLAS~P;H#yM(_KR`;psc^OLI|Pm?$0Utt+z>Y#D!Kyj z#^$Lk+p!;fM|Ci@$){P)r~~xN%?U0#e}rw{$}z~lfqU&P!A!nu(Ea4|I7)pp`*!p& zihrBPIc+Iqd}ukhPgtVlo9={Ir$(L=aI;GUicwYlcD}E+J0aXb=y9l3(u!*%+1mfI z=)(Ou*n6k~PQ~72nc)&txHy3Jk6(=svkzjCQUXr@9D_FN4zt{@B%Bz@QR{zmE=eUF zW&;b}z~`t+3O!g(${UQiz$JE+yT6Y4n<(R$)1IhWR|H3-1hwTuZ?@iU3?yB-Lua;{ z3pm+j)GOc=|9+f{C5n&fTVEa&Rn8Rd&EMD{wJO*=`W!z}VIqC998bT*yx{7bTsH8P zL)}A~Fi1@ackMe3X5QY^VfUBUduK;m-volx*XtBru}ApXT5Pgg4_Rli; zJR3uLk?12}ywzaojaZaTy-OzI8rV}Oh7X0F(!9EE&|aR0mYYVS+noI6uu-D#z18?d;~Hf229io`5Asg=cwj>>6xF3;T(2e#`T^&Y)8{C9pe0q1K`+)8y4MF@ocf#ZECQrw(^^y!FhljwtpXB>K4KDZO?78 z+z~>iStg=#OhnHk{w&~P8crS%&h#%2MN2Q2V4>c9R`ONATFMA}CskASG5e2IpyVq2 z&!~x+M!TX|z+|*IQNXlkjS}YaGuZef54bNk5>_wVL?@kFMA|E7a|u1wcuja$uYaM9 zTd$qN;HVPPxvfVh4bDMdcq92O?1S|uUqOVZ1&gaTTy>GD`B~CeJ2C6` zPfnn2Ng2#D7e__AmH2k%ZSKThXH=FEr@$E%;4!(MyFacM!%gmyM1w4hENX+zd6C%m z$(j$Edx{y#?4ijw_fW;j1N8gV#fneM#A!f(DyRzc`o=#;s6E{PUq2gz|MGPyWO)PI zRr8elB^k*7I9$rhk2@`3Q$vN0>1$ZMJPWe!Y@}eLBkaq#IWW@ZI}3FYuXf(&48@xJ zn6h3P1SF27sl%M9@k#+X`kT@b13OHMtipvJ#n2ia!`D{FlJUzj3amUqU(Ie|;r~& z^>qM>s*350ULAWPAgwM9INV&i?-GW}}M z+b@N~;vb+z@crWJHWrL0_w59R{ zOg?jjN)ptm^^r8aTwupyvm(%HOo!F&Y*Xs>7)7y{wDCdh5pI4-4QIdOrqG*eql#NY zP`+<8ys|zDdbhXXFprTq(#IcFhy8@TK2f+a<0am8UPK?Z>;|>7F0^V;2AlGJ9f?Mj zlH`XW5UHn2Ey-F|qA7FXfa4H!i8>6OU7z4%e=;3)xKE#6U&O$X_NWx!L@67`)8{7& z)$`@HviCoK!^Wu(U`){u80a;HGNooPSC8e*mkp);0yjY6^c}kMWhCv;Da6d+LYVfp zhFKVYfMZF*Q`HI0(NJ$4DWxKDt9jv$isT*pnGw3+(zv`Ou#8T+@o9P71g z=u!73teTUGOAZ*q0@Gev8WMqis)m?7L5?w@-!MDmGAQfkFuyP*;J<{D|3D$H{lJ0U zfG5zq`8sGu=HN4f+qgwn4HA7;u-w2ueA{P^jyOFe+cEn5#W~{G$&x9zP=;Dmym9(% zMVwKxpZ~}7G5qLb?#!Bf_`2H#Vw)P_MRURB;rD0Zl|*-J5m2tpRdQs0ryN2JBEY*R z7I)5gYNhbPjkTM^1#eKexzJ*6UxsY>wr`w~ z{yw&J#wy%rf0iodjplBc*Dzw$*tx5esg!hZs5BJS2aUi9#&?AM=5=@`e~<3Z&bb`^ zdk(69y9P~nhOo1flBnQyCWvinvKqH~6mEldw6iVd56>x#|Me?UfI+AGPq4?ZQ#R{U|<@cg62!)^td6Dtq^S z9Ln2RLz#*q)`&}^T?5`Pm8!P9po~@B`m}FA4@Z1VX7h!{ zo7Cppm!I7pNq(my(Em{g_v7bl)@S{l({DIW4tYLo_poM+pC2W+KJs(rP%3iC1Cr$P~(dhzRcZ~I?Ge&k5mhFDt)PV`k$$#j@Ln4zTiFI zfBYTi&0U~G=LMX{T{UKwnF1SM@8o40Oz}rh2^$;wA8Q{sk0m5tpuw$Kw06Qr?#X@` z>YluXD;2VM8~TgshFd6289trO5zv65w4dOae2z(^Y=OF;67b5r7-=sc zEW4KqiidJ0uh#&-3vXbj*)*E+Edt+G4W}Qr5;SLW8UIjSkxpkXp!%j}_#o2?Z#D~B zy+@AlBm0HuQ}G4ZCc4e}2s5uq+v{Mxg9|suC6WbNN5bWUlDIkG354C(!6S>7Qd@MH zmB0Od+@z$`b&H>~Qvw?=qw}L6hzGC~O2KaJzKOMo5BblGsU)Yl5jBC0-^J#@ctjij8nOQ!8 zoc?m08c+(0yE3=~?y7WHP*LQ+RKwdr$>=D)oJySXSlo&|^s&By@lNIVy~~@%b!DOM zGEMB2h~~_?g)H#pNpx~&6+dZM42kzmp!RdM@N?5sF0aK6+ualR0P`F=qPd-PQ$s0U z`ZK>{<#F;!D~IWxerVXSTHuUjk)`G$*k!z)gcK>OS{06?52{-q%#Vb6$sBIUy9P8k zm`f#(CeW5r73OLg1ak()vtZ#a-w6?zv$qny+6%hMoQ;@vGYFsV=w%A~%wVl#0^7XY zRkR?jm%TD=U@N=+11SNcA=Pe5ukV`(8jB6!weSSBem1~@ZHn}$Vm~`SXD}`FErch( z%1B`#l;x_Ph1rff@y3ijplx%G{~{06Y49D^xEI2hm+~m8&ZCd}Oey!Ipa4=fppH>< znT6U<&~^O{m64y=kVz6yDII6^>#MHZ711cH_gp|*O%k~&d!*oR<^@z*_MOeuOkt-L zk5PWKIEAz?!Y;KXbo=g8-geXidUJ8Z% zc4n;(Z%M}QEO+&mkeym|hr4+$k(85ah4=h0G_Mgsz~4P|+oBi8|0_Z+@;N^ywH2#; zggJq6IQwx+g}yINqH88TG*jJ#MriA^rxyL}--lt?>v0SdS4<$0Q3~2GEa4Pd3{mV{ zIvv%xKZ^WWIUbZg9?M z!Q0zqK-65pd$z0LBRx~Hc#z9NKJLV@=xodoyGM1qe=~#sM&bs|0(#*e%&KCes5~Ts z!n1_C_NI4isq0M9A>X|T`eDyu*AiLcopV{}z78@no=)=D8Zjp)64QSCU{#MzVejy5 z>|tRX%~dIYH=kaChx2n5^GJ?f7u&N)`MxE% ztULu_UsmFxW}=~Eyzs`;lk~Nrjt!Y!!pU?ThYERh-2L?~9Ez%he<$3DYcS!1J>p^J zmI(Iri5ShgRc+;^+r&34`U2B_DzkrcRL9)ldhfZz&VtzBndp&vmK!m$5Pin(C+iVkt!{YcL1ykk9O;|Q zep>Bg=RA*M^Eg9rvPgqBI(2+*R3#8>O@L%D?k8)!Ps zF6&zfyO08Y!}&sLG!4eMpYQSaglkxcr%-LaIB8o<00}QsjLJC24T*7vK$X2P^1K6` zyVL<5Js&F!F9Qtk`H7pRH?h1-Ppe5T7M$0XSg1dB5oc{ZLCUKpfS=-R?9-%NG4ewSMHH#?(w>G z%KaG(yH(Ge4_Kr2stOETJBc|AGQdmf_ou@x6 z_d8Aomc`_9(vIJHDMxfgcpp(7twEYbYv|CkTUMzHyZJ38BHAgTLgigfXzzKF=C_t9=Q^7_ER6P!+>M_bk6_@z62?0`5FD;HOkg=#;;1urd~5b}e5DdcI`ubTr0N8|R$$lp zbj*g2RYG6!Xb-%Q*pHeKBgrT_7v@~gVfQj*(QU2*3~L)NoS(%|IAssk4xY~Jj}NKV zKi$JrMf~Oe`nqx7_wUelbTBpfNuuR05B7C=IQm}xY4!MM4QKw;l@^P(la8AvjapD9 zvKoF6x9Ke8C&kx5P$GbZ=1!)T(S)WKUa|?B)WGqc2Gc4$PnpZyu_t&pWn9??T};DT z<ukFvLCfL2Ec)XdD%d}SoXQ4Qw^!fh+b9b{qkDl< zQNXRInynmdk6^luwAD_r5oEk#TDA12RE+5j0rxlK=+^sx6#J`>d(kY(21X{}y>@$+ zcdP*ax;=#_!amM+nG>71)s?q-zf}1C`Ot**DOS%e6*B+A^Kge7PwmkfT){bO)>^8E z)BbgExqn~5I(rk$4VS@9Ch`I&A`x<*t)g}34^rMch9Y?r_ITMhN|2hIaBrJ5Tl2mW zOm}r-!X{ZkSNWLzl+Of34G!I+&I|MI6jISyOiillWc0cUwmLQ9>#ARTrJXy>cODBr zhQyKDw?8a1c`5!ikEVE?UsNOU1!AtIGPte{L32OS;L6|dNyy&q-@J&7@6E?~t5(C# z*minUw;v<+r-8iOR0>$R2b|O*amkkRRxMwblhHe6dS7XU<6do?^6xrmKW`pwU~ z9m*B{7lXaO*Pu;V0=LAdn1$^&g0yUbIaRz5baEwe?TG|oX8Q|t(=stoV+rc))Iz^L zVK&{lAFk$?5lisLb6yYlH{ZnYK;;qk`nx;M55L8dj|rKIdCJ&~;?~pO4PdER4BSc) zG=@gEpuRViF6otv{-)(K)T+lWxjdA)c$lQdN9^?rKS%G5DiAksAIg*4M9$amCdl3B>{SfVW1$M&b7~Q!LM_cmV zi&`CKFik=0>LcvpjR)+|tlk(M=Z`e+44*-M&UYYha5k+FzRP-x#-Z4r3Z}R(G9U8de+fLCR^;m@3=dY6KP`h@=Taro=Tm*inLo$BmNWcylA=BZ)9td)P?!D<9aaWW zR-haAXlgixDYSy2loP6PYpMLWIW=4<#4FN!*sw1nag2T(n;U0LCm)>v|Jl+g+gd`$ zCWeb_*Tv9T^l()e=`hrFD5ZnU0VC=4wD_c5;sX*q<>ob zxXHF3Sa6>h6)9}w${jnnTXQy0;hWd&T5KL{n5#+RDMLvAyaf{vcn?E0gtPG7F>Xz+ zA*?l90@-%+;p3j2yj*n}e`!}Fv$68RIgQ&%^~7euXpq?;zTHVlJj0c4HBfPO>SrjcmQG z7p^;f9BW4c9=tY`iN&Stxdi&yU|f}XvA+O_Aizl7C0gWI_hXG?FDY-(IVgVYw*|c z*Zf+seRywt8@GRa83n&Mz&dgON8bGb-~H#J?#MJNl`T)Pdyo=36s6$Gfw`RdkO4l^ z(imNu<*9blDexOR9M^dWn%XfPaQ5;EZo9P&tkBS<9K~pSR~-c_#|@=1(N9=FvL{Wr zdJ+qM|KRu$_n~KH1y0R8j$bO8c{OQ0I%J`P2J&M3;JuTOwz)yea0Oz@Q>f1*n>67g zf5iWVXp#LOmb=FXcDU}K;migPPW_L$S)LH~210+uNV@vRe>|>gS3oxEf`AU!r?4p% zOj4>$Byr3G)z&A2$-4!h6Lc0#re^T^M#E{y!6g3jP9J#b6HlkRf3t;08O&W#&-&H( z($!JbLY9|7-0Ud!qWc9-4?l;>(pGqD`Vu_eHi=doyTKbt^ijnlV>Uac5(5rr!Sh`U z=x)%JgdMuiSis*;>|s*@+tVty`yI^g44q_PmvPZVIzIyH?nD!H)I_ zozR}o!%@fYHJTo-;s#ZfQo+wjkg6v}B`^Qdc^6}5RUwbF_H?m=$_>;l-AF+;YIsmm zjj9y&z-@*m-AVCdVqcHYD@zM-=;?+J)1FxU8MFhQPflZhO{b#Ue|oI=R|Soz{{t2g zc{C;KtEer_4hEl-#PE|1@Oa@U8vaRII3twE;qXluD-#IItPMf)#Awkmej0q;zK9L& zz6OtGpNC(HB4${Z1V+b4;imytoZnKyShXo)azE_0+m82}JmC$=qg0o0M!1PEi1xzD z=4hNH)e5xK@hOcYOI0b`2xN6fu^tf2c*w7p7YRzWSy1GrY zXjeX`u|^)}UHyRlrpuU%<7fVaajQr|YNgQU^+TWEX;>$3gBuOkaZzI>`9~lA%I4NO z53HdDT(lsMn)-#j zQEv=WD^;(~|4w$9qgnPmr#px zTot@sr&L|v^#^ucF`(_XCWtly8{uySjuc5^vF`{BF-Sn~OLm5! zB{ZG4rDIWkG*S3HyjG-R>Jvdl+K>(#^D3w>g~OQ9uMhD4c29o9!_-4kDu5Y)*(KFWc` z^HYFxtLS6Ux-UZ}gVnjMqn7Njg58Vo7pCb_hn?ZN_9x6Jaj?WY&NpayL?)vv}d}r}l zeAg0zmw)d?56d~1P5R4mym<^PpFD_ON{U0^J#E~dxe21v#kl)@YTWFd=6G{Jo}CbX z1KAx?)gxxv;KSyAaP&Wc>Ag<4>bVYuJ6)i$@+COBcoH}2WICr*nFt=`NJK^@TSiHdxSyjCDbgmR zvNaHuel$e(%FL!hW};HY{Ty0KBrP;lXeyPKl<0T=0FUSSa=*_#=e*yq*X$xE%ovh^ z=#%DP<+&0@?VnJ+BYWX(-XS8rq=?#DSHNIR1UulaXo?YU$>G?|=>GTw)ip8#ea#aP z@vnmof4l?+?gWzi`F`xJfiOtyRe@3NS!!KmLvx1j;8^uu^2TVCzR)}lE%SN6$2tq# zeX?-A@oS>|G8{ruYfWC=^~3#o1vs*08I~^JfQu{Sh-~i+x^dPm@~LHzUCMFdQ-*gy z*?-<}=A$6KD2SnJeVVDopD2z!|C+8#mqxv_g>={QaQyE>9o>4y4(0C7gl77e+2Q+@ zCM`=q&qJ|L%Oo)3%j?O(?W;R6wIjH_B57kM}ko?Z7~^I&VPs<0z@BN`fq|Gv>ID(@DLaj-%QgSaZ;u7^R#7%vlS!?x(Y-Io6lu zqb9snp@OpOf3kX`Kbk7TeejPa58l(P#=8$Z@zgIjI4}}Rzws}me#_bjzauZG%gHr= zGyBR))qR3rGIgxj`vMT!xRt#5m;+U_ipaUi+f;zdc?u8q5qI9>+}xiP(*44pS5RZ=dbrH2<@TvEX&LfddG_7qw@ zO@+1R4-=OUD!}U|iu3Y!(mK)8L?PcDiptlKMU~2se5(|`-B^YEI#ck1f)vg(;x|3n z+C={C9dEoMw+~9?OVEIGMF{P20y_&$6yL0b0v@(xe4w9-b+-|#{tO5>u^bX??-5t) zar&Nnmv#vDrSC1deB;+P5*w#%8mv@;4Ssr~=Y&uwEoTvvcL0?M#=j}1PbqDrL2 zOlWZ77eaY|lIXLt_}_yvykIc3`GnLU^I^#VoJVKud|O7%0t}$YHx6_aN7yQpMR@%6 zeM&FRY*JZ$6KC%nCOTY}O7d?zWB2_J%QU*dXSqAHTV^%gW^PW|mDY4;V-T_jzfdbh z1stgf$Dr#{AakIGdNk(PgH>>(*gB!fYmYQgW$k3BVpVVf5~o#Z~>sA zOX|xo{`VjpE;npGpKXGNrW@gq+>DE^Sv4ROpMbn|>X;`}OyICnQ;aiZz2vOv7Ke+( zkn2!O*-?+5;#*|2*X=$F})M_>DJ*n^mDZW zc>V~$p_HHGZkhyC6sMR>l?F69JO#zIVi;|YLbC5k5|O#8NRtABv3d>xwR`4pszMQO z_MIZf&vJT|`(gAbmleqNuY$VSXQ6)N1 zMbotWY8Ou5r4Ec66;Hz)z89v zr$oq&Oe7af^J&p-@#ZrvE8xfc*~E2C3K?qG#b@(HKrrqhd@r~~o%;?^x#bqD1iu~B zM7W@Z)L*tHYaJ>6y9WgRcEMEN8$@-x4m)+~HS)7j9dmeWiPq;x9D9El&a3E<$lb-{ z$g(9=)8{)CxNC}@)_ip80&5VtwiX0``jFjCie#uq!}MYQFS2I721L7^Zi=6~jlIg{ z>zuAOQT~`6xFIi{^m{)g`eCu;_77+1uum|#v3wz%R^0)m?hDA|kT)6^Ng;E#mYS%0 zg5J+G5+o){MyoD>(Y;jolG=|_$5kmV6$HD|0%%y(Ma-J-zz(txJD)wpMn@}HvV_HL z`z635j1Q|>KaM4@Pws`^po$U1)5MkyoZ$%z>-liWY*+MYRiQ^p zgP4WZ5|mdzjj}ru=ycI!=Ae%ON^iHtC7CLizNLfwXpBRNq<2*M!T|iXFvp}7)*N@J z0bFL!z%r8sV6klgydH2}#DBvCS4NO#=O|ovJ_zp~ScBEw^=wSP3ZpdWMo*5p;-f8m z@seY&=pAJvOr9eQB_;A8J`{r1)8AoUS_LGn_F`RQ)?nG~#aP8w!oN;LK0^~I31_=Su#)D-)-6ZjN@y!tzEp}RPjw-kjWgN)@mcWp%qAE!>LTLa3dDco zbd&RDMriw?jyWzmN~TZpf-kQMSpGTLG`nOC`TBl@?Ek(I7Ogo*y~j_ZuUb0FwL5~$ zrEV}0cBFk?X3#DakCOB=nPISt`Y@5OZ%Quv=*mUt))gg5JFejT!UZC}4exNN&}H($ z%^rA))j1xDA=Y+g(Bt_tU`gH-;y+InZDy2Wh4^D!_s5-l<`*|Tv?_+)T-!}Lj&6WJ zfwN%QJP$WL48x9;F51sAYfniu;KjY|4VITDNYO9JCVj(m7`CJlr>50omW>!QI{y^j zdt(MmKOw3e69LKibM%;~5jLHTq%IY~kntp%>vIW{m|S@%3NFRfx%YATGbg-!RFY#A za_dX zn&_UgjHsC3CEI@#5dpI{cKe~-U@*%W5BC_Lu680B_>e~B&w1fK7jJZbvkIcPT^oj` zVMm?-hWrvn7vA}3T3}Ct9`1$R&tyz@hA^P#Q%3BfW|1l#9el6whE|%-!rA^DyH(vA z^72D)oYQcQR{PS=&avp#EeUJDaYI%Ut5Fh5h2`5RkGTMu{vwY0jhsT?t-En#&Q`oVCIRXa<9$uoZeIj*9N!8L?_QBJ}A zd#0ef{04INx;Rh^2^3M3g!gO%1S~3ot@Vj;+#rQLsd<7}njA*${oGhKcRMa~CRpX= zMap?*V6IU!8Qvs}H()W7@(yW=B0nk#?!>FE)x^`9o6#dVMd_DmpnFmscKY8WuYLe* zWBb`DLp~4_S3!1c$!xmZD}Y|EHt7EopIvzO{WBwV! zZ25mwo10lZf30GM(7-j0#Q()z;+!r22AfE;r73A$APwp+Ui3`JG1NG48rHezz{jND%$rr( zxc2TDW^LWn=COH}pl>}uJ(fj4&=(=rOTQ8K?|RRgX-&}mTc*)>3G3j!8K7NbB)-^E zNOySYqmOzkd~Gm>*QPqu_eBw8t41?VHKS?cmRHoFDjy^!r=sWXvQ^GAq_swp?`8)N#l5cK#KNKf_&!IPN- z%@dBC-T57*t?iM%(nh@IU6 z7z}D+?}g5#6S-AXRDCZKsKoT)tU0qTP- zY5k4|`0o2Uk*R5?@qD5r^uB+@=$GE4C-2WO>3bJPJip8#vBp0LEsv+aANJz1u^T95 zpThh_ThQO+gWeijF=c|&0ABjU=8C1FMp+FSbZy0hDZ*S~W-Lw~3M2y93BOPV;+G zwT0aL(v5mOuc-;AAa0&l%xZtBM$5($Sn**69X74U26F|_Z(oIXm8Ic)(KM6)TElVO z@?`2Ersg3tIlkKIh9cus`svK^%l#mT)@Or&p#c22>q22W9aCco7(U{SW0mE^ecc72SI6Q(D^9LaD<{x}Ji$U8J(}~`x zR~I&V^a3CIoj9+Gr-dI5K#^4*jK29owF(=l>fSdnttTF~AKnM|#MEFfpC=hQ%ys1- zI8YNKeH_=4fxK8>ymzOYxoscItmse1dhOYCG{%P>|5%1%k{rMKc`8Jg`_V_@XK-eV zIkRm$VnzNj60mbBx@tw^k;S*E3GZSevFj zt9vx(M-BWqr-M2YD~aOoT$I}6M_lT@lkVGEAa$k#uRm6UnOyh!_m;V)i+7xZQ?>6X zD=iE2=9H0D+a$pEYBhJ?x5SHr+;e%c2M#ajx`;nCwpFx3@6lFH!VEHl!!ubsKF z<}gfOa)FiG?F+(7KU14@NybiTE?l@C#7bN#fEnu_`I-qkXVo zsuCVMz@p`rIC{qYH%{dIq_+yqAhb~&=iVO2t}TDK?AIi{Kl+ZCPZG%!n`Kx@(bG`yr8(trEo!&Q}^@oc(eiV|})v(f((&V&ZtCY405>P@Erk#ae{CZ@b+T*0LXxed;)_ zycltcQl^HbJLqt;0=?%^K#OY9v9MwXHYaJaqSjx?<@fz0>wG%5e+@#f+HkD%zR#{m z+Dv{p|Df;xb>Jk4psk~wCVT-w7#H`F6g-tAR{L7WWcgxnYIB4wV#VanpExwLGezmH z&1A#kHMmi?3U1~EFy19W@aL}x>!0$NxaB&*>wwEt{+l$U@7qQLYdHVk+Fr;XS^;M- zuY|STWAugVRTI&Pe9RGX!ad((=-$=5*yx^#e>|hf7PFmD8~z^=z5SdD9W6rFCwok` zPn(CSA+d0`lH&;-UWCR=XOQ8wEO-}i%&~3#%&_QLgKgVb{U^MH+xaPitDy7JN>sGD6U-#a78>TDiR8pveW zMTU4cMHF8b&BKtNN^IH=anpKtIpXB}oko0rApCYjpZkjE!|1Q_&?&Qq-VhIm32SA% z6!wWqG>@^zV#QGh9I^beH?COR$0UXbQj1@G__NEJY~5)EpANkxyRTW3*De4hxoD$i%FcEqB3|4a}s{Ykf7yGD+^8N@&}C!EYA?$i%wndK3Nw>do_xOrwRB`dk-J{Gxu;8~Cnhj6VKmVD@nVR4nUidYcx2wPSfG zd5FjKSjsK<%FQA^FL|&eej|L|>Q5W4X@Gj@kEZ%1@#GXw5uG?~4GZQh!{Fngc<@pb zO@1s$wx%`E^MS=A?R*NgtJ-PO{yB;2K3+=hY#Jt)eT~73m4J5ZRcv21_xv`UqvLU} znfcZyFjCqP!mML(a_}a*Et1PXA6-KBP3JK+x<_z_(HYpQc!kr5^P};ux8#)vg#~== zq}*bNe%-4NFK=9AiqE-#cCZ1&tel{+60ab*vz%5>cSOgJ>&Q290*akRs7Os2wZ7d= zejaUss}DNBd!W|jt|rGn;qKo*6~^h-#wApGgCLXExF4^mnvsjLzVy;cUeHcaYTmu{ zA91*mL%S9zzzO~mxYUw?N{ugZVbWI+9h0GxQzq$;owd|5#EqUiZ48g`0`c~&VDuNS z0UQ6r;Ht6}!fIoQ%07bo4Y?Z?V-DZmt%q-Q0gWdLWl13GOsCAV!0^)JScx;6a@Wqs zhQ$f+(kzziL3zVEk0?BL)}#?z*TeG`PZ&OWm*{Qe0iE4VAS$_w87f%t8f>aRjpwB!U@*z*(7c0m;wqqNJr!a*@0P_cv{*N`O9nY{<9k-miwPM64gQ7 z`t;Z*54Xbf_xk9Yypm+7&m(%dGs(E@5}F{o$$^eqaReA08n&1C5}zZWcONY$hGg?&8i(u@FYr5smh6+Pr*fbIwIk zIJT0@)otx#OMgeuhkG`{v$KK_*{9IlljV&9oAStLb`M==E=L+y_Ya5^&_1%^Bu;C*W#N4%i#Qwk%_avEo6LCBP+)__vgpu=y*98 z4_jV_SNEJjvoHe8IIg1DPDfJhl?ZVHa%{DOBK_KUkAB%70%6BX$j%)vF>R#|)z_^h zA4EkkDr5=SUtIz+Ew#jf+xwnzC_=GrCDNg%MT+}cXuUK;_is*bTDl_~ecyMJBC#;= z^@;;OJwBou#C0sQSHQEEGI+v_0jsd;k zlR${SJ`V4V0i8Jun8JsWtTW0&n&UrOmTS!Ux5UVgqc^FFj~LkOe9qk#HPGNjKJK$l zkNGNdole}=$JG{Rpu%|tO%{qKFLpko0xC(ww(}6XEiaKq8fGJ(@&TNxHy@72roq3+ zBI=cXnF%|31nSb-;edxD=Vl4V7a~e%`pTX0)wII&i3(hE?FwF4u@5r5pHZH93qi=y z5c+(U;t9zwOJBT6p|RZuDecq&!P$kzFPAGJ{u?Ai^H)$6!*FJ`#YPxguM5@O?f%GB zHTcxbIiXHHA=|{4;o)ytP_Op4DPcI3(7p)zqtb$GJzq*bMz16HCX<_V)Bn(H^Dtby zWI2H96=v5=&XZB|jMl$%Zu}KcfuWVXMB_#h_mSn3h`ejmQEM6|%@E=4GKG+K<_g`p zh11t-+0z`20Q#UJ9Y@k~sNk_;e0pUz-4hf~!jfN;^Nl=Ub$uqov&w_cJsU!-Lt?R* z5hIW6J4xU118~_c6ze4SqkkcH3k#VJ=Q0kXM}PyI@z-irT9ia{67=!WM}dnYJm1Kd z_bJqkT*kVxg`o7&lum?xXWK?*f^6m`IIva}u1OfR;?^&w+}RMEo-cyZ|BjO} z#(-))7X#G~^FTSUiT?Muk49a-Md$RVnDk6xAac_PnUmr{znw@V%a-%tqLYKfz9o>$ zrA~pX>?T}kz;VY$dr)0<8|S$_4;eEgN!(Xs%-tlz9DieqW$nVuLFZQJn{fhGJQ2h& z;V3x!r;w;kQvvCpa?F_*IdHqJx@p7rF}9k^2cODaL)8cV(5ch|wrXvlY8uPHXoTy< zuP(#s9lIcS{2@%S2tuAiXJPzU4+tGt0E;W_xeQ4z<;yt4zUD84FGu?*|56v6;B%(D z+YI69Zb?i|d(xzNa2@>2JdV1k3RBGr$mf^e*gfk!!6D)n#&@RD6!kUv#?EVMIbePS?F}D)f(QyI(tVo4-S-~h@vKU>GW1wdupEOn3z_%TX zF|$@5;=889>`hi+Z`n=P_|4&-DXx=vLmgPg6nAjl@6?`D5IK_s3yc)dCu$9JNNCev z^Ihpr$zwo0E`wU3FGddv;g<_{Sf#zDFmW@5xbMAAT27u}J45$@9>*+M9Xu1S?oWcy zZOL?AOei+XJLC7i+R!>)iwfe4=+4K}QC3?5t{z_k+xnl;kBtOASF4dU$6!!C$b-sz zPE(;(fw*dG3@&gpr3=I;8J&NSc0J;I&^TX^MXT72w!t42oT9#;qg2$?)wV@*n@-m|wpY!7=C=$L)&7e?^a=?MMdj_1vJP zf!Z+h>_ITNvKMVn+u&Q@0lIiq z>t#&~n$MHuZ*xtjTkGLN^C6nwPy;I-ynyk5BhW6t4w^HSFvUC$6a+Xgyh}Ufh}~q; z?i`FzPatXqNUD6e++G;?4E^G=N=BgJ_k-z5c#b#4``F*1t7yb}2~cj-gyt9{^5n;9 z7!v(S3O#dB`JgoHlkjI(2X6y8AuG~swFLQujYwd{3$lpg;PKm>WOW8kk^(Cpv`VhW zBMBo+`CcKyd*D#huB78A(Jn*%g0|s@mH~L|W(TcaE8w1GHf+NvSe+A$y}dV?5rdoD zPX0DP{2&<4IgO{c`;iCbVsT~lLd=mw6RO^31&+&_@Ykv!TurmFn?JX!__6t$Dv5Hon!O#XFCppjki0o^!^=gWuTBqCQ-iSxI;0 z?Z*9)MbO?($QoNAcqcAFl$SVSh3+a=(Rqa0jQX-V3(OhCwa?q$dWO6ryWEGU`C1vsQ8)_a4c#_nC7p&v1IWD3c9; zbDo^oRKjFkxJi$kcBZ=njY-0293%GfIWwm&6$O1$uzSsWQmETO8~3cnQ!f4{oxNSi z@85`*`A!hG%nuiaEAATK2}*}Y`q?;qRe;p5(4*TrG)c6lHVyG~fyJ@E$$oBj=}yk1 z_XlObg@1rlNXdiLU>Ge_$VR)@2k0}H1N>uFIN|-7m80W!>Z2g`ZKF^{r zj8y33sX90@A`07nNkeOrH_nS`Uhs;&-x+OZJfwFRnslZb{%HIYwT z0#|}BnRMFC!pD^};b_xxRPg)2=&Qs*;h|@2-DfWA&%c;m$18v<#=_v1s~#if|CLsC zPX)31%NXwdVCpnNExHN;01C?>^@vSJI=bv>}8ZjGKuZhSa|mQ80l~4W~NkE zh>s95E&afCarTyxk2gJGi^V)J&p3zbyjziXaU~OeLI}$OHp5MQ?&j0Ff_UZBp^E25 zI@`yD6bDy8?ujJwtKOd$aW|NEN>gC4u9ArRZzXllXOhH?Jf<6WAE29j-gAm@^2-hkzC4dL^L|UV9UozJu5E%d z_LfxCnU}adRU$8EsbYCS3B8&5lK8uEd&y(nxTw$-{LhKO4iO9H?y1FGADEl3m&V`< z*Z;tBZVuxN1#Dl21~bb+x;fnW4P8T;XzXMn{!}}T$z=@Q5kAAQTE?hkpJ<%0>n(== z4xz7nkliUyVANj%Sf>C0o3(<1*>kGKwl(Pfd_xv2+QE!@RQNu>Yzvz3a%&`4JAF?%D~~ zbma?tXqQA~bni0xm%lQB%m0IizpKIW`CU@ET$J0fi_-;0dGL248{(6CVO+NoN~@mH z^%IxC=ClqzJ1CC7uP9>QTv5~e860nI`x~nBwuPo9-(lYM3Zsv1H|==09l8#2?)QHp zoRc*XD;lhr<&V{Iie)HC{T)KaDwctRhB(H|>1M5aDv7k7AcXu9ffl_9YB}u#ovBnz zLq_8uQLcj?^|Qu_Vs4&p6vpW{n1!O61Xh=vbbx6*Wn_)`PrU63)N=g3&kPRz8t@yBZ(eLhT<33;LgHE zc6n_axo5zSWx*;C6t|PSN_&d6?uB?wu>sce7jihH*`|Cu1=#d9LoybzofX+D%Q3Yb zU|8n?S)0cLPfZGGRyM~7)byisM!LzvR1wHL987=8KcRP&rlHxkP8517!FfLF>5}+8 zoDDqM#f9vtjkVl&xHP--$1diuwgCO%&`TSR24MS} z?RfHGDcjT+&h;5@6LM)W+;37sD=tf-^VE+%ZL0$Pv^0#YJc;)v7s7VS%M25K3q_5x zXhY^c9OS%dvs{|kFX}n;&ep}y{WpfJ4|zjNziuG+H$+lCxoEJyl}KWni{NFlBPv~4 zhX>89=&Oe;cojsVMNI3jtr~; z-)?z2liwZ|DZC-0-o0$VKU0t{n8I{%d}BeOq^5fb=I9~470&z)hRKfCbn9YexO6Zb znq0Z;{em=d?}7z}?#UqPSI-gcOhdZo9p|X|riWv;Qfyn4I(`z{!o9QRf~Ng!EOXlf zck}n*fu{lxC2IoZwuzV)v=r;!4?@#VL)gW2h;}i@4nIbF^!}1y^)#?C5u&Zd!z7z~S7|8A#B+8Rp;c-4;Ck;avYR-sBl8iRPym0#?o>&q=a+4!=6a6 zQxP$p{InEj&YfuFTgkBzP9?(kes}PEpaQj9WTDCK4;^}7i=zDg;8k&(2pmbLPZ<+B zzbX#5i=IN|KppD0{3rBN%HCflg}n*2DDR;JulE&$&smJK4@1Q_f~f65X4UIxus>o%ehEYn^ZjR-_9GHZ|40>$6XN>eZEtYl>pFF6Te%TtEBI0sbqn^X?n64)Q4J=!F0gw;2(}%Kq;m{+f%z+0 z>=5(7e7Qyv#yRUdw33LZ)h8_%(%L z%f|>}bY>Z{p)L5id?r4!OQcS(AJAYIC0KOB5o&ig5bfu?kg*kqHEND1wlfjm9p>TK zo62}GaTe(GH-UGB1IWAoU|(o2gsIhoOsQQV*=%RP6!u>NSzjkOFBwIjo!-uR4A{iv z?Xzc-qPBB$^JNoR%V*T@Rvzen`b^d?cfxkV52RXmH%?iq+8iHyhf$oO4SIul#3o$~ zy`w_$Xu%eEq!dDZ6Th0wcRq>>cf2A#7h>>`ggiaAzla!Y^uqni_rsC!Q``>E6}&>Z z9MUa)a7)al%U?%=`t*1lT_Vc<(k!NYn{Km(T}NoYo;mJ6xE&Ae$^?4{HwcQofy*++ z=!MymD4|#ZC8vWiI5rrzBx!=Rb2(jotAakZ88w!jYR~f7Z>IECebcAbNVG{7#%cL` zu&2TcQoNRf+qeprsz{SpT8mKNd@`7ai7^TPHE}(rlOXb(ADi>_;E_NoY+k7b2S=)@ zGnYs9t7(Mc7ZS~0Dm}!?+7}=1l*XYS90#;_19r4afJX5{B59O?WvwYdmPImW(|hS9 zH%IIWFo#&%nJD|80kR(o;33x+5kFE#b2hDG7k|yhqk7R~veh1aAKbXGe^?jJF8x8} z_Na4NN@r*?H3R>xh3wYE0$g!W2ErQjVaFAL=FR4U_+619HZ>V!Z9*N*%=yhW2n%A4 zC6~o@J^`s~9k8{ui)!6}Pi+>_CSxT5{3xZ44$KGQcKSDcrIm%2S$m0=(-V?BXan=Q zbI8(cewa~afW9-|QF3A?mx;_~<{1Q%Gf!9Jpld(Ld^LhO7mDFjgMg{@!Dn=rv>*gn zx^Uf^8frM4)U?Vxhf(!yg^i$%;dM=H zpZ;*xBlZ&R(`p^CY6aVWCS!ue6d;f_*ziV9n<=qZY7FB27+`Uagb;_v( ziA24`>txf4ZU#9axfcmCA>Cqq?CyT-1Jt3KBo4OphPoH5?-M%OmJ2sSbRe zD+a4~N{~19Jk39k?8OiKr*We+Kiu@30?KRh;Y7(Ydi`-4XtwMimb`qpXZdkv%tHds zJ~#(|pWTAXJjT$xrViBJd62JcC!Dfd3%3W6=2aGQ$= z-sn{(=R*5gTg7(HsW1oMx$|M&6hY2!TL&Mu8KURB+c0DLRD8ny9rcRau(W+CE(y9# zhOHb~57RQzR`7#Pc#1QdueHD>l0xIxxiYI~C4jL`gvsW)PT(l88Ft6(Vq0(}Il;}% zb;nfjq}3-}wf`MvtaC-d%dzM(O^7_06^X~>+v4UgI8L2%7h&);b^LfTgj~CS(`004 zEoz3Fnm%(mNMG=LqB~_`P~9jU&h8qgt-8ZBX`4Odk0(?88YL`P`vQID2GhO2>zd9^ z3ZZ)QR&cO-L{gpP@WhD$#$PlWUAsl8h#PbarZB|iC}gaGUlG(n{^ZW~mnl8z{W|QfRjA$SLhu8qOb!pMwH`|3n~sYZ{?`C#zBNbu_Ww(2gr6 zbs@v=IQh$$f;od6tLpm{DjTH)7n5D!`ug95-=_?BpBiB%#(c5i-(wo{`yZVnZG?)B zvDCJ?5%#&{v00O$P;pX|jCPdZ6tg%K3HV9nF1~_8Rl4|HGZt+2n&RQFc~sJVGydMQ z5PJMqke3&nNke-qSRGY@l8p?$WfJJqpmU74%M18Wy9Pr}^1xyFOQ^f7nY;{4A*UaS z5@yLNc7~P)Iv=!WdJlP%!{TP}S7SA?S-p^|<~M@JP!BcoYapUi`RJC7Gw{;9Q2Kgf z8##MV85Vhsk$WLrZ&Q37m@HJnuEY{D6qrSyW$uEZlZjZIy@yH-$b$CIX!_=IJf4}` zf;PvFU=T9}>$hcN`26k*G)MrHIwI((uR40n)uscpYiQ-KI0#CV1>SQ9L4I{A=#0ov z{ptE>*>{(o82Lu_eAb~*e*&csgkxBLDMY=z1Z{iwVN|*^PKKA_x~1A+@#P;avfBg; zZ+Vlyf-^v;YB7p`(luEzv6k#DYa!=X8>5SNE&b%)i@y%=<1AhcHr73a=mq(cecjt} zh~3Lrz(c$r#1A~_i%hS}2a>Ud)v&|Nhzb{cp>yZlLAm*t=>6C2=gH$XbssF;5 z^N;po0cj(ayKm9lv8fP#u#0dw0(m>RZ`9gq7&dS7X?$-qhnP#(cvyzGR7BKhu_!8 zpY?8ZoA_$fj^W%NF4K^oW6XHxiqM5W@6p;u2F+Ar(dSSfnfbMdDz6~W%e}iCv(BQn zIv41juP@=K_%s+Bumb6)>tWXXYSK{b0T0%jq4ihmrrm*k#wvUT_-pJP4fncEj>^@t z(wQlE_IU)JE3YQsXBAQ%buCyVd5p|VPa$i0#u?L)F7}(`UQqcf3!mqT>@0UG~>KImAK*G4Omv( zfO^U$6j(|~lNuGnNLuA-4N+TWfEBL?#HiMx#*J@Wr{^loG6+Pgq&9lx=~?RI6bdB* z?--{C+`eHvkn7}Xm@3sTKt}_C=HLoVcxz#e-#ZjwYRnPPmy{<-5QKlCrh(y}4JHlT z9^?CI75Kej9$e2!#4+n#xMAFYxRhkFLoXs>WnwN0ZLlO)R#0}!iqFKC+c_8QiD1ok zHeg0%9yJR7MWmR`c>cZ{I(8B^a2}B{{%EWD4^N#gM89`Uajyer z;j&RNG|yYj{%Q-Och)b%Z^i~7Tc}DWB~Al#<2+4C9;Wj@aQTz4Y$)C4LPcUOLagr% zSgvyz_4v7bh1m-7>d0Hz{K68|U7r&jli3_^uG9EfG3QYGl|{@tmVwKI{j{RLpUMvR zG76Vl$%hSLxU@nB#8;U^fmAOY@S8$zaL={%It}1ieTT*_mcyE&QLx)k1csj*WY z-)4>6dcnKI081Df;B)^2n`fAyb=)FMv`V8wt^D|S0YyD?dr~#qn0#51NTy%%gl|r% zAo|UhzHsBkJySl@*Ci`h)6JTgvFa?{HRl|&QF)wX)jFclmIUEQeiP@Crl99pQ3#`A-k%guI+%e6} zka_0qgwygSX~4V`P_tTu^-3k6&h;V9e>Gr`K@U~q^TfhQ2FgG4aG&*W$~~{>AlpTR z@}AP8rLjQGa@g~q|H0a-{a9bmdGLIO;P2Bz=zpt~m=AtHF}^QEOeO>r9?wVH$7ez6 z_A!(q!c7O-0gN<<80pds_|Nw)gr6E`E-)DFD3()({)gMIuIgNyZaZ@Y%MVF5~ls`a{taf7=jwU9KDd!Jm38h@xk|$v0P> zoyM--UeD+zexfI=#EC~*II}CEmu1}}vFE)iZZ&HnORYzMTSsy1w@VG90_C)T%QS76 z^Bg9fMM=(OWq9lDgWpqHXo-;tYW(UVW1Tl3t%G2}Zd2Hh9|Z{;qllm;FM9M2k^=#r zEzAT9kf6qkBIC`AOlhF@!g^YxM^nsE>w@3)y%v*$^G#4)bK=>NzU>N z2%BK(p7;R#5M4<&uD{BhKNSgEJ|%;o!P_R8)=@Hlu_%7IeUhg2=wi_>9x~TX3wUZj z(Z&9YVDZv^FwVHj{f+`|$B>S}%r&O8#ewsfhQs3;1?Zj82ub>n$;+#s>3V%zs2kcv zPfLoLI`&V2r|ZM;j+s9CUCAbMD$?L!-7Jnt_nf`W@!;?I9V3P=hrv1E2C;wLK^o4z zBAqt~It)Gnr+!5g(_0N2+0!tP*8=xW{~=!kZ;}7jrGbn<5Quo3psaQ|-MG}46ue)9 zYa*7R<)voEtmqpdxdpV9^CgVPRuMmrvuMcfz-0xFn5Mas_UGiG+s?N%*!C{7v!jUm z=?CDlKwF5GucwyzV$BmmfiR~j3lblTQ2oZ$O+WqeXz5cKxPI;p30F?U@S-^IX%qqe z&{QJ3zyg0Yxs$yO5j5aq9hU`t1NraI(ZG5ils9-yX6(3uUvvF%KEDc`v+^p6j>+SO ztO1;;k3-(5+PHt8c)<9bF!bimf-6vrR{jZSuM&l7SKqPm_Sd1WNDH5+xMROl8QXX4 zFVR^Q4mXPO!7Vc%OyB%#GT2|q`geX}ay}e_^x5ah_1_OzshQhIalHv@SD(cD@AI*? zj~~ifYiZMk5b!)91pOAO^z%;>=A&@}>oCOz2WI&(k+Z^JaJ?fKsmz7Hv;Q#07OQFV zu{EG0EC@T+$3gqUxg6(bBLL$H;qSc3stQ{ZFa1hXcrFg^B~6^;C>A@VtLXs`D>S+j z$3pOIIC#y8{C72hEMGPUO<8ShI{c9eDxRSOo{{kHq&3ID9;XW~@-UixC*ebrEgjMR z%N|H=hu{C5!YaLJe6X+$59TihH1xufi!+f?Oox^ofoNuZ7?m|<(lSG3$mZVvUqb+H zdG?S79yWx#1y!IB90B{guh4OGM{IR5z!T~AOrxj@d>R%t<|*ia`t&EEK z)E?G9CXV)c?1e=cEL;8+@$-91QnUnyW@`viMW=$MJiXc=~#A7GCil*9MmX2K2YNSL z0sA$=4Fc1nn_T%qNx+luG-UDy`!9AM9C@cs;>EH^2TGE=>o4Hml{eWL$r;}R?V zN6+ME|0EIa7J%$L9gN&ok2fCxq+`4w<8(O8>4a!(y>)I1EEHt7JKhRd0b$>HFv+Md{{8x6Z;JCwu%`5k~*4SH$2# zdCrL%jn1RTX`cKL;0}oM5UbY;Tj0wY~e+s6jzO^xbM%Ca=#}~Tq`(}JH zLjx_l-0+q(fv?g^bmosbI^n_Pse`y3r5M*4pS~XN%1uzaAJ53=t0zG4vp1Zy6kMu! zsvd0Jwt%2dBAIGl13vbuaQnO;$n3kb^y%Vue7r&ybW1M5Q+fa_ntiC%FF$g3povPO zC^N?WoMEN_tZAM`K1bQ&Sk6k!daeq0y(Va0??s{+_=U7qok7@jfyjE)!GT$i$nJ`E zT)k-)k&@_UEBiQ}Y5O(C_J4}b!y%{ljl-oqlu|?`+C@f5<2-kzL5Y@-2q{HY5)o-? z4-K@mw5T+-&T~hI$c&;gq7t(DmaOnQzyF|md(U~E`~G~ctH}YczuN)VY?_GmlWum_ zRb_Y~bPLwZ;bn|Dw%@!t6>RoIDPDE-!K0EdiAt~n%FZ^ym8YEPe+M(@JEsN2gNJk3 zYs5p9Y%wl)w;Z3OCgVi^8hFvG3A6Aj9+@7_G)3xB#Vl2#o1}uH-0Wv%g%%owgmbgf zagGB%joIeHvC;b}`L+6$aaZ05`CXQU(V~MiK%#)zS{cjvIDF7GH7Gi#PLKY6eTm5L zG9%IU0Z_is23@pXF>=?AvoB|!gV}bJluOv-qvgdkXL~LBIpotDI=1jl_c1KHCxOY& z?eM^vTHn{$z_xObcni9NbktPx z`L_{s>T46oeO!rRFFMJj^&Bc$RDcJ{>e<4*59zYyjyT`tA7O8Wk;4Z}>6iU=_}%v~ zhHIQ7*Z8Eddi_$PJ=*KA=2kk}dP<#rv!WS--P-6Gw+GD589KnXkn64IrZd$ucu-?; zD4bm>3){AYkT3hg;b$)&_>4ZIE3#)}`NDOi@L)Bb-+hD%?f%ZpX^bZS?oTx-KcWd6 zUTg)E{af*m@NBdm9;X$e8N_aDp7E@msW4kQhdEdmi)MbuqfhyMz{G0;ApeM^_aeMm zd6{U^Gcy2J%%5cTCJS=Cd45dSe2fiyzM#tRMWQt(VA9ko$jY^NVb^nQ)|vl0T#Hu0 z7`LX$U zx_Fd4`jkWRSEwM5&k&LR%M07bXL3C{S@O@clL(vbXKsatV_-uhipkE$8&-?(#S9%X zLr@bN1aleL^QP2)lO$er42N{#f1nO$8m7`9lv`IzmNoKk?y>q>8)J&?;CF1YbGQ{Xf-PoVYpq zduK#*Us3wz-2#}c_?N_pUI){>&7hd3fGKrWP-w`_P@C=X{X-dCyt|vkcTPi-lpIvP z(P@0B*8}7S9$=ToBB1(GEmbDF@YYKmmdux|jV=@hH%WVvIX#^%{mHo+Mk?tOl_(6I zx0lunmO#|@^Kc_E0g4ZXFpO;l8HpCAnJvqxY)%ONyvaEo18&jY+PBQ}V~Tjy`UQ^f z`@tA5o6(#^M9^l78<&M+7?Flc^iSk4ir?#C23MJ*_JlR)#qJ~>W8&Cr@5y!QL+}Rg zZg^8HL9}e+Xtdxjs#SH5ZAf+mqxc-CDU^q_Asl4V*;e2{$JA;6G<^PIBpkCnFG;_UJi|;Kd!}u5P=+_sObShsfZhO#7uR6a+{H%xJZn0G4tReKN zye6}Rj7YXy6E%=Kj%wTGOpY3cVoRX}WR?C$qb_a6@y?6JGq!TB`IYftmMp@goyevm zo}gM+=0)!Pcw~x|{SNK`{b^&DEIhP=AcQ7(=BJGn#J|s)M4? za+sR|GBOvN9+KQm>b zk4X~`AEd}EfGL|k<5byHuo6^-BMXv9l5Z?i{V;WB#D~<{MqL=D- zJp}jfy+k!{9a^RJkk8&LAp2zsZuMA*FN$-(vBDMH^2bSkY8Z*yl?@e2L9E1p8%=rQ z4?+eP+0{F4G5yVbjL@Vf%wAnfWs0=u*~>%3W2+e+Su71lPZTnJ>rUh8uM5~!ih-cz z(1;6l#%Xu_NBTEhjPU+vi0a8)e=1qPMCtis+AJ`@92!4|JjaLO&le%GTg?m#{(j>4 z6*6R^{~CEQ=|Gpqtij)h7BUNX-oo0y0yODD5GePnfau^$`X8Sjv>BO!yjdD9pDNH2 z>7xrbFHV6Ee0yk|h977=>LUf8mq6&iSrof_9c!=MB87|m*BHJ-ap0}r zPsgA5;&6Z&DxPnkZ^igp_LWRP@8nlpC+h|$Uhl;B3ib5ewn36>r3(4_d9)$<1X(X} zlVoOcj+n+x7_VXqS3;8UsP4V1$#+(IilU53XIZM(yBecp7@mSWx)K1L|`f6s@ z_(4#?TdFQ)i<#N$$b@<^n`jY9FPARBzXq4#QvNddBUVTT&mM-3jjLHVucvgm@Gup8 z^OZI&87857PLX}9esMj(Uu?^|Dvm9wL26dLW7Rm`(3V$c=$$Lltc=$b*!-;@&oDjI z=GA-R5U$V87hVX_UbZN5QI1Hy?PtO_ShH6J%7{`Y=Q#U173I%tf}Nec#BfUySUu2z zC$gjT)Yr8j7G{ngqm*&$r7&v$?j;ecw}h{nvT*d3INh1&i|@jVVFF)LX-QkM8BF2b zvJgBcr-%XF_t?_qXTj#D6nuKu2HmgUG6OOlFr|8$$qHi~eETVxR1dkq=mr+5PdQ>k zUOdV2KMbFOHjxigb`UmtCQkpf53@Y$>67s!y5W=za3*O${|@@Ya00jW&45X>z36t4 z4-SJ0`WRd%q5BKi9XCJGI>#aUa4eC2mG+18f{x_bnrQSH9V91Ch?{KrSxV=yuP2VN(Ys2XoLoxGXWHP!?CWUdbcT9+h7%z< zf0&*$7w1&+n*lV`_R@|o)t>|H(|?>3I2f}{?)F#3qTeRcu6w@i>d z<6Xop=Ligqd||5QdvN|*t^?t#jk$Y{vd**Pv3=EB8ap6FqqIe#!`%VK6w^pCUn>>A z-bI3Wrh;vd6Vz*#g5Yd-oM6o8Qd0>kpb(CVb!%ycyD8BYE~bSplQin`8~U{JI7IbD z;Onrtu#>l*r0P1-;VqGvEapKP&zci+&j(a;c|C63Bu8zhTj0aMR!A@kB)VCg1N-c3 znDP1z^%5=xA00)EJnM^RNDnpTnj;-os+rVdbFpfK7w)*ZqR6XpnBHa$6=S2syj&06 zQ$ElzA<-885N;M!J7DCVx`K=~ExXI-+K?u)FTumm8NW##VxK>iC#A-b7`pBqIrBCE9Cl^k zH-2Y&GVU%MEjmD5oE$OixF@_VoyQ*KYh*+e2Fc@W18V3umpSip92>S9673hyplgId zFaD*(W5qq3_%#mMhw8y+WFzESCpIr}T?nz-3<$f=Vg49I(tzKa@Zfj_%$cTxy}y1k zSA~@sdA(RV+s%V)K6``k>oRbeW1uCAbdfbn2obFeGYY*B1KO^=?5`yvkR{H|#tqJM z+%a8RUvriEg;bCa0wM5v#&Z0oNR&8;YtQCv{gI zF~{er;8dNbOsa@#%leyfWHsuD!Ptgd1OBSerB??bF7>`_IXE_Ey+rw35b- zi;!i@V#v8@M=aYl1)taS!2Z|bkm8hxncNI3(V+r$?|q@)-V_+~_`M=gm0~SHDvmha zP!3$>cA=HEGd(=Dm4P)~sPJ40oE?8ND~yDpYOyKWNGOpxn@4DbnHuVGeNA3&Kl8%= z9hE*`3U#mklIYl6jC#TC+Kw+m`^OtGs4R{qIdKl9#l=kjkNxc3pR-JcmYZ_Sw43aE zGjS6=!wjax#}ULf-)o+X`amoT3rV!_2~_t;q~1@raG5DzcS*GX87qKZBWATABoF z?f7Z-TWj>vm4IhQ9YLS#5+{adz?gU|bK{l<(JD#A5d#nU@tqdFb+tg3{g<%tLNI&t z*-*39yGC3;-5ebE50DX0&ZBVB3f)az$R!DJNN|_LN*=E7=55nVI*d@G=Lj=D`8cR+ z7(;4E7FK%m6U(Y{V^={t@Cj)nf4P0G!PWfc@NHA!RH8JyK5z@xLpVgw69&y$Tqnut zC$(H;$y6*qPiCrTGf$K^GO5AK;K3_ibUE>WjX0%*nTHm`Skh&w$fm) zH3?}kQ_6vv;@ zxS0F&g?$oUHM1jQv1?FqZU^WE$l_H)TiT#E4IS+@NOjO$^bu<}QW`uCvPb5U)pyeg z;r7P0dlW#rUyUe)_^pHLKlCal!HJP>ZEbwNBl9|R?Y+7I$z6vnFOSctU z6qjD7w8saMMc1$sYtx&xXZ^SAh_yP*4XA{HmKxl-e>!)j3T0#uTqo+Iet2tZ5xXev z0NJQGNRzzv&?t5u&VA1@Q=S+>$I4Lp=wS|C_2>8yTV=W4pC?Yp-N4%&>mk4X7*5IW zrb4Ow)XJpPsK@m?Q@tdH%uhDOkyTfjl(&g!9pOv)%+z3|nh`w}=nowQi{SerH55N6 zjy8tRV%9`I!m}>*bn$*?@M>QTA#W?u`E?q;{j~wI^^T%}fC8@Cvy@!yoCiXk>hQ2l z6yT;CDEXN}x5s0uSAHJ8ZQ4O+9gD$=z`NAKB?8voz6(WM2FXl67B%ioCF@!XNY>6o z#9;RFV8?c}Pb0lo?3!>nti z=s8ltN#9uKKG_Q5j zj)#0exO~0!#$w!UZAtBP{lIms5wCp9qau!_#+u8mIgWEFR2#3OZ%2AaTl!{L{lg6A z`rHHGttHIG`?^rRTncWEJ|>C!MZi9&1c6Z#TKeh=WUjnJ?>^^v58LE%UF{0^UhaVB zc5P;gvjt3Ue&@2SH>RP2fiTOC*V66fAF1&vb3DX(r^4181NEzhWI|MkUdX&F7?z@h zy2&R|sB8gsII9R2`7!i{kv%*5p&xSOUXXc4#yGB5ML#r^qF~{BIy|xlCLX;;i8bo* zF=sigbg+VFfojlEuhequ-E*?hKLVp{GpTHKmGN9rq`AZUNScu*KH40GS59BTO&@*n zN8kjBbHH=?E7~S?$1pZFjATuTrkh6kNxIAj zde2Q4{I9f<@1G`!PrgO-s*|(9MCKx0>nKCkg)hLn&TH7R(m-@g6CjJHiEw^1!pMDX zBk^}kpxP)5Uils3n5gO`cHcsx*)jtx>T+T7iVmIT_Lv5#FU7y{S!n!_b9&ZsPLx@k zYfmc=dRCO<)~LPM|2`I!w_8Eiw-sc4XfrtNdq5n-Cn0rU0u-~av2V+~ae8V6RoHie z%ft%+adu{wi$0~fezMH&#!E<|D{!RsvGJ$spSYs*2F9*dLcV6X+8s$X=zaPdX*iOE z`S%uKbXgkx6Qd8Np{neU@icOTo?wEftHI^B&xlvxBII8ijK8v)@rG+ZN>260Xa!4r zKN3s2!fvz6j`lH6c@o%}LH`&byJ2+K%p>%$C6?r^#4!1z>@wc~lB)m8=+TBjGMAfM zchnrgh-|Tz!GC>Z)LR{TQ*3agOA%ff&4x9zg`lQq%=qMYbz^zuG$J?uGX377PnQn! z;o})s8GEO6x{KS9D3D<)9IFrC2Hm*)Z5kOfd-Vu3-1;)6VDXE!KoJ_+ipYkz(;WUej3RMQ*1AJMb(1CXsPTH+#GQhWg_+IXyg>g zt#*U?&qwL+;5s;0s)rN2dx@#~6r3(12i^lJjE&|i5c$gD?mb-BZsP`;aNQj0j!)7( zZ|hNdt|XXVizkBJT(}aA_Zugevga*Sd=K|Mc(aym znW=(Tf2xxn!?k#x>jUXmDz?O&FrZbRm(w3UDoqVSr`VJxXL$Ll9qwH0gV%G8;2QZ0 zRK)loJ!Ga%Byz*4zw=&NE>udgl38Ls_dA`h(29*J^2Cg93T=4m&^%?o9y;3<(|PYp zi44bU7@ZYLL*4x#&P5nCQpD-9-e_{vgX-YFYCrvO?f|pu%h%?}x^Vi@Z;0*`&^rKR^F=zu6CwRiBg%}yrie=|q_{{PRGEmOVG7gy*kj6)$ z#58+=SYK`>_nXa7CT}(ooP7|@om2xpuJ{JwJ3?U{ z$DIzz+Y62UglT`2$qpTfB4xumsYEsUgK3rXI9o-Z-8-VT1Mu{b9oX2mKg) z8s6)hD4eYJy zdi2fCsW`OE8u_BuLa=2qjW~|Pn4>9vGTsf^mP@h3K@QGIlrgi$T1dNHJ1gTI02bEa zs58?CvJY>>DX(+DO;#4RN9+d6Cywx^^8`~iJ_p0XW|F$1#b9$P1&TGgVQc9)=fFv& zI$zG=tQtE=5|~BAPH5l{X(^ zetMBNTqkL+`#5pjG>JdoD!>G(0}20f%(7aHomO!;Q?CORL{2eT3$pP{O9GWhzQw3o ze5~2QanHvyoFUe04vzdfMX7EwlxpvTn#_AdTVENvJI85~R}`4PhyjaB1GHsGjrmc} zi`D**@r`{8aY<-E2&3e~^N+;N>kMu_+{-?YcfhLo$}M|sD~X@IGTNI);@=z*{OtaQ zEPV5jJ|CRce8eM?e%y5fznBJKz{5o#TbcCk#h_&Cz!=Xjcl{t4#Z*@M^t!Y$W$La2)N$7ado>yW4ZhZVI? zBL;m&H220om|mBPkDi2~ecmA^UuOsCUyR2~W47dTUpSFS79kq*=Fu`$d03$80je|3 zL*VDarlND6thIxy@lr8WjGgfWMca>JPh~6!X5U6-y>nRV>Huzmxr9%(g`~-N12Ni0 z+}Q%MkLzCTIu%5ImPg~Z{c>2w%RN^EFTxMeMU2h6(|CBSJw~}B3zu(OhY!tqh>P+n zIBotJpN*8^P^1nyBb>{oO*xABMnS0PRtoE#H96m#08Ay3aO3!U(#q|PXIU$wOeyCY zSFOh+ogkzuE7*Ri9AYQ)fKJ|8j}vZb=y+y`n0jk9%Jj-((+u0DY0;x}(d|*D^MDxF z0rP|zGTW$nnlsxcu0W$zr$b}QR%ZDgM|_a%4SA~9Fz|jXNCoE+hrurL$jksruk*4h zwk1r)7IP{Rsf;g$Q;GeUE!3UfM`a#!9o!|;!QO-)W~BLG&Gi!`>DFAFn&(S~E)wwB zq)a3fuQ1h4UCe;V6Kdo%03X4FCt84{pBSon0n2(FN-Tcxc-D4pf7$a)*^M~ZKRilgBNtMij2!x}Xg=7=yn&GL*~IkSWy*g*j0UVV zglLnNrtcR{f!F@J zVO6>`*IjTT!5bUM^i9R|Sk)H1I?mmnSE(Uurbc)AY^EI^J3)8xW87Y*54H0pVd1%) zW^vJQ;(99vw7Tm^rd%Lg*BN94+q0Qjsxol@{c{?5!HY(nnTpv8f53mvaX6CKN1}=> zP{Z#GC2@RIPAZP&pMMMg-m`;$SDfj;AKO9xRu*HK;s&xdiX^9O!nk<;Ys!i`LXmSN z=(Q<>kZ?Qm1$dfNOYDtn|4NY<6T%N|R_`;+D zV-y8Rd+~3yc4@`ga@>5yUA{JHbt{d1U8TvS7z zf=9$^Z!57^3BV6SC-Gg}K1|g(inAl;`vpL*Qcc7XnJaHF=LEYWT9E76A6)Z8xu8@(9jwD<5E*p;`?*$5&`%gllMmB?-8|xsy5|)6G^iMd! zZ-QUua`&C8P|h{55038GLw8S0L+#~rFnd}S_8cvs-PK9-i^n2LSN6j`!vgvqMcBWi zi|B%T8$fohF!pU@VKgZXWge$9q6^%~o5i}sWaI>X_3LC#J3TZOs=G$Ms0@-brcpTV zGlxz*ieV;KFNVQK*bP` zotD^IycVU$zp))lRp81SZ8)}y2iun2re0mkNVDTGo8(zToOo+V{rFE>mEMHqdB(8F zP!uH^duV7}JJr-0f&t4BD(CctI7ATSx70?v=Sv`1C$ZUj-$K}QdpBxS^3%V21zO~c zzOVuc3*lrDKgX2}qwP}HiR{}h(siMN`A@z9javgy z$1yMe&cd{P5}4b+8nn~~N!GR?RB9+=KiF>NzE=zI-hV07UES4K>$LzDapy*@!CMTE zPz>xI;L`~9mR#gywn?%jYJyRK9Ba#!4B zKS4fanGm7c4rX$P6*U;jrvKbEP$)3G+2Lys&Eq^|^PZN#$G{AR-=!41mL+fwu&>No z?!7VF_OJ1fVkvfaio^BpC2;RtD_-UE!}*g2T(3eC=b08khBPI&PVvC>GY`og?yh>f zZWx65m1tgpJFGtN9yV^vhZhQVAhbgn&OP_UXDKXPzB|)+$GHz=N%O-Z$lS0E=rL%5wTTlLQ+N#8pT~o2Q#frnUP+?g?H0}+<@yaL zj$p1#IC=2R0$siMQ8E8=^SjRPAUZHfzH0^`@9g_*TwOiRDp^c;x21#A{$Cias|r(P zx!!HKBwXS$yVqm{TH+)U$RDY4s$-spd_OfwQo>zK9SiqLd58|fTda7d286LQa};R#oIvsaws_U&Od7jYc*D=nn< z<33m+tpV2$x3J%r8q=paZ^`$vN%qj|Oz^zD2lmK)Y+j~4#02RE;Oo0PsFjKqrU#!O zHNKoD=^EFkwu#2kK~wmfkN`)Qj?uV!A(N5jS!ApCW$5134)Jm z!_gTHj9|xQ;`eMObQDwO%j^o+ecOa29z6-ywyQuq|1ykO!m*`}RiJ}f0eU=7!1qfC zBb;kMubC}I1G&BU>f}kbhW4CuFK(atVQgKPtrKOu$(fQsm2-y!`V>r|A?Ud z8W`2yh$4Q1Ee<<5Cw0&d;~Upz;^{ZL80X?5h>7?{TPHqH(<&}^mLYF!pgctKrlnKY z=4yHtgyHk9H#BIE2KnV_PFI)SgY_D+xQpp){!d<%=t|wD5_j5&-?#;uwg8M>6UB{R z*P)NBIZIA0p&O>!a1L2>m~rGg6y{ivO)n*IzgHjQEL;iNC-RLY%VyBtxK#YFi}O&# zu&CZAf==G=SZZ_<IysCVJ>0uP_LFX-)JE?CG)R>a3?=5XVHy zfcb9O#8<v+9gq1j|IkZ(`(OtwKKGPrTU60q>1R>n^&fJg!I|Ec+6VRy|ESi1 zDu{G#fE_2FQCU|bGGAqktZB(%+E-*U>$ZB+^dsT;eP$lC+cweq|9aSwuL(3nY9C6O zL|}`U5R7Ur!i;AJp`?2`y%AH5rnPCXDU1i_fB1|C!d;rxecXY0Z3j8Yr=hQY4ZIf? zrH^l(!KE!4SlzS}&UVf~HAV}qtHfG9eGY=F{F)qN;~{M+%7-n7XXEP3CE%s$OyBO6 zYT4bHLkp+6L%Z}S6F%h{W~?fphkyPibC0J{%K%CG?qdS#?BWHf4s+W62-&c<5He@s zA$)e&5G-HsNB1fLJnhv@#6E@N=2xkB$NC`&*&a=et!wB55WRR_00S72*a^JH+fwk7Jd>w-VxG6q8Wai-N+Q(J}dySs2UiH=l#2 zS7)K8WFl>T;Y+SsY$vwuQ$gQWt68n10K`woLHYY>En?2km^&9La8tW3*RwlEr-mKI zg=htGS*j4bVk>?%)F!@l=jpb)g{0DIEfhQTFh~1J>16*$`snLDEDCCauq+`|P`uyt z>#aNF1j)ij-cvYh%17Gj$0^@t?*g;^DzA%q+FU z!0LQz;l@W>gPoxxC<@!nMRsFxt2;)>2}N7@@VkpzT?>W~Wi_b1dD1vfieGJRV*|*Y=(vrq52YnCu74-+B!FxPdXs zi-Aqo)?oKnKk|0#d|JnrASUl-gtjfgvVwjh02g3B$)}ksmoUXY*5LTZ5q6PmAGb$7 zhWC8GF*l8d$?JzVG3)*syq$j=4HpX&rw%RHGR=nGXbL3KIU%rN+!LSsS%TuIF+522 z0W01*&gu0Dqhn1$??5r}$dwv*h|J0RHSDcKirnmOEk4ET!G@kE;!B$~w2 zhcCG<^XfFTPe`Cal5_F2jvYvzJO}ezwQ*qEEwD>*{ z5{A%`5(tc zG73?7SPkAfRMET-oAHuHF@3&NfE>CwkLx*cto6XhCK43=&R_+ybhd^4ED)W)kVSZNpFa5zew zUEu_#)c8JXVhXob{QdJc-Ki5zbhxgcNDJ2u7?LGkcMjm@@AshdAumqTxk3}(-@;>Y z*Ki^?guIvt0OQ+l=-XsD_96c_CaiQSS|*IM23P;G&IS^|dQ%GA-iZ-P!e15pND7}J z60_=%s#7me9TNs>r_lMSeLwe-LO~>8G%gNPz2GbQ%QKLeDyEC65UXN;t zOp+E&-n$Q#dHympKA$C61+d%w5BaI1Mk~r%sd@TXb8B&V%!dAU+|2nKqNj$F>vfed z_~8)|m5ao#1BbCtDg>j$He*R`3Z7Bf4kg3KpyA(4Vy<|ST&^f0QIP^Iwu?59!*v(P zQtyBC0GF4UeK?wA1`ZojMvew_&PTcSZDi$pF*sDT1Txi5Kwl>h&0nu?Y%Q6AsrRzr zulNVjw?m&PHJb)exwE()Cl6@vxlipqr{ahFc*aOMju=1kr8NmGUI^L^n^R)w9Nv4d z7&p6@@#7i$W zFh2`7(|f0;v@GYJgF6~LQFu}i+O zg}^BRbV+kg|#d3d(}bn}ZDzL=Q#j-55y z&bhUp<6zeh(kjfuY}j7SOtBWi8{LxBYtt9}^?n#CqN-rRDuF!x9>)aiK;m{wALp-k zV;k>I#U-MXL`C5Xtom>Tx#1rKrY>k{f2fIXGnH}KZ)9G-PlnKRCpxVAn3TW00c~MV z$#UDJu+S|Go}P*#-pjVaP02}`=X#&^FI`AyW|opw>twMxSPZ`L^J2vtecJVJjO^U( z4z>HTsQG7p^2VnTKaDJ9hD0qe)WE;fQDkFH1I11~AIKHD6|ti#Yjb`6belLe)#rMT$RWpc133qv@MEN_S!#LUYl zQ&k%2w&-!{Kivvj`@eGxbS@hy7mo$pe5-br5&G|*1Hru%8Ty{JzjPTQrVD^Q8xKG3 z43Hza^YHzo0iK-8b>D=hlU+w%G0zOXQbE^znEtMn?v1J@b2=ipT>+(r@#$1+sVf#G z*8|T!FVxp?#g}n{Ezfk78Ox*m=(1uZEZh2l*yvjn!EHipyOlXw&rXN!VJkph-S(C%H16>yg(3W5WtzAjebL zlo?7*7u#aHQ!KjI_A=Y%*For#5YnH$09KlolfwfR)GqW4e5trV+~%2(BFU*ZR?|qo z-Ir*|9rGbgEvLvHn*w~yWU_{4^BFtQ9Ad2Sg{X5LT_1x?S}WPt%(r$m^nN~tzUxzI z%7hQSZNNE$l@5}}(+ptpWE}|!{!EU(oghvVcj>Ry;p~z3eQbKY6WSdsN8#SX#(AJm ze1jLGcUU1Ut24mhKT?ox6-G0L7jfQK2_oN6f+>e}nJ+v((2y@cKFrI4kWH0r+qc&w zxh0iEj}65*vt#HnWDa{@uOX7+mY^b~h7ug__4Nk@vi+3;=}D9%!qM)OE^xqs^I_oe zC74%en60-(x*T&j;ld6!w_MNAe5Bt&u~t7Yk*sCDd%$}*qM?|G<`+z z&bk-m9@pnN(WFE5_ueKKS6zfM%N(qm_J!2QF9s3%mlo;EQl;fX#6~`fjOl)B^eho# z#d}V%8Vk8u!37Ka?|?Qr64V8DQeD^-)k?D^IR~Cz2#DQHhQ`g!Bq`DkG{P!D>%9rw z=B^v(SO=C8-UWAeDZDcBQ22T&Yg(r65?B{`%P-~ZjX3^K+%5N<) z_%{)%Yu>=fnF|=@b&}lppiHig6yToXRrutmcnjatH!!-8+YOKRHe2f?lddhlgpdDq zgY2Y}>0CzNkHDp8@wia&D7}yvOL0Rr?l~KRN8Mjx z5N|O=3x*J@c0ZU@M7S_t(&Rx&KJ`>sPwsjjW&Zt3qkUc{VU4l~)=8Mc+SysnKeo)r zT)!B$;^_`3FiU0c+pNR5%ayElTrY9okY>EydLL`vgG(cE7zi>dag?YTW5+Q` z_*A`~7$<7r7s)<&FzCgu{1F8=-fe~t*=IqutA(DN8VSsga_(<#K|sk3{rI-C^_LG) z!{=4-s7Z)k#cVYCl}q;jqEs}?6~i=?IVMdjj*Z6SX{w6zcJtL9-sXw&@z+R@OE6BE zJWqtxudvdUicN8I^XO@fHc0q1LF2DYXWLV!kgVD7i06h@WZ%9ghi$_!ZG#dn@2^IU z(`{t?yce|WnJ5~@WKprsvVP;qeqQfXT$m_6U48 zmPEd%ak!;lg9M$(GB(cKO}Ck=!Hd_sX?e*Pa@FuU;2XfIPy^{QxzY%$v+tl&~KU}`Bk2D>S#ipNs2OsdU-MpK~te8Ul5C?Rpuoda%=Z9?TA?#>z<9>!-^qY1TJRjdhhX_yB zeOEUl^7lI_E- z!0&B8eIO+V-$Nw8%1No^`tCHkc+WKOJ->m-Cx?RAnP?bWbD4T@dmcmOlQ1u11H4_A zOFI`-;?}}K&fyq^ofGOn_NZYulLvnV6sePsDW3YWpK%pf1zwY9;l_X<`1A|orO(e$PjjFZjotS#%NETVm*cmXDU!?ZM-I0`T{d7u>g921gD*WwY*kQ}uJ&SdcA- zHRd0wU}8Kf?JOddYg5>xD)(veUXB5oeFnGh*@e6G`Dy&m4d~1^k_wd_xcOW%{k^=L z)T~v5_iwVNqBUX) zd(3`PsmdVy;4%}Qzs>-chbGM0PdA9&W$v71=>jSDBJfvxE-kE@BnN)IW^N5g5ucwU z#7n4&zUo_zjy*dS~M z)tea0RUGlPv`tCRM zX+I=WZm$FTb0VNo@m2WBM#{YVc%S}f6oMzi7r|R)adz2+3!WET12cm|Fh>0~Q@DCJ zcn$Nnms5y#GBp*;gfGITc}MWi0Z|l=n$Q0BzDAyYGlPq-wn0Ydds2IE3wC@mCJGz_G_^?& zw{yM9iqj5IQjrUV&tH?7Yy})N(qId2K4<6kdgD$lU6^*Ym`&$TVM|ovV9a1MSZjx) z^LA&rGl#cjVe%G|$XkyZ|Ej=_u7-d=c1+z;cQ|~hhZ*}+)VzEA6a#Js=~gO(|Ac!f!$Hak8X~xG>tC?f@j~PTQjE8#JwTltjL2;!hJA} zy+If3yi#*OhzB=+`$4p0&SSiD3B5V7ofgE4Lxqrtu~4lCjLmn%%04|fxpM$s7VDwR zx*fD+Z9MM4yIz51Ycs^wd;#lhCvZE* zInUm4PVbMWVZYofnwWH%y{uooUvNqB*#_=H*{73rlBL438 z<+7=N>0GO9+T~V;rCeXqwQ4P%OZ`R6U7esx=MMZ*DFFU|O>io`A8iN!vJWrarD{`7 zfPGXaIh;{V7ats9`^*<&mUA>-ohy!~`qLPh6Q&R*q7GJt;n3oD4A&Dr##XBqGoQ`F z?|-x&v3 zKhzI5&Yh&?q%rzOjXPLC2Rvx@8QJxBuZG$-?KT7&soS!xoe%-!wtNS=8iS@6e^3hr%X&ix#s zOHV2@!Xtx(#_Qv7kReoFxJLBMRdCJ9Hto6!}1D zy%_#VeM^>SuLG@w8}waG8}-;*3Q0%ZK%Tdp+eZg5in=beqSA&8q#nfMsx=tcy@~3q z$|hl#Gg<31Twd<-MHqhFPjAeKh2!71z{jJrP*shm#XjjYh!ifMRoa2D`>Pi=Jh=g1 z<4TN1Z1v?NKCQhk-SG_>S*|NiJdSJ&mi z^W5j0_xtrCyU-7OhhZ%4Tz-kt3)Aptdo`1KTFZPph0MjGEUta(D^Ai!xEXxD&9Ww* zGVc6#h;8Dl=rg59;YV?5vK>lSJ(e=9yE5SKmqWGzt0B0^fkvznn6Vxqv}i^)JGr=k z9+;g0mHXEGoz1UU{>68YPgnWm>gy!EJ4|4>da@_G3~1ZGr>4gyL_@*92h3_qH8yzr z!vStTRplwti#9!&U>gn-SAQ2}?e!7u6mE&b_RPV~n>n~n_b{57T7i6k4DWcv0KE>C zaqo8R!`h`8q}4f*ijKySA8RL;JRg|*?*?PySIfn>U%Vq=LF;Pcm+xt6^9;Y9boqTN?09X zz-r$(!lR!SLLdGPcxX95$BA8d)mIt%a#zE&_-4%dc>x?(39d|&}csmi_?VPn`ch& zw92jSL#q$D?ooxkzstGd*Q9D+wOimB&F66asF3#`UPNKryt#nudr56VIn9_d5k4wD z#`Y(PXmRZasua0lwn{B~*SeG$+)Thdb0g5cunlhgz0dwudI~&f;F$rAZfhc$|HHvL;jiQ!6vZx!PgH1Qo;PuLGwt1;OL|?nkPuNpL;sNonZoDF0 z%F>6%u0T?IoWb%O?U>Xwd3u+kBJkN%sjABg3a#F;aQ{ItUc;D;b?JpEb@?##>j_-2 za2o%$BLqWbqJ{bH6toXfhj`)6t+o)rE9)TDj2cn*cI|4KeKv&VeOkaPCK^(B+q=q* z>Ml6!hdUSMlfW{+t3%qn9NIN;EZH53K#u#%U7tG&+)ejFFD4N>D3jTR1S+{w%qFy) zVpbh*@QNrL7uw3>%kZaca_}M4x>E_c29Nl+!vx0WH4A*I`IDK)xQaAmCnY<*%de3>|srdg}gsm94dH+2u2Te=zFRafu>^0|~Z zd>y=PPo=!u?N~qQi7+P}1Znx5)U)6Q*gSP*Vw+OHGo*mVzq){z@{}N{BbL;KUFfg_ zQ>pfNGWWIqAeEjyN|~QjQFVbOczhUPmZjIr&wMRK5#tV%dsjK6o{h)9n+Yy=Tt(OZ zJ2`vqYoE8vS)+S_>_vTxFGH=)91>j$MKb^)r~e=O~7| z2#nPS_R!L|jq1ymv#Q}!q2T-?e4Qf$C2Fz8Y4<|Gb~vHQ>ucO--$L5IQJoe$j%77o z9T0kE8VgdN1M>wo!nYqT;42;iE{(2iZ1r=Tb^1I?JY9(|_Ee+ggE8=4W(Z$cCr;YM zeYjj?O&hf5ka>>@Y`AJg#?_nQg;p7#DkmlN+rueCaEA zxubY-~>==tf90 z{}YLny(f!hAMV7FI<9Qsi98rBoJ;%5@?e9>42aE^12w%u_DOv?{rwvcW3;>ZB{IgO zkQzqy%WiQC^oIj0t76$36zHiWA)NAp%U7n6R?Bo);PwWNS&OmcZ{AdXJk_)%L6x@jq-klWOH{tY~Nc4e8Wi^QRqY$ zOs0{~h79h_=3>x0`;a+L@`LPwWuSU#7W^DE+$?u!K9`bzhIdjv4yumB>2PNR)E=8m zec^>bkG|ubd1JulcOAOrt%SMe6EIBhR43Ma0olIWre|$OkxZ2oXfM~I(ks?XD>9DV ztv&-r`eiU}unu#c7)oOK&a$I&me9gQ^{AWS%-zWdM_;v4{4TcwSN!)3zJ!i2tw|V9 zQokyh#@7wJhj0%#VjIe>J$iyT#ROX1ewf8aZ-CRH2`oP^g`Qcipj4Ekl@h~+-K`6L zuPKLL^@iZ#cz~U9IZg$?!%4&V4vuym4X>F3t5C0MEIrEq$}MR+0Ff$N*&uTE|R{afC@Hwl(TjV1-NQa$woC^Ec3n4<0wbF3Kd}o>sBcHw>NyJ{klhJ8&ntaZ+ph)Hhyb;fa_XFZ)#f1*^Hdn&T{qztp zH@c5k2lDx__#zT34Z_cteejO2I-CAR97;D<^NkC5=uY{ERp&o*2JaN;&G=M^*k(pa z(>4fBBoVFnd6Ef^2sYTgjo)x*Ck3QsamJq`nZ@E@c2;;VSGC_oKf_d*`OG=6pQXT5 z)mkv0wT}v(M)Q&(9GzS>gOomyRI z>}DIB%ScP&Gz{?_Np{YXkpK7r9NcmZml}k^){;{uE^Ull{<=chZa;i|I*pvo9-~zQ>SUb&SC^bCuBHhX31=$F4!|*vPi9gILM){C<<{yxL>4tZ2Po%k<2cyjj zb+f|oeCDSdfFo@mVTO?&N(rx1&_OkrXnUJe*p`Hgq+2<75W%Ga&ldXukMC$P`TesZZNhtbeo^C+pln{@@WqTYvEYFw0! z{#BDv}}PqQ``|(yIJt zc=GQ|SgE)ILI&BAYGyX=D&_f#nU_Q%$5xT2o)YoXhQjZd2uSSC1zG)Z(7WR?Ogkss z8WG_ElqrX<8jpctyz_2BMP)9ipo;Nmt$(=Ty zdmB%mFIiEqMHL>H7{$+tNPj5f!$$dD{UfTD>1cZi%-+!~gavqSXz@h}#z`USfU zy3ng*DLZ$p5+cso!{UYn>bLeH!zU*(fAtJxv&WF`?-FXPNFd7x@^Hlc9veI@iYY%C zRoCx+iYxEfNxKi_^H(M;2lr)%c$+U%;e&Yvy|pi-TPtVdH@i{fdh|DwyXC=*C*`w0 zs|#57C`B{W^rBNcY}l5PkL;MYDXdT~h0aJDSa#_rC)XHF8B;S@Q1Cif=%WJClASDa z<2dXOvVj3RFG_qL$ky$7LbVMyNlZDTMz_!#kqNc@Dg7+H{s7eGjRcqL&8+{=Y^JI` z0X}+{0lZc zO*D=8G#ZwzGl1Jl!NUBqiZ-?7g3YULcJPZK{at;O_~OIZ`8(8Pmy9GW?P+6@Z~t;~ z>7PM;em};VucM!TeOQ>{L)_mHOd2*JG+p;@rOM1JBoEiQv+qCR@*GwSQ$}@1~Rwa(OuLuivjTYQfIpAeC zk2Q~Mr2i(x!B8_LdKI8fzx-Tj+~(tSGQ<}CqF}H=OY`f)ULR<=Q4 z;pR?7QYJrmIIbD{7dqi~BM+SHd;=sK!g0FbMQV`g%MU^-obn<4BNm%qav0_MHu13c19m-P5j`(8B2pCdZU6gLui3P36=a#0%@N$AR{o0F3$C#3-{;4HDjXOhrM)PtqM2xBtu@~ zWIoH?32wX}3%)iP%>DdZe0TmF>cr_m&C`4mVx{C8nhdAA-9Xt<%EYNB6UX#R(1AUr z7`EA${Zk8odkc4i{X#9aVaOGls1VP_MCgKPY7~~W-T*DZw-6E&2J@C%gYraATK83q zEZqjN_mlo$$7o-ADOnC(yX0Y~Q!(VO*~g9sZ>6Owtr#dio~@hzmAn02lE$e;(3(au zv#D+3q~EWEO>eu{J*q+D_fDYNP=@=rU8E(N8L(8_vvy3bKT4XPg4-H0F!fy?BoAz5 zj-yJr!l8oWEh7t5B&UPx(LLZlTLxqo9f9#~RS@v@CK_yVgOjU_pvx_lB@K1N6s1T= z341D%8D(qoH~1qnzZ@tUd*&+>wJfH)Dx0YPj~HD|-h=b17+l(G0K+;{X#UbbHgwBa zIkibS=y^!8u1!kd(;jtZm*-6qI#||pa9s*0>-e+t5yRnAo+4YiDj$DqPomwMCCqlA z5=lE41Kdh8WhWN_%h|~;UN|D;*t=M^>tG7?QKg>nMB2Gu0xD9cfWoan%wKka?i{;H zZuir%bMy`}AM%cW;eN%{UxH>TiM}-KT_mb+!Nw zMVIsQ4!#D^`y*=HGZyv?)u-CR5GY&mk!iQYfm!2fh|db4i{9n9;?*6xByCPF&7^4M zc7ge|SOazoUd-b=q^Wx91;Kgb1!_8hK$&CVi(UqL&X6$M;vNnUY<4lvY8_heActv= z4B?J{4`x613}?zY-Dop3lY4JH6Aw2Rkfpd4H?~Qo&TH*EvYuVbRFh=R*_?5qB?lj| zal5Y2N#Pk%gr~;YjQCCc{Tn!C^%IOdcnrDlS%Hb$H@bW=*F$_T%gH#+BPwo zD!L~?L~JELQ(g~J>nbpPLJWVH^TUGS10t`L@sv>|&vuPILb++_WHY1%KUquClb%t~ zg}>QXh{0XJS#s+{4? zd8)a>%oj^&U%DRCUOo)FRun?}p)oM?rKFjSgC{$*P!%q2e8egYKcV>10JuB(DvA{> zW-{v2xGdjOd~~V;RK$2v%Jotf|5+Y%qc5{etR(w5yJ;0q zncH0{q3j>?bNPr1u7|Lk$cOl(Z$2D$m!V7T>8#cz6CIb&fQO5;&D0kiL2i))tJp7v z9{18YIiFU>PyjnbEwXuwX+Fuk%J8YUgM{ z-<|hd{d6(mb}SAvGaf_5w_JR4B!>NwZe+jxc)B|z5^bGFg1n9r{G2NXHQu(Un=<&q z-!J1~?m-WjD=@Sj@0??wT4vJ6a9fh7It-ql4l(`D>d@!B947pJz}<3uMol*h_>D@k zym8(zcvAu}WmG$By>5rcic(3({SQ9jzM#BrAWZlYfGzU}xMJ7oa9wT??6W#eFVjTK z;qoLZuvdD8fX zvy6YI&-Trg#CppNit}AcDFU-9eRCNL*;UA%YP`kH6Jq4znnU`NZlSHekmcZn{@Xn* zv!`hs(?5F-LIRDMX7v#2Eee7&K5_hoH?r(O;xP>H%w;FODM5|NThquTDU{(8OCIM} zu>IvrDSxR9|KH9=eq~QIP1X{)``4qu#V>`;yto@bjx>QWG69t9C(Iv59B^TQDNQc1 zV|XDJB7df^)#>82&9|LB?@xtgFUP|z!=X?sWnh|;T#LI~Zo`^#Y4$H$O;}<*z~_IS z(DTSqu=ztAXeJ$jo;lNK=-|tkaxI0X?nq&O3TDIl>CRwfvKP9A?z_XB&1CsBg2MV= z@!xA!!yk7Qv&P(deq(YR4aq2otU2@__E)FV$jSTR(U)2}>T!}hqV-H)?wHN?$d2cK zg`FeSr~-;t$rCt?$ppSx5M_H3GYf?qZ0A+>v3wTv^~~Zn31!)(8~5-pk|gPZuydQ` zFp)bRw4ANE+05?$o<)s7Qp>rEi=ukwE@@auQj?QTIlhKeH$1slJ_Ue3&JWo zegdfsxoX;Qq?nbfZNPXz-?y;Tg;rf$iha9;)pv$4vRMCQd)L0De?kNzC)@`0#KrNw z{95=taXDLlbw2nWE@G#{j?l^(@vv9Gq<&lTkx711r9ogpC{*KgFX)qA%Vr8J&V^z6 zZM3s_KJ5_j8!ffN&m9=Q0s_5PlUmq>y5h&V`21r6o1K1`UEcniyU=l&{VtLgG%+v1 zs5O=a2*38e1A1hpeHHf~KTS##E|7{$F1L@H4_5^=!S$Ddj!NPY-ZfVMePMO*u>KDl zF)9=vNNZ7=+(b%!(8Nnk{l(YUEMy?7N1YS4ke%W~njs#`AC4Ud&K^3ZKRVB{m7{FQ z_}WYipVKCwplZOr*$P%o%^`_S6}YqR3jcW8cADOGhk5+7EEz5pc-u+55Z&V<$TJAsie?iWm>mxInDKVK(p8bqLoF5p?bBTm#MA> zRsTzD`pUcDsXPm;TuS&~i5gT@?L__sLT&k=3VX0^96RIb2pcz?Kuqjm=F4x2q7x3l zmbo8T;ffIMw2`nxe`CuGWP7E28xm&M~PgjZnY(GZtm7rw~0$xTP=@?pU5gnfqJGW`7bm_V&_ouRSbbP#To~ zcLpfOH`gY$?X|A)BZn@DrLkRKqu;EfC;!2<1+m zW@;^Z_`!N8%KntbPxHLt(6Q@Gd6PXj-M7LAQ!lfG6CUug;u2H6QVa5mdCbXaCj`k4 zhV9Z0qLTfBrtyX%ZJyu5M!wLdn-U{2!sj)zkFppKFt%NFGFg*GM?(P{W$O2zoK~y)b9sF4ni|L7Z^z zFZ`H8{XO4U@trazu_1=kbDPME%tlsY>AtX6kK$$4_rE8)t2_^DLym>%mksIhVgrjnMFG6lzDz#lA1Gyla^( z+$VGFE$AWDw>+KR-GXDfi|F&jXc%O}vDf7nOc#6d{NTpjXn#Kquf&HknT@)*R$hui z?5@#M0kQYhtsI}Xw_{U-8$MBRhuN!;B@EfZmQ}lhZLo0Kl}g3Z;ptR6RfExUk<7qNd2eBs5bXEduR zj^DLBmbTBeqIru>ai+Iz;7xc9%pEcd7Oih&T1$_^vbpIvZu%U|P$RBz^b zP>6BsKgNRRMl`kO9ZeV%4R&7Qb#Et0)%`s`fi%LVozvMk52oc!F%@6DliF|Cpyr5H zUMBb&%8i`IdwzWfb@9e*i01&SPZD&l+tuKk=@4e~L&Vo!45u4yQ&`vXaEf;t!wlYE z=hn?iq40J$>{{E0N)qSM)_olt=h2EbqfVpi>l})RdWCNj{ApX-GSKZFPfzRz)lHcx zoL)1nX#1dD&=OHBDtfL&&xUQJmEFC(|H>#*`E&xZoPf!@T%$Wm10Z3z5B&Nj)!iwc z13T=Rxwo1BSoVx!cEVK#SF&HcvExqc6RS2|FuosyH&!v7dRdzGqy(-1|9N6~0j56C zfB`3Wm^#6czcrB0r{ zZMyjGMb!732n93TO{ZIhLjFJnton2ZUwfUQRR2bBKbQdWa#=8I%5i!r{JqWb*+xzK z#Zl8^Jyzxl<86CW= zP84j75h~ENxm3}X4=1)>V15}lA$pMo)7|++sCoy(YO4@-<=Y7uaq&4iX*|U(P2V|Q zp`ASqYKOz>9O+#OhrCUu0)BHLHUGWCtW}R7Gro(O&(5J{K&`x)QIr%Bx~`rh;VOEQ42O@SF!ag>&3NI@3cXmaU%a#pfp z!+V@e2RY87*#qaP$-n>-T+h-huOU!*wx5j}s>YUvzM-pgXF}iL`5_- z1dM??3(rsji>^^r@}Cet&k_)jom+Xg=!2l!Bx|NJZX{&af1uxyG5oAWSD234By9il zlh5?s%eU;dVbz~!(c}A?G*TfbYFVf!wI5XE?&Y_lO0EHn{&>-%dvV9VFx~?9J{K{$17A4&IeJl9inF!;SJ%)mmC)i=2 z2;GxppiW(#P4X}U(TXWtiHSStH@HB&_&a=lGXO%0MOb$uh@TUDjjX1Zz`?uI=<~C9 zwyCv_O{yO+C=Fa7sM`h974=bcdIG7%NeU}jUvfCx09gti@bH%xCGYyju596P#fZPG zyXq*WJ4}X8A0l9H#b@qT+%ZTvw~uDMJ42CaCm?jFEz9|nM~_lZ z-Q8X>inLzEXYBvMZ`{3!etTU9>A{OY)MSbYMGAFlc30_vK4aV0*5hqwakEx6Rd#De zBfDfSO_PR1!q77z%s|jRZ50#N_`~B#XGJ0D8cbqELWN=Ho;*Hp)n!&Q5Y0RbM&S+F zS#b5T2c#@{#$2B=${i(1Q^ro^6ojg2sEr<*RWXN)Tbzh->z8xYUDr|aVhi)`^QTBb z-4x;)#$;aj2&=y&(N)t&ST?VpP3GJ9#cealwc8)RK6}TGTkat1ECDe#qZ~WxvINzy z9TeM#?GBya7&m$Pze^ z;XpOQh+20vh{Vs;(1^Z%JhJhY=_~sTHf*CC#s2l8jfP`b(U^Gph`A<}PfPgNnW?yJ ziw_m#R-4w`aG`){C8CA8ZS3z)RXU%XPp9YQapx*DXmp(fdF49u$A$V@?mXfDdCxQd z>>Tjj^aBmkp5v>XMxeds6gT^R8@{z%!Yr*jSx0IO+u^nq3ey*f{7+f3Zq4nYzbl%V zPa{VgB6bU3uS?P4*<@x{iXUdS!`y-(+H%(fE(j=h?Z~lkrBK4`=;NDM(&r7^58sF4 zW^K~=l?XB)JjkhD3!H)lg<8i*DzV)Rffqd~`M@paajHn)@fej;MX@+nv8(DA~);!Fho6l@v@tbbkaahEjy;l_75fg_4 zA}ffMc!3Arw1R@~Dbdo0)9K;o=~(C}=zGP7n!UUbkCO)8vQ*4C#QU9Sr9Jj_cu}ht=ZAl0)14IgBDLu( zanvmqy+{Y%t69o!-z{hu-XV)dUiOI8xjEYk+(FDLMa-z;2MJrv3lZ3N2V44RYZk3IDvQ9dvHapK-r z?8;+hYT2O)UoD2<>#!0*HC8LkW_@fU?WE{4Ie2fMlYq`C!mQ_EOy1-IJLjVXU8%pB zVtx=_7yi{K$WWd$k7w8FW2=)c6{`Dip+~jptWh{}&9@=L_B33Mf5en`nuCm>9}B;G zTjbCigG1+qz%wHaSmiy~Y|6cG)9Oc!%&tpVxofxLj{zsxTyqws2N#*%Fi?VC<8jbb zcoA#+cQdvB*3cpS{q%ACTimfpn1e3=Vo7U-+Or*y*yT&G=3E1lwmm{>$|mGDL4yUV z*wEYIQP9QYz`u9^w~GsCg3x5Pyn7<1EmaPD}dmBMU8 zz)a!wZhA`@f-b1qpibv-VbqEZ<)oV5{eC4A8ETQk?Ic>(Ji(ff)|F-3CAl` zwR;J)30S?Assg@Qh*a&EzmWNz`Gb4zucGf2lJsqT51X1`M4CoXps8;_sbgQFi}vWc zhl||k`_Rdh@$4Wwe>NC0y1STz?-RCecqprG{LSR( z6NeiQgqeRW>-$m;y2Asp;anuUW8?tZEhoS}-ibzb-od_@Efh7m-&B8v@S4vZ51)1h zQt6u>Q>`y9aIUe9ABa$bjmw-dW_+-yyeJyj0qbo;H~7s8cZT3ui)O}GnxBROZ>5a2)x$P;-_xd zMlB1!@VbsgyzHmP%u>0A#ouxUkJU>s?qd=bd{NAHK;vi-*R0R}=>cOKD zkx&t~f~kL!a9{aDFgDu+-H9a-QWt|M;f?6)ahJ?of8!lpYbso}6`E%DvGUFu@_e2^ zbNj_jHzYaHuFTc!hY-KB2utAA5AH(o%2jOfk5Ia^U>F+IxbdIw|G;mX)XB|MlH0y) z6Yv-R)TSF6;qStWG;WO%1^m6ps%-Yc@}f0hx!Q&D-h{)O4WZ0RZ#&K07Q}S?3PoR( zEMWNiRS^CD1v9_tMNUkarWfzP!s>GRa4i&`1b@bgudyuH{Tvt{y)vz#{k{!Li1iS*&hqDovR*1)fDiXrj2TWGN4Q|A7vAD2f9)t;=y5W43q zXjD%n_sq>q2F$VQy^)zki@4dZg(l<^)5Tnc)vm0cFvpcRk@NLzbkn#Bvy0j&Iq#q7 zmGL=nm-tVJr^~a|y=&+@(}Y+d#``i=45sDYVo9DH-8#@Bn!VAAsRm7?CVOo%5HO#m zUxKOXOd$1EMetI>_xvy`0}Lz1a_f9jDE8(}&hO8BVa}=Jjs%^;*wu9qyJradK4xg$ zk;2F9&7is1lH0-_o^^tPn}^xK&LjMPug0^TNxNuq@>Lv>aTSN!T!j%|WAMJ?Y6^ih z)Fu|iRDI{7$%p~oH}@aEQp!fy#dt&ShV^t%KG@9&LS8FneD$I~H9c&FmyB@6c_CQalE{(Qfh{xF zL&U-;Hn2Sf^riD)@L~nZ2+<^W4;~F3Cy8EI7vXueV6mmV`X^V4 zG%pvy*mgZ`22w)OYT_>`hqLI|vdI{m^1k1&WPVhPT5K z;9~e3+OYK$TikXJ#Vj}&tQ`SQ%|ig2q~W#sa7r;5gBv_c(I-!VoYSkBp;9z@n)NYd z)AL|s@{U_zwuGeB#Gvq#pw~_f6v?)0)5+F_Ozr((veMJWe)kF(wWAu+uWbNDsjcvF z^bn?TD;PS>+`;7IRI(BHiL_%Dlef*KMV^zv`K1K~3)&%{4~IbC`vz;VMzSIyCTL(t zGdbZzcA|iu1~FI@`w@QkYe8|nKW0Q)Q2mD;toim`-rS)RR2t@E?yb2bah#W_KY9e+ zLyGBIaRM7AZ$ZCD3a8Y^%FNemFr|J@$3f9VYZf(g59chQj>ifV(7c`--=9WShu+}M zx}&VYJD)jau7HQDrRwC)8AH3IJdExaW~Gc{Z2r_1rXT1=O{oJZ4)jhuOlj=tWF(mZWXADipj9nVr;VvsSOo{0aR=+B@$MyYaCF=Gj=$h3h=!?-&Ay zeHFD$y&B-;Jt2B^>`dM>EMKE@p9O~Tq?#t#)rZ1JS0i(1(0 zDm{3!m<|dSwXv~?{pB~Y_y5ZI+Vm-4Kn6a z#AeX1LPNSGAd>b7`n>0F_K7Nem%(*MX_B9?i~>&@ptmuD^G8+Tw^%QA3=x6cPsXfm zchaUW)0oZ1;ZXn5iX1$2!CJs!gzncN9pRMOr9Baj*r?-}q0;nkKtK^67{@GO71X?_ zhHXKnV6pHiyFUiGKO$+8b?}7;$G>v1w;w_Gmm2m`MT#r38$;;=22`POG;i8DokqxM zkn0N}M%`>oaVjlvM~JF?`0|W9e|87==k7`heq6&H&My>{N)lvn;0=4bWF8$&@TS}= z1#FX}Ef;v$0wzTofybG8+_}V##(YVJHB$qbxoZmS$E9$zbT2j4=ELj3mtln1G&sEc zIjb{>K-b0_+;gvbocrW1O8};kT^PWYeN2KGM>5G2L)hg&2b|5{<~1Eo z(iN>WOtIuK{2BqfU{wGX(|^*0uw@vry^EzOT*cPBXnb0iFPaeJMJGNk z#-|A$Ab;?v5z?9nnSpSgo}eA9&~ zSw@_sQ2WvrDuurGDLCz7yP(2)%A${j;vEA5}r!iMEt| ztq-kUj;J%Axm&=BhLN8zS6zBnNoHrFqoRVY@d36gK;%;e*3%!drEzDuuj3ce%dR1C z=TI6weW-x@LUKr|_q@pw1;Q)(R_LQ7#(fD+hF>c(X?uM+{c<`%$|;(7=jw0VoOYh` zOWcm1O@HF&iDPJcb`)NaX~iVp2v)yy4rKq*hKWm6nak}rXxrcf$G6Ed1Dj+_y19_6 z-=R#W#IvYy?lG#;T*M@5lHo&T6V9!7!fgX5P-RF1{x{baObj#NW84p{Qz)j&ua2<# zg$X(R912zsmP226JKjFF9t|QBvBLTWevdrKvg?&7K+cjTE#H6*>)l9B>J@WcENwQ* zDG;1L9p@*A*@O4ND(>cq5oUjUO>y3s7#RLvGW1Uwhs~F3XjSP?eD5}vj>M><)0GyQ zbwrkBIXx65ozG%VKJUX-Y4>n-<9>`4v>?b3X3@AkM!5I+F!=CqJIN=6 zQp%%3QA!XQz^6z zU18@9#D8K_;8O&ThXmYJ`q2!`a2U@{m1QzFqfRU;%Vr*lYHZtTTbiM+1gE1_sZ+s@ zjf+koMa5s0A&VYyMd|+ZIuYqbZ5bN&j01y=W&92AP59p$d3w0+Hq*SE!BpR-5zMN= zde1WYF}W1_Ugh$3`o{Pgy{YEKBQP&r$|jm?vT3!5H_KMTCdDfD$iafe^xAR{J0qd< z$XQ&BM@3&B1anOfqnOmxN0>9U6IV!f(M5Lwp{X*SE%N6SK77;rfE<0W|GBVsiI zR<=s=I-9DX0SEO@L*ULF)1jZ1fKz^gNo$)o44V=F0i7q9*_Y#7Lb5MxIGuwlJrFmp z)x%R3lQ_*1dm)-rR~x$jCxe^a!hOdEf2xFW3RBf!iAE-A-rCF0KHWgKpV&c!c@h`x z8O+u^>c-IF*%+Q2$(G!^h`(Y^;=IzO(6;sn{CDXfi>=6De$qFXOX(K2bDA!>sa4X8 zugYN4cZ802>Va~&WL?u3C05?-hq{;7aQnJWun)9_wf?t&o&DB#;oh>n0=DxGwST<~ z9yjm6{;Dk;`MqcQS%$D(E{7f27)>m|5(+l{ME&Mq?$BL?zZd3#mZ2{T-*3et#)Z?_ zH9}|Ba^w<-AMRwEJ6%D$U&u!+Le}={AB`>ihgt4xX!(+4)|}~& zOA<=Jcg1QpW#9+}2_t2K5UW(+v`Je36PEbRVa*BU%tSYlmJPOwGCU(7q74ioYi}+- z{kRV_?}su~CuGCWa?TlU zuhoCdDSZ;PNU6i-xF6IQ{Tx0hM?qL=KI`om4HGwefI@Qz1<5x zq#rW(oMdYKI|8?dDb{)W&jQU)p6HvC31jMGaJJ+KZnVm5a99`#?}r@aZUv0Q&0hoH zV30JJgt^n6EBi(1iq~oDfFmntj)&>f$HVFSpJ}t-*Um1$);wdJn zfL114rP9gv(0BU}866n}8doeJ|78OIcBmz5dErLbAWyybbjjy&99*+sMi*vgpqBl6 z-Y@tS_|5swowSb!%{e0S#*r{V*&B+^zvKU_O<)NsK$j2NAO57FgObTj-t+wfu+G=HjKgCmaO!k&r9Uu=QgfDX}gy=$mGSeBY*cu>&l zSJ-Cy0Y~-~Z_gvVOM#UQiQuBCm@Iqncl{PCga*+OGDsqcs#%5f;O4mxrG_I zMNrc_0iq)B@x2YvwEVa@^PWDFqNh2rz9p8R)UgFdJ+q_jg9DjftvOlvtMYGG-N%u0 za%tBcbtr1sOo}P8Z_A=dvlA)ISqs!=x09rH7!5Vq0sHKX zz@~Gg+2@5V=yQEH7(T3p8#9Hh!FK4eCB+YIN>Sgvbp0oM7rtG{*7r&PFvYcH8!+Rc0y{Mjf_uw`({4W@ z&z(heNrU0H@b{egpFf-1`31ILzRt%MI+NU9EtYoXI4HI#(yfA6sxTn-SSte!k~`5{ z){;&h?2HQWsiGdsBdkHSl!IrPta1K18ZG`3r-a$@OXm%y$+0$+axH?rQIa*a^z=fB z>hYkfFbWd?$Iy8=V%3IW+)PHwj3nt3B1IZF&#fpVp`~RtC}~kj(XeM$RzyfvMuWn8 zo*Nl0qoSpV5K*EuHGJnksMq_R^E~%`UB6##0r0*FfX?z2)T*GDJ!;@X|42>3(zmC< zVkH9~xOa6&T@v2WEu)i9T_VQoeZltf06o@l3{ITuqX)R^ZS50E9F{F6bra$2fg(-l zjZ%Pvt&;HLV?Qy>P(kH=0-Vw>77NWZ(8XjPs&TRCfLp6z@sxP@GVFsZ!%VpY_B^uq zLpKgt4ly+)O+=@+6Pb0b#CqRSnAY9Qe&T{G>!Ku?;f8!JuD%C)xHxfR=vFLz`;LXks>iRyF7xwiy-Q=?oy1C!X9}YwH#XJ%05;AF@16K_R&7cWO1mMCo zK6stp2z#`X>EQSPu9FrN{V*>a$GF+RPKgFkwf4o?Lu;@tH=o403S&)g8*Q>E#T$bn z1G?xTpm^1zZ;Hs1uyNl!6|6+A!VAhPt=hCw7O5Nzaw@@Osk? z2-k4I>3(JG!xf#l=%@gAnk}Kq>W>-ch^cs}p_)2Jez)R5nY6Kl?6;eU@@2nVyHn6k1i`ZB7LD88wt&ySd~hO7MOqa7!4=EFBMrYw!BslG#7!`0wZRDhjc z+SukFMMd&H-h8$@5ZWfU;pD`4^#5E=^^62C|CT(~b+yp0$^9U^S)2J_I{|J@!@OBp zF0i~u4@bAvkPz)s6cEY6;WkCQ9T7)F`c{EuUK@7pUrt+^B0zHJ9_#*mIXkQKH1%!_ zBky|l;Em+dboREh7<>FC?0Yqf+&UbL&DlG#*lZOhdD+0+7s3!H&S4b;>uI!(5^g)D zh0kn*V4IFR@zw3YS&KQX;W=-nc5yA{j$WXy7AjO!J{8dIhY6qS97VX*OF1Q_gbe+@Td9ERjqk z`SgrlUlN7XWo^s@4`0S^ejM0)48eEPY@USeV(4<^CsKAD%uAWSr0=scH2wBNJ>7M5 z-oYmDI->~74IZ%{yLFk5Njp&T@D8eYG=|K6=K{jg%bDpW>DbwQo>u|`Je@W_*m(Ol zB>MRAR34P!-zVD;GHOBmh#73XKFCNr20-Apli=nTPxT!AQC~t3h2C8xAD0TzuBJ0 z|2=R6BPw{v8;_V-LxpS`t*Pa*CE_1w$-qiXOmu*Z_k|?Udnvu)nI5iUcmRU88-UV| z8OXOcgiYC*%jk{sVN{|zUgsSrg9UFf^s^djJ`*6VX6`t-HWrUOG6t35YA}104btst zxP?QqJ;*M@H}{9={1s}jd?16wej1}!Vvo>on#ZdX*7Tn^bPifn$}A@*F1ABk5y0lEiDVZq-xy7Ht3eeX zT1`-0Ia6%7e2vIGn+BI+d^kH`G7)V2LJU$C;=-v(Mnj-RH5F#y>{w~KVV5c#PMpWh zyaRA-ID&Lcm~qCgSCkQorCA?192kEVmTDiO3p}zXe>v@fJqI}4b50Z8RGWhNu_AbS z;x`4YQC6>R7wj>1#`?BNm@;Pw#k5t?K)we)o*N}k6_(Id^7Dv^gn+U5tTGxazaHkF z?IJNU`-$4HHQq^`0W;o5z`@rX`r)V&Xe3slKA#iz{|zAuuWP94jg2t%@hQA!r34jg z6=8k44X?kuh-9z0!bBdAVX9No@JP5Uj8`X-ra<8;*C5rZpp*8r?G}fSTYCoHc}25R zEVfV)F2BMXNJPhoLh{UY3u$S(LHCblfi@$D#d9NJnqMwW8gPNJ;WhZEWIt~&!{F+B z8BA~hhgWHt41;#MVA}44iA^EkVOdAaY~7(y_5dDPCP;KVmg4r@EHwXH#R|&0;zkac z^3Xe!$W&R99-pL*IW!Tq5uEHi#cD)+r2`5bOA zF2WKEgyo@m`{TZ+rK*|Y#p zl2dm*zYd>$n(581XLRiT6KavDNw2*Pq&kg@@!^qoJJ(LI&5IwfQ(ol5tB88i{NfQC zp{@o>f@SnwKoMGVzrB-!TR9#1GW`6c4CTr%5ew5Bk}raBaAPW6Dzgl8<_yBeva9rI{U#8%RwrBKuaHgM zKghUY9)8US?im#WhgA{WvV6eUF%0*PchY_B{>1pxeG=Uzinl-Oz~-l^p#RweUJkgi z`m4=xR?vBHvwRO@tG0lL_FJC9iz0e#(p50vdjC?q-B>?I6iTwW{{vG3Q_5~aQe_GE z%x+_)Wiv7A&{W`$l|@01({Qi#E=Zj61?Lqy^ma%B*aVw^QAQmV+02l9P(kOBT`2$i z5pBAp2~)4?g0k0odRtP8lQu_OW@20&OVXQziQZBN zC_k44MKRu7R%thi*#4qhxxCb|mUbGzmqGK7&4>A7t3W8y1v~51@XMeBz36TXjt>7= zA!A`$?iNmbe~QB$+wZi|O^lwr%7+S-sc7|IEbt7nvFkrQFl)NPu3qIt8djo_?2E62 zHU2_B{ue~Yl;W7VAM7bVm+^{|NW!chU3k~ZhkO$WB+Y6zr=FPxY5&FJx)(2D{q>LZ z@j+eKwfhm_cpPB0{srZKu@CDm?jZiZjxZWa|KWrF8O(Dvb+%ICHi^t|AOpYR!dHhk zvqg6esQAN3j5kdscH!k@_C9kg5>J7Uy|;)?XDhxu6o^ZHu$1Zv()E!B81+0zm$L-uFW*7Oum_F!+UCRP!Ux5Jn zeas<-*YePG;cpret3?{+ns~MWu|P%IjIwVHLg^ko%+D49d*5KA^TRffEane`>yXG^ zy2Rew62#`UWTWwSaa_|g(@5Deh-}_%2-^vRS8*rzZ=H(TyO-hANBYdoYFjdXU?mnV z5{6>F5p4Y&4&4e)@YLfj*`>c7>TMj74MO0Vngw!~j$Dp_R( zS29Jwf<`}_0>AGyvkn#4$lxS*oTuo`Hcwr{6!baK5C29P#%w0CeowJNbPbf4kCWR+ zt6}b)&#-emnyD{&$lm0B`&JHi?3nx$$kgtF8m%;Rzdwlkjk+eEx}eHzFImK)S@OW{ z&raMXGQ^u|CV)E?9{Z-*^#WP<>oX0AwjoCYHZu2ia?0HKGN}718>`ODfH!i! zc<#U|JnR!k>~@Bt*?zz;zf$1fIw9D;!=FjMdm8SCe`liQJLuxN_1GL9kAj66L@wb4 zz8HQ8`ZMnt{b?;BpXBcu4NZ#U=GN(;?kPhh-}6DJ(J=3e2A4tMW`-XFCoxx~AG41$ zF5uN5-aum1gMc;`a z)Qph9%k(={ap%MC*ZKI*K!6md@4^iU(XcY{9?YrC#(fU4z`Wc5MX_A2P(d2r&U;GE zatd1UsTMT;x**(icBaq1tDo2VK;PX9$;2FIm6NLC}Jz53gpBg$hfaf z4*sl%{fsqT-xSGq-ScGTKiQ1;D({gSr6Rx^iKBH>L+MtBI^wOn0`}}sgW`KS_?df- zzg}<@Q$#kAXX8(Z7tbHef_9Q`jjD9Rrv_@VM*wDyr<2^indt5;3r8g@sB#vUyInE~ zZyfhzF9(jH+T|r+*E)m#Ik^JVE!IO=kFQbZ6*nq#LKt*zzrVVEqTIcrAJ!~GvTi%4{nGkFH%0`* zky8nn<(vf%r6*In(=W-2dzM@k+X8=jKEW%qVo1sU#n^P4o9o`l!$D&H3xkB^?ljt!v1eB(8__mfi*v3B>KPjIEE!`k&kXOK{uB#+q(iEJh zaEV#8V+)xxTOK7gY)0v-$rx&W9hJE|)yc+sws&1GZJ6YN_m3gr_nCnrDycJ@L-`dPQCUYW++`@f2a47mWR2VMv#L`-Ty^dz3C|Va~JK&f5RSc z$z%<~>)3ZY?)?RKjrY) zjpe8^$o2b9UMInv*2Ml{1il`!1_utQ?0(-AAD?;8EC{p*!Pajyd$K76R_M|m%?K1N zOC{m2&5*QkeSy5`@Wn<6Ton6YL~fA&%DzfI9$raSn>?ZYmYMh=L>BV@PA1tloLVWM zohc|-PUx;)rgw@TbQ^3Zn&${DvE#aif#>KA*=eL9ilx}J2$p(BV(HD>MCWb^)gMj3 z0p=mlGEOb5caQEIal})k6C<)2`1DO2)4Bess--Hd`aT&=8H{@E9#}TN5Kanv zQNh3%;$zuE>K5r$x_wQdm-tu1l?6_;mq~>OIv44Ie-G*AXRAoiLlgQY@douf zG)A7SpIvqH{Cy^hx6xpy+*$&CD{=bW#_-~WJp7_NLH5U3V0K76k$F^gaMfy1YSJ*=fYcI2#&d$VK4ud!#&Z!~HPr&b$U6gD0RlS~JM`kG=f$$A`aZZRH zN>Av5&4(C#ndeISxbFDRGagW-`VbAZLa@Oy9Jh_NGr3$I;L##|GP*gAEI$xU#U9*Y z-X{K`zWQUpUgI=rja@iNq5#S>`p6SybLQJ{H}Av#0HA3a+<7R1%Re2VN&K5(d#?}3 zTSlRy`cAym`koGSorQxkgP0PljrZCV;m3>;3>5Z*BRh7%h`t$qJo=vf;CO+kDkTy( zpN}B5T$%|sxCW1o<&bT4O=Q>G>EyipDf)13CZ4?hm_{CspgmW_v7kAWy0)hflj5gr zf0Gto)7k>ho=em8NGlB19;04e@2Q8@LHe%d8f-ifLvESAV+W7+QOzUKbm^1%;H{8H zC&yib=<`?U*^q9$qrDg|wU18Tv#FfK30#E#B&VUq-h7-|t_Qi^0z^vrH@$wUo(Q)o z(aRp5^tF;H9z8^vV^i$e;oej_P}q&-@@b$L^@)mAiovMrN*s)1z`bKqm5bUeD*Ua8 zh?rU7?}S+Lt+JQhLD4DPet4-7 zIk?P-&d`kp$utuViC>8)KJ(bc*_W9l#R14jzDCPB3n4|Ygp6IA0v_MDZTa^D~R7?K?YMXAnm!ztq6NmPMGHuTI2s<;k4;2OvJ`Dl=epj?@>H zK+Kl8BseDxuU$)l0KX2n)^~xqyxIZ!R}4V-+96yk?M`^-Tj_J14RH8%G*NC(V~6dX z=`E#`kQ3NS&%3F@vy0{A&n`2p_*I6Nvw}(btgTd}z!I|EH$(H?R`$@W5%#LvY`n4K zEE*-gWh)vNVUktW&0FhcKy+XUY5Bq_5L-6Fm;Nx;;Y=shvF#;qhod2H$!XY7^Naa- zECCCiC!>h&47zh!EA2>1rPn!3XL8L>m~?0y6!Q+FvWO+wcTNG*l}@n%N)mYdxfOkH zn+9v@>uA=LZCJ|VXZY==LD51LociE2xjIroc^2Q%PRJTJHg3Y;)vMv_vFk+rwgjeD z&me!**Hsn1Tm{aezhIv958^X)oqe$DJ2mN-Kq;=1rL=(Sg3MKbuGo6Ixc38P{cPA= zett|}aGn&4za?c|)7Z~Q$w@S)Syf!``%Ea_{h5dIvI$Uh(H|Tyyk(2k&cb%tCky+N`4dknG--ZDslOXSOuc=-G!Rxalz@>tK?wsS?2evaHxBi zjC&)$(UG=UWP~>#9?Z*weHm-%@ONblFp{FOj0&t;Jj^S#K8k!-&1ubkRb1MG^t8h# zoOvLYCY>&(V>4tyuv~&loU+8T%ta<})l9T4_rte}H}Ej`Uc4tj;7GtGA~yCMCcl`6 zl_B=8T;>6VxH|NS;YRJ^ZuLyORm@P zbL>13+V=nyPg~OmYabCsyUCDxY8Lvu+DeittypbsuKVsi4|ks%rWOJ=?9Xq6%zC+! zDq}tIt~^GRS4z-|Xm!fuTBFs@auR)D8Bq}HCHM36aBS}zqZPRy$dVb}u=|4?Zp;=i zKD=xaF67ROx6+$%zf3S*(pUmdrX-QN5esUee}~342$ICWG64S=I{jZCRTI~R|D0b@ z!!sqYaPz zB+N(r?*WSDYd}nSC9gm-8DGBCHh${-hgm&I0{_&<;PlF!ke@dT--H|C3#%*So4PKU zR6Pl6YMRa*z9WB1FS zsAOMA#Xi;H_@w}X@#jIX(2q%XISwNgD!@0aWBj?eoII&8q184{AY%EGG#~hn_^nN( z-Okp)9~BJWM?!I8UK+IzP6x9Z39_}Qnz3!{qtM7tHNr2!_w$w5KKg}eOO*xhvkS0~ zDs!KGCe9ZyhcBP~uvpgx4u-A=u34Qx*pCQ$66tWGO7FB5by8-sH*y0wm|6H;6gDAxFL2 z$V&B9bl(aARLlE97G;-`V)H#{`>_T~9mBfE^ifkThF|7dnIuclU;cUp;Ig7a7cf-b6s(6YgK(`f^!^zYUdV%jY zv<=Ru;wtxO{y-0bz~hjcqz|+*jWpPZf=5Rc5f%-_pE4#8&Zh&XGl!sH(>y9ZT@Iel zLUb6ZAbgw5iIjmG>v`oHF>d%?d3C&#XiUFCP73(Li`{eB*ymgZB)a}H>C?GS1h{*hQ;{L+Yspa0b{=ytB@^SboX~MbFpNFE4Z7QoW5e=195;!= zN0Ph9ilyJFQQIyyZk+~Pw>&}Tt-6Hfz1p<#>3rCd@s57dTTS`89HbO8HmCs{PEQOd+_+yCD_m7aE*Nzu>6B`)v?1DX_tRDu+O9`?Oiko ztIr#twsm&*sP*ow(FL&@@$B4qKecHT(ZK`K|SNYD6;G3y%K@n_s)a$H^qcg`)u zU@CzH`&z&=RDnZ!&W2XLbF5YWF0%NCABsJ4hi%6Xz?LRm6gpJK-skjBtL+QPrw$$P zYjY+&t2fbs&v9VhErhS)rW!B5Xb6r|=2 zL&Eh=a;V#mT%N3rU#FH*(XT8Go2EpZl^f_<4>OSY%x66G=PCJ@H44v$HRxrpsbt(X zo&4FF4ma~lnA47O!1G)JwZ96;V5}(Hu33x8akl8Oah&voM9?=mro5eNg~+d^bLkU< zR9Y2yh4Hacz>{gV`0emcA|RZFojd=L&YEgkRs4-t!}ax^JB56ao^tRqax`oUui&|LfUdEw@w^ox*ZAv(FHT@}D z&y+4dhl8 za+!w|dF^P7qi=44@|-91(4!C3G2|b6q(}(*Mz+!Ie+Tg0ygFRjmPA#wnrYy&JR13U zEBK5KQh_QyWA&|0uug@W@%~qfvZ8gBPvx`d-Pe?zqtZ?f#>K#AZa;1MDUojWQi6Al z#$=ywJZqy`LI#|ju;hR}UalXaF8mX0{4`0tDEowY?`}pm9&W=H;V4j-5H?n@>7!+f z&SUs#2NX2ug&P<4;Ft6gQhhWZKE1A{qeGT#sPsJ&`SL3}I&TLqIdmAM*;~xjE1dHC zQK8X;v-hZ`!wXQX3FAUK>S*)Dldki4OV%t~iCTAhiBD<=`!7@!`8H37Wp?lBjox%> z;OmNs1|ww9*BgA3WKl+09quexiwRAeF!Ie>ayp{L==-ivqR=-?hIl^MIJO^jO}Xxs z&toEdHVgE<%0afFfG!NXf^`c!sD;aUvaa0$PjbCv{|g%Uym=GYjm6@mv!yr3R>Tv5 z6+1C7s(?es-)17?18KWM95~kn@Ma!Mrl;QV(Z$VzSbQ}TgLYqsUT1AwZX3yT%q_-x zeivN0eWy`w`VaV%W=5}mzC-VNoTuOM2HB`xf)AdQ(AUrNk=YbWx4gN?4r_9%^_5$J zQTRx5-W1cl$4=7m8Kv0Uu^G!Is)^f#6nUr+!gY2cu*>@~*}JiXR%Bg=?mIJ$XG@$% zeV^S>mn}u7aSXSLPd@lEN1XMW9Sv)&H*lYs5`4N9Lgs&3&-EINVB!{V3Q9|oU!so( z*7~z1fA^8bDJ`Ho{}Yw23<3VBZP2q*i0g9J!@!?j-gZG%sMW55jAaufS1}uI-$=v6 z?^7Uc>?MA`T*fOt@d7liKPQcQ^x)hUO=$n73^IqtU^O(L|H^~-N;a7+E&ol-?Viw{ zx?=qHS{r*!RKP|^6z)nchCk<9h~~=%u4C>&+b%1>xAqab!g~x%j`TxfZ3O#u$wlC$ zNTOVfTr!VN>3awpE^VT#`}}F(wA6pK@S}N z~sk!eUYFBfto0A1793=-c9OR8=JT*kA>cg-u(gJ!dGpJnT zFmIju847X1_+Zc!bo^~tevT9Oe0c=ch~z@VvKHcNI0c#>O5;#w44kn)iD#Tnz*eW# zSpK%3-VZB;tDoagBTOE0_s(U)lRKFD=UZ@uIRj~ig2XWP4!zG@116!C3K$$jx1DbE zU2qhvvp2$SEwQTXBi!FMya@9=fARvRE1+`T-thRRZWvT@6;tjmz{I3Jh>h{Vxl7LD zkgOOy{CI+<2}Gdoq6(DTuE5$qjiRf|a!B5`v(W$e225hOJB5D-UYl$UexFysf}CB< zrL{p+q<=l_pOeq++ZK|QzR_gE`y()M%+;{F?k3%_ARnH7{9`obr!K7Cs)3VrDyXTG zEtWaFge%N%c5#go$UjkMt7=v8UzsALB`ra(z%iqkW==(|yB9KA{h_=eo|yG!kxbum zcDH^sd#~ReT^#n)&4tqepFY6`!vxx$JP%an2GA3oO0-1Y559D7=HA`y#7ZR$l^n&e zE;yeA=`@m<24je~d`?C=z5VxTnV`2^kQIE&!WWZxSQx|Y(;k>H+g`ZR4dH=wi<33E z7Q!*z0``%*1!^Fn|B==f%TT4}btp7{h$f4Q(?R(nc-?V>M7tY+&+V1CV!V-c-{XxF zetK9|#_fGR2gA~vQ<(cLI+!yc6y~2Lx@6{!0Y=d{1U?zeLa*DZ*uPT}j%O{RQ(M*; z?VlNlR#7dO-gOXPYWyM-y;EV^KNXnilZ&7I`j~y)+2CHOj@P<1QCKB{96dPBPP18v zx&JKTc>Hu2(g5^Kvgq%WtqbewH-naU@2_n4yaL5LHrL4K8QeY0E-=$o!;*?Kd6CA#Q#hcGeaX z+!di}*Ickyh{whkdBoye4PH629RJFSz?H5s+HtcPzi_;gu{YEQ*chUAlTIuGfz%`Wft{T;*%tAlH2Zrl1>LW9BcqlosmS(JvfZE%kr69 z>l#rr^#OGkVCjR1;hXmpL>Uo`q1$bLuwiZcz{mG8ao`VNg?dAgX?O~|-P4F@T`rV{ z^H=>o@e~4ORA`stccugH6w zTlC@g4$|7oJu}EGw0qvii#}CB^6oDqP7jh`x{M%P9=JzREs>sE+QmxT4g)^H2rP|^ zg{Tv2NY1%(P;BN@#P$97{6BI0eKQPami`BGKmULa`K#FF{qfAP;&7&D@+wlhghz^% zezCMbA6<@nunQLtAe(oYwn?{O2XroKD;|TO+EA#KGGR}T7lGF4yC^dh&h}`Z zK=~Wfjhh_q!i`v27>M>G0|9GcUZprZOn5|G>ytq6(hkf&W67b{TafScTDtdr9X0EE zM}PZlK|>p3>hLd@c$O{(vw)wd?<@!wVVt&SPcgHzG6T~JPomfLSjO&%KT&hBhHsvV zuw;7zy~yc$uXT!In$cUrd{iNffl2xLP@>O0ZUBTgaJnIpd z!Eu9T5~W=dpc25xh`RK!ySL}#QvYov~{-+W=J-rW9xEij zBoC55nuw%YC7c(zNF4`cVe%s-q9WHsD_#cBQ{wq>L`#zSDcnY5oc6*zb1kTCxkiHm z=i{fXHemj;ndpwi;nAF(+>TNWFNBwYTAdsD__&l5KDL45sc}#u5(MqH6pc@>4}xTi zDD2FVrg9@9>@~3+(6A?w?D!OecYM#YHJANplcxxpm4;K#S{ZyfR*QpPqm{A$eFOnM z0iui>aK?ZSwUUk@wXsd~bxJPH^__rRA#>`I`iykrPgoiJ>wv*;m#jPV%u>}t}DFVL_qJ94O)1~;GEY_$(**gjH%XT z=vZV#;*TW4{va7LP;P_L-e;-&tMyoVx)|l>zh;`nRjRhd9)xulWH89xo;f%)3%5BG z7;WFQA699<1+Vg5Y~TJK@?q#GR2K(hv~fQP;?5k;0t{Hc+iSU9rapaM*g#cX9DT<|&xZ(X<0JKo1JIZ_G*dg95oGh zIUDpfkA*eoBT0E>B90x&p}s{2sjSFXsy?QPjOmw~@M{z6^dXV_nW2SAoXS1fO47JR z|2CUtJVI0s*Td@#3m{`n1-*Vly2|&QCj_qI)M>xd;d0z4x#cee8>8NlZO_f|-s2MH zxupyJ+EGMSp3(DTEq4Q8b6Fu5U zOl>ydgO5c_=4&xn>d(0(L+2UZ7On+VtAFHhqMy;6{$i-HxP_`ex52rc(Rh4cBeged zCI9SXd8=nM6MIL1oBGS)!D$}3Jy(b7-_1jbWj%0nDWxWJqDkNMz1Zqj#@go~^U5KF zXCBl~XI4zX^{xA%JY_HLomc}3!sl??rgLCz+W@ya%cx<)V!XTX6owCOgX3Kz?21Hg zRxa%ZISTo>R#^htZ|Ehyu78YTcn%Nih{xtvV8 z8OGX5p2sWw0raKPSCA5ufs~Cwbj`pj@ar-om0fdj>HUo)!=aQUxZ0Eas~^ZD*#IbX z=Ogt?Dsdot5)KDu(81)TP;FPwKJ}c$Zrq}TYMQUeudkXoDPI$&{3k;49y*~+MkZ5L zRZ0)dsYQRY6jU&7CTjx@D64_vz#7GiFfaCdn~ynu(X@5bL+@5bl(&ByTys`n)4&$x%LgFqk(A57K%_sM-XG zzte!x!>`D=xEt3GQ9@1qDC)O;7Abk8jSr2l!1fErN#VoosH^P3-Q(nl<$6Q(jXFws z&F_ee2f^e?TwbNt5Wdf zQ7ngctxN%(S;7!$yNonitcF8Z4B^$}!&G(0HzH`SPA7aLSdN)YBCf2$PV-5SSUmwx zgQi2-=rd~OZ;jKE)$r+&$>dbjE9iC$gaCzCFlJd}BfyD_#np z85c6r(TBz5HeqM(Y#@i4x$KwWGjbJ^uqH#O%4DM%Iv$h2(%LF|cYZzOpPd1@TcXH` z{%`c=GX-WKLLOpfMY9K`GI7nH4XnV_FpS}krs=#Tr0e@a8gf(}Qi?(#WtTQ)b(q44 zYZ8cTScnNu5;%R{Y`hkqL)LG&i5|+=;oN^0Q2q25##HVY*(Nu^tP`0I+dhO)2hN~JfF@|`*y*Q$lo?K}liA5T&H%!QD-dLgZPW<~<7Bv3Tz z4M}}<81=><;Gu7$#P{(gfNht_>rFcN#3~Fsckc$Jrla&`ju#UzwU~;pJ4^zdCf9HCxm}3zJI!! z>^Po`lRmYAx#t2qxs%)d?2eKk71gqcH zv#jKPTqygOu6BMv7n^0niHFA^zSI*I-Wg~7j0@QJI}X7Et_L>$)&!!h?l60fbI-Fy z?(lEMdR+3+nJCTI!Nx6X=zo`wK*HS#QYrn1>>St#irbaR)TzR(OT0Q22zZGnGFynu zpD--Y3#A4BJ;Egw(KyWYl9nf(BpOrmi8=g-Dt>(M$|nW8L;RV!A7e4oFc;n@z5!j% zBXHVDmoy&fB?lb$5bu3}RUu)ZP%)E!J9mZN_TlE8ciO0)m=LSBIffTBe=@ekC4;z3 zGqbLI3NcX;A(lV?Q4{Y7c*S)aB;7WXLvjAhHswn&yuFYya$d>AM=gPM>uhOf!b%uP zxQb__W|7g@86Xnyl++tbLWq(S6!-bSH-Q}V@Hm92i*!M2w-PzgD9^DUCX+DUIMLwF zj>caq;lt4|D3nTpRwY5WD)J39F3+c9KgDjh`UY)g3f(hUnWOYuyhWYwa9JjOolJJTb!AL=jI!*%YrH_Jc> z`8R%Iawt%%Q-NgWwD(+pl)EpS^98SPL)5|T;H9RAoHWcRC1w-_c5A@oTOrV>CtHBe$h{Np_1zrZleA%TavSY?UQZow zJK+)&0SF@7Nqw~hkaPPuewPP2xMtB0dd9H3Vmc!k-U`be^BK$TDxnI)OR;>TG+q5Y z5RTb|V*1xXGTZAqo&28&^hcX=Ji=KZn_5W=ukE0gtP*eF**}tI5=2!ud@%~<6Ql1> z%W`+EJ7lzP821mH(`uNNp{OR5-%ar$uzQ=^B;z_MBWk8wL}W zK{%=@h7w%Xz%xCWt~&CO_NMqkHJyj1qX)3)gCR^i#VLGLtH`gId|r$_Kejwt1INGZ z!HHA{vOdiNk1nVrZG!}D-;a@=X%`^zTvl*sngmK)_ZlZDi8m;g=2f2;+4FkRk z5!+ifz|*V3wxm*~=8Ybi`&|!SMRn4rzxiR=#XhoZPdIb0JQ1Q=XTsW?S0MGTmOPz5 z721rPnF|}a`)ITdxf#+47d6kptQQHmIzdA;y+e}YZ6?#v+FePjAMkRIc;xXGlHk8c_l>d4a}N z_@etS?NV~Yyn!D#g$g9d%3MCW+^iB^v**E<;IB-Bl@lrDIyif#C&7@eAxymA1`_8M zV$4HHXlm}Ft}D3?_nBXG0aHMgI8EQ9r3r)_Oy-HnJVjGlKys3&8}DlfBXx1w+#Jak zPuMPDH}8Fm7Sfa9;I&8eZ?qINO>)MTrA0J>RN<5y&apAcj&_TO&<@oH2i2p1nlO_a!c{o{bTS+}=v52=;J<*@1!MMES~d()6U3 z`Fi{gaT94^VmN-Eoqrh($_+!mErD3v`320DmC}m6mvQmIO)ytZ*yzN^JJ7+gF0y|0 zfOSnPSieqU?wsZ~em1ZT1uBxkH}fmKI((FLMs0-?bDGHInhbE5n@Mk*YvGE}SlG}Q zhgI_5>8+-4lCnXHJlcMqddr32HsxxpdAfwe$aOMXk6GZOYiF7GJqF;eOVD*gCE*|R zM4yRds9#hITrGqS$#}CZ$92)~xfdv>hvJsSv!U#e22*IAhU?#cB&Iin@#LPjL@Yo6 zV%;-{Z|qIdlHNz}ExpbpTyB8Pnu{>{LmZ6lZGidtsSx%2HN9RMLO)Mt=$7~bwAGtI z`7*iBa$i5Z-Li`;S$GtE?}X6G7t6tP`AO2~Wew@y18DU%bC8vsP0ox|!L5m-bc5Dn z@OpC=Wa}=I-jTJqgkA<2+u20%kRa?{U<8iyUJ%jzrRZ8)gKuplaIYuF6g{pFP%^}N zhV_!9_e+Uu!e65GJPt+ry&#cQ;kYq(jC``i>4)kjf=daGIWMLO+73{5Qy3Qhx=gJ) z<6*Z!9+UXC9%H<`LH|M%eLJ{=FhmYb(=Ac_*Cj?%b2?m12D*+}g1L=_K=2|}D=~o! zYm?#eYYiy*U4Rd^OoOeR0p!UUj(_2ugf=yiWad?C>cpSVeOFBIYTa;{gT6O$6|BbK zwSd~Pd8bK5s|D*oYL?;qpyCRCeP)g zYkSfO{62=#y~2>a$Pxdi=uF(Hdb==8WFA5p5{W3Kxy0G;QfV?onyB!Ll9H6ULFRcX zQ;5otA{7}b&VHAOs1#C?XizGm0hLPf?e8COxz4%H-tW8C^V~PDbp|d7+0Pz65=Y<1#V}Pt>~#zy{BdbWDhz{Ssb1!3v;Zn7$Z{;* zBCs?T#hGV=NSR4Jp1kv!mgXMEHidfV6naE_IGxz4tT^xpzfJ26I?1^!|Di;yL{(es zHMn*pkSHzvOqT5GV<&HX#R$FSoFRv=G9eXaxHZnXBED7~XQo)e1f#n|(f31TUxpF6 zQF@bRI0ax*nLq9JI1QC%4j|&+jOotD8Rc`)nDOcb?VWcC%W4YX-iQD@2-`gEgMhyji=buB+bg5-Qo1L5U1o` zn+nq#D=}f+6gGcYg+CVHiC>&6aMj6p4E2^&2Xy2~tA z;v5)2$hId<9ir912tS3FEPix~PBf8s=SHjB{)|v8kgP4|8Y7K>b2yQkoG>9SVU* zXY0tXW2SJby$D6Ng`>p38d5SCOz$j~z?_Om$l^4-2NDuMB`%d$=YO4AEOI6h%R%8MES0SxFK26 z)1i-S>lnFGx(t5Q2=V<2xIX7WSvVIlkt{N@A!2Wqz*}pU4!=ADj+Z#c7?**$|2>ah zIT{I49g_Shr;2Dnb~`cs84SS+67*+HIibz(QKGGvb(Y-<=_{KmRdE=)VSi z3)aEZrMASS`#PnK@5zOT2>hi}K!gnZG05GW?27MZRx1dSsN{51ig!jG=M3WRz_I7e z>~KwfAMxrKp;ufe`EW%K`~4&E!>1=q`28SUpQj6l>t%4JT|d>-UBaJ}brzJ~#NlZR zeONN~i&Q*N1hUT?Dr?&q;in_4FngEGU;LIdHu2%V%~vRg!Un+zbuc^V2ez{n>6DM{ z^p5*fGH!E~T#hTnRFC6yuIO~k+B*jxWrPzpMW~{-&HUSCRv-m8#lR zZZMf4j(B9en$FXy!nmCxW&Dy}9PyfqM05eyt&Qg9so?MnO7e!+!OYw$ z3aT>=Ap3d&9rstPN~mtfzj-ZaloU_;PS~JsNg~X1r=&;T5)Lqs41v34=fI&GIlh*T4Zv z-aj{Vc&`ITw}jFhWx|HN9->zg=cCQ)Gq{rb>~~jvrIlgHkaA@+v+?~QqW!dk&$s=H z)1N8g;sa5nZIC5$H$upi(5EnJvk^0eXJYrPAV|Np8cUyK(s37aNSBz*aiiZrLj4SU z^51%D7F-G8pStiwVGjAc;WBx|^rj;2_y2^bf3d3Ze4*%j8&5H}m$J8`yn*&OWyg zfivvnz_c?dgx)zQgnhG;!tN0M!9=;eLMc<+uOmA2D`yD9d} z<(8fB`FAHZFP=}T=Wk}F3<{u7qu+&KhN#lPQ%VpQp zji`;^x@5T2*+Wsp{2cP^!tg!=G7~CHx!E;XW&G?%TQw!N4L!n#Esk>E5kAkuAH39@uxyTr?{M( z$JqkAN*Whe3LyW44c+cA51+Joa;(xRoW8r1*)Mkt`}$%~$8!Q&m0X1=13i?TWZL=TR`xOhzDlT^QAgWn3ZUq+ zG4k-2B_tSJC1zS1*e>yXs9Q?d^#O(uz_H{K<*nh3x)z=Zs9~!VhKZ5xG*-R;1e^T6 z07Ygi@?!J4nfHssNRP%mbpNyv#rMZir4PA=I}Uq-kmoFj^Ua2Sngn}sKlBzRLt$n% zR0M=Gir35tUsjB~+a?OZcOxLCcLm5zNnv(wf5k4jn-5Ixd~6~t>t$(3`pslvQ|>)@ zpu7~7ZhWCGcSpE9+AC80OBkv?yV7M=1&~a&K;nTXh|Uzp?zWC?7ncs{t$NDg> z3i4%b<-XHe(HeLtc?qrEjM%UWML2xzAhw6Z(41#Hj?*BIdp1~t6PE>EVAcdSgxe2% zkA+8{EvdM@8~EkUqF#}$;A0$uA2r2sTV4%5*2@4FU%>vI+%vBEEu&WK5Q+Ln zH06st{?c~@`N93Xf(hYlYqBqQbjAls>xZQ z7&Z@+tFpO<>qPq_&!IijQG1yuh!Y{!&;=uD?fr5JDWqvLJ`=# zG6}4VJz>$3?NsehH?d2o;_Dk;MPc`gIBDs5Ci+qas)Re!5spbdCE^?;r6!Qmk4~c; zmrJ@rgs@d^7w!eFSN$sp)lKV3yy;5(_GTlxlyOSw%1>ldnJPd2ya?4fo`d6;e^Q_K zjqC^AIk-@!7^J-2dAe)@9n4~JOz{vAx)%be%I8=Y`AYU_g$7aTHpQp@BW&@P0NgFU z5{9*^iS%@?udGhc{*5Gr7jiShKRlxNk;^wu)du9eP@^+1l1T=}=wNn^l22;n-Iion z7}xcT1Qj_XgQ@CWqmd=Ty|0eiG-qzM)!~Szt2R68*i7(Ho*M z#9De0c`YYMB8wwPYU(`f5lN;COq$91>|9#DppO?@^@ti=J;!Q9Cc&I#lkwc$pX^7M zm;9M0xXg#|56B)fA!7X#=%x!BV1~y+c=2B>4o&BL%bVYT;Xn-RwGt(5CdJTW)v9U0uTKn5HB94Fivha_Y^ z!T+qMVpY~@{P8-DE*!l{jPe$N*~b+y^fQDks=Gs8x^BkY!3n@WvKTfDa;|2tLE`k) z7+N(RlR>9M>`2LgAf-&qf1kukjt!AX1*hmVUKL4fm4d?`97y;)N*$E*QSh=H(I|LL z|0)IIs{`EZ`SgqE<A3~YMH~GXk)TTxdmIda{iXLF&qoNi;n)Bg)J)f_^!;D zZhE1C5+9N|R&G43j5UPn&G%5z$Bj{#E)H+J6mg&2UXDW+4d>VWr1z31V(i2<*qHQ~ zA1D$-LvCH>O>ftL_m_hJ#?sh-ivs95j=>huX^v}?gz5UuZZafM$a;0!f$tV`a!|qr zCWi2!;B7F5DcGX*AjcZq|B2{~F5rTUCh$^GAN8zD$%Yqa*^bf8X!ZLI>^-v(ynkk+ z!rgdgU{xf%#fz|dC=})$?O_JvQ$S+rDAYBKpqf$u`6qb{cjTmk&kb%j`io`vh8^cJ zYy)un?q29ua)84bmtaW74OhLH4jT&6xbAH_h;Z*Y_g@8MqR)Af`dN)WvG#`rXAj|g zZqA;t<2ElpVmBEVl{9J%JVC?aely{<97m$-74eI%qNRtg^Y*)`k)mrWIPQE4<5rhU z2aUAE*RPhTblATikCcC=mmX^}YT=d`S0IXUbF?s_Y#PWNQp2Wq+tA>F8rC$5L*j>< z5L%={-T9l~kd+*Ko>2)`9}Ll*W`(q{-kue5JA%5FSEyCq2IlXQ1rT*K1h?7vp^lwK zm4rM{=KEyq9KA|D#&bKk{!$P>H-p>3-RH$EQKBy#b)jSX3WzHk!s|cD5ZCGaGxUzGzqy3E+;^rcUM<2-^?c^{JyqyFbb;+qwt>WD|IkT; z>mkAvO!>!>b)QcWxyKZr?W=@mg_Ej|I>(~0S_pV9-v>74StRS#Db9C$mrU@@qIfIb z@KJ&Xt~qcRl|@D1#KZ>T__Yy5B+}VCt!ZGCkcMYCZb5JUeZ1`Vl$I_Bj9SrH`grG5 z$Z}L+Lv$1{^WrB`^6mosDbFR=@uDzkwUE*6&Mj2$xlHBD->Y$#(OGi5*@O`uih$!S zsdU_E2`2taA|5;Zv0Ac)%t_gTzKxOWSaloRn!TDt3lRMT54kK&oIzLpPQXW}B#jbA+Q@q2kF@eeD&4uv63%Wkz?JnG++6b|yRc>s zgyheK9$Pi^J2^~Vxc8Ga^^d_yX*P(hRw1q{)9Ht=&&i{uWz4qk&nu5ka)Eq~EpTd! zJbV}Nr()*8cpyXsNrN#Xx;lcG&K==cI*Z5+xk_?+WfwW2*hj6*7!-Q!#;yQmZpS^D zjK`>;^hztT^K>SacbZI(^mdTbw>c+6KqfP)B*t!Zy+Gxxw-TQVLbzxD5ZMx}k7|!5 zklX`1AA8FO8Zl(Z_X+_+Buy_joC5Ji%02n9Yau86J_5S#FI9`x8&FhYrN-d0Yv5^u}zBw zd0$b~oSjI5@7iD-KBFGDPhjF4C7jgOPpeLy$DD#E)YYB4H|aJr3*A_lR5Azb%$M-{ z4rkFv6|>RL`8>yQbHY*M8zg`IZcuv1@=ZTas)}GTqQh3_V^>T%zPj&ES}h_Wtuz`t zwLY=uaM6Ql(r;QPvRVE91DXu(1~V!>Yp#c?g{t*x@q zs&$Upve<;`N`|tdUnHnZTMO#^)y6!%ujI$TPwKvA57uxVjl{wxGQ4sY*E_I*UHiEhGbrMQ}oRJ~MaGUR+m_0GV|qpxipns@d8Q z^XfSAfR+#!{vI2WTL>ACtzfwlS(uu z|0N-l<)Pd&p0gi1#JIl^B5L+cbVTqzSWSzgr|g8qjlv_zKvn_HopFSo8oS2}EZU2+V!3nOp)^wR zp0WYg^vQ)xd*a%rg2B@{k3vU344d@xR_!e!YBDFFY0qW&`Z^u=J~YS8^PSk(7FFg| zcMqMeHwD*nec{5c#aIp3Xr^%q?y9pTPlCj0w)c9b`j-`UUtIt~v6AHewWXjEbQ8^! zn!rL)nQVQ}?J>sM;reJ3(bHH!EF(-oYGET?7P+4B{QZ@r@0$}wbyw5A&arpKLaA(t!6jg@r0Qb z8$oeiDZVwyAXUE3q@LUHoS#BaJmewU|4IOM96Ab5{yoDd%PQE*)hRHu{}jjXG6lcL zE2L-dR$kp#2In;t(R0a(aMfxU4_!w-Xjm%Y^P78|gpNhT*-`Pv%_%yt&=F|9Ew|7FOqpR9M63ZOU9Dp$-hEHoEvbM9y-4iHbW3?nVA9| zBS$f8U@iK!d?)q>VK7PjE2-R_fG>*#!02EtU2|2j>gE%5+_-x?)})@s+5}Yy9?XLM z7U%fGlSBywMUr$i3p}xBHhw+8BOKid2V?J&1d)5Zg#Q?>dw!UGS9+SCS-cln!g2fp zqtQZZ0n^a65$cSlVgvssxtkhJe|s$8_K&B?3mS>L>$se(w8LNo#QNR%s&}wHW-QBEy;t-(^bHXryIMdbs`dbTTz* z7etDbLH;gh_^WUa19FSl*%3>z*69ew5BreI`hS>-2X=tV;0eZFcL7n6&W2U7&d|NK zhMBSR9h-meG>ztVn?r$t5Zf3GY6BW*{`v{$07`*(C#%SgPoK$`1DA26WEJLsA4=Ip zlG{gS!?))VpwNS?W>N$ReLV$r1UPTsRbiu@Mk}zuYmjGwrI2DDsVLt_-;QR zcwvI@;$;>r-YbV{g13qAo#`;vF-SIKRgn0t4B3;Jjqf+^M}fZ^amvM0IOGtA4`ZTW z-=uzOlNH7^c<+Esk&hXh;9^MQ?qKv*AFF($k<6+}C+&+ZaMSu`s+P7J8uKcM_#u|L zcgTi$wd*iQ#$|x#j0nt-ipRL!0_4!DZo0iT96JxF;`!G;a0`TRN0<>Fh$)9Pb#@R` z9)@BU)S&f&5oBDG#tR$|#Ej_xs6$1bs}8x2yt5e`!pe@r=aZ2!S<^HQ*+{ zon4oClLSni3d`InH5dz~Qjb57x!+E*CtK6`$M2Sqi5KFr>~=QC>$8Hl_DZuY73-6wa5vjosNTt!F3|L z<`$FCqC-#ISEnEBf6#t~NSfsEv*O@CbEk4xpfh0 zmtCR-OFq)3fd~+Las#>qFOZur`{DbNEp&YCZG4^jlmy)3q1KTe(T^7`m~XrHLzG|` zs~97LpQePPaGNfss^0^WcNU^1{UY5-H}K!qdt`-=IQ}sWf@vF(_AGb7&>#2N;%95o zHg*pFQCdnIeEuR|CKt~gd_~rX8Bp7RPUd4y0$J|(mp&DnK}EL&q4T;;_%YZKvm4Li z3Xg{*VcULkr0fK1J8=p*UF-&Z_Lb1vy$=#Lrc$|ougGYGGTgtk1inkZV#LmFq8Y9q#CP-Wm9#N01tqHF%H~q}>u;FXG!N6d zt{4QZ&gEawe@(xO>I8Tenf-HC-tBw9U3M-zEnnPP4tAvPsAGSG4KV2l%6+ z2@Qu`KsTg@?jA8^eO6|Ix_&Plam#~0)_3sGuWS%j+eB3U)M1p>b7Hf%k2(I{1!UG1 zQ%h|j&JX;Xyx>^mZxuXo{VI+@{p1Rb|GpX)i*fF5^~GR1T@MDt;y7o49)QquCYoxJ zJC2>SXfy(yic@gN{x2gqr;8FvEs&FnL-(DEIAB~sKUr&FdvyfJF7RgF()}1v=LQY$ zrdEmkJqtO>34|U@W{d^dQJI$EG@wnF6!W+IAElElS(+Ux?@aY7jW3B(;wvsitUpt!c|FZ(?i+*_h z)<+sE9RyJtg2eVq0rTd74zAvt5C2JN5|68$L{aSke(ABGuWv1*n!8$Pn<#@`qE0{~ zWl?hA3thhJ4q)9La&B-o=vbxTjp<*i#>B(i4U*tJl1?&u#rPGo0x*D^ z#ou5227L}{!RSejQwTHY5Z{uK{bB`#?J$fL$AQ3b z__6vBmr+tD{<-UM;)5XkI7yI9pBqL@9As&8{8hNuJ`cQ8Ptwm;r9^!8E@GN(2}%z{ za7cCwxPOR(nu{TDm`FiftvovKx*#9Y| z_wFO=-AJeZ6%>KnfHY*PgwO{k=COzK8=?BV8eOr_3TE#drv@QEXv%6gcps`v7_yBrzX!UV=7LMl3H*1OLTq>E^2CK-X!kZ{9S%oi?BhX`uBZ}-7!Hpvh%*Fgi_}}3aoH(YA zp{4Sq+OiNoCCgxS#uD0F--Kz%!XGkhWtbESjzZav{rTD*qGxuaVD6p6{nO zUIddF!#9b>i=`lRAO^ak|0Ca)5PWi86uSavF=9^y*kAVQ=sSLp-DiD?Q!uNLA4j>r ztNw|sP>&^!aste6oBuHJ`x4x#CI%*JhuB??v0(kZgj~HkKux#CqVAv#%)X%szHY+s zvGFKgTbGGajvvT@p#NZ9xC-+#@*wp7SOZGTB&H@joH$EfhIk`2%w3+2O;^rQ{*p}Q zkj5m?S`|wM=JruuOa=*^^cM9ODNt*dtEej9jvnn}+k*LeDR8NBMtp*LctQYp*FjL1f5{P1@>ZC^o9=WQbstGE%X`-9Pb+61`qErXsM z`-O9M24U%ZeP}gI!J5H;#7S*6*;gLVcs15j^T=Q9CEbg7KPZv>`*ihfY2$RcpRIN6AZgNOjxG;ITTUugq$i%778l0nU`%*ll^}3p59FRGrH7P&el2@XEc$n& zzTXNoIHQE7dhSfU!sL!{emPmzMsCIV2264uR;BD zB%DbJgzI}=(AkxT5G_OKh}%rE?5;XgOgu(3NB3jUrc~Cl$b-wb=)=72m8e!}Mx4$} zFxqizX4N6#c&HFOM|65!aparC4hhMFx1$+OKhsWX{ka*`yIQQ9HBLK>1z}yCARIXu zf$NUm!6O?Kp#Sc2xUI;s34iv|?%Dx1%d?#Q?bQZf7RS(n816U7Oed2sWHYyXW2oyt zb+{UKiJDs%(gnhm(0^D5EdN>4>~9TZvUESST*BS8ekWtqb9>CZ?MF-Z@tL%NK=#|n zCwRZ^66xVar2gw0qg;n`>9kkt(00;yP$;!z1n+c{^J~_V*&NFM?YJF2tvgR$7MkPD zW9#XT(sQ)NVI`h7+(aZb62WUS*Kx}Kk7h?H;r^O5`q+DjtxdQF)Gt{o5=s#joy{|*!HG?>q}Qs(K9>2qMy}Oe_itfH$U35!&VM1&OS-kE~23NG?~a1)sYpQcFerL5jZLr$$oI_ zq`E#&Xtx1pAYUg3SI6fw?e`ah3+MY?nU`x=vGG2Bl#Mc!I_pF46mGy^`-h~^@CB)I zl0+X3bNqc@2tSxp-qr7(*s^pQu0OU2c3pT*hL>Al%gi(6sg^q3D&5PzP|k(wsDa6)#-D# zDN7tTq*BS)2k>;lGnnpdM89uPp^V`cA%;JyJk4{zGU2P8J z?&IUVTNz|DWh#0f2xB)?%dqOz%KQ(fjp0n!H(Fw6gkDPzLtgGE%FZt)`WJSfKj=RRm9!oZ+}Cspx(8G+Sq2NctET@bfu~->b}U#h(I&@B+y>p@Jm`1KC%q#@VE)kzO{A6Kwu3+1rBmU4ngIE29SXh& zy2*dPmsZ_x=%@Rq%*Ho?_PBb`3$58WnETy|j;z=QV#k*kW`w?+1DN zvj|_4n`@mtLTeL_!!||}1U*JzW_vgs{U#58_RK&J>3YM5%`T+bW)`e8r~*ZIN1Um5 z3bN`JLg?JJI6v?J6ZYXRE3I>j-Rz^znk_0~-lW#Uy~}rqy<`eDDa2ys(ipfka)IM1 z+d=c@8SK5SX5eD-m&i5mf+8+k=+0%*_Wu*1L)?z?qoyuyx>7{a0yg8%6>Fh8*pXSV zu$le#J(yJW<>Kh8cqqMMj`6b{>GjfPc$oSIB?ir4^ng6mc6Bbyzi|Ziy1oP14dI4| zL>)??|G^<##nt)36Iwsp&Kbgs(lI#0@mPJ^$^%yrc93a>0(CvHs15U zRpiXx2#7jnfGJu%_#uo3c7Ly--1RTi=Fck7`qoOFHW-4m0j28%#_1A&4H({04R38_ zxGZQ0=Vi&nKTIuEvhQGK$Od7w_O~m`p`Ed79E|G1XLvM)r5+=T!NK71J zTtCILp_UWiz)M-~c_xWcoyinUDFi*>fy&TRYvnPthM;;u1f*DoaXrHdyr~?<-Enj9jHxo`naLux+2_faQ$KSuJef93tfaRu z3sUXmY2f=p0VZgU=VJR=CZwbaPhTm?L{d4yTY zRR;X(@=3w7Tjb;HY_hY-l=)j12#cIGfwy}u6!ytN`y3wqK&+wafd!GcHVMvooWXCr zM_>~diCX1m__}WsxfpN(UcGrqF8NMJr!)63IZuzI&U!;0&2lA@{?*ib+y$7{xM?K9S)gyNZZ=KbE0+2J;crIFLEl&)fN@hApwsLF0nC;M&Z6 z#xIuh<`;OQk=ArHjGu@<8^s{BKM7-#xXfSdO`@{H4cIN)>HJ{sT|M(2ac)J-f6`AC zb|m1dBQaRKmSaISXY!|nR?{B0>9B3I4!`7@q1@FZlaW#C@kww15E;4J$LRkDkm8|JLM{Z_JTmEpJ7wP}G0dl{qgPe8jr^CI z6s5wjGw>UJ@4trSK~bfRA}=t}E1r0~?WTiM&p_dkc;M-^a1PFmL}~dZc+T^MilvQY zahL)aCPuLQ7aPdE8wtBan6gi3W(5Zb6v<;9y!c{Q5Qq;mjIY;B+}B3nSO zaVF`E%c31OVzABPHpljg#cm@3Ly?q+wEF%&vgoBCZa-lNUpZIpi6VQ$Y1?*_Ly~iG z`^p0LX5Ar}qIjO!rs#^%0|n5p{h3|FOJLl=@E&=q&%0&H62ab2Wj;^g)fF@G$F#n(!4#Z1=^_@W~eMSt@w&mcrV@afK z5~YQsw;;rKAz5MQz~utOh`C=3Jof*Oh{Tk^f`7q~`>UQPZ_ve15p}YCegU~&dy`jf zunR+G)Z+Lq&KKzKiTC_u@nhjrLJa+2`r|2d&Rk!3{bZ0FOmKxU=gla#-wyqaL+ELX zbu{!7cV~Sz4Yl50LG|EAbduf+;=^r-K5a3BbW(s0dh+0SFNrGaX;mF6f5DifEW+&n z?C3@#Rc@C49&Ug6N2gRalC>`LVQHYCfuX4?UU;kt;x-?sGdFWdm+e>#JfJzw`buJmoDrx&JB8|NJrhInNN4GnG->yqsY=yKwyN#pt?> zt>ED+O(RVRY`sO{Vs9Opelmh>Xiz{Y?MyQ5B}+wj50Dx3HM9C!GaTC7OB^^>jam(t zL3<;CGn_T6{$%suUsW5IjR^yvWv^JCND{n%8jWu@A0@6Pu5^XjKU(&BIaR-53MHx; z0Mv`*1b-lTZ}vj%vqP++c^f4hW3oRykaKtDv$evP=x>QxaP!D=>}}%qw;sCiciKLp zze5Vj|J5_YuM1#RS?qa&?d3%FT7`r@uMO z$W}S1Z=DHI9xQK_zXqB1g>vtic(l|~WyaJrLDi-b4#>2S{Y{^UY_^Eexd6u`p)g^Tg=zaZE%#_bJqmY~1vEL@=@4HHflk%CJ;=y~XrD4cSncX?*iZ)`4F zj&8@Lt|j1nO&(tKt_7L7J@kCQHV{ZD1Vil#8hS^9X0uz-!Sp-%O4ehJS0YMzE5MGU z`=GwOfF5wUMm~tiz)Fv)M0_-l*oW4`#=R`LpU>seFN#;K>q*7*VyX7cuxGSY*DJ~ zGIhVU2TkuskrPQuRSi{Krob{8FR+d{I4Vy~ExM`3paS#c?|Z7_50JiSCq|Tc;Da= zfqELoVDd^GA8dI>#>QAEOZ`StpC_Q9*%0mf%Dv}~mB0iwUFcW-O}#irmb%zQ8dXAx z?$K(JpOVS^$9b2Oei#vb$rgtEiUH|dDQs_;42Rv;(jm7mpgB1m^>Zv>W~4jn^#*aG zAuV_%`-v5cGsoJO9W?vES@vv642?*eN=>icSo{Kt*;|0Kz$iU#Y6*`=WwDMPfVBm8z(M&tHB4^>36BOkEnU>;i}6JG`T88Z zkY7r+`Noq4SF_k+zXYnmu`Q>*XvYBKGLolY3SH?LxGZUyGMg?^F>w#lwvF3k^4`<) zD%t$f_kW0c_zi5-*^R4@RI+1I3Q%>WjhJ@XgKml>xGR36r`KgObG9$Q^Ag!q*R_R~ zc21%9(}F>@>l(ybKcis}SD|qf=iS+6#xXn(f<^QSqOtQl)$m+_S&M67?>0>uymU6P z%UA}M`&H&=DY=5M5A4|rnNK#ugyyE`f$^kjLL$rt<>2bgXb5=o-!f8x>`JBBW{fL&E|0bs_ z=Fm-dy)Z~FDSEwoJ$7sQpoy~pu!6FXn=l#1O_t$=T^U$qW=$UNCU`UF1>el)40uch zqHG%iJJ%GG9|2h)r=P-pPU~gA@_+Ezo7G&7rJ0Zv9yPdpo;5I$Bpsuf#HQI63r+?z zM_ylGzR&zXDuYsP^O88RRg~k+1LCeg~q%`F^Sc;scO1rc1<3J>WbfjLGCtOYTR{YVuGHHh*tU98u0!l^g@qZ<5oxV^X;}}Cu=@!zRTa57P@+zk5mn#PAUE#%Su;V$%aCu7C9@=6)1@9MkFne5| z@wSXyphMGsQ^l5*AR^RD2fb48U7IrHo8Q9xhB~}{v>Q(j4$}dCIDYEj(-Z2lxcjdn z@U>g1kIQ?WlpCOiz&AF4V-YLfjUb+D(l0LF_Mw;%4U!%ep^~ybTQgapg z#_bvWrWu23csf~e;3E2GbJ;rC^M=;Aku)zj3j@Aa@S5>*=x}s{Gu5ty>|RD6_i{Tb z6YjhirwZHZ$8qv|E(>%j9z6#wz>({cs_k6GWkfvLcz-3b$4CQgw4ykUwH4MjKVsf^ z-TDIWkha7H_qp za}C$PsKGL>mAjBgxr`AR5(?>tiBL9>gFU+q!7B6sZ7+-=v($b-+C61lVHAn;4!@$` z-JetOwmW=*&udA<^O;6XAtQzce-DwqnWkvl^b``>6!7t;hcGI5l@)ZY1!>nOY|ZIK z`0(ygATHwIAnXCtW{gVC1wiR1`T&#q&cjD*qsjG$&uFVgJf7hA zuV$*#XnXo~jQ6tQUE08d?y*@woA!XhQ*AmuBn<}gr@^-6A#kua3N?@1#$`jF$ki58 z%&yhKf$(y={jVSB?VAprtVks#`XmaY?VOyzol5+<{y2`@HjUU6GFq<7P<*;kX>~XICj=iy8Gn{dMi>LMoP0W z_>C}>O~|2I9J}nm1x*MmG$4D$9uo!Y4QS+ak{+?xhVN!>qh0)~91Fk~1D6@1-g03W z`dv!C7R90VO(WcK>OUCyKSw&U7H+qe65T!P(MKX5b}f5Ir*6~($wi!VtahAi-(!tm zkJ#eklj}(GKL&-L90O-wI4){@N&`v?nI~N_Oqc3+s)UPa0H(o`p^M}py9r=K98+u` zEk7u`2j#ALah$jS$f!Dif%7htrHfY)p3Onl;P52+w$~2o1?0I*uO@My*~C=cyae}; z+<*zRlUBPGfzx{)8D8gvKUW@ytwZu~zHSX159*~A65F9b+!1}#Md+^DGtd+94VJ#U ziMlydxcn%ezWV#|s!w&g=3}D~4{I;6pq_ zPk~n@OF}waVU;jT*Pi*u&@1n0xPd9whw0P2&z>~((r>8SYDJ^Yy}>=&j-+=@1)ah5 zC>sCVB~v<|V*M~f*H`5c!>dn;RA3?8axX*6Voj=Sp@IuqJ@C8bYHB~&ODl|YxY^$x zI5_r&-=OaVQ&KmuQr9^j(<~|CcP0c>yyx<*xNpY9>q_w8PduskBw2q|k8{x1x-<64 zQm7mH6KC#vi;Wt`2{k)~pt21v|4zp}fja0CWeR7%IHTqk&K-Qdk|cUokRG9Dq>Got z8_NDbq{daCYnKK#aqsRu8{+63?b(oQ9|;o+ogr$uV7>h2?N}CJ#7d}%!vjlydfu`K zvL|x?16JCo6C*_aJLLgsmZ!KK7X#_O{p7OKbx5pOz};D!p>@Y&SlaJ|P1}~^teyG1 z`hyK9Dv^Vh%Q9(2|C355w{_^-P(eO@uA|k~F}xE2_elJ*8t@vFgbE)M^8I8TIQCA1 z*R>7AecB-$+f_})st)3$#WRt0RYyCwA!fQ?5UhT;2=^()Vta`U`{zduu~T0I_WzShg5i|rCn`bZweW4h^cUkBK12<(COHO%S3?~Ly92wHUR6YqpXK9(<7!?~aY1dE$cFHgBlkaRUtP44}3XH$r~P7R<={h_NW{~XxO`EH*s7@^lp&cHqqEjS?{iWA~8V8*pUYE?6ZJ)|iEH@1{e zu}QN*H&YKU2e*+PX>aJ@dIFN$cac`1w@mky{doGYM*TmNefTkG5!UdxP}SOYlw4vM zefhH#F4hplRAn5cOF^4Oa`{xR42|FJ42d7Id(;4pc#m zz+aL(aDcVuzQs5EcbojFl%u^xyYcK(eF#%)1D?lcvSi{0+<7=1ViN11TQ`pM8O{d_ z9Ro}at6~i7GJyXp5AN-e$JH|`Id=Pb5+!vLSMQ0S0qfJi|IQ~mVc{C06Ly)zD2S4T z;T@!9nkOb!Ex;+m?XY683Iy)qb|h>Y8GXMPN;I43nJ9TG`1Ki7?GZ75@4OyGwf4X} z@8=|;!GMYey*I1dQpp(H+(hbbkD024C19YeE_>qD5~yG0$Y^IS8Nh_kG@XtBp(jn1nc7=Sh?hq#LE2jx$@lFW{cGuUU!p#@-@7+G) z(t3&xg!YiVZG+73iLEq0g*!(%wu$|SB6uIKr9oWG!$;B)ZUjWpbGKK4`Rg#cL3$jt?<8J4#eKP7UrNOc8hhx_IVK+JIfHI z;uJVo9)dFuXfnH}6+(nV3A2M^bDWIKM;(`W;J=wNH9cvt-64XG%fyqpp2>8_;id5K z`h2eExF7bG?xgpPGQn8(A92{9!d&^~L#J{M!F$E8X~ie*?@{Rt%AtjP?T;~3igN+R zg~(zls1Xk*SI{jlCcbiIBzj~W-CTPf-r6A*{_v0SY2{|cIypo{Zx_ft)PPe1a@g3u z1{EJn1*6A{pzm1}6|2<7uZBsW^}8pfn&~J z>G!)9sJg@-rcSp-<@9bM8nP7ZHnqVc@1>AgsSUoI$3baE7=-@4j=IY>s68hw(fm6>?6qM88eRb?7 zkt^D$_N{U%7h8q->izt-D$WP~aB{tUwBI(UmMoqphWJ@%JI(FtGb zCqTo86bSg81dnb;K)S6wIX$%(b{HYGUl@FP3h^4NC6dpQ44o7R5lHf- z{-@+&d0jgV`>cq2ujZlIv1aP-KZO=IO~G7)=P3WCjjrTJ@RQ%{2K^hy;f8R5nbpn^ z82ggJ?ZG1~t^%^tuWGJi|)}pI4flR$?4qJ=v(rR8DoO~EUDjgl*&8xT6F_L38 z*5AfqtGD9Cuce7nl{?Bb)RV&Z<7Cg;1$0%YJ}NiQ#k|rshLzev%8DnV)VX%*`R5J$ zO)Z;LgiS*s>PA{JKhc$6b08*v1xdeKh><&E=-9GM^j@<9A7#bk@?2Rs-(AQ=zwTjj zq+g(3su&&e@THgxCq^Nczr5c%L1QzavVCo>e`XuD(c}xJ<=|n-W*@dhTKU zh`4=X4{!aLp ztwC3;4Tr;})r?+nA*KYhb8|LpROneq-jAu{;DbPzdN3ReQi6$*-;_ ze^00p1KUs-6iLQUe0sPjRMSD8&5u z%Y1$8OKT(z>F#9C7c03M6thN1xQ{a!nlFK~uWL!wo@ zL~u6`O;*o9;{ZwYP%0ynO4T{`u`<59`J2ode@cG46k+j1IovcZV*X;*Rk~_lB1wJW zfun~n!Bm)HmeUi*u74biubjNGDyIUZ1fI|hxi1)%-3al#6*Oz{LsFJILO#xwWcz~@ z;a{^ID%Qk-&&ou!S!78&eS~14yhOe5@?)^7I@|1oO%<73b&VEJRRVu5)8cnZ3u@++ z@)e(#q1#5TzvP-n+UC5V2JI23{^Jz%g<;P-JyiVfFi}qa zh_e>=&`~*a^t6efv-TD<-_xt9N>4C&r_CT-d!J@2gy3RUpYb@=0cO_g;Zw;)lf@i*>`X4+|s=KVN`3PO|s{q%m2>hm;FTz+)dxlBRHtUa!)F`K#-2$~+lz zGqso~!UTA%mXErT1}Hc!n1~bylIsnp;i$PQiJvrz9ikp^eDw&v;rM@3xL(x$!Kw9q ziwd#*^aQw?brHjfDe8;rpw*P^G{ChRkK`4CLy;4G`SdfpmU9M={gO;t{2)6i!LjIZ8)7|qdQcKSwQ#oTi0BgnbK#@0jhlQfPMaFsltrHZq|Ea7to zm#aO!16Hr+T%`;CFgv#8kZUeqXu2ThrsxfaHD1=>*K>l~uOfC_Q-oV~Qg}Xo7-eci zVN%=YO3fK(@u%G!`gBJqzI>2J*Dkvc;~!SQEzU8mck2u^ae39qZ-(%tD3RQCUCkW1 zWQD(XEyC86Ql@G9JJRek6}l&g<17OwA|}UUi>|IkJ&DD<{x?SKLg_qq&l4w9epXGb ztQU}rd82e*L zFPr@0&KlsYI!=!+7h~6-E5sW& zU*llQ{PM$TX^yB3!?pMx$h~F^!BFq?@f{ z*-J*MydNevd8zgrNMei~=3H>WPnHP~$}w5)|1CgIm&@o=+s}MeT?Q3OzI0xA5m7BK zgSl?va6u%4G_TFY-b!hv|JzeCu~MAB@xPV0*HH;%qME3ZOC;K~|6sh5#YlvS0lq2P z&0Lxi1M9@Bpv&C^9#kj5GoO{TVoM8dI3|vrTdiT~{oUBqB~^c^=2KMZt5Hl#sU({v zR$$-6U+`dy67oj-h-X0>#uSNC>2Pg4dqSdKUMHWV235kzP6@_6&Ifc7dce=a9tG+y zVoSyhFnnGO{d1k^x_BPSj$S0km&m~M?fUp^!eyHGqL(4r8yQixhs5UjA<(e6L{sJ@ zk_nr2NtuHN8PaCyGh;dMsP6$0{t5UJp@W-Vv=PJG=_FR{0~)z*1_oP*|rS$iSGmtA? zL<~U^9~ktJPn!1lYNjJ|!DEbdT^mH$hz5GnG0iN=u)&(2zmf4fKY5QwX_r^Ug z59r0$4`ji>uAD9z^Z+SQAspFt4A-um3Yx9wI1YFSJP7+oM_QF}|GInR{&{Ko!?&C^ zz9}IQYb?R!z6{;IX%;n7A0-oPya`^O!7s~uj@?2+%mBXuT3$whhvpx0yJ$9AsBw3; z*C$C(&IH_MSp@|l%4F{FN_=x@29Reqa6O=gIGYEf+X)4nWM%`^DL+j)Vln1r+@?c6 z&tdPkGWchCfv8CcLe=MBs(3~W;!%Po_Vn@hUFBR38`IJD%o%E|dKM1eU~uKI2EDL0 z0j7R3#kr4!>jRbcwd(I<&Tt>yj3*;VaG1f@8#pif5u?Q8MUr&yMSw5X{yPgM$)}@rp+tJ(ivf zg`bAW!9Z8kzI%)HnsS+@u{p%kCLK?XufW@OgY;3CJXCu);l-^LWKsA%X!C(7TfXuquMwas%!8$mQ6_+WZ z>q|b90Kp9U*5fD~Tq^=M(&g#us4yt~M_DneIoR=RF8w=HhL1PLaJx(;NH5<>Z)e%S z{?dHhC6G__MAo9m`wSZ4Isw*mQ=wwl{osGS47Tb^K*ES8?Q?yA&EK@(Zj&xX{>;VA z18ZS=V+$S^iKbbDb9fcr|B-3klcAb(acZS<9M+LHw2B{tUHf?0VWmM@w4E?z!V1(a zculHHB4A#c2o;%w^z{jS?5I?Qhk3bB_(TdO8(gLVFFCiC`C^FcKgwSGIvw42?1aST zA!6lYfg_z6#3y7b8=$9&FH+0#+Z_#fr?3Pd;s9M=G)#CR+nB7ue5~=dX3IsRNYBG% z0LIC9>85p*)%FUsE!Y8}6LjgUv!QS_S{)zOAH$5;iTHb~ILQ2JWYw=#(h%u+aK`Ht z@reuK4V~@h_`PFrQt&iN%oMHj@D}!NzoDQz)x`?K68W>i^&8a^Bpgpx4h>!gY zI54k}ZX7M4SHJIueYdZY!yXHW!9ibQDZ2%{=0BjZaUsxPx)Itq{?Yfx19;Fl3v*1O ziKs>}DOX$t4$HVXtbZ%B{^LxIqGqrqLG_C1lksUzo{p z@YFuOrC-)X!V!l`YQ8iFqO-zC$IKA)>gHi((^vRVHWTL?PGY_0{-mAfqPQ`SD2_VF zQr)On-ia6nEVy^xb}>a5s<=ZFyw5X@J9FvSYjHTF{))HLeIk(44LJLw9T*=GhWUa; z5ssEAY~+?*NIp*lR_z8^=KqG(k1fQfl@cKMsGj=POK=R!TSO%?6Z)Fn$P(Fq)YJDB zCT;X1@8+E1zmE9AuWWhEew!PG))RtAr@<%Y_$iiE`o00BwiV;RnFg9x`GL11*&V-k zWrFY=OMFn_3U8-O=4%S?Bev^zg4niBIMY2Bt!8P$lbCGk43~MnGA$6i?k%@Z*~J(t z1e4~n04ixR5j2|C;j3M31-!JZ%_?nxOvCo(Rxrm;DZ~3 z&d`P`KB%8cB5o2palwaEbh21K|F(k~D7^-B`!f#>W80B%Xa4-L40<5EmW`s4McxCjBj6G6vK!gv#p_}DgibEI%<;Pl0x^%9Yr2la;sVp{uvB3;EG>CR zj?FrPPd`;q--!%L&ho^Ct2&uQYrMIvM=Ut2O2N%ET}F*#hj1Sx*czBdFQ_0mTs_5X z$ev5QEoP88dw=2F)o+OS;SEIfMkjx_OE)R8*QVmyjWj_-8yh(Gd(c5+@>6jfC7lmK z1;_7H{}cyr>|=4}nJS31;j(dpN>Ib`mhN=2>~YJJ?A&NYm@+t(9dFx=MG1EBamgX{ z;Y*;e&=3i`k&7ETYIsu;OrT}bI3wGB68GPT#@oslsOCqOQ5J2-8+JBe_``>)|2IbR ziysr)J%5RAT?F)fy#rpmGl}HjJa+e+VUn?3lU&=;L*JiPs6VE&1sX%!sGaZvZXO;1 zt=d5(>|X-KvXTy~O@*w<0!;nmgZbA{N;HrrUicp#GkE9BEfUji(o= zS!67Fn2NIjt5ac^>j}ENF~soooy_#;YWBmVji7&aG406xLtFL-;n93OEaT?(507uA z*TOm|KkYW@alB6@H#*TdtKGr$(t14nV&9tvX*3zk7c)Qc)SECjLgBhA%RU-D3$Is1u~Nr((~5)KuAbJPz)M%Pn7fb8a*VxF zGXL}pvf!sWdM_wK^)3N&?%zGEDUO8l8LIU8#Pq0_QPm{OR|bv|NMJ{J)$e$x9A`@IyhInjJxm`(@$bsXV&ftBMYa_d?MDHzrS<^K9#7VO3HhHLIM0 zK2I{J>A41&qbP#wMdPqBcM-%+-av55bett8Vm>8G47AG4$<{m;Y`t&8_uyT4HR}S* zI58DWhtpBbSp+}Cf222}-jei#`(du}H*h(04BV4+ATM!pu&ExmyH+f#-2VUMdxCyscnXU2@*x`}uaPhD`T(pUTx_urvcgdL9wOV8auE@c0*G`=4IG+`HvVlA-lrXRUr4DD$ z>!aq+N+zQ5F1a)k2KrC+naNuusdZ6Ft(AQW@wAjPSGu<&7#M-A z`#Jnt@DQ`MPbJ$agDrO=I!_~=|VnS9-+S|`!;nHq+3?8^Jiqm5jqfi7j9;r5JWizK@rj(vcA=CAv0wWW^(EsML{1exx}5GHj=rRdICh zy+DkyIf_;JSIMh88r&Un9XxCr1l?MT8x?z~e9j$mQ2Hei z_wZu&Z}|c@Qh~)>|NRcn|@7CNxf^` zi5uQHJ|>E9o~J-tsV_t*{iRQ!7w%`Jvvl@*;<3#g#o`~~t#zC6$chvw$D z7}cs+UisB}Shci(sTLT-SNdO=pf%TsrCuLqSq1ZL4)v2`c_A3$+st+k9EG(SF%aeX zk@ezc8{Z_3@dfW(CBdfUP<_&oRR4KOMjkD|QrRjJBQpzy%e!bJj54X`Z6M&ACx+x3 zg2OFonw87>Sj3`m+RuE5>zWHqttl8>9m7a}xkBx@eMaRpWpZNAGs>%~g>|WEG`P4} z{JG_Ax^6@Q?#w?+11em|cds&><>kp{zK|hFum0nBX9~DuG#bs!>PgoZ8QeHe8Q1gg z(at}Eu;5@1<{uWRPaXHC6PA_1Qxgj;5=h73f*{n-$fsK2+WeHY)$An44(Rdo!C$JL z_;?G~|83Tw+7?H+`Nmrsd7OY3630Ub*&C6FS$%?ws6m88=~XiCro+pD0t|*(SQh3I_cXuydC?Z9cnC|2H$6Iq}7%Yb&)5y{cjmrs2xhmcnKtZ{zke-|1=2Kb@8JP#luI5W|V6>g&_y} zNnEoMQvcT^V~Fcpx<6nCOcv6mS{v|t9_O|*+D9JD3Ls-U`n(0yzc2Ev)XfD{^oe1Vf=;}SG!D)%`=0^PzO0Jvh`hY)%5Y<9Pk}hLAgK# zv0KWh_NBZ+WRj_ zNVOD{?+&KzcdLnw_$B^MxolXzTb5eNDAM!>J7^O7NyLUCz`;WSTg8&t__v$DSCQj+ z9XW;{LKV@iwu9BF3E(>Rh$&njd4=0`O!&2v$m$;lP5VS}-Jy)TE|$SNt}7dRMiizl zV2MT0LgwZ57BW%z90@Tj!*7EViD>g~-1I$$@Fz;vOSpDcUU!S8)7|Hi6Z3^}mv(&wfT#P?P@xYW%ddbbMMk39y& zyFrRd8yk}m=U{j?n1m%^q15q6I83hNX8Z$V*m!jvMzeWPFHW#LdhCc^~n z26P=yBQvLJVMgO~!Ya&R&7KQlN6;pkl&Z$f;e)Bwq%iEWp3W>*+lS&RGPvk;6gusy z#tmF&ka^9AUkm-A)-fKK$N6|;Ek`+hcWA+FxI^{#a5=8KYp|s!hG=V?)4{645K9st3Kp(e1%;0=Em-Z}z zZ*4n~x$u(g{SZnPeOL(f?_o_2bCq)n9?810h5WdS#$&rM^TTc<*Q zX8j=UuR5rwe+5l8-A4|kHIYqns*voJN3AwpARgL|plMJ}uUt1o9d7SrWfBEC#Xh7q z&4BLsC_ra6X)@|9jrdcdit>hfXyKqL>R0+;#3q1B2aZFqY%TT~aS2DgMI`LP9cpnw zkzVMNheuqdQl?z6e*53wkqsXHSn8q$`%HZ41;yp?Ve})NE^wbQ<`}9PYo)=KdB{#{ zWYF!b3_6W0fwnSbSUGKo%+&FNcUIeQ*y14NC3X=7lkAlCNLE$;aTfC-1Rtl{^$@CHu z`m}+lC#dr3HrnF_dmlLPIsqJ)r7^#yh3R4gIU?d$$*8?Z=F$7lX`R()65DtZQkO}B z*Dtkt`H16U=wm^L#!WeXfH z$>}$Q_Ul3ztDlH>C+Wj2*>@0rD2vMrYaz?3u=)??!ZV$<$Zy}z{J0|zN>I)FEILf4 ztK7xw6Vn0^&mB~|nyw(Wa6j(&@BOmr}eMR=O ze(14Y352~nX_&%FPUzlTq7cHnf@6@T@t0-Z9SCc7S*HH0w;?03 z%%28-2_%C@xOse5G?b2S<6Z9BfxA^@(C(TKmlk_%bjFTMx?MnM56U!)5jRM(lEKDVm}A(0c6A%7c~HoFUO;2+p5b$oxHg1_tveJYPHwZ@pw`nbtD& zl&_-#q8nM?Tdr{MuN55bng->cCCppCJ*3IXhKy;)5B76VHa%Fi3L7(5LiY3wY*PA2 zR2ve2zeAea&tKuPyO!Xjd;`3B=OJo|AO7%a!nK=1F^J4!|1CU5)0gcA)g4th@yI{2 zOf`b(T5e1SW!}=T4$k4`c9TtOkmfoMNt_QV8?^Wd(B*rdH)};87Nri569LJ1O^jpT zKYdQ_>X-2Ts`^8~4<$J9?;LNQO$RZS9A<7gT;bP7$zemO39J5nDjJ#GqqlpUN$aLh zHfS&XSCVyMkfT52TK3L*|hx^`O4H698RfHF-w{v&QiEQkva`yYOEV5Y2 zhdI>QM4El~W83`eZ28aycoFPD*Bq^*MM>qm9Y)38qudyM>%JP0l*2zAaqQTs6TR9I9hyR5O$dVB3c=*sawY*cIDK) z=s6^f{~nLg|7JMA_|pxzakV!_Yp(^}tu0W&FMutjjgUFpQ*foAcn3Bqi0$| z(M7+D{0$Pvy#oZlD})m1-Hq_$(Mi%Zu1o#DKA`ws0KX`ofcerJNq&z%HZ3hC9y+S9 zY$%^xYu^b8_7mWtX%&68Vz{ zTNKFn-^(UXau;Ec$PcFOpeF90nT0b(P7?*0P?QaOPd0J<*V$sWjL8=%vd3uz`X{cd zv-rruC7R0*QFH-UGKbLTYoVfD4St?zp{llCC_2BL?Vf!UCJ0)9`_EzWiEQM0O$pTg z+#XV4ydSTtEy0WyW%@~iV}s|1z(Z>#{CoTWsZBTxJ<;!pt!NOV(sz~wJo&^la(9Qa z+87wzUxu*@LP*-kAk~VxLiDdSpz5uIM67NPIKPZ1v#*pO`!p04f}WBE^35QtxRvKD zm_(L-J4B7PekONrABWT|K7Z1#(4O@^Ef@B$hpT&)6TdAsJ7CF?OE?osoQ!POaEY z_et4;<=;BiMe``7ZwHB_Kp`>z>P5r$JD|H)J}>BKJ;qHpsxP;jfR(Nvh~Up_=z2^H zd%nyD#f1ytsI(DPxMPCSZslCxe&f}VhCB{E#>ZV>gaPI&~nZJ+Re!G=+W^_P;)J?Kj?mEQJ zwM31X5mb1426wMdf>{GIan?$tlU&1y=(%#-Z_P0iY+sQ<$OKbK?hYS)5DbsqM9CE% zxamMHKPBZ7@fB`|sz*F5uRBG;zyBeU&!-@anqxobZg)T3#ICZABb9c5{=uuEGxY?~ z9D77nrupH7BWEB%T#bH934>=Mn_$srU(kQD5PHqSLH5-}Msac-%(dAES(+g^y1@z^o2pk&yMODkMva`kRW0xm~ zIlkcso<_IH6o*1e9ZVtg!&b&gC>6ZKd}vQwHi{H(Cw_5PX>ZjX{FT4qrgKxV+J;7h{n6GZnWpxE%-uA1bnC;KOQ76$KmVvDw3uhi>>A@P}R7L zrFp^hD()i2$*g9VF_F<;e^9#vW87G^1cJr4;IzaP%QDWYHR^sTd*JP^`$GeWv z1dD>7G|gLpUPw4Y(|3h}(EB_P8_|Vnx1-4=K^98cmrQ(356}JdU7GSa5#6U)5&7?9 zbp25``kd?CuH9?|?uN=Z`tlt88zPNDR~K*@pT%_5B6pD7@sci+4I*+n9eCr-ch+a2 z8j)}NLEH{X!;*$1LWjYp+LKCzD?{Mh%$*ope~eB_%prRES=3ET0z#^y z$*+?1y3Diy@DhmR_q+bbRG0V@WyKWi=6|OBIhQe(renN#Bo%mki!a;sfgJG&fOClS z$}_+;|1l<9HlFPJo&%6YVA4!O-08m+zIMg3^``4M*AyRZx5Sc@7vq@G*U{hp@YdA3AV7`E9QbQWN!g zD3dr1N8jqR`%`|=2H!K#+S|^GPyd2_df(`0E=!xib=`EOEK#o6h^(eOE^q!6Hy;e3 zZ9=cf%sU*j_{Lij8#V>J9z}7k)caIpj~hCjbHztvgLvelG2$ExR7|L6xs3(=H(E*O zoU7&>Gh80hdtEK@0w(P^EJcaA4=%pN%) zo!7WKFXv{~Ispz()G^Dxh#E;v0o#2a2$Q8oqMz@?<_s>2Fnc=lt@nys24*L54pzxxbOxiJ}?WzWFKwscZ| zycstzO1SRP1=iDP8lLAeR!@a;akohrT28$Nk6KEJiv41G{+KA*_(no_y*rLQde3_0 zOM>jgQcw*ShsldC(63|=hmSFk|LX#Lfy>0PM3O|QOOv&i)!NLNyXZ*ROY&Bcb0Du#2g^BeRB+@f{^a;TYD0Oj&0;H9f4)Y=_Y2hL?EOm{ zyyp@(weGsgqLZ+1eln((q!XQ<>om(?K8D{-CLQK2Ormrw>xQFd^|B$mrVi-L!enE6$QDDYKkgs-x;kbip_topGOa>kX2!TAE(b1H}2 zKhez^-#UoD-kC9)75T9K+B>rN!7jYuq=zZH+K7)#B{ALof$Vznff&~)fVfT=IxF2^ zj|W{5znL~b+mlbzrcL+jDh9lGmlm{8;}|O}91eg*8D(tBTwUB(Gz-&H^y+0ilUPyp zg%FkIi$*6j;hLx#tH_+-=X-!j@A|HvIzqA4BIIk5wDSaeF0+P*O5WW$)+Q7m{QYN>envDI;1W zCD}VGN>-UEWHgL(U)qsWGAgBrs8rh8de2|}u1jEF z*ltWXbp+DNi->^(57+s7qme)@M%HdYE$6Aoe4k(}|HU*}@g+ePa(VT4%6KF9E}ijZ z5y$kLgRM*<7`R;EGJmd6w7?fzzlDR5coq%NR;RD@#>s|^VW@tS0OML^B(g^aTSV*O zoTfCI?lFSLY$?dy*-n4zOvBoG&h@Ss32V;`(bahx=y5F%LXA7fp`Lm0$s?6)tunz8 z#R!;tVk-m~^3fVufo1{WtEBg`Q1chfOLT*w01Sm0;hU;MlsEnwQt^&uM}>!vy$4E zA~)}s#bL)2gn9dx*al~@77OiQtkD(E7ucD7^kX9>VNIPc5!23 z^G$|xE{ zZOS5C?Zi*l@oLDEwR>@a+X!0_p95)1>vWCW+cmh1!WyJnIG4%$M>ZSkU@{7f(Fyrg7hVz>oxpWOBrB^`u z$1T*otqvwVl%X0Y9*{3{2s(%=(M&&}W3tm%Gza zM^#dM_%o#k_F=}Lh=u<)ciy`|H+rH;7d65Q==zJ?{i4qojO~mds#QHPW?>Zk^{j=z z&Z$t4x2!*lmyGm3IuRsB@ycjRtr%|1M4mE2Mkd@{q_) z!RT+B>9Q}^sPU%DY>%NRIBUC^PyK3uZl}x{g?+mCdy5K)KH}k{qI|I6W`w5vkIAOu z=kzgOGHS<}lLIXrBlswM>jp7i_XAHMV2JUK?4ezoHK=UA8a<-eKspX_@8+m*AZrK6V)YQX zvtJ7S<`2L-A6+sSCV=Nk_=uuoJ`oC9$!gfgK!yHheD&Zp{cSQ%J__1EP8SbVc&kL7O;%lXOq9k#NEiX~ZIM;N|zcfn=a(dhM59d9pABC>bq5(z0ASlysR9TX&* zFN+@`T%=FZvpH@lZ(<~iVu$-c1af)DsbC)4m1WiWuPuzDE_X&##( zS78&LHaGvvM_y;1|2zQiI+lR6 z;!$`xp^xJ58du&qO6r7FQDfpLo{Fo$cK@C9`w6LLsqf;XPFDcL4(p)z$`Lwd+Klss zUpLPDZ2(v8;_%FYD(&^eUBB|6b$u#x)1#6G=W)CD6F!;9SQKK4 zXvoNA$owlxNbDiJvy9tIo|^*u_4dQ-Iz5u)8%e@XD8kc1e;8bO8mt*l=)F}7v*!kp zz;-RT+#f;apSg>-&TT|y;soY*X`@r!Y+%0FfODhg9T_}{y>>R_lhi1NxT!$Fn%i_MT?i;ics|o2z*E&8 zz2O9ue?1Nowp`bG>@Cp`>q5tKZ-@+3^X{i66US%vtpBeI^y)2P&`YSnr4DQ0Th}z^ z2u}%=ws3j!F-3sq<#5SElMO0pZSs;0!l{<-IQM-ZQHYxbmU%ABhk(yy;)@R!Ic{YG z>kME6$DUbvzm;*C;e~=RwJ^qeO?WpM;&xmfLJqh4?E9NO>GjD_9A1ua$$Wd?Omrl>`u=fcJ*qF)h!-sP2BgX4iAqpnruS zmME!UZMrnAI@b%m&D>q(<`R-6T@F3sgCtL{0hSlZK&6TR9rpW6tV}OrjmA0rr-4L6 zD3`J6S`0tLBcPV=Bz5yzjgKW|=;ak&81r~3Ju_Vx7ysMX)HvlK9y_Q^uCgcaxyd~0 zYP1aa?1Zt+y^gUs5{=;{v24Q*K76mzOnhA&X`rqEb3|nhefm@yUSHqF*mzar_sy~} z$KI06_kT;a1g8K5RgvHO<>*W4W2ovioyZ<2#JGf~=-BX?SX%2qzX{h1Y`IUfN);0= zr2MdNjN_G$18|{uXpQ0+@|wv_vJ-*OCznm#*cWtn;avO^IFn4ayTP1nTm;%|J;)A~ zLC-oRTsf=;saDo7hx2nQalbEqqat{|OD49T)??$OZJ4?s0(XBHqZ75obhOBrjD6InUGBt^C^4$BdJ!Ognq8~$)U36jIR27c8QuS zwuNkj3kPn2r0H!sKgb?BIX=U#>kc5p=S82Cih=bDM<{=O0NHrTHmq0<%ecGzZ7vV6 z!+I_%8lU6N>(-FzID)^+bkJ3^g^vFnBg4Uj?&~P0w@>I%Gp=*>>1Z$cvHS=8Cu;}I zPVv;Q<}R!(7=vcT-K@T@4*tD94^qWlKw!}sa@sfvgo!=+ifMC9vR0CnXTgfDtAq&Z zjMct+G}>t{vF^D-?|G!+Z=-Ki>0t%A#^aQ8{U3??;|Y>|Aw-3D$333 zN5^ZIFiY_U+SH#U3gXfh_ZO(r4Er49A5p*sFI-thaGpi&!&~J2gCNvc(?-2ciGk_O zbtvq75!=+b?###ic>cw28UyX*+&vAP!vib}mPXl#bGTS}4%YjYu@8gR!EEz8?5vsk z)FAv7?YB;cFzw^!e}yn#20rT(L;x_>6)xpM5Vk46%KksvcP4M$DW0P*|OLuUjq7*SK^FQ z7qPZ67!3Mk;N8s>I>qoCdE})4VWLNAeJs~kv0aBxzSfbH^EKoU`F37ma2cl7aAdST-ph9QMdIhb;A?C9D6@LpeQl?^dWloFfk|m@72r8oXvC!}h`K z6)Q>C5j%F^{T%qU_ztV|suy;AKSu7kcv2E=jebc+^r5;nHo8Z$K69p#%hP|;hr*|5 zzWq787NWux$)&jBp9~RZElBsoV|sdXBzYe3i&VY0q?;biLwoMKm>QOeOexn> zdm;%2XI2v#)4$Z<#3w3$G>06vxk%3R2sWG9r$FhQA;!~XKjXP&C-{$SHP@4TkC}hZ zk+E0XsYkyo35lBq+h*tDo$JqeRlHAhS6wKV5zmI8&RMwHbPm>trlJIs#XM9gB^&*{ z$xa!5{8y666i7rvHxml>>@M`+nKDWFv*6G{IXEP{f|XVhXfA!Ug?ZMnkH#6S#Y1#G zn3eev%eS(mN~@H{JI;+1s)tq% z-1{Ojmtn=DzSTeJMUTKZE0o?1NS} zO)x%XNF$G5r9mde%;tu(FgK)t>^&XN9Jw<_KHqW0Khpl_Hj}_>a>ImtyGlBpZC6(L?C|D9k9e z28)(RY#OMc`j^s~HSJ-n!|+eG>$hb?N?RDj|L&$o*9?$q9c1n0+^|eT4Z3C1Xvo6( zaP?0bWHN+`6)(l%d||k#p+EyPEy$_XSlBkJf;P(jpk-OY79Anmp}NMMUGne%2Ix=Y zW_Gmp;e2*x!blk0h9{bNOIKOE=~7@+C@vs>Dosh@6d4rfI3U zByHd~O`TlMNG8_v!j=camTXJR96L{*>b<2~f)X$^i;#?>G@jkfQ1J3eB+BJWAB%%&bvwA~*D9w*V%uAx?~_1kF2Ohk7Zf#X7>u*nlcri?h_?WJ`CG=K9v@UTSBmrA)EbN8Myv1 zTbnP=ED}5>)|hOG?ABaxd3%kDsve=u$B??K$Fb9sH^cU?vtj!_ZG0V7ii`HkH$Qum zP4BGxMtFZ;)0tlnW8B*zI(v2w9JemV)278_;@u?7nHPiGcW5;CaR}3fHKD|K+H80# zc9yKQpVq8$qliY-&Z0;7+dw1Y6gbqhV%IDNyxV$-q+S6@c{`=Ka&rM(3d7c=^D1w5>s$)Oh%n~%ReOo((Q9(dyn%WsHTbcGh7 znOzKM&OU;vSDq35@qD;H7RJc?YC_^b7%X{oiu6nkB|lF5q*J)jmg)Rfuv$3{jWt~1 zUgtb;cs4=zrdx8OD^o16Hivtk$JojqEh6{5f_GuwY4S897VadEQUxnd9J|8@=awC$ zW4|M@dej*cIMtjA9mC+oR~Y)t9jad_V|LqV`lERTZoVyq;y*aNgg$?>->Z0Bc(9Y# zY5lKGvfU0{V)KBp>Z8n;D5A-phcKI)bhd8`y`gp$HWj@i3!dC0y3Kh+qc5JL#Y2l(Puzhj}8~EocJ{gdK7j}GbM|lkfz)4~xxdhgK z&1>p?TTc$}=VEVVhoE0`DS18a0Rx{|@=078hRq#t?(s83Jt2d9Y+8UTxcKM3>8i9n zu$(4+xy)vHr^8gf=kzDzi0ONpQPygN>ALoYk~xS~&Hnk_X3M z7I?u+fx1;zlOwCe;r^@?Tr^b&f2`u_KL%CAdfOqW-o6R8^jJfLNFo@WdIfYk?E^O1eiPA;8VeLaRI_SX9MFgav&EJ+-pL{~o>whq3Z+C!U zYzKKc=)z$lB;nu^NtCNROYCf25DsjhE|Vz{YcaxrIrFeTkwZ}=n$j#m1560>0`F-< zjKTOqSHE)MiN3NoDnjc>;|Hgv$6zupZ8b;^0}ba8bc6ZZH}&+!S$SY?EQXT#hS=uaOf#{GDzExWFD&A&lo6IRb}z{@D+CH=X)epq=8=EAA;|D-Kp0fEgZO!1q0B+-k!J)V;!y|9LFF z-~Nr|d%v1Ce7eN6UY8{hXpZx;&SSx~Que8p2sB;yg9MdrST4Jl?3--OtU2pVx4ykf z4CNrdJ9ckZx9tXD$mhhDnUnC$$-q7{+Wh%NPzFRQfQD~PgWa9fy%SD zjHX946r7DAlA%eEIb}E5G9(Q#wz{DDRub$q6mhZYE1J7Q4O<4?!1LUE{I*vI`y2OA zmzkEJAas>Z6PaZ3!ilT4NBF@qQ2}h0n1_dU=8AG*87x#2w`hP5Y`y+D8f#Qc)#?gp zjnEm`!zT#MN&={6RE1-$?fCU)Iw`qE$S0>!y7uXE91U_umxp0Q&Gt1e2@7wST7QvV zNg2V7s~_QCk8qfFaD=4on8Ez!;+1|;g^0GrXt|CO;g|m2Z*mBw#0|Q*J*0Go_+cyiJn5~D!mkyFVJHF=G zI;Y@W@hv)`Ucnsf(fX>;2@^W*&!> ze@~~K-v>wh+Mu+i2+xPtHKlAx1j`TFsC7Udl`nRZuIJq(<3l#B^zp#$!=sF(RUS1w z`<4`p7to=*&KNZ?gZf*gkuP%BsdJerE;SFM-*ZHoZz#NCwrz9-&l?;XsB;x^nMqt( zxC?8->q$q)bqu<)2`gJX>6yTuy5%LZL`bFB*{aTRnw(y!lGCfAxZ# zb2#jkpAAzLo-}a|I=DXS2AZlDNKKCsu@rSBb#2!8ls}K2=wpeWP8jd7KrTp3tZP`} z=7T&lbx!A=1y&hN+-Sf94?9GF^?^J(DyPILe%r9gPKv90JK*^-Bzvnk1$zO(a;NFU zrf(0A-7bae5+0FR9CGB_K7O$AF~Ry46FB3P3M+iB(*J@MgMM5FZH~?&4+a;|h_Z9+ z9Im2w+B^zh-07lsMJ8eJ%-Z-2F zlZ(}O&3zd)_3(xL%MBnOBtbzT1&=I?qz&KHv45>DE>07K@yT3$)9DepaLko8{l1Sx z^9PfQDiZMB)Q46+BXC@y4UdLd4VDvB_gg5v=|j*mcElvv&c}3R*<{(;~UCLM80{5XyznW$2stoHEwK1mY*ll7Pir z^yl4OEb#My+Gm{p{ihE6A_v$*q0U6;TReHau?pgR96@7J5@-aMQ@#sLRCqLq=@z}g z7zb@35+6F*F)#(o%M$plCj$$9b%E}~t8~@xIQWs#M$TU^q?Y1IboIC`%Jd|l_}mv% z;PJAC!NnhmphFJvYc{lIx`&g0%i+A(XXMtG zC@OU9I{PbQCCNM}WUwbtyHitzg4^yRtaM;)QoG}>u#_WG_AHQDUq9u!uLD#DDP&~;L z|69f;m{=r}riZ7QQn6nkXE*@0)rWAMbt|LU+d`sV*VEz~N6|L?KkD-EI4GXF#wJ)7 zk=*6-=&7*)tTwh`_kSIkvJMB`R==w&P|36|xmv1+hQQmFaanIz^Q6|0DMsJYcDhJ|x60W|e&oFeOj5==24% z@aIZB>NffT9P=(Wc-Z-HK;*DK0yM;3!zZ|7ws#-h zv?Uhiw(r6po>i1@TR0rnSO--vMPTucC9r>s2kveIP>4#V5RFtCg5E=uMMQ>z)fEqzBfroe?xN z%nqe~P-^nvC*54^%f6q|*TfU4A@$OSv3ipL$xD4rYi1pWzMsA5%+-*+jis35=R0Ac zt|zCTEu?0~`{??-O8WJ|Fjz@zlBXJni1eRVq-ymCh+1I^bxHH_m(*PR=O2phJyoc+ zU?cMHiG{j%p78o_Hrl3EkgUh2$U#m!*^<7qiFd&r<$MY;-{CQYKQ17ftEXV7<0?83 zI@5yReJZk+m$^}QGl^o3(y+Djm^W@*+@@Nnc}edFkX>2=rbna6$A(&xJzoj`X_t}Z zJwtHFNeW&yM>0E(O+~)}IsC2L&30Y#1_9m)lJ6u&<_b_F@H zTEXiLm2|nnQn)kZjUmE0STbBhhB}TBn}1P_Q$P7o%dxvq82O9%CFM{F$#7zCe3zz~ zErFM7+>v!nqX`W+SXn6!)$+z0`CfJre@MgWpYGC>p*Jw@Xa~;v_vnXxEin3{gPC)3 z7R-%|;X=vwo))1d(?5``Wsz95?+~f3jv)>A&f})x?MU^+$%)e( z65Mz{7W^lN-rQYfU4AyqHTcFvzdQ)7@sEhy(>QW5>l9{XoW%M1+;hg?gbtSPfWH?O z;HcOna{t(FydBU*x}y$q|C1=z>}3+Y!r0KeA6G%Fqy<&m(NBAmg7D>#Dmp02z=gdI zF!)UyeUPj494RAnXWP<&nJr|dhB&3$jme5Vi8Mm!B{^MCMz&@i$67T{+QMli?Qh

m<5&=omyhra_KS04>>`27CU^fc4eOU?@|TcSS}9gH3JJ$&xxSYqMzrV2BPPL$KEe*nEvrwn&2)+4kGHmV_L+7UF@M?J* zldm@mf|gObu?)l~=2h3tW+pIxZ5VJ^PecnB9%hT^o=OgQ=XKawT%1KpNi z#Pbr5Q2NP9^5T~fsLmO{)5|sSS9g6=yYV{s<5`cf9Ny;%Z-~AXUW*fkkI{E)38VyE zhdp;D5s3}9^v^^dPW?LvFD;E_rT%u4>jM^W=Sl(op|;Q;Zp@V51@!v*70-#s!g8*D zE$&^!oUHvv>!xa<(f3R|d3g(YQDsg$>_Quj>3^WRE|S2x2n<*)O4B#klSiutsKxHn z@YDY~hUk7KVlwUYF35tH>`rKnI7XdR=9A4YT47^fDx5D(BSz+TNF5h#`#O9BL?4u( zsnjd>$GZv=ll+Wc)a{_2i*C}x<-1_r*>>GKXQ@ zrKE?RDbMS+H9a8jGVI~GPYf;*TTXXhZ=mvLxk|?DN8Fj>BFufrtVp7IGAE5#oUkWr~h1bQB~XjAlPy# zd%?;JliSu2vjwy1@iPu|OvaQM$y|W}>w@sftze?I!VZtc9>>R>S)_0IHtcO$hL2+c z(dq0e_-pcvSFR(9*t`=&Ih?_a&GQ14K7GCCZ=9K%4vl zSU+ti(OQy8Qu!KjwW=rn@wGt3ZP|2brV>oo@nk+M;S|A3C_RzzglR51$qIaKgz%6O z_`5IzG_5W0id8u@hIcaMe^V&$9YuqqE;Q+c4Fm-|pfgTcLGbTOWQw&iDNvdqdok9odCPIFc?}bZ+>Qv8MgAJS+f&OYbRDJbAT9~{at?jPS z+NgSN9liz1vM)mIq|11(F$JD}w4nD&Qt7=eQBrf;41efpLyXZiTDkETJWBaUjU#3^ z-~GU0UlWegMcXH6yuvK{uwf?ZdUuo4+__@qw+HkKhcERo9>&bwYvBRi3G)u6Q9rwC zoIPnbZW_NylH6uu)BWAVh0mBbJaP;^@YLDiTUMa@sE;TVDzifKZsYLbKnP^GI6tPs zhTCc6`+h#OPRs)PZ7Njn#}&M3ISGpGr@~J62DV*?534*9XkT6mm6)vnZ{u&$?QYF< zMwT0ujb4nqjLM)}d?~!JPDbB|a$;p7j4#h?V)~M`q-y9j^L9oPvtOfznV6qPv)jMX z%=tZ}AtQ_E94G`;S)S{9aMU!}u{9KYWLNx%!Rhek|kl zc*tXh(^c*cB+Z>6wy|Av{;^-z?x8Pgt?`ynGCZ+#Mv3Kz$hR0nRLF3LrLxT&c5gm0 zbelzap3%gpXOwLD;s(lweGDk^(+2g+_(?#4lst$cQ$v=bkx~~mO0z>oNf_PsY$wN8 zKExrVldm47ctzUj>P2RuDGk36ClTU|AcG zDPt|rdf^nTtSrRp`YgJ-qZB^zS2A(Z>cBq9B0+`;7$rWf`Q|+n^qa3n#DdDmoQw)O zvvmeKNBN-j!7QSv+J~XRju`!UC*2n^i4=rMqv&ljv6j{rPO0clbL>x$v+EPt5vxA@ zdBu`R=5#{}y0vt(sT+OLAPy5Fc__O;n{e?{9M|0srlIP1PbC~A^~1#COI8S@4mlCpl3mha(*$e%peZ!^r}9q@*}vOt_G z&`o}q=(1~)SJ2XYH+)ntViA7P2Ze_1iPu&UC{UjQ^Kxb3`_?Teewb4MsI9^!g0iq} zu_AP(l%ejuc=*p#)FLMO9i8`!tNQ;thy9{9WZs}Q^nZ838`EORO~wnXchAI|GZpY( zvlTtDUmf(HUWT2QoN>RdF4gKDX1?neQm0?(#Bswd`uFB7Y>BskceZz7iRNL<(Tl*V zp{`_|?`0GmjE7zKdvRe_EOaIh5S3$h>$Yr9fQOS$6Hoc))X3y7by;r)9|ty(Mb6JPNa1-ZI>Ouom_nW zvHx>s{UJRx)Z=uOJGedFp=@^YmI|1>tcYmZ?4+)xmTYsD8E^eZajd?zmPwsiK@M*- z04J|8dO^biry4w`mqcxu%WWd)xc4uk`+FVa9Qg|$TR+q6ct?;Hjm1`tbm+{T1RVlC zFyq!2MqxmLc*h39gyLRw-S0wd{50tIKudZzL6fQ#R>RC#N)$6mD4~a=nTG&X6fNT1?IYxF?5w(!^NhriLSwIdhbalR{FZ)rh&(_J2HjQ zUU7_;^RMA~JmTg;T>_9XoBw&?h&7z(d6}>R`MYy4f$kR>FzHYyc**q zGG6HqO(pSUPH{f*K0KYO-m)NiQ|DsN`g81*T@1QlB(>x!|8J%yVXcQCxfQ*Sgg+5Q zgNjp4I)Os8abpBA)Q@0qb!MW7O*wn)%}2Od=}rf7dr*TtLfx0<(dA#>q3hCqMld9U zh-Nf{!0kIte}nT$r1v>g`5wiLa;xXlJ@@IeL@o3*UV>J#KS*?;7}#I!;8w{>pfydH zJhKbKo0TWAdUFo!SARk;)nCS{>&oa+ZqB@kpF|rXUIO2=d3cQN?uqpN4FSaSuPJ|)u`c_3Jb{H6%8FSEd0B3ze(lbOnjVq4`afP&;ole+Q-ofa{<(PR>B@BVHDiKK#@u`4S#%qg3uc} zNh*+861bjfrG+8Qi==7;n~6!83*;76u*bfhrbW~DLs(fXE#vCDj}GlZOOGvN&7WM@ z^B@Rxb-0hj%JC$FNRy|PKt!o7QyCQ+1^A%dNsG}QpK?7b>wclFulApn7LbE$*FF4V40`^ z4hm*c$z!dwXO0V~ByYhxGY!d;te4m;n~MXduF!WQyBN`)cDVebm2MsLW2dW6LA$s( zm^)a=Mt;kID?@wffzC==!FLTV6bP{M#iDTR$~o%L8jE%z#UKzY!mLb?K(9dX33Awtjr8&GHcC4SZpZ|QQRHi_{I`4bKI4#FIo+OXZxY137NQBGjv;N zNOV3GqOznb8?I{$2XY4K&ugnOzMDe;$OxKMr=KDYE*IFXmZhvt!E^G%dLzzVa2?X+ z6cgAQWpuq6NS!{IfUc-094ja%CYSiJ_|iL&PrLx8{Too9i<|#2d4sES{*gaA9n`nm z6OX6ufx{xZm^jS`@L~I8KrCt2&Ldy`Y=xuN zwHSE+6h>+lGX@0(kmIAoX{HHu={*#B^|0%M~ z4F_oZjYFtaaE9GnTa60aI*2*96LQ~ngtnK|kdfyK&E}5lkvGR3r_BFSC;jUo3;D+6 z!TRa=&_Wud<;%&DDW0I4w29s^oQmF`k^D4prfatqkmU*uYqrODzsq6!ED@Tn#t_Aj^KLiH2Cj$#`Nc_ zK(omxH80GjM|(LOp~(+==jN>DKZRM)={`Wl1ec+twGylClMa0HA@o9wEHp}bQkjR( z=n>Q)A8JmJ$dpB(=lGft`YKP=5-(DN&M+?icoI)>F~#4vUBP_10_I*81aB1`+|ibR zMW~8y4bqr%>nWZ4HH)4vkbqUG1LRFc6+X8;L+$OZF!2%5ATDu+_7WC9Xj*eBTPtGs zISY+TSaMy}mqT7mAw~tZaQtE;oQ@R%bID2AWth(FyugxzK`zSUnTWK!n6%y3gpnn# zSotXfl~-;+taFD2CNDu+B#;=1NrB@|AD&+2A~@9=i1<#6bsB!c?iD>tgJc;TFtos# z+qJkkjTy|emu>!FAdd6@nURGdlOXCv1?}10g#mJm8%e9PKXUsRUCiu1Pyeo#0j<81?CLZLSQ7b&y)h9*b55=S#kV2YlVO90 zqoOc5E)n-cm5_VR<8+HWA2YBv4s9ZuX@jx=c6OV>a}7-kb@vENmrlYE>tZTUG83Ja zFQKdd4bjLy6SON~gpQ==v2nezr0#kSeRt{}{hWIhoX06+mQCQ$tTs@i zBLF?ZmgHwjEV$2J2%21-)8ce-f}WB$49|*#(7PrOx_yL5I4-7JdyJvlIv;Ia<>P|ZxI!K;kByIJ)3}r_w;p2`% zs0c8{Nh@T4ZqUGgh7nNw_es;5CI^IQ1|*g@G}$X`!=R_H$Th#+bYD~^_^-&I&gRqc zN}6KxlO_)D(RPtt;rpENII)+}itePM-H*u27kgQ2E+SmLAd`(4GvO_M=K_|--Dvti z9_AQrgoFjEuwl)5@K~%sSB%6FRk^R^@7^?aY^tEelghiK$4-b$%d-Vzl>zn=-wB9z zenwi9UXyQwL*%lm4@4ZvL606G`rjr8vOYeGc)9Flt%|o%g}B$`2wTUi%!*+Yj%tvt z$HJlHUlWRmU1#XOcCi$HPI;m_PA~2H$eviEi0cdTI7M#?HIS@?2Z=V=(5k`BL+x?u z9OLHUnUm@J%qY@peE}86mCz|ZgZ6gY!BP%0@^qaecx}wafp1TEGeZQMhsK>@^NljI zt8X=_({g{f_UJH*OHIbDPtM_|ingX5Kb&CY&d($-E*@U&*TDZ4B~XF#UBu%rAsUmo zFtSDp3SP~{NyRxlZ-a*zvo(}nS2;tiYNUC)Tpz)d>zQe`0rW^ zNjoz@r=B##ty>s8c)SWms{1j0rxfb;e<6xf7_x|qKSwKV$NjHD*}u_>u&%TTSJm*L z_!B8SDqK|8G2<4)f6jvhH;O_+j3bOTMNko@mt!$S&h)Lu-}*~HazX=wjafV>8UltZ zH$z*2FWvV~0qd7!LRE_vxNz~0r!$%$Qh6NsSKTJcv$JWB_i8#hDgX+O&coUbr^(CG zN~Ymw75z-}nD|o>WLJhF6f^1=w^tp5^Niq?IVE4u{?z8xS z@}xxY!J4)BR9zI;l1n7|tQ)OW;BYE`m%`7JqjZ&WDBki+COd2e@Ctj5t&QQgID66$ zoI7LCx-f;)s;(!>Z=KLzBn-lYy}&YrpWV(?JLMu5G9F6Th#yzM7?+C%)u~(H#hMV% zf78JnyD$fe_fDbcXU&HFGozL3favvpqt4Arc##v^%u4CvjlM2Gag{74>&aKLvAzm+ zb)=Jh^=4Fipcmj_HrSNcFxSe@^R_%RB|A(Wl7-ub$9*vIX& zRZLK?|1x1d1`$&^8T@l89pw|n$%^vQMhX47I5#Jseh&XbugN#lrCjbJf1fkNZ@EP( z#ES6CD=~`$wZaIJ&&Z=G|AF(KG)R^1Cd^X{EFUbzkR0U$! z>_FjvBDnO|e0oDDg_#~@0oMvl;PG~8j1oLeZ>g1{qs{`7_v{Frd$5$=%!nidBdai6 zEF2swl!=nw0Qq{hj?PJ-xOH5Ceh}cEfA@W1@;4+SRpklkc0QnZ;utA=%ANl!Es2ob zD4DWQ6>G;eAT6l@|E}n!^B*Cy`+k#xO#wJ!o`Cx9GsyHoZL;1~l04rzgIwP66qTmF zZkYTf4li~DHZ9}I#%_c8%;Cj?^t(hXUWw#~C6ZIns(1~1kdGhVXs;sm4t@A#@-cF0 zjc`+4X&v3RA_Z)I>B6&?8z51UjGJc9qdyOL!NFr2$%>`dn^I#nphrcTHR~3!=mmaJj=EW2z~NlAihG z<`o;_(aPa+#zif%zbRv`fIN5p=VA3o4#O`eVv)QknaXaS1^va@Toybms&FJn@D8DNRuEX&E;7T%;|pIhEFwbX2kE#HU92;1V#2?(;1`wzrmiti8bu z>FLu+2cz+b-zlmt5e_je!F0V*7T(`=5>vj$h&HR;Cc6zTlFp5qKxal`L-$c!urUrz zvoEsWEvMqfn59%b)dq5T=kdM4Jp6gE2Ufh)Kr595ww22HLv= zsE$uJaZ3+_-xhbt#*96TZ?GoDthh;I*{3Yju7D11CLI#`iVbX83Kqj=WX;kdm_@Eq zf%{T$=pI+A{ym*8YyO3gUqwULRVzHdb`HGRdWk$4*M+HvHsL%j2N}70C%msb44xk^ zlZ4(kM1EKS*KSuLZGvj3zwR3~xhRChL=mf2Ct~u6TnIg&3r=ae^g_J?bKZE2Efd;~ zt$WId;pI9`Pt-u$S6`&t7mJ-&5#jbb#yU{9;S$+!}lBWp?$$; zVqtBCA4fxQ%TO`x9~~r>+bNOXyNJ%Xse@6sIs7Sy(P&OHh!)~a{M}IKaRyu60JB*Ljor0RIu*TQ(2dH z9BRMQ6x+odu(swhJj!!tn_o@G8#nZ!=<#*-u5%jh`ZNvNBHLi^`>nXcPm$W+cK}bF zLl}BC20rh&gvzVxnWsC#$U8|3e>6*`>X{-@R<}kZa53`UH@{?pI~rEeM7x-gy6tqLsIO- zf~Eg$I4W|B_-PLlaf1N*yvqXuCx0e!hm){eLkRzcB@vC7ID9EQhfG(AXH57X;|YzY zv>|*JeN>(VmGg7BSxGqUNtDEsBdQ#tubwvkTZ*a?4Jg)q2Sb1DYntE*GM&lpbgFk1 zMjh&4Ki9-##I|tE>0gMSb{--%$~TF>W&+qMC*dNCC2(iuEB0i`Tpqg{>4p2sq*je( zb_Uzv?ieX}6boqZZYQj}y#&v6a5^YIXB4V@Mx{RPr`qA2bX#r(iVD@^eN{2YKAuP% z)Dp;xq8G$J&mEhN7t+6)QqWnoA4}AaP*tP1__Sp`1Q#|z9eKv8a-ZM#I(a163$V+D zg6yC3tYeJ?ly2fa-&;Rov}rCTHkqUOu}`EVKnLc$ScRi~KWVMb0eV}a8A3Wm03-Ah z{_SsOomw;TyU|BFA+raUzc~cqv&}HM-4y3}H=bzGfyx=2WF7UmDXVSyqWTDa4Nb@f9U?-1chFUurfMh+#cvIZ5gZt zy{+rnT}rR$KEuy+>MS?ZxAVZ-DDrgUF0@*Gh zD!sfD^vm+V@B2s6BeD@~bGD!__x@JAYlYL*IP7X*9H`hEg3`BTXwJQN1bb@fpTSUk z-5!T`jZMM2NfL_waCqLE8*%8dI6k;m2dzII@*>}UBKmo8qsW_x%3l4==CJ=bUqo>v~_yx5d-PpT)?ss7a{n)IxUbdA0s$A(T2G12?56Pxk9?gKjb^9QHbTZ~@5b-dKw@2fN=3D~VHLAA}UXmMjN zbLfmNx^Zq7iP-*N5{g&Fk0~zoZeZ4^m83O zZ=iyQx@<^JQ3AfvQKD|^8psayAJkm$7JEvOC06s5F}iXZ4e>3Yi~SChCQdW+| zyJN8%sXq9J=-HdWAIVER6=^Ga|6eVZFXmzwY_-vMs2C=_ZzWQlHCQu{gCp57B&6mp zBuw243h(z(5h;84c-tGKtrW25TRTol_97~~gmh+=ErP{73!wWKqUXmVIGO8@u{X5Q zZ1-#Oq`H+#=Sq>$nXAb|5mE5u>|?HTteBfe=Hq?Nt1{jkM}DTHkmo&b=|E`+@%XU< zg(IVRb{VU5$d^NSBv}}i#|Ge4E`FRHPp5YA*Jzk)BkohJN}C;y1W8wPe11UX|+JpGF$D9QD1OJbS9a3#e{X>d_lJb%&2LRAU27_k+d-# za2*?Cq@Rnxt}FfY)OaBkxpo!b)?|>IGe?L;xfoWgo8UBAm&r?650tB#jjlUa5~k)l zz77|{a+Mww(|t(3lfA6S>`T~McMuf^R^uDPM6k^LM9$4W5#;u{gG@1)inZqzA^Jiz z7&Yk7@N3zm>3SM$yVA(ZPBmd7+jpVur>juKuY~>w>Nuz1UAn6Jkj zHYe%J8D~+rwHscplOgiY5}Sk>9-O};A{w!j*Bp6i9{lIKN53_P-7B$E(2Hxvs@Lxn6?_`28=+E2< zHglg7k2P;eVo?%#DfX7En|1{M%(W(muiHc0z)zz0vzy%7wi}fC4ijl+KOUMm#%N5O zV(&HlB1+R@@FTaQ-F10CBqefq%s2qPvwQvLzLF)YR2xX%?<+96Eu1#20Teo(ieF{h zNNJil7xfy6p6_1alAK~(dOnRTPgn|0TRgCdt0xa=#(@+s5UwwG;`Bg^@Lu5p(w<(6 z9J&dQ75ZS^18ows#T^pzjZsGIB#t~!!++0;nfgf&nB(zjOy{|)@OJPAarqHIO$E;2 z2d{(La|3Qd=AL3YIkK1dyYa)qi;?UX_pE81nLl&p-;t`h8uK~yp9C1F?17<2zlrkgG-#RJOCC?(h;Hr& z*=KILg!d*H_U_DPSH0<=2hG1T)(;oqKlNpJa$XVd{AFF%JS>P>=gH7nFT81}Y7;TO zev3xk69FfilXwoKNZ|EKPHVajPQ84Oi?2-3yC<4h_v}eHrk4auRD^K8Obe}(Jc&x@ z!*OMaA+~0gQXzwD=yZ0xWU59a%rF?#uBmxN&g!EdChua^xqJC}lPDOj zoK695VV#Qs=;Ac@AxEP9|SyAQw8HL;EyNM{Opr^X_3d2+Mw_=jd*1 zcG3N_d?IT7Al+gii{qbj(=GL+#|Q;oC?%^#r%9loaWa<1Gzo$NSi--+&39EtD3+$ zb0t{l$p=e$cZg$M58YtC8590{foG)y7@5lD&{!;86Je)F{@E;`57u3TXkH?0Y}`QN zHX2mji(v8C!7MPOOW5+K19W5EGSteFgP4&?jHTx#M!`si=+wxuF9x6CnGM=_XQL^^ zywV5L(qLJ2&T9}>>TKK)rm8yK2j1eLwY!sOS$G?bDVX4i?<4{HU z$)jXpi$D8so-5WaT8pI%Ua}Qu!l~S>7a*i50iRkWXvxW8;$i)q+)g-6FMJdt8`}*r zx~`0D%(}-#pnKx0Ny|uMWfnYpdkh6$wBw_F;n?_hDT!(^#t&N8Nnx}kPC79{o@-9U z9#<}AciKDre|^u7_h#SwV;;t4Rxc~&*+yc)Z7S{u}vsba!d4xJ)-5_*fbLF$qB zn9O52gfkECi|G@K6Y9r|`HbLN&WJxOQco>61}(;erXUV^4{21vV#;b_4PZs)w147r{~wdT9%Y;px!ZQ7}Q?GVWs z%Y;7vxCgwuN_#BnaOT2 zYG9+ADlj=>7#5FT02w(iJU@ASZ4z%1Rwzh8&e&(#=ZsW3oI~827vi6ZCG>ZW1*l3M2&>Ub_6 zrUdm;%MXGu+8qK9#^Z^~ffh#2%M66gmqV`RHn2ap9}ayGM&TL#w4u}vPIil|)s~5b z1nsSGP;ijW<7Q7qmOQH6aSoI$PLtT^C-l-)Uoxe@5YB(jIaWMqR^+#*aF--sW-$50#h_grtwAx|{+)A$$#9DKbT-$ov( zE^e_V={4H8$$1C0mQ%-#Cl-U%hI0DgP9Z!$vz(`E`<3mL-vaG(w`1^d5Lc&M#dQl4 ziR>Y3xFo%c$p7pG?<PQFjR+!VRF6@;*RJHh*)mwVe+#kO?~L}&(vbT!9@eGl zq3x>~beK;>N36BH`oYFX^3r-H*HgAoha*>DQg=7m9-&HVcWwruy%$k_^C*+^!MCb3 z(vPMopQ82c{9xxlkBsl*;kdIC6jaZIqt3->^S&4A6P(FmO32^ZZzy(R0lar9XTndn z@iv>kXWovzuR7fMh8a4$1oxQ6P`l48IpR65=HQjp=yp&6|1vjAl(-l*^_*o8KCFYM z@{FL9O5@zzE12}l8haFfL09cb_;GO)>L1yN?hkmd@^B!z`$d9vIv5OpPZ?nI13zA~ z(*`taJBq=-lc_-QN%DF6Oi;AuqD2Iw2@dX{UH;t9)QafTI7yht?chaL8Y%6i3YhuO3BLSk z1jnPoc+));-cMda!W|-sZ{v4zn+<{TnLn6SX4WuP^O#iadj;pVXOpt@2zFF;Jw5VT zp3P<2=;sQ1V0&EQTxJQ#iOzt=m+NuV-3JXzPN3VCHDLZdjp`Q-(gDFJV*E)AS7=9J ze8X1!I55a=xcY^a!cNky{+TYda)JhLmIz!v$ENi46KN|#Ckz6Z{MbzRZ`yoVJl2lF zUd1?ct&n}o_mF5RRlz}xNJjWc6fS#ci#MYhLD;{AUVJbIobJz|?|lpzxrQfuS#^J3L^ zq8`w3F*mSqs^tA0dq8eJoQ-=Tc2RP0faRzK(AcF0;#Vn2O6sG#2HePv{CzZK z>T^cR|1K;No}kv^sx=v_zEZ_&@2gk)+@raH0_3B*7ML6nA&`AaCa9Dg{__!53G0PJ=~D{V5+r(24CY$<($;m?!&#CT$$1OLDz&Tj5fA zvD6mjd|#26+xd7!zJa~jn@Lq(3zA>&-QeMiNDw(S1@iPL*88>t}qaO`8pTkuAq3MHRfIn|@Lci6WTzy@t$wRzNii+^E3~30P%wpUqym zjQk2$LI2)x_|?WE9)rD5^DvG0)IS$)Zk!+`MrYv9$x!%Gyq750X@R-wFXCE$i@vVx zAs$Bq*r>NU%(7*n_}i60>Pl5~W`&WiJV)}9`D-K&06v<;;IC+3w6IJ^hmI9c-RnbY zRFm*rSp~Lmy9;JJKRRt#Po*}Vf`zy4GbZ0ogMZF8+`dW!9~|C7$`ZD6=rLJ{+7gLw zjzBVg88Q~HU2xB;D3Yh?&G|CV)6WOv(XHbbv{cO_PCF{`>ijNp`R@+;q-_{Z{_rMm z^Vj1`!%dj@t-qR2!~*{@r6luNFIkxDM^8u^k;CbY)mC!8ApF`AeTOfT-QTr{Oql>2 z`}7R>r>H><*CU?4w4VkGJR)C~uBLIZ2^eH~g+hylPHKBNInP%}R*c&){W`JS=gMJ& zSI;6}}fUGk2eYk|%rn3ABH}^C2-P>Kq7Oc7;bf4plHr&fQINn_-`M1qeV_xrRm7yx%>+957>a6u%U=ClAd$UTLP zYpB>f2e>>;D$l8YS?P(yf zE|eBnykfi0awy$}5s+);M<1wd12c!KGXbQFY+E-G}2Y z3FDgtKiKFz!5%eLhgNYBvS8N<8sIQa3bR%5&(5!G{=FsSzSbHHJCK5c&zm8*^(9m4 z*+7aGbJ#2X7`its1!vFeVGK8Od)M?#a<5~YRGamnxxFLd3o9TVT?#d3m5!)W_k`+p zeJfeaTLi8dTTw!BAx_(_1T`m4;bFOWoVQj1W>`}YZ|bU2;!ruaYwnSl?}6;LfN#`_ z+gEyGA^EG1~LDggQxtFYGOXp7nElm{g)S8!JuziH;?A^QH@s?_+z~~xppTO zVml`5yuUkx>@nN~q66w|`uYHTI2Z*?#0}Qzb-k?et znWUS}p#l>~#HL~puU-Hv+tgw2j}q9h+W``$1McUdn>)q%;NQ?khR)K4UCDdEKdlus zM7Zx?pEB}UMbiH~gh*jy3%(f3g>Nli3DLHIP${+QZmu6p7utqX^Zc0XE;Z7$pqiaa zUgC3=$Bg228)i$3EOrWCMU&$vY4Sihyg1arbbJ*7$upH?MfYFw_dq|D=nEmDKb7!+ zK@77{u#JMgJLI&jLbc9mm~?kGV_vQe5f}J%&Iicil6TyG* zNx{ZJJ*fTBMDLWwVb7i`tgW{a2} zZ|G1M*i^)m-G74ckGl<-TRa2PKAoTf_c<;H&m9#%U#9y~-LdEU7Uqs%5zT(|2TTK(_itxDu(^)z^Ve^QOsWPdth{sd)4xM;gQZ$QJpka{#* z;n&y&5EOJ09Oli%y>+jN3SB_F-;R+UH91oBp{!bd%c7vM?s`T&PzroLs=$eVtz`f0 zWys^=Bo2m5B@6Zn)475i($?`PsCzC$dtL>Y)bPj+=U}|9_mb?obPX4+mtwzXThkx! z3UPO~B`%Msg3g^L(35%uvt8uy&55bdFnclf#j9eHyA~Xg5kZF;oab`W3hw;MN!+pr4J1}ECm_HsTg|o95#;#>5SSBFlG--=vAtW zCNXmOL_855ecy`SLTf-@Q4q}rPUFM8Eto=`8H3!HWMjBIbZtCGTs`lAQPBXgJkf^I zD&CBPr3x>*D+Z2DpGht(Z=maw*5OD_A!8fphJWuIf!fBSWMi`ybL)VB4&Q|nylVTi zq)Yz?E1s%?sSi1P*>iX1!RQp)ORUh!oWYs0$Ej)O7*zc-fZ69qsCbGn5#n^_ym**E5f7kTl0#i?`#4kI`tgZwpr6Rfb{?TiX3jAKqJYk<}gi7(KI@ zOfr+j-(%{yo14Sst`5V5PJY;v{E+UL{ReEeQM~M7K-Wb?z}LhSC~1p_do7x%q+&$7 z_pO6J&+ZWOYvY_YtBf?8y#wVe9hzLE1a4o}P@_5{viJO6Xzb)rUZ+nGzO4^w{x4;) zII@I3jp2}>LD}T!(@0v#^;lA+lWX|Az7V~!4D2=z2KoQ0U~YJF<*Yfv^yjbTHMf7a z(1;s9c!P1#=E!*TFzrhtgCvn<~+Sg?MA1w|W>y6vDN-+<1EP%bk ze&iK$=+ckPbi5#s%@R^4N2{)&^V0yCqGf9a#{EOqRN<9t!vDZ75CoRRo< zmHud)R^2&6jq?I?eR9r3b)e$_X;%I~M8v&0AB7p5`sl=*k(a1h#YNW5|D^;=>=v?) zZo1t4vl1isAH@V=Uo6R=h1dQH!02LsV%1pz8gBVyx`jE+DyU{sxY^-oX&Eujn4lM$ z8%fiw*+ee)G^+50LsM-Ok-XLqz4FhQtusPM#LBriWo0@ERF*=!*vHg3k_Q=ADeyOb zg{rrw=o&o_7|q-R&)%fMyp>wqEI^2Q^VG3zS{}A`|D~mxyI@9t2Nw^s1&@`TX5w?s zFx&5m;WEeH zlN(O=SH@n2bT;$PXPC^z)p%aMNj_vH6N`cu5EO6{ray5YdnT(UNfVt<@9>8W|bHF zE_R4xXn4^kLyjl%U6~k97*&^5@LO?j9a^oRB9$|8ppSL0p(8zj{%k8#w}1!;j7M0Z#i3=Rj=o}Ur8^wARP z9~R1aW^R*cE+%)t@Dt~4@uJUC{-f%Zlc8l|5guKqf$L{+=sL4*xch~rdg)IX;n26# zR;mbCiDnvgG#lb2&FTK8E;#bJ9x8WTK#6Onc(PrF9k5&ub`VBlZ!ZC%O*2SlEw>Nn zu(_kjS4fnf5bS)ORV{z!H;I!_#`^fX+V{D*x(@Rk@}`B;_#E)T&|O?4XV45>IAIEF z`fi|-OG1$0-qpC^UIXt^PbuA1)JTor>#|P0gk=3ZMlHiv<0u~kgP)c`t4AEZ^UVa& zd@h>l`z2;dKc^$;xJi;OSYgi4BFOd80~a?I{*&KK-B-@QOMDl|uFlVl-EbcVC1R1U z^(paj8Yl7vH$b#Mjr#V)L&>sd%pO&FtX8+inYlLTDXk3$w*6(-n{DDa4)5r)`YCw0 z^BEqC7QtuZCa4>A5VVFkZ|pg5_!KD(V$U0yZcw4co!Z`E8UNG2b>&v`#| zNrUnrYa7-~ewqs4-LY&oZtgUE;qjCvO^?S!zX}>Tnd^sKtl@0hR9I05f^bjBz9pGeR?bp4rvN=-jpmJynRjc zj^Cz2G#(U$4l(I(IW*r?JM3PNK+2m+agyIzM9Z}VgVz(yb(V18^8&bgzc;{4c9a;4 zBvsoF?PC&!AJG1n%P~s!Dv7HQBHNCiM%}QjP@d#N&nAl26km3Q@4J$Ktga-suUv?a zQX)Ace}m=)@xx$}7*L-jAhCZXy1vtfxEu4JaFrVPrpck|#70;-zOUxRYT+6wSs7G) zcoS>qw~$ry!?`>6JP3BR@x(F;xw-#q;_H5oG-h?s(m_tMUdu&!GQKcLbqDU+sR34( z1<6wPjkK$vfj&}IB*p=!FxD%EE-m8le+h1&aPSO#j@pGAo}QtikG)aoYdtL8nhy#i z6IA$Zl=iQ0t5C|iyt;C)6j7bz3Tkh|!A?j4yR(Gxs=XF0OyjH3JhugewnU*@-ZT8* z7ep^^>ZNm>T|jgDGrT<-34V_kXxqk%!w!E1Fi#4A{`IF|!l)KE*Ulm}H!q`gq!)e^ zDx{7C$UgBfJk_!k8gDqx8*m4({K}ea^TJzV!{ncHFzns7h0Kg{pdY=5>7A-CWPFD; znpwHx$$>_e+3k*7OhqAEGy`n%-_m{Km*8xP1UYs&30BOlLdOO%JQi-vDr-)M1qy@o z`<#4a3~lM*i2SM<+f;QPR(+-Iwf9*+2@Z!n63WG;=OF!1L%g(PaJqvxxT?qEhr?5A zZmznh{c?ddE;0W~1!MkFO}^tKcc6hu&6tT9hpu3;g*aY)b{ejpwt)X$`GBLLE9~jL zM@2ViqPL!4O`+}~ZcY#gR`Z=mPi-B2Zs&)s28YN&i#8~m>5FgTOz`zpf0F#&4&y)M z(W3$()NolgOc7hno|_os_H9C--5yKERpc@AxG-vk22#Iosq9>h6goF&7?z~&B>BBd z;hf?kV97na_cxg=3wp!ti@CtXxb9#%*TV z^OGadY-s{iU9m^Cm1<1!%E|OZ`xJP6Q4Mmf??9MiJM5~L05|3THHX*2)9?w`!*8rm#0@h=pnZu3iXTp-$!{Cc__+=7C_l^w zUhg6w7VaZEerQs6<0_c^RuN83ETs0?r8xDCA`A!yRL9vR;F{_Alv#8ahbH|1p-X#+ zuiPWD=iU-D-!;HHG_RFWs}3TUYyMHU54kuvpaQpxxgBw$2D&JF6X~>@JQtZ%a5gB0 z|5lXHe~%lu_^N2=nZ5w+wK5s~tx6c>mPy+()3euhqPIVBXX zj%qMG8#_>MI!&IMl+eFvomB2iDH&QK!tPzJ4C&F8>~%W>)IIdK+Os5_T(AY|x%d+k zDAj~6?PZL0<0N9su~SEU!cm~ek$uA~#!d}R*PFMn>gYvL7=P49yj*TT<|9dPA6*A` zu83euvK*K<#?f!3I!HF&!CRV|P})_2O_p>B*8HD=pjNv&~ zpGYS-QmGFSc;xlCwnySPanb)ue-x=AU%*a$pSXy?(fDhHEADt<;Ve#@rsWTP54pGZ?0Jal-_LF|5T*Pe zL&oYpFq+Qt^pD{mvZCw{(p5z}>to=O90$?2OCY1%6C);Z-U`bOe6Z#c2wa?x@|M-KDM<+JV~)eF zu^968G|Ol_oJGDUspCo|E)M><92VXAO+LIEqdgs$Fg;fotscF`eQC?d=KFtnMRTg@ zs?m~ajSD5z)c6f=x_uM!dE|k_qclvQ%OJS%I{V{75~J-#BG<5Ta370UdSUm3_? z{Set`v>N!0XfWYOi0q{Sn{9I4YEPPj_7earT(LHos34l;qF!@l&P!!uUzTrz#3V1_F>59a77$J#M?jaJiw;b&qIz3ZyZc{7U`S)EYG zla9b-#a7(G%}A}T#$%QLGpx7e>Wnryvg;9dR-+2>%olFv5I>W3i!y-q(hni(jT=*L zm2mSNn9t}?esPsv%^t=3xZtdZ#*)1HV{AOUCkSTl~m8j`_bA*2Q;)1~~dSuFX3%vXLDGE4zfOC@vtM}g7kNmHi znULIrD0BQPsf%5TXKFR+0xt4!_V)@d);5?KE9V!!E9eCq!-D8$(kaLIZ}c8$%lj&Eq;srZyuBGm(SP>%R}iO$^CFae=d5| zFJWd}mxZx@M|$Zk!Q&5pV6(|b`c}Px4l8qamv#j+8Zuw|_U-p@`@AVGU)e?+?Bkfq z`F`~4H7$&rz6Pa-6w&E4C6D!r=-=b^tfSXfR_5U+99~lb!~GA)l7XFAbubUyj=ZO< zw*KOUwT000;tXkhe+vAT4^h|tAk>q7iDi3tqaBT=sT~(Q~Fd!&pH6gg{b}{Z z5nA;~O-Fd*5;$6p(Vz&HicWB`5nr0|FyUfs53QoU8c`&3;2HCu`&#@m&5^VSNuup= zAUQ7ZA6AgP>;u`bW(*pt| zBbkdp(?1Il=TfQcc0R1Ul27`!A0@hz|IpFZr%-V9ddT~miihuP0{ewCxk%NM_|!jv zi#p)Esdcg_yhR7%PK?pg`LjXOMFAZIlwoGFFPMBT=c2zt;B~Jb1XxCZ=fVs+&qs$H zeK?6E#P*V9wrTWx=V9XgV3>AVjFZLllwkj|{jjug8oMo{fZp7$2;u^VA+&>=i++xU z!@IxIi2gF_{!dWn*8>hoU&&$D7fWISBMug~cv#2vLq*GXW0<-m1IPGvyz3NjV8CABj$QE4zO{ti1bDnsTRHo+a+J}|9!YH;?t9J*_6D)}Awl#z6-VhUo7 zNI|+2^l*LFMY%CD?{gCMi1|aS6Xwx5cjrKaVlTN}XajdlMIil@8Zw{#*qkEHx6-u> zs-GUCaiJF&ejOE*6n;cRsWh$o`2f=2G@^xh5QHD|Bi*Z{$opUd2aF~|nNTq!R&)+( zO(f}R|7!eTSxFRo8rV}qwKN=mP+bcb3^%@m*DpD6@wf~POg9B3iA$tz)+1c-rVQ+p z+~D@LT+F^X4YS65;IvZ_`Fc>8xOmS4@8($2sGLFXG#TSH-7_Rhznq?2{sAl9IiARY zW-8LZ6kKo4qD$8sF(>9(kl@Q{uzhDD_}=-1A6}Rd4~tw9?ja4fExz>VrU-Jw-vQG$ zhf|l?fMXa~JACy(~61{B>0Q65=U<~ioDcxEPoeGPuxc?*Ke_u$=v5A>B# zG+aqt1Y$jXjca5{mFKUffgfyX+^YwJBFCt<8j3MZ~6^NuwLQxYVyl)&4 zw0uw>cLmhI{9CE)E;(<4pievuL6q3AX0V_Qa z1f|wbh}6LhTKF*wxBtCCx9OFD>*xp_?hJ*%#{n2(b_!ZAeIt7wuEX=@W8~|kN%-yJ zKK7~2I_M}l3!?^4SXTZbU2ifQkD7_lPfm{bMWB%U;dV3<5AV^hHm~SvI|6SOUqsnv zDU=6&oOkyyPCvDhk{AUREw#C ze~z*+r;O8=B<4b_R0KIWm=4*yX2ZQ#rR1u~J`ijP!w;ph;Ji&k%7ykpY@3beit$)5Hw^ zgAin3LaltikzK1F(LLtxi9~NXS?|0V4?j495~b0Yv?d$1jQi-twteV%a5mXg!3Wzp zzTDFfb~NtjFIJ6z2=}c_seTkN4<#=p;l$p*WIRX)@@uET(wBwI_7(GWV(!SHkyI=i zzxRjpep7TFlC>yOm_qb@g5kuH<#>YE4yB)!vFb}Uq{`P&wN+A>dzBx*%0D3^NB!W< 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

C#_m%K>XxXBKvQg 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%0AkndD2sidf!4TZ(Jg^)}yr7xR#MjSqypN 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

s;ZJa!`3XYqOA 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

@rc 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� zfrk?3C;a2o=6*k4s1Z$Q(c}V33HpRNPHpQC@`akvO^~5Y+nNTlBy^C)9m0aaUax$np%8b09^YtY0=ALTYd6?63L9GOduuicij*5pp#qU^MEpm^HaW%y58@%A7aoMt(rVG zJB~hOgw)Ocp#6{zozrTY4^&D#pYlaiOn>5js26o^^~ndSCB9D?5p}n}a6imwvn1mwUqU_Z_d5XfqrR=00-#k=@RX5I&-#N7Km+LE zR@+yAMpE^ZFQrudi3gxT^hB%AE1*Nt{*;kYFZ&A*KxS0b8vhFDl9-1g#JbgS~Y(FA0_9X zF&C&0{UZ)RU(w=L+c&^xNyIb$MT+USI|O}0t6F{D07I--letLg`&|z~-_eHF_&30x zl8k5k->Lq7zeCVJXnU*X4e+<5;2HBf^|3$b5cC7>X|;U|e3Mi?<6mOOxQU0L|DmQ< zpSQq2lJ;lJCF<|~!b8x%=$F>`x4{1-rf2-i)Ia^*hoJw^@2#4*z<-kO&zQ^9fBhr; zpb^-}HrqnLLOM2!A4FLW*!e*g*w{9oLcmfwBZ~>5tOi{Dppn?*w)jHeC#iE5KbRUf z;O7TfVl&z_g@Bb5&0>P72?IfX&?sz9n{5#=M(UZxzd~6LB>F)=Va{znMZh?zZx(Zf znl@1A2mOpKZ;LMievzKY;)hT(2fFY=d+j)YWBbge`qweuFbX>m?VwJ z;)hZW19tw<7|gTHrx>u7CS)<86g1%K4~@lkw#63%Q=}PL{HxT00Y86e9Om1mDF&uV z3$mE2)RKW9f90l0{%w;=)YGLESzgyT+yTKE*I+l~@e%+r?Dc#X4i;I+RMfC<}f zm8e0fAzfVXG-vdfSdNwX<|@c4Gu*&%o=$R#UO;wp(TDh0dVxNrDwAnuX7F!{5Whi9V=>^RIXks4a@e53-M=p3&oP6)eTu(HR-Xm(Vn*GZ@6G)8gc z1FHh8=3tXFw<^>g()l@Fw>Z}a_6JxwV78k23iSploYQ!Vb93NgfR!U=ubEV--YDIe z;}y+`86X3!=3-8o9hGWN>E4{iXwKb%*8x`Zuw|NCmFg|hV>w4xX;_X|EGK0EJYwa9ZPM(hQty<;#<^;PQK(!89;+ni?u7mrvi#QZgrs?~d?6**paII02i zh}9zOxMoMS+E?0^(|CvTa^Uq5tHqd5bE{gtUuwwly32Vr(0Rma33g6XU#&hU{hHHw zm-BYu#}TWgSg>YNjoME-TIChTDH#BdS}nuEH9Kn5ho#e1jd5&jcGXd<<=8FFtr~TJ zbiT?fo>M)r|ELugi`UfGsEQmBVDzAH-j)BgjEX6Nhlf5&Xg%qhu?osatz8=-D z#0oWI)NBFpn#!G|dIqKo^e|ST*`!v8q<2&$B-O{pdD&%2ohDqZ5KAAa+$qX5uvef* zu{KSmS|OFbP?b=WdEl%-k73=KF|~@b(sGsied^0VoIt+{GiWx|D$Yw=R3-PRzXtLI z`qkJcO?a*1qO?!t{($;E&?eBY!MD@8A_A&K}FzJR|_hf4FVB9fw^EJPHQ@tWWx;wWdnVLG7cTB$?TiPC8 zuZWZ$&2>+qW(>9+({Erqpq2Fs_8@ahQm9#jUytc|m}~o(21S(gTCRI4HD_@8as5VY zWBaBCMYQxzZb>R@O@@!_H(}e`!y6Q_(nq=O4=Lxty~p*Q*xvTa2E`rei`j2D;C%u-l!m@eYx&R z3LR`auE(*n?Ujv+`_jL1OO(`_!LP^l+pwVaF-?j@>CeyI)2MZW(@*HPV`1%^niR>> zsn1K&s11Yg3H=T%sy)0(kt&_{+&!K09Nc?CzY~jVuWV90Vmaj{=@dSA_JrOGOKcz0 ztVok?cTGNikomt;`82lGzocVSuW;mwLn>Cxxzk15~5wiEi@ znBCaIX8fsC{9N^zb8v9@1ic4)+iuZ?QX5WCPBmVl&6$rFzcl90A_AK*9yFr_B?lZMx7h1JE=T~UH+>*zZG~R z{qj8J8TI?%r<2M<*uU-HT7g38$QKS-RPf-$Q%XPV=MJO|D3(rsk&;DS9b9@!>5l;& z=h}c$>6{l1*;M%8_EXBk*wl{vHlSR({6$JObz?B_6g#4s)$y$jsF1FE;gCb!8jL!n z48Z1fAR3@by7NU!4t0Al>y+{cwz%V*2B?u9e&L{^;s@(aDUV_+I`TDuS}J;xqN1q5 zPp6auY)!{E4Nxb&^1>mPN*bK_oAMa8p#y0L8l=%LQgW$>gG+x?9>?&GbL~KrH2Hru^T za#}9JZg*sNs(YnNU&iEfSP=ecIl+#!K6k41QrDN(1)Tc9$kTE$_ON4Fm)a=Z{xYV3 z(>$1YS}wt!bO^iD{ZhY|)~`64!J5-@DfYZ0yGuPN4SX5%iqkbXa9S?I{^sa<){aG6IGUhd>cW~hu`8n)ehw#06NSgT4`VWqAaLXC_d91l3 z`@Q;4Y1Ye_KRAPfr_RVPU|k)b->d(Y7QVE8!}&ZIc}9K_>+M+fLH$iy_cG=U=g+~+ zGxFcD!4BaE^*_??m)37N-v(>W$S+}kc4U80|4;hqWz1X7{{{!n$S-66>-hXZ{hxG1 zo^>I|!aP1u9)$hWxvX1lA)A;NQ^@(ryf9E6jQ!Fn>{eUK?DDLOIAhFP0_9h*DV^Ef z>Yrpw^J0oPznD)2%0n``0G67#IfI8Np*LU{zXt24VtJw=w47gNSr zWIiR7U&jt~e(q6ElNIJ!mvfexBZcxv>`3P_Mr|vr%Zn-J{A$h=%2_OYr;t(4ly&D> zv&nOFjZm(@B%Rrenj`y^7ejMan+JsQo7nH2pBeRR*@%4W3XYq3yht8}UF}?^V}l11 z^J6MF>&**A@>|#qwnU+m}^9Ww=TJz z>Ai|YveWq?)zp5oK_s|~z3w#jDwfDD3W6Le~^DW;|$IKfE0V^->Oz&gWnNRaWSnrAX7$LZab#@wAWSZ<_bb^9~O% zV$Eq{!9#3e*Kwm_v+Q7jWg``5t`G|zVJ=4K0(iZmO<0wuPp%V<<=lU*vX zY@!m)qa}hg%%jW6q}U--6ofQUspk0-K{~dj>$pkbCA(i>*-WLGH%bH>FJ$}}I72p(hmyNo8q9@(1$%N8oj93~NDVgX%F{R$tMx*(*5$~C7+1W&M2UB~+s z`(*D6EL*8ObA?3k6ccx)_bU#_%mpE>)N8XrB6x;f>@xN%4$1y4uxz6W&7-A)EG)Fk zX+YsG173x+QKjbjQb9Hr*>!wC!I#Z?WvQVm%p0YG94xjgeL!(Uw)j|ilL}NA z*<~D12xMzsS+-Mk<}j%s7fbDO8dMx-BQYWERFgSPDtL}%b{!v7oRl4WW!XWsnJc7% z7g%mr`k>-B+38my9aN{;AQimCUUwM>6=!6ZURich-R99UK^|7x1KsQ_ViUq7d2o$CKJ5EI=hT!g-rJ5mF0Wt zlQ~Q#c#Y}0oIWbf$<(hx-c!ToG@0NJY@qA-N5uu%`&X79sITSd#h^hpsc173%8Q=>l4KPxE0#=k%Q z>HjD?6Tc>|whe1T1u8XKt;I?+MyNuiR;#u8GBc>54pi!Bu@)<}rcwtgwYIU6Wr9mk zKry4@f(i#CXh3n_P!Z7}iy#3(L`5JFm;?e@CM!wpm+xQ5FEew_^W67!$z`dWa+W6F z&loKv9z{&*EHTOVq|PpvY4QV%NkT&9?Xv4l@{ClpoYj**-k2#Qsv<%^L^MWh>nvg9`KiO@GCChN>O_P(g6z~Y@`BX2bWl^3RR?z8my(Z*;o@hsv+iD$^W%-R zVxleLMrVmlelc~+ec51sf>9?XUPP34>TUANscH9FhWrgiKMBzuQPmk{mtRel-Aq|zKgF0NAznvxbn5N$+o=`z zS;P5TjhPanBVvcPGt42sliGM+Hk`lBSS%slL>M}g9rAmro%dPBe4(*cLUcx0J4+n$ z`>DhCWyXBcsFM&~5uQ%HL;fK3?R}Ohf2YxpB)TJfy270DhpFQ#WTyOdV<<^zA_BUS zopMzwr-Eh9&ooApL{G$|t`evGaq8>}nK?h(m_!o25kXygr@ST=tzcR54;nK`LK_j% z73PxHrLL%uS@Mq?N1RpOSvNgb|`Ir7VmIw@g{AiMN#c~|P&3YIg! z(&(2)m?F}bM6oXMUA2G>tGvWOpTd?puiO_b1{U>*&Ha?KO&9@qhcM$(Z7`l@Gle<$pAF%$*cN%MV z5N{)_T_yj?|4ki!Ap0-hW7O>+{)_N*>Hm}em-_YrYZMq|^4m#_f_=KfMp2`M<0@sN zfR8D3Covih=uRF*v4osT)@a~oirz`Q15fHM8AZJ-oLwm!4FXI_I|&vX)U6*y`3liW z);nOlDRU>`1BY~nji&sCD=KC0fJvs}oy5Cvcz5z>YOHX5C5r`?_*Cu0#=!Hs6{GR@ zg(;N+7AMH0-HG|a5#5^6I9o_oGAzzClg}>959W1q-@zvcvnvHYoDfsUF3cZZ-pzjp zpD4_$WPCWGrl?)mdvIL0;vM`$VQHn{T~4?uaThigUe~R82meS|Udg=6nPW=dg$2MH zySXfUim;(lForYVRJ03wAKu)}XW?Efd?ho6v(Qwv3mXRux)m(^6QQ9};LCwc+FckM zmUL@aI3)B`GQOO}CZFBdcv#lW^})HqfQJGrfIU7Q{X4vYsawX2~R%k4FH5`v25nY@U!j{ zW7uB_&p+JwKGolLK42Px!JC;lRUZQWKM=j$y874U0cRD;lbI4B}B@BHO zH-WR?l#uQl3{TNC`>B@+7d~p4z@bcJx^D=~)lBeLM+>7L#eKlZG3BNEehPlnOJ-M!rVtKlQ>EfyvO%5c$MbRd+OD~;zw~GaxRz> z_V~_%*J_&IQ?C)0J!<)obJ;}h@ePOhnh9go@xt0iag#aMO?i8KKZlbwtH!E-61G2T znasIuD&ONf8~#gkXskLxsCyLm5$B$%V~_6_ut?K9R=r;6eAMz0=Yh$y$9E3AOEV!r zy+P=wiVNhZOhJ2n=fYWg@LM;KyL#a-s}4%d`NRBK%FQIRmDx=)SD9a`p$z- zXqp4mn}iEhEmJsZ6S>!SKCIA8cwe0)j8?^c%xN*@?e+Z%F43%dU;VozOTehuHzG{3LjA{44xrgFMWp1oeM-$TuWaq6wYOjVqh z%3uo0@LdGgX;zI>|1Hc_wFGf!6P)4uKe$g~cZ zRm&$FqlwJ$g<*|m0$VK<)~ez-9LAKF;fug}%__E9ENoY`a5xTAd4?|vn>B~nYEr0E z#X%gmsUyQT5_V~t+3GZ*Q`G`-{xf+pd>6x`dnSxm?-cq~#ZBX|%t4vHOJM(=RpZsW zg@IKq(>T6nIMa72JfY{%cy+okv?`9v8Ea0+^yR@*dYZ?3MRNTXXzA`g=IHr*0B;&MW3qg@F}j;XXPJKHcO0Ayq6~_L%n>_}-kmk6sNI_r!ci zofocotojVBF_-P5e}J#_WPM1L3O7CG%>qA}+xO9H;If{&52;JS?T=Nnz)9c{w9{-Q1GGX;&-fZxtSb4-A7b~(EeEU1xPbH_tU??qk8=VsY>CP zYTg{M+Z>opZ-D)JV*;s1!pYUDIbg4OVK&W&$MP$M{2gW2R6~glno*UxH)ivTXV{cusHD z6skeEshT$rob)DfNSR519S?U)EhIEdL?YDRxJRx%w-4YKj5vsSyQPFVShF6YjD@x zet_Nr3w!IPQk_D3waUx&H9Ni7Rd{Ewe-PCz98<$v2p*XO<@8oKvo|J)>Jd)%VqrkF zd7+&C3qIJJ6+~%;GirF=-mN)aPX7((_SOYa{lWz`szspDER=h5`loySKcRHOWi`D2 zfoJAiIlT=o?v43`8W66jQT-3Jnakw#Kk${_Ebrn(xT%I00bZHg<@9#AtheqHYFN0v zMil`%%}(#Z4zBF=_kKr&duw@^=5G>tMFV6 z4*>(_c#0Oo?Y(sz$}YTBqe8&2SxC_mxVzUMqMX9&8XgKP=3I&J&THaFdfhG6!b9|ac!SnI zmX;{ z>w+nc$X=_$0BUg_qA7T%)<1-rCK^-6iv~+AfrsgXaHcjUgqkjzT&Ic#%Pb2I(}&=L z+N=;NL^Pw07X!Yv#2=;)!@1hJ5Nd{KL7gfFtgr|V(>d^It^cP~sAyRo?_2P_CHF9W z1TNObd`f*LT2rU`7Ob(99j1@MSF~B5QsJUab-d-^CrkTb`WRfMt^1UkE!tkES`OA* zoQLV-aHZCN1~o^tw~iMJezgSV(7EuAT5Ze>>PykFI#n##WLcO)pMaaRSu?2lqH}e; z@4)Yt_#FBq+^(&gK`jv7s#ARjwpfHY^eMPo>pzoPD5|dGt?(A!b93lCZ(AY8o0Su_ z)u~p1?Uu3}?={<`&6-KUqW(G_4#bxB92&q*ZQV=?71`@lI7qWNbLawiRG)t+wOBOf z32!CXZ3#R=pN9SVVnV5Z@BlJ0VVP9PswL-N0i7E~Ni}MJrgctYue@3ko?R~;q4bE5skJ80( zbYIM8)c2xePgJYHIm^PMbP2q&FY7aEwdmXv-VflsCH^RV9**y;`;1y6y7ff$1Gr=n z9;Gk98~XfbQSqYcC%iS_nkDxrT?!}l#mu69616>1tpT?zWk>0Y@YcSpSyY0k{|WC$ zaM#j)l)eND`|4&<>qYh_svkjx#d(yz4Damo52rSW#?>Bc%i!9+nAz0dqGR={1kmrL{?K>e zroOD%)Hcz%dfv}qz!HC)z6-ba)y<~1i*D7ceg?x9;c@yN+}-E@1tk|QhqR9=a4PXqjFqeJ^kMGZ#L#2ymH1PP~ zJtjVvegp^h*Uh2!iWW4e_}+xBFqc-r)B64AQkkM<4ZL5$2Tbn&x79-XW9Cx(L~9yU zzkEei~Ie*qz;Pq zHt-U`bSCfwT?o}kt6&VK*-)M?R}r@SPvlnFdZH^G_x zG4rW2qRCHHNnjbX@Fd*~AMDSXPZfz~Jmn>WZ<+X$^fNfOzivKt&g(%|C4&`=@Fd*= zpYHeniYgW@d&>JAe9z>bq+8+Q{+O?*^P)9RRlkEZOxa2LIeeu*>no~MwCO2tGx&*V zKS{U2W&L$uQI|y9pQ<*4^^Ef*{Q}-m+3&xAx+2>9l$Qd2Wdcvp?Qm^>%mV6~=$O}^ z4K^_gPth;orv9u2)D6+Or#!Fum5Dz^zk=KQ>lRSAM7N%*{s3DT;VJqx+}-d0HB~06 ze#+Yd{$_H$vMpHGAM-VJSJd`YwFPWv%1+U5V3SwJLY0g9pYr|$Vy68R-3dGU>%OKc zM0PJ2AEYtPQ*;+RiuPYfRf@(m^0tEAOrV!g4EuSByVN7m8yseG|G)bfO2;gs zo`}{os{RJYn6f;&51vD3EutDkn;LoEsy5S}NB6@E>AFQ!qiB1hN&o=k%%f>|F|8?# zX%eM3&fCU0!%XtlcDy~xCI7>piHN#FC1#s9tOfrRPc2)t8dZM^QC2r}0E8Y8@e9u4E&A|LfUA?Fb@ z33%^}8T1ku?-5N>Ul($!nfai`3{&1y1+Eo^sOO0|Pngx9#sVMrKBsuUXukToh||by z12qg>K#xXnohV8@Pt1A7904^}SV=EI@Bz_k^>s0)jky78Z181z7lIo^iRyV0&MT%B z)Y#$M^g{$67Hv~smvA~6E2we659rY-ZW5)d=aHNqW>P_o6Rx3`ptwbJM17s)^fU7d zYFx0I-i6{;QIUF{lrzArE~puSU(gRx+%CGIzAohqGusMk+;A5?Iudt^s?_t+I2Pte zL5&Bd=_Qf)h^SS4J&j{$ZWPq~3mfTOk+?^sRnObO8DS1Oi{8R^`c5SKt;nJ-+yVY& zOa;pS;J36Zl08cNPE+tsFxoo)v~m_{*lkUEqD|*3(KB!qK@Fv;D+Nn}TKgP6u4;c!kmrS*BaGl>LGDa8qGA_|!T_q4Y;q=tN7|lf-A5g7<(h>q>?4 zJ!FmU&QkVd@wKMHJ>YZeR)umbvR>y}$_^AiY6{*9=2{OblmW;lZ^4)SvG`e2;a>2S z^@>9IKC(q8;<3F1(x%`HFMXj&;Z2@z*WKZ(1Vrxr*RVsx)0=~{K#X@Uzu)M)?s^p>uu1o+IAf9GngK*71eP zK%`o?Xc_xU@!{seZ19_PPN8xN(x?+HW6u|#X%0RBlC3KXl^-K*x;x9*3&hu&3lD%l ztXm6}Q;|-cYZ-f?_)&AP+zWj?Sf~s_`gM!EeNpkV=0Z96$9knu>D|=oMBX`}xTiVT z`;xad6)HK1U3Uj#qhd>QAqAvXQ=t+<-s)T!d$IVPXTb--F6;OrzpDb7a6A)MYE&C)1L(&0$J9TMat>OWc{6J_A>F8&k7HL174P~G8p0LUD51l z@zQ6(hruE1!6Ib{GDE*8hW)Mh`)7rR!BOj#BIT#ZY`rLk9V`CzS#S&OjFE z@5HcIi2r_8m;>^yrXuA`1l7A@*ek`mp9LQQ3hVf@%1~sPe$lt=@5P6o6&?X+t#i&Q z!;lqv(YNf?;xo^JkAf2G%CpMPkTv={-?G<;uRSX~3NBi=o>k65*6Us0vg5^%o&_HR zSFH!nD#MXY-nuyZC-Jjqg~z~6>y@+0&yg*9(Q-clZImll9qFDB?;tyI1bHQWloO8;#h+Hp< zWhaWKw*;R6b=H;VlwTsp^mk&}o5WwX6rKQ2ty|A2=OIAvie)E>m$n3-1kKii=alo2 zbNWT!v40nT-%@xIJhxssr~C@Jq!)e1P7(jw5_}50v^JenE74Rwq(bldj_s8Xv;=$E-PZ9+c!+KsxoV6>OpSQA@D*LT^2&RKiHVei67b_PdV+IzjWbYP#&{}vJylb0NtXzVO8xXBzr;Dez1}lKSZDp}?DKdHB&Pw)P z@t3WI3h=&dYcb)R^T~jDr8+~rxOL(g&IDU_aYhvK>A>7o>MZff)|@k(4{fEz8Q&nE z5B#-CycxH?`)R<9uZ+Ey?%}`D5U( zIImf(yLF-#GjKIdoi8@E=6D}Yo1r8FN2CMhICX(|)bokOoF%q^^BF6V zJp*%BdpQQV@RBjrh2m+?CzfzxY_aDvzDJG@TwSd`E1vT_r-bvJ zE#-Vh9Fjj^UaeM&7eAkPp0mo9eLiC~a&}0xy1R$R(c`hXJlyLuOHR- z#NE#)Ugn5w4Hq)jBesF7KdSGGP0w>KbEG!Ig^XX2e+QO)z5jvu?Q_-@u+!#ON^C%U z2E*d1hvIQOj~p*@hdWEuq2*(ES}vay9%;xNu|U_ zBxq0{Pt}OgHr6$8(3V+BBqAY$VQZ;6@rpLtHE_gMTuS_ggbyaKrRv4&+gR5@uC2C| z*o4dn*iUH4M;bfpA=z=Qg=lv@RV97e_jd-|C zb{mx2bQg&~5pqz!j_MM>ZDW;zN}Jy$Vk?q97?wb3#N%Gb%0QJZ^b+wGl0BH5K=q0_ zFIaa#tu6Wz@i%g0uq1)%6VHAjy8{|*NtXx#k~gSN@Q$I-7p%LW$(DJE*oG7hhW$+G zy@U7JZozBdvoa z>nWS~(hJ#rV6Y`!CL~D5png5&5LdilRREJM^D;pq+QG12D3`eLg{%TtZN--fDPkB* z{)KXjJ72IK0H>|?GLeQ@2TOjT{uK|ukUan%o9;5P1Mv*%f1&;pzkR`~1f%SJSBRa6 zk0ERWHCi&RT~-Nv?4ehPT}Xf-c>~3gaN1c9fuBA43b7lRWGLA{y(^jBE_(<9>`7M$ z84_gBZ=if7Xgli>7;n$KLZl-hhA=+mFImwpdjuxgi?0xSkZ?mXpBgJ!-_BBjKzr>K zVlOh^P{OC)muzX5sX&ljcZJA6V1u4du_bBktST_g?st{QM4}8~zfuz<@^)Dj2(gD= zC9;rML-McGLSL#E_rFPk45N=PpO6*4x4EkTGk0cfCtZFdF zo_UqXMiLES8>uOh#&%gXm~St>N*q8^49Od*sgllimX`r%jW~=PF_a`ypGs!Gl+}S)d(t%`2gx(&6RDXJ^d;*FSZU9^MjSzQ6dA&Pqr$wr z7TFW9+FpE(IEs`Sl7FLSN!Gt))q{9@?KR>Ua>G#a8}+$l%S%~3NU-a!5yz2ogZ?+_ z3rX5bRs+~z_q$HyB2|X4P1Ib8{H3e`B-%r-6DN=cL-HmsU>Lk)Jq1bj=80!`NU}1V z1P$qvC`^LBVm$)~?U^@-A|zxeESZXttav4R29DT^ZxCmZ@S)^nYPn?nD^?50wb$Mt z&LQ)MN|LGXBwJp|T0ov%cY{zO@Q^;4!X;_1Sgqi+-R~w*j6@BE{Z6ft$Y04?L6JT5 zCQ*XK4kiCi#Yw;`)^kv7kG@HqM^+D&{7(HKx%5i*9F*FVZW0%egdzR!)Q^&iSFAR0 z#h!VSC`A&7!ZuTDC5^9SZQzEz_$F}?Nf}DsOs$i2zGA%qW%k;e#3f|gP|0TMXUXs@ z*$Yr^*WDy8Bjk{NGxdw)?JHJ0sI>dtBCa6mLt!ZtUo!5stQ}O@LvImRk?f)56l$Y{ z^P2S%)Y_wO5!a9-LnSHHZ<5)sWiLU4J?R#49myNgr%;3hea(6Wn(Udkh#N@JP}m<- zvKRg%dj(qU#kYu?Na;}WAJk^a`q!-2pxs`3i@1f{7%KUL`a`niwd^(MujplK&@$p75-^;+m6Ay~9V`v-b3~UBkB~{jC0nUIlGzr+scgVCL z#1VRjs6%3hlmDi2B%p)U2SOducZes*>fw^VsiTrh9kMV%}RL)H)GJBsfRPmz@2WC3+b(%HeH!9qvv9ikE0He4d0@+HF^ zGVg%ep}Rw<5pq~Bpb8{!J6Jlf*x`4VXhPD5!?sZh$+$N%9f)#--X)rm?BV2XRH20P zhNTD5j_A9@Gvvr{$u{b&WcC}G9>hA5?h-A?j=W+0HcBZ$->?S2N=N2hq7^9`4*Q2H z@uH+;17NkI_%87rDIHG!hq@qH|AsXP;vKbji8kcMaLGT^Mah;ovO$pG(A_0oAmzjQ zf2hlnv^OjR*x>NHN3gcIvjI;tgvUY;|PbBRUZ6a9ApJN7DF4HVn2oitiC` z5W{eCDs@lN`G#c#LPzaAq7$(Wm!wknCBtuIMnF1r_lPdUGptXg9!TE4VVS^AhhI6- zjrbVDgw#XHxK5b~q&q^(2@MipOcqiq@4$s+2APiNa-s*BWGoR56bYzwjS|r36CZg&jD>`KsaKuquPV^z+#$*vyFInHoVnD8=ww&lk z<{L{y)KkfpP8kF89J+FEc?CA=MU+~S*2%Jh(+BcaU(n!X2$(*3d5n4f*k!)i!N%cxNT`U)wFJdqz@1We0&Mww}!0D)cK)gk)#*!V> zzmnlD*?+*}&^;jjLp(BPtN@5i1V>%S^-DonPTQCapafVc4qtUS@ z{!W}l26Z!|AU|hRCH4+F(WKakze|R93r0f$&csTLg-$hTcH+Jy+|7)J#yit1F&{M8 z#NCDald;``cc4kmqDt&tbe4&~3m;1+bTjWjfzGN*Yz#Wjq}YYOPo{JWSWu8tTZ#Fi z5hl$p?+uvjW?0ZPr_V#o59OJ-yYUHRcDKL>3UP)!#Qf3aCjM@GBAM6C_&}k~sE62l zXq-v08~>0j?H0TXg*y`;Vq?*DCe3dABeJ}kc^8`FOn-<4pc_qG89s$<=oXBD<~xfX zV(+7yO?(+XmF(zd#y|_5RS&UosKBI<;h&I(Zh<%B(IXVfF?19V4@Ns*3!N``9${!pwl z@ewu=J!R6Qdx;~khVh41I@2Fvlh8sFcMl#$#%cubL93lbkFXEX3nu;^d={CYVcvt{ zomG#p$>?>HVh{c~nW7Plg%X_FN7zT`J(Fe+{sl>Dn6c0Xr;iE?L{%p4UVJW@?Iq?x ziOvueHU+IW@%Q5M$UF@b03|u2RM^L8i%GE;|B5Wt2;PTMoQW!ID*D=_*^7TomTQ>z zp{>qz73LM6n7A3Zms+b4jDxm0i&WSr=%9(8fk%)X8fF|MbXKV_4$7Dm-fK2#&C~z)2z8q@8F(b=(J*Xir_-kjn})K?+)R848PFpb52ZUpsxU4(*38eud0wdmGakxx zMpa?c(TQe7CjJc>-XoX*WjhnAuwZnmS(AxlB;3PHfDSs-tFRC>*v!qsW60PZ!3WS0 zXHgaQDLTu{&%&3J2|dgQP_DD83Y&q>Gb^(2@5q!M!9*y}sjc!7s3OdoEF34v9%dqR z+UfHc3q^Tm?mm1KncX9p1Qj_$9%EtXax;G)9!KVR$JS7>GwLz+85(C+?8AQ`OM3(# zLZ!~c$Ji`%omsOF|B)>3VLpVeIMW|v;pj#)cR#+CZ0He8hHf~E9%G-Qo6Y?F_&Tzq zhnWnOIjbIHvr&Oru^<1LH1r5Qg36uR$JiIB#H`tm|3Z3tn2(@Jr%yFD2bGz***KpJ z=oJJ)RnCxVY%aRr%+JO*l0m&pAXMv&s>Z%VbIgit{5LYZS1<)?a3)q`^UzafO*T%D za4$0jYI3GmWAo8MGxq?VOvd&KK89MIMb+3>=mj(X0KS<_=w&{J+MQL^*aGyrS#bdW zgG}iaOocj}+G^};^qyIB0RNLDdzq(F%vl11Ts0(YcLq?HFGIk zL^kvaIFQv@RD&VtpqcMIB9k4x3PO~6D^8^_#QG` zE8s!_uEbi5hfcL<4&oUktYx^+cvpHY7KH{|xQFm8GFB^?4oz|u)nebEvn>2W_@Soi(zPlMRN$JNK(rLL(^P7byzgYvv3dNhsbQL zAOs3=h16j&=yD7HFrGu^X_*ix)D=~SeT&9f6o>JnWT{s0DHQHXtizV0>nxhX_;Ipa z%X|vWai!N`vFJt%HwQmKHfRMip!u$%I_x`ivxT37pCUW7%nWFutE$c`u@G1kIe0#4 z&z;3`ZpvO%7f_dbG?;XtB%Z3APfIS-3}V1sTvM2!*0tAy2SX=#Kps{t>*8 z4C-S-p=ejs6YP65$D%lbpC!Zl1YuCDEAa^yho17P^Kc~z_c3A6N>}<5Y&BYF;U2|H z$k;x?XV7X_(G%T*c$Y@MR63rNT&1&W7UW>}SncXj#4;8sWo?^-9 za)y5rZz1!%OE#$374;PR9gSlYC-LWGX}{nrsMMAC6x)ohV>Bo67i4)q^A&W(mHre< zK{qnoQ}|1=p`zp}Xini>q^Fxyc` z{zh{cMIPQqhSP#YP=hP6(M#w)#c1+ynuKX)5!B>LZ^X8tg$&nAs3T)(!T+FES5YJO z4|;*&dyy$*0?qsnYIjvNV%yQ{j3OT&B2#EV1k~ZuHe%j}5~In-jU-7k5m2|wM~w+l z6~hI%nauVk&LFKTM2(5idWH{hhRmZG7}B|-)R-7;VH5zjk)^Z%fefxhHRiq2G8%w8 z$a0!NAd@Ryjge?C!!5vFWCJZgA*-uMjY-i#hF^fY$qt%9A*ZWKjisRsqbR`tB@MJ7 z67smTYHSDUW;6x(f24H+k|DH5mt=?XG=*P z!-J-c_%vggD9_40gHMoV>jY6y$Vf;tmW3|2^3UKCrFl9g3JM*GYR2}VaaP3{{6lG} zPVfyBK9bms?MK&HHD~aTq~$v18)(i*dNY=dZnSa>@hQ>f261xJAiJs@(b~) z(heQ73|cr+)r`qefmKn6eCdnuXrYyR77vrg>IL6It4E5SVMoymR{mLhmNY@nd<(^oR6WCvq1Ua7 zv-s!I6un?MlrW-wh8;)mSv6;zhG z<)6dnN%QnfER-}7)q)M`bbDCra;Hq_{BKSE9_!cLYX5`t=JiK zqD@hZeG_dLEznmr&`4Hb=qJjY7V(>^Jtt+aUTCcS~?*30V*9y ze2!f}*V#1Z@gJq-1I!Q5m67!4SSh;E#=U^Al{O3r)<8E#ik@Q^(akpg1$>>fV}Mx$ zm5o$A$1b4)o8kigv(zvk_z@}}(LTp6qY|6u0{)BCGr;@^RgUw8f^lg#RNg9TfZw8AcLcVE541Hq9kGRa!pC{0x~!(qCZZXs?ZX z85c<#1_kRO>qyZH>^?eZ<6p)l(vCr9J>(pzdVy7-j7@PFmr4zTf?pudi1r2c0Cn3m zm+>7^&mi*)G|KJMj#Z*8JNF8{OB!GhY=C^+A??^hbgZ3!1(!*K49o_|&mGl{Jwhki z6<6>*(r|--4+Xdr+c6b7)vmdMXGmcK!-vMZ)7!BsG}z9)if2h<4T4{xN$#R{>@hma z&cBN9mnImPU!g#ERXbLV&a*47;s>ND2Ej%s$gOS1YS0L~<|W+GeJwfB_ifi~$X{kZ*8x-zNe2LYg z>+G6q_;G2uf%y%Z<4%8xHJ}^q-0S!WX@fzq37YRNdWk(nH{1Ew@l(93#1+cLqLn&KCiGQRA%Siz!lPfAwd!p;ShCh@B3=6hG>F$sYOoNVf@XK(OG-#OF3T3*ZI(UO1DoZWn?(|N~iWWM!_i?>6 z)+i7|t?r^u%!XcY@bBY;(gY(ThT7d#otPcH?oiyvhomV+fduMsYdbLqde5P`j~k_= zk&!^%Zl5m9iK-mj3fwHs_VV;0tvjR(bD{MPeg)1*^Nb7$>D*CW*a+I@CVT6c6x!r3Ry52jp>UyRiRIw?p#)|4-^MGCQDA9-nS(R3yvEt;9#C1(*ao zAs?n`Zj2Q<)v2k(ebZnQ zvkMyUN$dWhq!-QtVysNn&c_!#@>yb<>Wua$EGEinB7pIr>Ywp6FJYRc!mqh=Hyo4+_V6*AQOu6g!Et&BKJG_Rd{e(keSJZqCHVP*awk0 zPDK^|X6$ZHYu{u$$gB6rNx>B`=HgHq8{wS z$O}&XV|-Rxf|=O|#e1rHu*s3vor=f!=V>Ws!G0*gqwT>yioEC4JjTCBBhAcyXoJV6 z7YmG3Il0yN+_Y@3odHVpg!E!lBI}*}YJ6T=o|(ypk~~qp*vFAAPDM4o@w@ZpjRy)g zdp7jWoErJc`Lde*b=qBX?}36pJv)1621WKbr+D3&X-~`>E4;2L=HID*03Ye zUYUEng?CS7?@Ugl#d)H}o0S_dZ}cuYJl(xBp~w;E%NllM+K9QAD%jx}rJXq~@*UTd zTK1B(_beL^7RWr)v@^Mp@4432vUzDATY3)`WOx>9XHJj&z;&XQ{Y~0umW_uB_IoyH zX9h=p?0Q+t#?t<0={;0Hd3I`NhD1(xO{rtYq>FPwBIcoa|%v*y0tUC$aUAtIyUaj6!qp56nIAU%?yoP>YDO| zy((>=W#f^8LeI3mnPHLNy4F5n$EBV0N?Hq)p2dALKa2d{b>a#8hqUvSjYkVEcsBIS zoE7<#>*W*nk7;);y+;c!dv^BC43GTPHKm@tHtmUJ}oi%7BSWj*_sv=K}1@q!1QQT;RLMDBJ? zX<+ly-eWfA7N|Va`e)9K+~-=`!2Umm&cv;WtBb=~CYecCG6?|zkqj6}z#Zd8)me-h zaT^4!SZfu~MyggVTBSDYU?X)IELyR)nNYNmwzgWdO088yzev>tQ7cxh0&2t^0U>1h z<}b*TdFIZ&_nh;4-vUGYo3?%I7%28gm4WW=tGp~(Zb*MqP-*|$zx0u6G`hd9<+9{M zL*|>dO1s-{dZf~zRef<+Br6SBZwmI?oBhlq)flv<%;BEL&=-AZ|wj1IgeH2(fYo)tCDqwvNr_>>}`JJ zG3(@P?8{?~V}|{2+78$s`?1HWO!Qh`dpS5azLJ657$1=a#T-;x}#I+28ulK2}XeJNqi#lFtnt zZwl=8e*gW)DirngwYVilgYQk7-5&CDyebUk`Qkhhvq9Wdz}Wc##H+$lu`kafu^QsL z+88?+z`QB~mH8??l2Su@SAoMG7Fg<4O+n**Egs1ihRm)uhg}jdc~x38*%#L&*=ES< zDyXtY1sJbtDw^)gYm$6vSk%>4WgieY>s3udGkldzk{yPku7dCF0|WQHs_AH^ucb+{ z%TUtQ_Pt#Z;5nflU*0vzSBCvvZPj*F0DGd!Lg)D^ zuSxb8YPt#z+J^*|K2gm?7x`MQNxnAJb+sL|j|iBasAi%0zPM&drJ=E_;E;WEfO(>t zjT(!5dCih<47a=54%x>A&OTAiLD%~#nj<816YS@9=h9Cd0leQkltNz z*gi9`v_qAH?)SA^m;7MJ>~1^EUhPdCs$8_n7k5K)*pSs-P-D*tFdeG-XpJxLhU7=X zqVBdD`@4a&9jXQBF<<2k$Df!K?zq{=x`-cJSscI2=%~yF-a@_zLuMk-wk!$Z9m&T4w#;*7Nf1cxPK(442|6dM_36B^HlXd+TqLlNAic^c6Zwm z`=^1kPgP6MPG99el0OX{-37ncKMUM{s`>!+`C9&w{AKWUxBX%_1~|`DOHp2b+%3sD zgSe;QSGzTUJX0-0#r=7=BM1y8|0=*dQ!Pi+`}0~Pe;XF{v>mg59XR_; zwF1rPuWXTAF%^ZF}qOKuoydJ2x)e+(>ruKEaF)ZcPj@{gge zr|r1?mw@THY89H_ANQ}M#n9MOaKipufO)Q3jTZIi{VVy`aJ#4Ng#Gux+2^V?==%Q3 ze=I4Z62Eb&BS|ffwKg^l*RnU3Z6J;oIgq#Xo`5FTjoH(f;zg?q>%5 z+nhfX{|4^706#@f^*7yhzc6fi+x&;(zkvS*xCuSiFTLk}Ww5=?Ijv|5B)kMSqnG-# z@3}h-``$L6Ry<~h>A@1TslWW5yUXBwoAamQX<+e7@H4cfzv-U4$8hp(^Ph^Bfh{k= zE$D-O>3{BCgX?Y18O58xftTRt=#&2J|J**qt+&l*6mJ8kUxEhob$|JPZok3%Hs>!z zf8fqb(1`Z+H~r@h8hYP0|D^~8{4YTh%Joa{yE#UvH|MN^A53@!no*HI`@WlJjO}ec zs{n%&Ux5}>>My_V28`<7oO6n>;Nn-H6^-*Z-FFL(<9nOWDI~!yuRsb-@=G7MA)~H0 zr(O{iJn#xEMbrG*58Pto!rtb3#em@HSKwB3l)wCeJKU)6%{i|a7`*ce`~sceZ+hU4 zG;Zo`KCe&&{jWe9iuZ)$bN7*F;#Ur>w)Zg~xEM~%z;(l&Rj(bb!CQ87Ar;5E1d zUFpwmbH^KR^)_Erj0>KA4emtO`ODkf2}W;k&i@qe1n;~CccGj8O>OQ(V{dQs{}gD@ z{~DxGvtQcoMvPEjj!Q8mn9vE9quc!1?e1h_Y+tiWF+Di36WooK`^($iN~5|j=aOP( zaB(O26#iKhhO^8tv2fVavBvm!2_M(9`vw3`=LACxUjFe zQSol@bSJnMJ?bxi=pJg+_vQSpcrSRT6Z{%I94 zO^@7TjVJq>uP8naZg~S9Kp*&}kKN;quD+bBtUL3-8;}*B__H6oGmW?UnptLA@bnw- zJM^``oQ+!uU91s+021x%D#3EkjfbVeY%!#&Tq&)0li z@k4N8H&}ya2g*C#xkjfi=Z50P;NouZM>H?c)Zt!WJn3t`q4*`Zr5pSST^5i&buTo! zd^tB2zXcC;gFmAy1KCgAi;TB?%{LXl2Tyl{N6>YF@~7^_Mz1gDAH^TRJKf+f=;lDv zQ}+^Mudn$Z#a}^xH~1@R4oIK5ml~n|oLh?X!Gs?0D7r0>{mh+jjO}l}rT8Bku>p^v z<$>~N?n0xwKc_|UcW`kJ_#0XoXnN*eVI1Gz+@f#?xAcIus3RbK?$#T1{W-T4&A|gb z;BoYDAp5zy$hfe-`L^Pp;OQRl1bQ@3{@lIFsPE7DSMhJ~P7nAydMeQL+`Y!QslWMO z#eYG64|ozi7m&ViuQl5GbM7eGf(dWIQ|P5Y_6zrV3;uz&1e#vBHyTg&H{Vsf3~qS~o<<)8q%Yl@jIREidx|%~18>1U(Ip=NS_ZFkKKj%M1fAG#*@GrD4(Dc%6F!uI0|ECBA{cpju zC^sm5{o7!G1lLFUjc?D_JZ}OG+6%1O&QhxoCk`q(BfY3 zJQ^2ldgb0~9Pe*_ppb;N^nwj&Qc(KZZ8PfpIjxGQ(1Bj?0-6@ge(f$ZF7!9IDh7m3 z_ktJEQNi-p?(IgsKc`JGFm$IE{2wb8Z+h+CY24&*Zc`{i{$9|9;z4Prn>O0~IqizU zp@cs05;`N8-Ra(K+~;p@SExc0`@lvtJ6PW7t}r_NIS&;>LW}#rzfogeu&L9%*Lc$3 z{7^9>w51Qcj4lgG-?;Y~UH+U$iqWA1ec%;zWib1Vd%y9Pzxk13T?>!>3r z?RGnjxI_CVDhj-tGR$s1M{kRlFCv;{*Re zPX(L0-A9a@0?khqAB6lq@D_S5DD83oYP1D%o+%1K3H@LTdMTLQ<348G7ifN__%JlF zAH0n=1ik=ndq&RD2e?(+~cK_63{Xy3ZJU z1I;fL#*n`sypM82(q8vjBNWVerLcw){NMvr6w2;(*BfJl&94++geLmIR#X})?{zmA z)xn(CiZ4Tp{a_m!7i#KtUo?&nHosQvVmnV@JDL=d_PJd~T`;Fp@m1)6AAE?Wg|hqH zjmCw+=1#@eq0@fw5jrYV-siq-)CY6kD832Z@q>@i38AJw_f_MjVDlS=J>>U;UK9^W zeQuA@7R>2Vd>={(fKSjFp=_V~nsHySxl8dwXkq~DK(j;TKKFH_Gnmt@_%XCN06sTZ+odEa}-5hG_ci%H^sXY7EekRl#c>EGIg+%^c_l?5PlwNy% zC^q=`6>1C3@b79h#)QuI+AoI22Oqyi>CiU+u6AQ;XiA^GF|;uFxD(wMy5!&W$T&81 zzR!L&v?<7T>X?uyu*++l7MkL-UkmLEKJG%Dp&5Z)9mWNr^FI5{(8=KAZuHmCw!p4u z#ucF{{r20TTfxUY=*iHfz^)g@jiK}X_Ish;;N!RG*^nr>>y>e9Xo}z78j1})?nPap z8Npqh#=W8Qe*2@)_z)XZ^n|trcXb&Lg{B1T9ifGx$3FB{=u&W3kMTt4e8B!9v?=ts zAH5$Eg?9BCFNUTB?VX{0p~rsI8=4W?$nFc> z3Ox>@y`f8?T|r|{=zPc?4E2T{hfoej%%M3ZAxFz$c$_$nmxDo^EDp^x#c&!pjF2;d zr;@|^ zDb<`TE-f}K;56`<7|tnP)Trkk7wAv2b9itCNQ z)^f`Dbb{#-M+-8UoEEM(65GUS+uOy=})y%NmC5d$=05^}W=LvZ4FUKq1+ zvH&{S6vJ(Rm}#5|Jg*d^Ib{H?G^KL2A|{KI$MedteVj&sPBo3?Hi(!xoXtFM6vlAG z0$OdF#?^|Md7Mg~SB^P3SpquUw1C?nW)^Tx@x0O4ubeUgJ=C;Bwc$)YCywup#ax^$A)R5`%WVi}R&XZpy>Xa_ zQzoP}rbAq91XIMx<9p+=TbxEAJ=S!B+YrI5;cVu62V(a*Vvrthy2#Z=GV3{&d~X8g zfts>7z3w|?^R$N zt{9>*laQx{84D*4@FEz*&4Osc6vJzPnXQ}&fHw()xn&ToHKp>jQl^ZP2Y8dQSZ*Uk zPcx0>HAtDAoXvoDFotl&B3fsf#?#7}-JD8*wVkTDSt2^iw1C$jWA<`R0p1jBB)3dN z&oZsxX``6^oEE^FijC(sis(6}jl6~^<~vRwz!pz1u2@WyrmZ}!oT=i(3A}1d$ITMc z^Gthr4RYoXXM(_+hLPMdF`a8V#M4GIHJm(wHyvBZZ4}cBOec5^(aaIfW`TDIwv;Ol zqZgVk^0YC`F;1nxI~3D%v%=^_rklKm80G}$l)yU-TgxpAqZgYV@w5Y&I!=qgI~?1@ zZ49HAn0j~(1DG?MK7n@xX5xy&>7^ziUmMHRbK->Fk(iB}6;9`yV)zZQ%tg)wp?4HU zbIZc%LQ^VV8^<(q@`TN9hxpn-Oa~_q z^k!nWxQ&tYM$-v?!yx7bXEW%12fNP|OXy9ei+pV&)5)m>y%RAnH%mg7m~QeL5}6*( zDbPC!d&Mo2&|6H8_*%Ae%xM9=ld)cIql7k?diV_rCdlby_aThK6T`I0Bm}ew!{f$5 zUJQeHSukxe#Q+TmBjiqiyf_B)%3zu@r2^U{M$FBFyaX1@YlPv|5gMR231RJR5R2$h z@OV9*s#BO@+;t-7Ol%3yCxiEy>H%dcGm2X-a?Zk5 zuuflizv&uKoyv^i9u+xfV{3RNQSbp%E1*;{6Sz$x=NxP!uPzGy&eRE1tC&gL*COX! z%)s+S!HkI~P^uZ6D-}CQY%4EQ4p*6Ef@(E0l{-r8%*J-|O5||0DOsRQV`gx(#m;%y zUS6FXK4i)eRHresx$DHv9F}S1lfzCEAyB3>+1zrmGZ#C=%Z!F=O!EZQ>CAlYQL%GA zc7#_F4gX}y7bu4?dE6$ka{+dOR~HQ*F|8L=4`JTtz7{**#m?}2(eSS(i$FP)S;m!y zITvCVd6_ZrG1G2A^-yLxcT|`&54*}MiGgcPRRZNOW+gW}%()1=$*YTjPneDgs)sSF zx$DB5?_u|Nz8LtVsa~KQ&aC5>hdCExk9e5_;5ySaLG^HE1NUf{^L^|EuVesx+SDpg zj$k%(o5Gw+upVCB0QiilQ&2sE`JDSY%=rNpj}GnL$?aOVnaEWa)e zzG_-8tRBsL%Y7a0{1BVW_r<{;lSQc1Fb=LX!l}om@iXJ$Yo^`8Y7KLcJ1WAt5}U&> ziHEP7s)Wih%wcYJgtG`+z^{vkZ<>w?tH&@ubJs;UKf;#qeev)uQ@v0*mO08Tk8rNS zRU6Y$LyJAbii%DXbpH{K*d1_k9%4m=l?v+T}M(jBM*dWhy(^|0N9po`#8@AkRya z36xJn9&jThwoTXtenz6_wTT8RSoRTjn8dajyTV_e=y_veK=~x(33s-{#)g0S#}Yl= zreDE|Nyu~VYKiSL>@L4E(eu`H7L-p$UUPRyY+JC0{0w$_&Ex?qCL`V4pCz`>vFH5t zY&+F-AC#j=ANPvHX281n#}uA`=@nakK?2<85}OeV@H-Wrkck7y+5L?d0ozQN0LVZ* zTr&(+V2FS>47QoE2w*+p;hPaijw2%8Y}jVOqJd+GM_?WaRp3YjZ#8VQVuOHA!~>c! zNKPP9-VWGCVJSdHl1F4Fp$YbKxY!$UNAv&Et15GklJk6R3IbSBQ?`dg%(NS4U^ioVY7kt$(|@P z1Iec%X}sA|TNySVIF{^*HvbA$OhtzAR!ePPV($Z;$+QYQ0|C>Rk-TkE{dTMnNFPkc znXf|BG)BWaEY4D~Z5HOt?&$}em@5G9Mn!)rS^9zWY&P?Pzk?MD0TY!$i zw89*O03CzzL^3^%Q9!zqPBKS|C>^8arOEW=*mj^uNe?zBihvo6jyFT5-;GrOHA*_g zJX}Q0U}o`F%Jg4h2Y?PGtujv*0a*;m+a}XjVAVi+3Y})2BcigHT;5@seh>B&P?SQN zz{R4jEMy_?f=ss;`wgf{A%~e)iHc_;i+K-ay05W6fYuapgn5goYbLUkCy3JR!_EWA zspKg0c2V&xq>z^qrK`mL2J%zM(dGl9u33nlH#JJPA8Q7xQpqvqpG3v8kyX6qQMzxi ze}UFia-8|JsB1Q|mRB03JAkzT$trS!`Ld{Z4pPiJ7^V9bdkW;M$al% z>#DKAg5)%EhIy8_cpg&0J1Ey3#D)m+)5w|T#p14c$Ufc$x$Y1)T2Pfn&Nit!X4X!Y%H~VYhBUw9bi55hSOR+2-xy;#{PfmlCZzjLj6}r;|D61LCe+ z#L1f)t*gOu1XbzeeDhD@;`zu=yyellAF=lYt?A^u=F{S?`N*%l(rDdJSb-pU2$^TT zEG}Mv)bb8S>wd;Q666md-!tD4cP&6p@-9T{j$oe%s)ms7o1ck`-$hRI9!Bea!9EkT z4k14<`^8=FB4>Gm7~QX!RggTCTxJdrD_)2+@KR!QN3kyj`9sM9b3$0xLd3&insywVt5E%v=2c^J9UJS(ht5ptb( zFh+MA`%#cTjQq&FIIL?Ca*KB%Mt1`HO;9zATy0(zR{S1vhxaf>_dE87pmiAev3W~a z*L%o)o?w9PBz9hqJe*u--X2!G7-{FF4A7mz{ubm9CqFSC2%VEV!kS^ZA0lGi2 zr-J+uj*UfzWPx--}tLDdNIbMv#X;tvo%@8JO5Us%7Obp&ZN`@_0EKsbCs ztnMtv7bcG+&F1j%;-v_{Pl?r?!@`95BT1_{A-roT0`aHD>gus5Vbw^o)I2o2co`DT zUmmMFj|~*IjwHV@PYmx`hQR#NSX~1)SeQJD+-9B?UYw7}`3GZl7qB71{88kW=EdP% z`A97PLagp0W*jZ78b$6fuL>_NKoa;5V|D+--VwHrB6qPFfvy4s;S1t)E^LZ0IfE=W zZx1gnM3nrLINc>|rZ7K){K|YFysHpV^QXq?8nGN^)&? z2Klx5ba>ZtWF)^dPInnA5GIc%E6ta~i&r2T{=qoi73?En{%G=R+tX!2Y0v+&{%k%|0=aXL5lnXq*83vm%O%kX-)3c-;-`M`8XL@<;RHh^``JA^$?W?k4t|uxbqXvw2lS z@khvF{=<0PKiD6_)-mKS<}DFjA0bQmf`PhQ*m+^{Sn{ZOdqnXnq>!I7P}hR}EzBQF z{$@T9(X|TE^QR8f-Nu@QRb$EH=AR;pS0k(V%LnTI#r_qxjwOFLpN{BSjjZLD4%FSj z+JwpD$W!La5yfkeV*bH_y1Up@Vg5Ms5A&Uft~JOe{)K_Md)OOc)j0A`^RtNJkC83> zhXZy0Vg16^apYfSe?-^Eh>0&q(A~%QVDfnKoH;zQcr8NlQxbF!urM%xJbB)n5ZSdB zvGJ!S=vuKTuxdPc!8|mwcpb8xzdS+Lh7AN;$CLjvPmJtZhtT}e1YJ8e7)+i(UNX;$ zEMAXP@DC>F9%4hl{0ZdW=Ead+>k;FzvkB@)SO&O%0(`|>6j}HQ^9}z&g8DI*3ErLn zyUit$?VqsyckUpy7bC#6vfe&jD3q-KSo;O$KKA9Gz~`v&G0zIl-P8J5qg?%)=4V`Sk*<~P1$koq}R z1TK09{?~jvvYmDN@XrlWzrfam``>}@nmZy3KV|;lKNzHbiIsr2-+})#`y$&vW&YxG zS)Mv(0plma56ogo;U?xhKPgfD8Y=@Av0z(syrg{-^FKbGsP4pegZo)jq&ZzuxS9Ez zzbsMx2HOwbX5nk*OiBA@#?3b;s=Kf%Fn$vJ#GJ+Ad6;ItBT?Or)qsm8!B5SLB<&^4 zKm2ov>K^PExPKD-+*~9n{EYdR{~%HQ7OMkqPl8{XOC;@|G5_(oY?u$L2jeHhugztW z!Yxc2KZ%XUV2$9S$?zNVeo6Zl<}n{<2hOo;;Qq;Qx4A}A_&M{Gzl#BPJPC&O>e zb&~eanU{PsI~0Yrg7GNaXKs`f8kjeHheFME^T9Ja8*E4y&W?31(`nGm0gs5v+fif3yQ7BO6CV)#H3qUPdaXb}eUE%9(W zdpZPgM9srx(0&XSSkmD_GZO|ZV?Q5wJaij_K}#mwZe}Ea8BqgxG8B))B1;xrXknrN z2cj0>#&l>A4u@G5!R;1i0B{ab3-JtSKMqG&ir_*kGZ1)ys6jjvx{bpu=m&1MG75m3 zq=s+;iYH*Hr3@~ln883&l3IjkL5m1D%CaABrx+E0C#l8wJZL`wM_X#(!ct}kuq;U( zhA)C{6Yu~_9o$~Zi~!6@>Tovi@l)Y6OO~{-jL88U$?9ml23j-~9%5M}Z7*Zq1 zd$P&1su{fo^hBn0ZdXt$+%RsTj$YQ z)Uw=iFDzW_WZKoh)}Z@)hu{5=z0-MDu2N zvMdW_*LNdd1Ai)^RD6_Z_YBW0i(Z!d74i*mR|%=`38HH=Jaa6YWY@n!>_9*Xsc~E+ z%kq#Gn=H2i`5qXQ0;SrQjeIN2amc$2T;N_y-vpj_sbyRL8@;C5)Dl{6e6kVI;Sz#F; zb-faC1D~ft8r&h0&GzUmx~Sa!NHg$lDl`T^ESfjlQ)F2fb$vhb5AbIyG!{Q9+CAH| z%A$|T{Ra6LxSI-%!%vB>&GxLZY>K-64e}olNQK7Z=R~qOp0yTRRPF(!4H%??Cg7Ju z^X7QgTlPg=KY%<2CaIuIyh*fsj;Gk-jLQ8Mc?!JGnql!4(X~0O_wHoWbyn*Ge6E66 zX@N*K*R#puipu>Cc>{c_f+pclMDyl)N-VdcuCs&(;7=7a8GkL>J=e3v;*H9+BmKZ# z6@=n_qV%D=43?g#20If3`c!Oth9efU)hCNku4UM>bsW1j;gC3sq%D>hc>}`;1ryX> z9EZhaBu!aTU>yDfX=4b@DlU_zRA22P60=Ft_FLvrmwCS8!1 z=FP$viW}$Ado3sA4F{Rwg3W2(nfOw%IEUV6xhU5jVlo7kY2I15UYwOf@3-8PHymQd z3QnbYXX9(dWjXW#%Okn=2PRX{lIER*ZxT1=(BD~lbEJ0qncOJe^+?Y!rvW$&x zILyouY)<#);EY&2pLSZNMQdxAd4kGxZ!Yc>XU(T;EDNF=YM2FrQ|aFM_^;x!`Seeg z717!snMHz@bngQEq_}ZDeZ;acy5UD=iJ&jt`!0S~EM7qWYS|jC{fWsJ#0~K-#9iX7 z1@tk?-spy(m=%HvL%eyoM_jgmuC*MB*8a>C3G#+`7vZMc z!|#j5@6snN7o)XDnDv6nA>PHfSDf`OU1zx&-Ef52C^$95`#%0kT=p(~+VUt``wLSd zXc^*Ng7=CW-=)u3dZHVCVGM%4A>I#gPMCNhebyq3(f-O<1aU*XOK~VHYav~4iHT|W zmDwtoFx0z@-Q&s@(hZi>80}G}OprI!n~%qaH7=wtTE@mS9A$P2HV^d{;7FJ_k9Jw6 z#b}Q)y9Jd)y@j|sEGv(0v@D2eIL7Q1oEqv~j*kp0%cCz_R>Ww3WA+PLhI&`v;!T`aD13+HU4p!U1Ba3E0?J4A{V8d|NC%8R~_de;hXa{KOkZXcH!(GMr4`Ian zWQQepKy4jzQ*eB^YXkmEnB{%)nWb=m<`3ky;Kp#*M*R1%`uE8fmf``me<1e+-NRj< z;(vwlmXNP3)Bw$Cq*V|-!nFziUl_53?6g!2s6CB55{wz)+KjuyEKA5POZ5QFpGb#b z{s>nI{!du_60*lqJD~PYYsQ?O@*YYYB3jQD``S*{PL zJ%jWJj*oDCj=v1Ed_ejw?E^G_AwI#45iSG%Hmv>wGHB@}54R&x#!3+In?S#aZk@cN~s z(5i{8J%>aJH;i;y+2|y183|dnv6^~BCfqa9Md7M&Vi_s6=Em06BQe6`BVDEVh;Yj? zGTd4it2vLv3vY~cZN^#A+@g8NxlIT-)&v!-)bi-g-T@_98M?czl#=2mW!mrGQMZw#RDzhhz$G zjB>Hk!|?h7GSS)&M9bmbEhp7hZJeeN znJ3(n;i|xY2q%`4>DJu1+D2r7@OXx65B^KIWjQ(2S{SGK8(Ad0k>T2l{~lhyoE&Z~ zj;s9}St9JtaD9#c70z2hjs$O~xaC7~qP0Cv<3>t^H%7a@!{3J2e@ITYcE#1Y5reRM zw9Af%!g+cUvjXuN4`LBUYg`NtMi6?Eu*&0WJ;+w!7>&z;OCl_KQfpPlYnqTU;e3s& z3Lg+ruP3KjHSx7g$WGw~jq7_{5y4wY>a5y$%{64VaF51SjjJMvm1LGRH@@~7vR8Oq z<2s0sh_I|AXITs5HOu3Dq2+K$0 zVrzT6<{zX^cw>y~2>xkA{YT^yYgc^jKgbzj_ZZhNxG{paidkVfJBv94qI*Aexr$Q4%2z}gn%s&K}{O%_>m2iD$3ZVHc&bsfiliLk6DS6K@OYW_uT z3vY~doxp#Os9#O4u@(=k{TI0>>>lg-9setWw}xD6r3Py5Ag#jaajuj2|00Mr=BjK2Fu2Z->!m@@ewpI_++(kNs^T)aB@P8ue*N_{nwF7JKA}@p+#<~8$|BK*# zOm4C^4Ak61I)!`2xlZGcBZ!a566^JWwfB%7;qh^#yHm* z{B1=2$E3m9HL&(SBq;12=lTl|Mex>=CM%GjxsUL`=<%+zI2cK+B`sEYLhXG-2#y)= zI)@u2k(RY2WmP6<9w1_H{&-hCJ|MDwExFaINvM5*M1mW}vknkNBySyQvuYDGt%wZV zGv3vJt0IYYWSKQLp|%x?0gsP&UBE{~TGo-bvzJ-OFfn^5}@84hlk;QAZ?Ad>e9xzE~=pm~I3fO{smF5@3Y5}%O! zt=AK3A0cDG;}cw0@Q))cpO6Qv?FpL4NG5n=g6k^&X=ME;l*%b zWPLGt$f_As`vjQ-Zpd^s#;%A z&zaTWx_6xa;`btb8{w1I`a#MU%sR0A9p@eVQDo+)Fsq#%RQ-b403LnEc^7{XS@J2& znnDLDUoxA)rgxn8@Se!JPhr;WIH>w1^EvqX9p`^|Fw*xa%^8p?yDcJ-!Sd$Z#ubFLNHtTT0VfnR2k46{X;*lFZF;qjg?lbtkh2Jj$vq@Zpk@&G2PwexmXXQwcV)HUfOC zq;4~O)w({h`VI3f>*I2=%y)@zGwiWi5|v$y1C&m3dhuy2SsT7)-JMw7#T*1jO>#cL z=ddtj_`02X3sC>(u0-Gi|U*H=hb)Ug3@GPMQx@r#m71Kerdt*B-bRp2Pp*@s`1lo;SHYn4LDhG4*K)ak=-O6m-7kM)?M znvE^6Q3Gc`eox{vz`fRbg)+#zW{1O_e*BRn(+K;l*A&%3rkkDTbq4Skk`g2Ax3(&j zA*PR=z;p)j9!Z@M4q7`E)gdMTzGesA@SwzJggF!sQF0tyNXm{T5k?`LX@Yr_45{Wg z1kfmUoP>ykOH438B_m3%Lj+}GP971%Hrin!m4Q@q9TCtv%*iJbVV?xRX8T!afTeOVuMvp<@(Ojyu_-37lz#q68fipeEcINo-`-ESyMnBGsT{67(8(N(ck{m|?b`mZXFnI3y*UFtHWRq~K&q zmQ)Qnrb43#rV*;K@{HQnkeKIrMspQ$Yk_ z-xn}O@sgFW!wgBaPWId?&9uP;B}=Y`9i`AHtuu*;l$O|FEtQ984rieKuG}5y?uKqZ}&NI+aAKG;hn3(qr2^v#GVo6;VmwLeI4}6){QLxy>_|G9}C9Ne(Dts!dHy zm1dNAvb#bdicYSOCmn=_O|_*Fv!(0HJUJATERRk)49%WuODE<_kCl1mQ@VVw!CvQ7X;Y z?pa2`gDYZ_EE+sHKDD1Cv^y zE7NRaiR02^J3OnYwSy}LCf$XePqU39{*-p^@O(^}2FnwY9zYS(ZR3dx(u|#+bre0g zA|dG^G;F$U0&zvUey8UXws0*Ul=K9eJ>8Z`+>jpI>DfU2I=Es`(sO9_blW?`U1{e| z&!^Pc!SckU*U*mXwu!_;X~r(kX38_TfLkVL*+PTYPq&P}BlEf87=xi7vkY&&wGrI;WkR*XRxK=w}eKWJSB_uc+^oK#D^x znlVG4MdZqAcGG*PBT6d8F;uj2hJGfoSk|$d{+c?Y1X3L%McZcRXAy<6^snej>Z+1T zb!bF~XXs}Wt7JuA(ce(_lz_@HUUX@OehyJAtNDulmU^M2RE~+FCo}YOi7m2@uV_0J zR03)TCK6?__aRD_UO_vk$P`NL(2CNs^x4FASy2W3J(ZXOq&ak=8Cm*yM1`!Tf<8zM zPodHrvqUSi^f|-(7#ZI6kw=hsYofvNV$tE5`uB-vvZAl)Kd5^tzzD}C z(WROCB}A93=4<*->O~4Q!m&m4WTyTD!Y}Lin*NIlrT`-yCXr~CeklP&rSGH9QIV-B^-Dt$lQMD0waG>&Rf+HCzwVpdeq ze!7|ZE)^K#aEfNk))x`EQ8oMN8`P0hYK-G2(aPESkBG%l9sB8js57a+SjVrTZL{^O zh{CA!Z|D~4YAQ9>Q7bw;TfdrE6;<>N{V#Pd6&U9@DY`UUzlJD|s`-Y#OT9>?#yL)l zp3K&NOl*nj_=f(E3Z??%9cM+NIr_B(6_tK~en3U4sPT>lQQ92+I%0cN(E+-RN>l+8 z94^s}Ir{ZPMO4iJ`XM!3MNM#A7OkA4|AaUY)p3BeAWl{RnGTO=+Z=r{Q5}{3E&YU= zqoOh$*F}ft=r<5QMHPKZKc$wafOj0XM3?61Hxji`HQ&HB}4zFm&Tvk1D zJ*tK^9Z*M9)Fj6<(aO2{&xkuw9V~j6I->$6J6?&l&DC!q+N09#EDP?cikj@`5*?na z|D1RhRb*%3B==MR>gW|+nyWVu#;&LuI~|~2s3EF|FrB~I|G>1lfI9tDiSS2s|o*r0wPYq0Wj2BW|NG^hsHp(e3to}MNsd3rUSR2rE^=^R>d+B|(Zv0YwNO%E( zbmAHF^t*`)c}+E)QaU`1n&FrwUO7+y6>&h`QBA8#C#L~f4pO{rp1y*pmZu-2(@N*0 zQCW^$@!@&;J;YD)qJ#91(j{rYOvgg;rFr_jM6JB$AU&*fO&T@Ru~__Mp8jj%w7laW zJ)+c*2F!9S6^nB8`-ld4`XPE$>CQB2mZMOdmZPsEF3XD!(W6<{6ENGM7thGi?dG-{5cSbR7~ z|1I%MUi1U~PU*cgV6J16_)-olr0bH`{6J4CeUV17C`j>>9KD_J%R7Fc(b8ZVKsrof zQLdgLfar85jhFs<;}bH85~t+q97I@jzLT6%`hSYfgsq9I4Z}*cp9KO5OcHjOL)ZyR z3@*55W-y}Y1OWv>0l^)`7+g>*qDf8?s8I)ZtBIg-K`{mw6h(w~w9;~{*lM*^3KrYa zY6@+&+J567fQwvY=A8F^p8F1n9-wjPN@Xam3U8s+MpN3@XpIW5axKnK9#i-WyKVGT z?fB>c6>fIbW+-bFp+azyo~E4(+jk3qX*{TEw00}lnsh)LU4vI(7MNH=HhLxVOh%a3X`z#482er5Hm0r zXI-f*rCqUGXgxz0Yhz@^?+FxTd3-GtDy>pZ|6#odj&(T}7Z({}) z;6vPo&sDZ4J`jNhdaKqwR+Enpa|@fRys2;#6*kb@v;naL`S=JoYObb;y!NebCsVdMvAQG>1yqYSj|E_z^!+#@^i&FQTKUz2gimTScr$X4bN7x3PJ?z z^iFM6tfq*&QH5nIZ!4yY3hnf6?V;F#A}&QiWh?I}W{a$Lx<-2@R#S|}x)oOF#xm#VfvO}?4RCs|tsC^teum~UH)}F2W zQn5y4y+9w<{u--UjE{Hg%~pP;*eL40Kp)Y*jU8BwbE(W6E)%d#1RS(Q>n_zS!AZBU z93@hiM1>Cem^MH*kK6~{!Nkv^%NA=NCyGu-NOlwFF`qQXY{v^G~duneE;)}Et$ptvBiHq!Ol6;jP| zJlCx^NBNE7s;Ik>KBwIz9axU%yA7YG{8n*G1TNC&wN+Bh3cScIY@YI=;tNsXMf!sF zkaS=LzQm21r+lRNT4cRQH)_vFH6{28x8ixq?-bvOx-Zg~wAZ8qC3vZucAoOF;ztp< zL|@U~m1yxX ziho4im*^YXx6*-Cc!k^WT;)^62V!uUzNvMO)0E=d-NJH}&lGOr!prn6Z9v>WDGuGJ zTxGYyTWq~dw`*hLG^_C{x8hvoj|zWr_htHX?fAHX)wtPBo2%?mgo?ox`nGmPoJNf^ zZgsiJUPX+!@CyBfHaBiSjqh`7&s9EGj1*h1&>h+pahf&wA-CRKv>q7@`Yl$xUh--T6-vNK!e-ds66G*irHdo6Wygf z6Q?P|&$t!mDSuJS6L&Y!-)OJJ4V2*xZrVKMuZo3YaFu?jy&I=li#y!v@|69G<>JDt z^mp3FaRY1d%Wmy?%HI@g#MZ0y6YZ~YnsxX!x86ME?~0A$?yK|<+P857>+lx0;q#R* z72Cw%8vRV`9sr`X}x9_<;>vqFg&)IiNTu2F>&f?TmO$Iey=*ZoYC*aavs1O#h8AZoTuBe<`ktyPN6XwVUDxHsVj+hA&Y5t+*uy*XdW< zs(8&NyvHqUf%3KD3vuCf`VZ})_<>D$pBuG6`9|@z*m|8F)SijgY{q|eD_)@dNAaDw z`#SxX_FDYFX8fg_c7gJ(V#|+WaD#rWy&JFj7$0z}TcCWW_(fcJgZ@YRIDX(`{BO7R z1aII5qIC9|I@yWAJ~EqaUY(q9725H16t^zI`;%k z1wPC@EMGa4aPui_p+C?CBn(vGBiyNcr3>NhV{M^_>tYi$TXA>y;(X-?guhRB3;jRc z_=JJ2INx2HuN+2%`hc7CN4gmano1mVugg~sCt`dGZ_;kM+=PKj+{e8=U-=<1(#Lv} z=IK@>Xtv=2?!EcS{}JPSx^HrVw@nEH+wc(g;RVVO1mOc(X@RaPL9?A3_JkEEKO(04 z6t>ddxH&Dt(DBdt}S5?o6Vl z8eidFyin;+eCN~s89iEeEpeb4FLl>0R0a?~`hd^rvAVm7non?zd)-21An}V&;pg-? z-Q&c8Pw@5b?F*Gb#2-G^&*=%eUlTPua1O@3P#H}8UN*23H@j<#loBG;7u=?&>1N0@yKu(6u1Fb1#P}B8rl;$2WdpnLeeUf=%5Y+& zul06kEN_{tXt(8{`?I3R2qM|{(QW=LP9WH`+j7KxXmMmDq44#)!&m8yvLeQE%pEU| zj3QEfbMEkG>ki0z7|uI7r8qL0NcTN(ho7lCB`d12oOUlLj*KC4d>`H6&(Sr>dTK1^ z+_x4-#uA0TeqZpjb$4V%dn^~YDP*LSSmvAa1%ICIk*sHr<&yi&;>b8c?R(%0exB}U zS}0=-`*=mW?br zX!*|l*`mnNM3e8M4*n`#`N*DwmLJ@QE{+^SwEFse$zQEAjx0K4`OzI;966S_iLhk9;3}$zP{y8rgH$^1J)i z#gXHQZePEz_?%IFWYMRVKin;gBPS3)`{sPb->7>uvgcFFU+y;-M{@Xi-veLqH|u^L zS#-qmkNdO5k&}owzK=MVjqc6Jo+Fn3+=niSoJ_d*`E~NQ>RjbTM=c-l@FkH`2v@(H zPX0EXpS*|L+VG|9u@c=EO`i6jZ& z=ZE+PU8=muYVqb-mPArSv|kS5n{+wy9;-#nySXGXg^>9jK>TXmGI`N4i$Cw#lE|q< zvfm@b-=Qm)_Z+hX^M)>sR1yk5zkB?hI-|U())LCYmqtz_QvGu7@ptPE$a`un5xgl& zBU6cVzXSL9HM&#sqB=_quV889bRx&^(LMfNU6Z`0&Jxetx-@bIQRwIQHGjYEj=bo& zWh6J#j!Yw#`Q?1gKd5^o?>TN6&AYiYawehnJMcCCuW3x=vJrEMn|@voIvaE_DdP;jkQcaoL^g5TFa1HMUAHgk{uz8RZ^Cje(Tn_cJ?M1k zY)KjQ_;Oy}@)7fhhko}SbY9e5PP$)@ui|Z9J|dTR=I8ZI=Vcw6lyMec!#liuL>}?N zFa4X&Cf&D5_s`<%c+Ja4%qL#??fRzknyxP?;~c(`_hk8q1;lH=d*5_k*ZrMz{~W%B z_ip)!d}64-*SDQ5x)Gx?8t`qrz!f73h>!f!zwK<*`HZ^Xfa`e^R*Wbl1pd3e?QGLU zj>*^h9pB4qUNK@3k?6ncVdq`ll2I8K@PoW3D@H6P#`@oT*!iVy{iypF@FTo; zD@H6Kruch3>g?3PQ5g>W7%#A7#8P6KfBK`&d%At2?mKYKl3X%k8KLsu^{Df{&NeEe z5kJk#D;cqz$o9YYsPlpD@~Hcb_&MI@k`XJ20)MaXI=|JiqcSey7kGzDMwAds{nNke ze5Ct!)cuS2C0=vMh?T@@|6Sj8KGyY(%D9AIfA71_?{$BVx_=43!FyNI zw%qS0-aEuZnmuIyV)nEj29%{{vC(c~4& z9bWdzJ~gq=|ITCcbKR)XZC5N`@;0pG*zQODU7wiybSb0BCd)nE{*`?iZgG?P#Qd`^ zYjj(a-}>MA-u$QT;^?+!%Wu35tNJz)PyJngF#oOlY&3b@;^ggL)whZ0^H2T3{6_a+ zblY{ypS&xp`Zg0U{f$4E-|C)^CU02Y@E)$}` zfa_E95dDW^$QFx>hi_?L1u-HZ^{LrKFB;RxO!xl z_Ei!-0e7C7Kh%#J({|J1;jy8#ZyOO3;QGuwLZ32*Y_)iK>@V%xPDBQzJ~O-Ov&OWw zT0|aKO8c}#LV)p^*7 z7U?gJX=}H{d2CqSXCm?gTz@qC=sz1nerAz*>|fniMJx$O{n6~Fe=w%)Gs`HCE35me ziP8Y$kLCdV^D*S-mSm5ItNT77)(71A(Hx}zb4=UkmWdv(SNH88Dgs=4xLEmzV@cMc z@bFdlnF$z>+G7TK(bzWDlHxH|-M5o42N-+I5`Fks^0p<_BU|0Ki`W-%r^g(wA2qh^ zwq>Tr26f+V;%IV?qj$$U}~>9TAwwx?T#hOb(_7QAA z>T|P9zh`XQUCSbmv1|JF6P*FZ=VrP7_*k;TvdklUP2U0H+kiXI&7<@e$F_A?R(fn$ z(|3?~8sPeqd5r$EvE-K)wa5N7eTRs?fYhJN$@&Ll+rG4{^|-R8?=bN)!1$ASy#D!E zl2ZbEJY3WFDe-r}ouAAT^?#0S`^xgM$LlqHM~ESTu6^do`VW)IPD`bSucq%PF(NRv z&#cgklG{2hI*+lMJ`2~2F!q^AeRwj7EJlxPO`nzU3B1#1PSKA_ZbOz&JT_?hj&Y4z z*B54`J|&sFXW8YkU(;7hLJwBAU*n&83~JaD#H2u%pJ`gZJz0Jqw|WT6SQ{}lQ1LUJu4j@R_wnN% zNoDLwViwoepfmKf$?`7zlt*S6dy1G7*!nY_rFSGdy703eWo7JXVnLwGFZ5h}TeAEC zZuh7uW6uza0~No}Ir{s_jtBTfkH#{#o>&!F`3s$^?@5+_gEx6}m9b}ub%CwF(DU^J z$&PRE>mGw;>^Wjfpv$jxzJAy^`L}qhhj1<1K%PW}+T-TYse&>E+`b5AiQO%GRa(`SrxJi;G%G_GYEiF1LK{qzcb(Kz{c_#=<5wd_UWQebO8y;83k=lBl)-eYhr zdx^La=<*v~s^30N{uuA}5Uyh{6I(v#7Syy_&x~_C#-Dp6tz)kcUj)2-E_rS{E=}r2caq=JVHy&N<*z3e!fvvyOAL|FkIex(Z^%z{o-XQ)9 zba_cv=!cD$KgC@5~ zDt5fMR(SF}-|G2YRQi#!|G zv+YEDP~|JSN?$Zy{v+<^*|nbij2Io%`ilNUuNm+75fAblT+e<^ObT*w(q{ek@$w!V zcnUYLEHO1m;iPxznemPuJlr#B1ACj86;$b@8GY?|c`qLAnYn?zL(B%s+;mMv|8`v+2bwRCv(4XoD#yft(CwdNUV80@^1i1{*NA<%d$op`Gr?8yuB=kXw z0otm^COG==6wjn`77;swDhKFVy<~#?1)l1eSB7g5w1~)3dCc{hBxu z)E)GeM0;mRQVTuP2a;A8}V|_u8r*X#9u+Jf6>?V1Duo) z|JZYIBl`pKUy#e+bc=r2L^)^F^Av7kpA!EIR{Tx3>amFq&Kc*Kw26I2cm`MgO}FVK z6XmaPqi5zOwwn+KxBg9krk782aON-1vQ6xdL~yXnYns)Q6Xl%T%Clw@+e1VID_+xg z^cfQ!oW9AkaTD81#0OWtrtj*DCdxTOk!RN?_Bk;+xb-#trCu}9!AW>L2RE@l5tD*l z-q4-;?Gxn#xYbj*ne8K{1}oms_w>v}2WL|8Oxn!8AZBsxGWx#0cA}hASALtzu9h?utvurc_3$Y;Bp_3}xM z*Z7wl|C#-RI1=pgj_%WwljLvkdmQ4J9Ux8wE8fvR>oX=f-rx^7h%Y-xoC~geNB^oX znk4@Rf5dTW**}R(!L9G;-}IVEj(_m)JqJ0+DzW88u*<*nOa1mq^0#=mr;y`_5}$L~ z8QQ65COO{X&pnekR3`CNaOJ=BfWCH;{2l(YXC_C3B)$o5{g?hz@0jFxhyUhT#sLqB zAA()}qyN^oO_KkMJ3VVSq#yB9u;M@ZjsE^5$G`ZWo{b!}j`%&e@;~~mzGssBKm3hn z7YAk|{t9mWkN#IbFv;;B{;%iY7M7!m1-lGU4S~ZZ%ZC6LzOaJjxLF~JAu1P$O?C_c zAM%qbSPl&pQaMC53`!=;hXPlAW(CX9k3w39s6K@9$&R7GgI`v`a)6%@m!YZ=ken=c z0bcx?3U(M75u)I*GcaSa!v%==jTP*0GCrh|Bdfro$?^|?AHS=D{g50T(mGW29t-05 z00i*|E7<>$lR{ivRPV_b@?ik*gZ!lN}t7fuFRM{fL|uQt6_?VC`f% zhaBK%Ze?A`IU%hsDiL%{c5tnFe%V&mja(4o@`1_+woR6Ey>WicR@R+d9HRI@U`E(Wd|1sziyJg|~qwvF{Cf8dhNsxh!_ zirfRJ`8C_v0P-g;%d1L;_op~Kz*>IeHa3v_J*1KgqQagja!*js@7l%&k$;7>a#>3_ zFvZ~sKIRW@V}r^6LR`2sAsnWV^FbwFxSb6l|A#BMz#POB4nENFleV)s>4{fznJ_3( z$OXX2&)m)eQjE8bP`ytxIRxMne%W?5lnlmQK2p8U>&U&pE`H5+RzgPLijP#OFoO%5 zfIa-i?Q9qsk5_)AngNTrJOwzw@7m6WlcVw0k5n_EhKme&qEm!!BT2@M~!YkcWxv)ne=k^Eu zE-f2JuESg1RP*70!oiKm`GZuXI-xK?x!E1$Ve19V;XE;;rtgMNm#Se8HF89Eu%D9^rT|+(w@yMTFcRJmPn8H(_$iCA^iVS_w6T!ykOlAJnnj zH4b<2P?f^%gggLr^M!hryQy%oK9w3Wgd+ev=O^h|?l6v5dZ;w8mXHU6pZS@3Hktf} zJ4ve6LI>dp1i$gi^z1nD2kxw-S`XU@c@S{&YxL}R@+a;%pel#=2}cn4li#T4B-p=m zZ#>l|*h9#J!5e;;o}Ebk#XZzjAHxB{5e)w259--T7<(jfuif+PbwjhqE4y;KaWCFNltT99dA zQ^`4?)l0PpI!H$th!d0<*y-c~;Nq>?2ir(_IFJcy4D1YYF;IA`4#4}QBOHtpG#c16 zauulbRvm&pq&xy73%U&KOmZD)^;Uff2S`T*m?#)Du(QZ5zy(tsg~KR$B=@HYjVw*- zfdW%mAx1eOL5d*B$g0R4pb}HnLJ1|00;z&bBb!d{1+AFsIFwV4C@@n{W@KlRM}Uh^ zWrHLoj|S<28Y7!Qo&XA=>J-eN9MK?4&}d{c$#bAms5%3SD0vLX5p)^ZEbr;sy5!bS@6naTR{E`-iQS)&?QBZ zVBIPZRN3;$4?~lE1g+33r8dE;6(m;K3P_L8avwn(1Syh4tAYFPuoaS`&?X zn`o^Ttgf;xB!fcV_y}02NRh~_I|VzdY(->vXtJ;14opv}m04>9=c{bRWL#*uui!2$ zOp%PV?iZjc+ahvQXp^ttOQ=q%9ceu*cvWRvOim1a<16Uo>bw%U)glm7+m?_ip~-%N zdvI4ut=w8CNUXLkC1-|~`w8wtYlq51>7zHpyBqSY2&fPRsgm*5X9B?v+j{b|(BvS26YiQ?JKowWNZeuDKz8yoKFUC7j1ZfOgnECjSg=3Kskeol|QkS>Fj> z?XYbj{|$W;EEr;NQA#FThk6OjwhHn?NiqliGI()ZChKsoM6+!x=^-f(5ezc`rDTfr zBksXst0YB|rVzo02AQ&Uik0WJ+RPPYf+TN31S1Rzr9@#Bc*PlWVY$ZQIaNH;9*cJYYA(h*DEu3t(qu#gA4eEN~MIf z;$DKC-1j_1k_-f1hF!{9(i-NKxYK4JXG+R}05e#X63QCorP^sTl39`_AP^bs%38`Q z^;*5tW+LZH-T;A*p;akKu_k)$+-a*K7fF&s1%8Hm%GwlblGpj2wrX;vq&!p*VCYs# zrdr2(p`Er*$hDHDP(hHvsjQu9o#6Fqr)>xMvE)stAjIG@O`^0;@e=H^nMs`_St0-i zuW7YPE9I5A%eIsJL{cshNDN?_WSVsv_tdrRBKJs|B!X~*Y+CI!Yns>UUAEoir;;}k zL8L)3O_FL=dF|X~W60x@q)KS(Rxbf#J4${SmK-Ti7`#$zX{**Nk+E4wkFfGc z0cilK5|!1!y^?HJQWVw{DM&HMQfpP#YOmFd?HCyp_9jxGG$>Lf>DHZII~iLo86K7# zB}g@-r`D!hYrM`gwmLE{tUO9E!%&zinQh(gg&5m$a#UDTlwhVoomxBFdf4j~V>>}k z40{tLpbeF&k_@Ybd)wM@HEE44PmTIZEmV>?OC3@eWoWEiZel1%GKFIA20 z6qyy)6fMXy*i&mWt@U23Yiy^<`C)IO1#=Cpsgf+~d9R%{wln0Su;ds)j^SQvZI-ps z>wJx^o?IDL9wW##bf-$@Sg&}Y8rxZNZCF!`V7|ebT06(u?DeX~c8>fw>`jay-{3M` zGS_<3OR&e*K)Pr}M$IsOTlF3Gmu=HAyfJGm#UDORw^ zAe&yBZSC+{y~lQeoZIv=7A-bRo{r@JFtt-hr$OeRH34fJ#-2XuMW?}3eEeVAH` zmK*Y?WAng6uZ}(NBH0*bm!c9w>2ziuc;eN+2VNqZ!<m}U_uaF&K>NvE91G8Xx;DuM(Uf4u-h1ug!nW14ilLz{}O7_C5l`m|6X{5d>7`7M_UX-W?=bXs5fsPY$1n-%M#F5uB5@_gW=xNeefp9 z3s)zgZHAB;SONIRJ8d6qC57Sk1f(@2%wP%t&%0zFyhR3vI}?!JFnI=62n62M@6SA8 z;j%DF#ySlKt=tGB4aILwgM^Gq6P<(Yty-yh|2`%SNL8hRzwxB9P==zaMsx zCE@Ck=%C^03~VtN>)o**eo2;v+ef0qhL{6=gUPZ1%aYJZ1D(b!1DW3S2VfUjAFfV9rw#dO*m98V-Ejau zAREK&NvPgXn#L>#dEWg8;5TG*xHAczGgPEuD?oua?;!k^Y!8=>Lgx+UG-d@T_Ld%m z56O;j^(b_~a5N1o0ZYBp4#G!dSGau?YBV&YF(shHyW}AJj(igC9EC0!TGFtUV6}Jk zLHL;L4VR5ZSGblAvl5hf*B^vW$o_EkXmr)^G!0t?Hh6a&gx`~c;r7v}+3+%rSp_zG z_aB5mknh5sqtOk+keOI1*y_zY1fP<_BV=RHO#^=>Qwp@+(nIhW$%{~rLAMMcGqKgc z;GK2|c9X&g`xw-2NSMj22G!mrhv1K7V1#oF`rI&iCZ+~Ey{q4!mct@sW6^B`J(E#` z8t?i;u$PoZsK=r&4EZy$HDJGY$07KfOp36NMIDCHnamn+*t`D_{E3_p;T(&;GE~gO zG{EA`I}H0sDngcwkik5Y(SSN{>0$VSOp8z_qpuA|XJTdGq<7k3_%oRqVNXU~hK89; z8L0OzIShXx^CFzd=o>@JOl&PU?_GTu{z?`{$i|_EhR&JHTF~fSe;D?YB@yaz=sUyH znbLg>p-)2|6%w$xjDi)4*g&lG7DP|ZhG@Rg)d2Mgls%| zX5i0a)`NC$>8J1&SskGskA5_S%)&N++umuPLMK@hVIPlr4GFWD4WPrju(pkEC6v#^cep?Alp@K3Ta z!af1@8%k#}8^IIr{!igwWOIab0{Y!hF$>!So_X_*z`x1%2-!sR%3z+wYy!RB(j%OY zy(2{y@@a;HCu7p1a45x#R8K}97(-}mEBFXY zJIdiTg^~8jXt*(fX0`$zR&taB)&+6^HuOK^WE!jF&PCPlFIr)dG7cSNq-mxS2(kL3 za5yE6R8K)}#(a(~0erEJqwqs2DbhX#@rY@it}kYXhkE-IN9 z=~N)Gv4zHTAQ7v!aN(n3E=+@bjh!^314&rD1@fqpNHu}{jZbMz55{607U)5hMcN4z zXne`-3BUxb-vT|U&5=$51sjKOlQ%F0<5?k}(niWigd6!P1_BC`TA_fdj#QH<)EJ_| zxKkLGW`$l;{Li{#qD8O^9+% zL$iz(=@I6{(EobcO+Sn6wT?QE5@?R5aUoG##q}C$Y3T7)@nH*;7%b zu_2wQ0rgl(9gLy!qMWH{jj+XK#H)panIDvpv(N7=^CbY>4|#Omvylq!i*Pe=2N zPt&oz;0o4J2ji%+DEoAjXMCB?>;=tOe;tgcHb*(9qXotxv$1{PCdNAs6DVzzYz8VY z@@F&qKszQq4il;BDD@1q&=@is+YfGIX~&_A+ENo`pMi>v3A35~paUy84o6alqntC) zV&ml5*a3jB>h}YTx+qy1T5659KN~v;9%3EG;V7yx%ASTw zjHR=ggWw6)e;kgcnxmX)XqB;IHg*U+!+0m)7^*!=HWRHjnrAbIKrbde0mo7uQRRps` z7TRJQl7SuNAoV;Ohj<5Y>!Fe^8h8jP8Hh0ard!Wu2{)C36&HTElWo`jphud z7K90K@Wqe`OHv(Z80(+unc7%S{J31?De(e~Nsu<>OE za{^2d_Me2asLj#N+31LING4_jQ-r)zkZZt1%QCp_KR=VPao;=XDX5~VqtzMcm@y<1 zI|-%<(@w#3swUc=f$EG2naoL$CM-DxXH$oxof+r^hs(xJ0hO@&{phPMT9%1U8tF{t z6v!0TpMsfGeY83goi^rYVy8j2u;UcWq8g*^nW)}an#r67dBXlva1PZR?aV~yj1`&K z8BiePorZI%_GnoaI&U;*GG{=sPQytOjEOfzmG!v@_OND8tVGh+5ZO=lD#)eF$ z9+U`6PQ!WBlW1oax@2t0#Lj}%!s^p7m+Fm{%|TbVVl#6VlnLuk!#t`#T0I9{H9pP6 z&VdcWj?-{HH5hH5gPM&mGnsQ>v#|d(TtK~xcFsXJj6<@p2C!AgI|K8n;W4tg=%$gM z#WVn|P~m4OF(HdN52}SFXW&9AFvd9-eQunb zh1tPQVfFjDU|5VS8{Ib2S&SXj2Kyd7@n{y- z2u=#q>ftgfGsd2Sx{M83Oe3flmej-LR9=iT2Yq8~$-*vz^TO(SxPmHT_@t)f*#QfL?PodFDD86xN@Eo2mX7^#b&d@#!4w26!#(I0rwb24n0C z&^zPHIm`|4PS}4AZlT`AI2WM*j6>#PEnujK*8nT1;jyxOG}OeO%d~*uB54EMO7UXV z`RD^v$Xx6u_(+u204phBtUVtMHzmwvZUUaDqycWD0%M)|=zpfkbFo%#3|0Mpq#PD2 zD?lHa=($WQ5Q^#>pq7%xstb^tDSs|@3;2pU8la9!inSLYo~d*$a|;BD`Wv90nh@(O z;N-a#bFnsni+JZD_vnk26(WJjJeO$$VIt{yXrR(!)rH90baXD(4x&V9=b@3xjI|dc zp{Zdm(+;GflJn3+<;6M+k=WES7yAq(imK1UDyleEwh;N6I_ENs*ymuZsN+2Rger@*FGPW+mvfoV!30tNdANhx9P3=jNe+i(V=R~=;@P2@(#Fb) z5N_gUGc2G)Qajv9RmZA}P^c*+8@mmriPG$F7gZB$FG69agly(ENE4OV;cn`1tg{G3 zm?mdqcYsP%{eIqF7b`19Q6@T@xdSpq^>$c8)yJxfQH&`+8~Xxei#qIZ57iiJFGf;R zX*Tl($P@M3;a;ja)>(|=O%>VLT~Hw6U4Z+j_E^~>lxQ+%Gj~C;NO}S8r#fQQi_l2Z z(QK>(EET0)fCs3qSo$dI=&-AvqWV22q*=S}CE_z64RG zgd7HeYEg*;9-{)K&LwE7X>tyB59}0GJGf;;m{hhDO*7Fs%so&es&~LTN-9+^Mbl0B zIoQ`=zo^3jk5frf`%;uU5ni_JLE>JHjX@sY#JgIXTnqz9o z!5)C~qUuI?hANiImZNM_XAbiKG>YmQVLeqMRWC>LOiy#LZ@?8%M^$N7m z6fzHc2yTngE&F?c58U4oaXcByP7T5U4VV;+NEk@OP0LUl;hE72MbD2zP;FGOjVU=!6PwXZ~F zriOXU6VNXzxdg9LPo&P3Xq~BL9`-$WC91vzuTi~H*($VwBX}_1gF#XKCD=^$OVz8; zM$^-I*bm^fsN)j6P7O-!tI%fC%X!QX;GL-d61+jZlR8(SEv6y4*i$f6%)1O*sNr$4 zQnb~?&t;y1;bQ4!c$4DAsY}r|Q%ElM416R`y9`??VVu1bX-x^a%rn3fmt2OosK7X9 zDbkxJ=VIMJAg+GD#|n#+twshDoy&9sp}77sY^S7g>ea|(%Fo4q1is>q%kVQQDbBtc zRhvq4nIA!*xc@TzoSG2lT#a^^Dsr(NfQxxoxcwy+CsU)HCUY*+1H#18EATdz7N=ID z-KL|tSTBeYr(J<}sLVLK8r7H@a+zKr6_;FrUr>2*PBq$VYRSc(gG6!l6?m5_j+3oH z`%Rs>%yW<=uD=31sFFDK8g$U~G#C2`j1_lWfnQQ(arQOnu<2zk^AngL?!N-RqBh4l z*PtV&A$eFI;81)`u#?ip$u!7f;^#4afD%iaAfl?{)Eacm6q1L%0Mo>2P4FI76KB_; zI#WU(^8%!aOPb)<)ZsX%2A$ySB-qbDC9ZzI8*%r!GUM zP5F7)FCbgo(F7k*jdAueRBtNHV}1d7;{GQ14b>dyEJNo^6?xdNpg_#K3csb=<78{m zd6PMh`4tq4rB~rYsv}Ol7F{qM&BOY^QgPZ<_=xI?v#&*sriMJGAC!nouEOuACvncT z=#r@=5Bm+Q7FS<|kEz}`**bKE!@)4WfiiLZRrrMJk5jKhS4~gzu;0N3amQ8oJvA6- zUx%7aFY}n+!Dey)Rrmw-F3!0Q-7pQAkG%w2#k_0qDK$J^wjSLy@#ixyfmSTN2A@&9 zc=dX8%M>yndj$;QwD-GBVZ41kYBwd!XI_D7amh9KBNZ6$T#r6CO`eZA!A^1YHP}Oi z#mhFJ+a`KG;{-M0`fIS4lE$kypf61M^RYj`esRY&_?$|Lw{JilrqcP$AK znh@{YfW9(S%*O_RMa*l4eH0ZhD@Vv=p3e+`IE%UKI!Fh3YGyIh*j+bpj4^5r(nLk0JxV{X+#`^RX|)Ubeg2l~Y&H{d_ilX&OH=r2>t z0_E#0EKk!c6e*^wUy^D8lLI0VCghKBNdN?PCtDS-*j3iQ9K$@!Q|sKBSX1rAFI zOOS0vA63!$j7zA{r@jRaPmw05w<5Qy{Cw<#P+y;p7WiRGQi6Re;#HOAGarNo`t-NJ z|D{YwaBfAORTcTzuu$BGcN30Cp%P@3NKj?YXNHA_`ABcVk5bYS)RoA)>S#VTJT%HD z?Iv_h$xN_UB4JfSJ~KR2>QizPx~1eLI4hC3swE%$Ff`Gp`X+QwDNc}WL%vm=`OJr* zNj~*AAupvQLA?$6S3S+g{uesdr{gB{NGVIOZ$p7qFY}rIg--D4zX?54HYYf@q2Q_^ z1sF$j_u;j2Br9!#Y&*iM_yx>}P|8Qz3I!?E3F_@Av?`THwRG1$|Q56J$D+ zSY<9?+(V0fq_?1NN=JfPhelQ%Ex>r8OMTLALBEu)1iKC;RW%eaywDP#l3UO}7uSJc&2T>GvClwi#m8+MEuuw(3^-{dbp zAXiAvnRDLfxo@w1=gI`=s}cEYEz#OX(>(iurCyiMmHDC{MjTvgiPgTHR@n~}c)dDT z=7)BU=vZrs*LKPB><@~(`ZSaY(e6RPWtK#3|E$XXpww$d2L)>^xOQ|_B^OkA z?Q19#qeFx8HI{Vk)GW^dV58UNhOz)OH0YqlGEzG)t8xI?;`OSbY!Dh7)StEE9bQ8l%cSVYp!{;n811$!&w*gK*T}}QKy+--!E(!3 z?SZVyfncB4?8dUe=;WY|a?3dF=`7D*z~5fF#DE4MPhT1|3{ynXG-ARrxD8<@KtuY&cpF)UnPoMcZY%rx!Tm z)#rTK2(&aPSZkT4?LWQJ3p9ESJzo}t>VopMmg!pIbWd+!_8RGon^XlI)LOE&Bc@k+ zgUepC&zFUuTY@^YmYLdw>7G1b@zR|ylc76;f^`;^cJ%a09=PGP?|fM(x-TeSXPKp) zI^EL;-0`}6zAOwq9CT1;nXR2Sz0wEV_j+}{EF3)*)SAn6yz|msD2qX#1_c`|OIhw&r7!s4weLb%Ecz-a-(Xp;eKg(E4}9^u ze4#83{Sb7}U@6ePonGk&I=x<9D2qosgE|bBLTwkNrx0}Y?$cD3fOZcKHd>0b{gstM zz%s;H#539_INxYltraRg{eheJNM{!3k$AU}r9})=miU``d#jpql2LweyOCV0jZ?ac zO#QtzO>DFV2@WJljrKQXiOA&Xy}KzV6&(_sOOorfzbjpV$;;c^l!KzO;5w4jY3C?Q zfQj$j)|7*xF~RL5Y0$1zx*{f_x0^W!qA9_F6iI6J$`ZtccuUPWIGPchOOX}YKa{Rw zlf)Z0=cJ*E;5v$|(*C6^5u1j1tIRp+=%nCwirk?6SLqsH8sV)m=VYMD;J^xUqxOoj zB)}x|-fhkqiRK08R*;*t50tKhOcCB@bIxz5I=HTa+@gK0EE!~q@oqEcj6zogw^xu` zwLg@u5>tY=TQdtoE(s2-B)4n(W|v4zDc;iN90Jt_=T?#?t#7ug)C9fp<{UX%8C+LM z?$8d)E|Hotyj9IPW6;gP?Um#%ZCti%plP(Xra4E!#+d@E$lcoCvP%L@3h&*`Ib+ei z!MRoB9_{bhu7gb#-5ZHp6k~iQv{MV!w7pwqr1u>fQY! z9FLw07Of}#)@rlWLx9p-d=XASF9v6=Ck|;hXFG;~9PiYNFcZBVT)Un)tlgWf9t!fj zvoFGl=-uGf^~4eF$!y0^FweX6BAkRi2^MW2j%hDttA_!#_l}EjGWsGoYXfmYdoSBD z3@q_(ya<0s-v`%jAWmstW~+yT72Z!T!YSyt;MNVqf7b-YdhO^PaSs_`Qh`ZW*GaO;ysCVOKI0s!2QoD(`uYEZ~9S%-< zKfMg+qRT^CHxUoD-)A_&!D;XASKvH!b%|0VM+~rgr(T82(CZC9s;*<8|Oh+tu>s@*ku0WrJh_(`6w3lY8 zz6iHe!j{7=sRw)ovrY>xSg06G3lY z_BFT~^^vu1BYNs$avX`EKdO*j#My$ zS89P;bh=DrBD{5%a@6cgiZ_i`L4h~PC^pdMWyt2Ge<-OC&`24Z+m zEzp22m$jM*Ki&5n2L=*&-5F>^SIa~+;jinhQbUl!6El!R*U7SI0_gZE2LzCp%0LRO zkk!(JST{ta#z6)zn}HSRCRr;@4ARA@95@)wD`j9MYLba|5K>)+N}UE2yd4azLifnB zb`XPglT?m0FrL@Q!1d@MHfc!=)#a(w>0lD?DFZj4CuFTVh~c^wDn~k)%Ikg|R-@-+ zqMbyLPODO903}a+9d1M~%CdG6A-c^fM+V5@rCx_M=yh4`P9jvdSEU{a@_5mEO&H=74AR>hi2_2(sh$^9dc03YqY|hXb2laB}VG<8%+<*sw2keHs?AN;7?xa z4OoYc39YRo#_IOws>g!8yzCor4>~clwT>94JDKYk3l8u~Z@|51;q*|^9%6#-Qm*=c zpq{tm2Hc0v3eDO>Ow`@Wb^H$;3je zJ=ZY~oaS}E2@jyFLq&UuX}aFC)Z;+|Pka;pjjjvL+DlB=@n<>4gC<_;O?VKk2(8^q zWb1~^QcnPvc-c4MA#_t{>t14}E@qZv0=UL2y$S14Q>bVkq0(i{QfC4yZ^un|7~K<^ zwU3ykn>5Rj32yTmZ^D1jLu^Qwn61m3rJe}x@t)pIV3Un`Lsi%PNyr;L|f9SW+*8N1G?)xmq6wt+|`)zm{?G`3FKoseE z=c%UxS0C|hcn0kimUVzwt>fo8rm}*r)Z6eZIv}j}08y$NlBb>qdi!MGhUZY9u+{^_ zT3t+@V;bo1Q+gXVpdd{2H=)sG`mi0HWPB$sfkp;Yb8gIk%Xb78N zCUm;IJoR+I_j!67UO=P6TK^^tx)phj=|Jey{SItGlfy&@2~wxcQ!4@DBfbO8XnI)I zL83ypInSX45}(vNuo)c_R(p`B((TPtXM-U=*>~VYbYfWRL1KgMWS%1%jPNPF123V| z!$gOOjk-&D>KQ=hv*QlDjLr(nIz(*J-OF>#01-Zocib~bWW&&2p+QJIKR)>k|iS4@Hv(-5u#Yfx%E$F(ita`$v}ne9-4(LSXu(2APEM2Cspx{TTCT%hpT z(E@Lvd&05~6MJ-%W;=4hc%Q}=coRLu#=D7qy1d!ySzwaS(-wFOJrUM=nAopdG21Z< zO!eu07v4tCg^B(l{?=({tMh=;M|>CFK`(}7{X-nmZJzDO135macVP>9J*@U0;;?S- zZ1rrA=aYRG-bL?*wf;jK(Vd*_m<{Inl-`B+&?jM{Bg8S?rP=B^K<%^RF1(Mv2+KM` zoY38y?U)0W_%z;y5775vwMU3kx|g%nbHNIqr+48)^jlc#5#m4H_t}oQV3kkzd+-t3 zEnIYzIHT)5M?DXe_=xYp$7rwctfRy^9e<8v9w_rky$4&-0pYbriALR!IqLa9>yv#C zK0$rLTaOYKbTM-r^I2DM={;yeLAdA`Vb*2LQRlNr~6)-mFuZqgh_KB)F- zya%75A#C=YxU9>Yqh0_u`#ik|pQF*?t;dL~x)pOA3&1v??)RY`O%4|wXNTe1Ichba zeZ==+8=4-Tb)2}a+dRji27mgb-iPhznDE-;#0}lvIqHRAuTS=U_yR4Q7~XoExTQNe z$FUF`@F~3yU!v2)MJI?mx=VA^i$J~4j{EQxIx9Tu1aVh)Z;oRTIO@}QAHGHxgx8)R z?(1I8Q7;B3eV*QjZ_wr8ttW_wy6GB!6l#U2kVD;JY0DeICglC;1Ug##xbu0t7eHtIYkLV$`89=mhu=|lJ(eG)GEkNBdy zG*`V6IDB?Igq`S%@T~ucZ@PPP9V@|SpT>vq2l_s|_CKOi_j0bf5PbJ}`Vjs^zlFE{ zNBq=%pX(?DUHIJ}!7f<02+?VxtG@R<^(x@X7e9hsv0f2br-^QQ{yfJjRwtPH2)bYc zB5F?)-StD}sf$2we)c2S4fBa;Jx%n~$INpSf&To`N6-}m5u!7Mn?7Tnx|juQ?|1~e zV}m2I&Jexyljb>!ffv8=5$u75u(b)Ik3MgndNttlpFV;;vFM1_GekfAig}LJK*;a@ z7;>=W2+>)>L$95uE&&K%{201n=@D6Hi2?e}^Bg5W!cTn+-LWwdwP%Te`n~fSZWRya zPkSusg=I#xob~use|(;;)HIx5^jOjx%ZlKg^YGR;&r_~3h48mOmh{1LBQno<_~={a z+18lC`DY$W`eOMJHRn78`u2IswWetP024Zy)nT;NU^_lZ+<)(4`Gp&+e zu!9jbjUGewx$~9lOcVK!TP43@$0J%AJ%;O-&9|*HP2qQaBJsk`M)1zF&6D!^O08)+ z-~Wlk8*7fpJns>rubFStnr8A7pGbHZ6H#;CBUE2EU#TyOX3 z=}dF^MNcGrtTlpn!6Q=NJYT6dE#Pl|A`xKi5t$b}qV+BFZF@K^KDh8{rsY*k|9`DB=4ff1buV9a=qyg zfBRF(P%Jkx^P-4rQ^{~_S!Byak16`E z`L+$F|M*>>Nk(ABk-SSD)Aa5Ol+~tleE(;XAgnwx^ODDOJ#T@n+H`@R_>7I|QIR#5 zJhJtH3zQp87x~kkNkXui$d*eUGxbpmY#U8i`9;qpGPY#GyX>LTr!7#{n6C4;Ka+%F zb&;8uJ!a`M7uae{xAOcthwT`P+zw| zxyAH?Kkd0B8f%Gcx#F={e|&*$i|Gx&=(!{YYmMYx^;oKJUZDKL^q#-{xg-{AkIcO4 zv0UG>!1jmf6aUO}NgUP@S##B+K;OPVxz+TI|MEa@C_y|8;?FtLZ1dt6h?S zxkT}Z8=Q?WTT$B0JlU6-DtZ9vXd`T3KrvAn5GunujHQz8eJOT;!Gg(coOEnL6vJ57>yN6fJHT*3K^rFn zn-;~rZmHHcsTDgxh+vD;NuY|7U$@lgZ>z04LAc;l8|OD{eiU`xvRQ9eD|Ufs!TmPQ zC~RpIbKUZX{I?wQDEDnxHl|+>fu5~9T+3n z(#{!+?T(V)u+-_tFSOQyae`Crod02eM^QH{d-XXB6??!$!Tom5IP6#ybHnnNe#t`X z9xz4F+0Ge{or&V!v>ecvEmZ6U(*=SToC%mYN`BLFP+z^!x);n8#J=EUVwNcCrlp=O z#VPiIS%S$gI1{luQOr%tKl-B!t^2@SLBR{oBSS&d8g7Z7}E{eKkIjOfVRO|=K1@~WYreI&9m|K>A^`947_k%(~=L^nM ztZOv)w&k>*vq*6OtQH7fa;9N!(em4tvwE*Z)&pRzAoe9E3-gGkZd)4kl0}NY!8*a@ zmz?RCcQkX`a$X;?$oe-h2nt?ul$d`s_l~7W4;Lv8f(pTwmz-=&8ZEzLY1WTlWIYHr z2u{7^%)o-8sXLZS`kY0ILtvBO{!7kGEHaw8W4WSVvdDS}Y!!6AU4#mU8pXsX3xWgCNv!(fkK@+;0PY(g~CV!5e5 zy2yGM>=zWg;^bk|qPcf1xAjen6#sxjf-O$Ng(_Np*V3ZDy~z3xI3hUpiZchBA5Go0 z+|%0^DUN^>g8Q#HbFrn-%w5X^{pUs2Bj7(l=PS-UtSFj$&+RqwUfdK6p`#J=X_V`MaS&tlU{7AuZ{i-O6oISa6j(ab%|GkwHj>oIUuQ1F_g z#;kcrV!UhEssGMav&rzUpr;w*Ct~2~NG?ti;|$Qx7fQ_4dVz|G+oF{WqLK>`OHB z(DFn7d9n3B@KezFhO-Lm8pC~L>0;n4QJe!#uL|Fhne|o&o)Q3*K@{F#j0tV++RsmnhDH0lr(_ za!N62jQp|1-7tQM^(^?+_taa?8Z0P=dTi-!$XTK|2Yh_*zvZmOB4e1xmcE81ORVRB zpKs?|P8pUM!);}`hh<9?4FLEG-f=V-9wTqHa1GTEjBHN`^4g9Xj-B;4?=vmICUwi82J+m&v1K* z^*jjoJ@t;G$L7aSPb_?ceTm`%i1xkzj$^=<#xPGTzJ||BtQSDMZ|6IX5i5$}+AKl? zXQ`qIB>M_FI3%Wtk=raHgV$1P6Tp09J6N?f8AI7Dh(WScVFu~GlRG#S*v1&fW(hDv zEVY`!DBpq(P9;m@=RUPa3~;HU8I1AW(!r_1cE`w{S^^E@ms*>_INwtpob}k>G1ODb z5JS#V#YHgD_kIUw19mKid1@JEShCc55lr#z?BG;mXJWX|EF%nMOBI*EbYH=H&PL1} zBY$QIHdHUQUIH_HW8ZUXFiQ;e%pzlpxQfeQmha^EoK4uB80MKJ%y4w6^)i_2TkxK< z8G9VVeQt>`G%ZzJ0SkP$yytAe+G6C-Em4NsORZPHV&7BmIe%d9VyNeq7=wMO;wo6~ zd;dLWEA}OZd2Wd_d|qn33JQHY-*dKMU1Pa+OM-#3OmPjY_7yld+cCFTx!sav@LFcQ z2G;t}r+#DZd}HcN&fXPM$U*yMZP!P$jH#xiY|-waEZ zS+9ewzMT%vpIBjHEVtb<+EBJkVFe~%!3WN643Cw!TjYl7WmYTLF@6t}<$-}@gp`?005%nQr!hR@5ax4?hCogX*{ zu%cM*OUqOPXSw1wIOi+)$oU)7#L8bRa%UQ;%(r<-W4y7~pb63%KsP6vfjC;Kt%T5S1iQf=6|JmtQr-FLz#`*G}c zZ1Zb!k>Q`^4fjp2eV2Z+pTHi)y1yZp7|ydzZ&Qcw#!vQ>m_0V_4Y|y4b9uu9(?{Q9 zpX{fwx3T4K$Q6cX%TpejzWUzzWd9fY9NYYcTxs~Yyy2nghwqn9_W!Uhaqe%)RfZlb zQXZLH{JcNgPh*_8w6|oj;g=N+k4!!MB0t;DVEyCD-;yPUfE6i^P40dZKHJY?UUAKD z$u)+s6%CI~ef^exwx7ddrC;sW zv2AhgAK1<~T9DFilKXA^YPVv4#-)8A>kR)ZXlOV6&+ph*`wi?sT=@rbuVF?($_rDb z-<_}ao7mB~<`3jwhD8MpFHFDtefes?g`JLb|41G%tSLx&Y0C2R{${_8HN~ZUBo7+a z7c{&y&G3u-X1{}7i!1*~)*E&fq`Wfa`c3#|Z^3TIHGd@kG5k}|@X9pDZ|OJtUF=bu z`zP|K;XKO-Fy;Gg{ARz0+2hhak;e@;3mRUV7Wp0fX1|ZUjVu2|o-{lwNO@yg=6C0t z{Q>qluK5%Bui;}s!yD5|zc1hH53w%s?w`rih8`rN*N-~D^D;pf9oqmy> z_BL#EeEC<>YS_6l<%6ltZ$hWN9UC9t{FS_E_-AFq2h(4EOFQi^u&MFx-^kmB^DIHZ zbkJ{Or~M_C6QA~tY%$zi+3?ZykKeIQ`zvf-eEB!>p5fWbluxGPes?JAHoy1FnvQYL7wD{fl(ex2J5`VUncx_M@)_(&J z{673>`h@)#@A89qYbYs{eFv?61AjJs#xBGs{UACFm4)@+!85U;LB!Zm<=~ezFjX8$X+VV4vd8{v>`F9EJ5i!4JO=KbwAH zKjU4x$h#O_SIN2{F2aFb%w1s5grqKV7o+E@`YuQhVQ3d~SJ*G1xQpD?h^&%zMcjpB zyO>?zuL)S#8r7@nyCJ^94_(YXVSIv%i@d+F zWR=Vn5eWx&HM7Ucgd`U^*I2o#-W3TDhITc(!BGjtE^P6RUM1^}1PaG?HM_%c31?m8 zzZmOR)ptjR2^V%X_kvRrT)N4K5hGG0b3@XEV_nQ#xFO-Jt31FcE2?)xeiJTq zF%N)S6I{B>CB~E@nL8pER=Jox;V#yFCl53#it62w{|OJfmDx7|E=GvRm-ZkX|45#5KLEu7zNw-0=f@U;gw z!g#g_=}XTSlHGRm;j@IOp4=!SQ$+Wr7YhIGwp##SCoJp9jWM$cku{z&-Rlbc|4DMtF!MJ(iGw-EM7{QC7$qOpH*Wq+hpIMh|+ z5BntMbC_hKu-KEudI(24Q78iw4|14P z#fiag3~n40-}ckiUhx?wTPmJ2AKyGr@SR zxbhd|uy9{@%}_WyF~1ix(fFv?^H=1U@N##}Ft{-BU@vB}@ojPCugEFktL~cNupqIc z7c<4!Wwoalaz@yvhh_vUO$_eMOf&Xp?e$2baA*%r5Y#2+_hzOWg{wWi5wmcl6BJXG zc(6B(i^!foZUkc0=Fb~^k!xn6IOfj5Q|XPLnDJb5`+6ND&y$Yl|1Bza97S()I`Jki5-2J#m3XCJz3?kP}frv1D_@a_hXi_hQ~_QHZ0uNQxgkcCFb{ImKz_f z_GCrB!pl81aqvUp!G27E@$KqL*0C#m)l(A>I}v=M_DRZT<;F%~i6`rH^&jb^=nPCc*qQb*lceIhtfBNb2a% ztTiT-c(Nu_e;r4Y0>w$e9*o8~x};KsICY_#R5&y#--B6aoLb@u5T~hAgTm0HgC2~| zIIpAGKA^&iO^7~$BYgIuP{c%Y9~ zyjD^ffQ0(*W0ik!c2fQTW|Q$ziRU0B(*H7R+k*>}4h~?p7~hsu4nku6U$LS)Sdi2) zfZ1y7QtBx|68-zIiaA)C6zs`tH})^Blpv}8Ls`2U)FtJ6GA5(2)KiM!{v(};p{k^V zp3Dy8h|)?aGSYuG>okK~k~%z@UB-k`&p?EYX1Hsd?y%s2%x>f8(#k+&tp7fDjZ@o| zKakmDoLcHR7@6RI*J>@&_QtsIO@_J8HBamuPX1~U7Nt4ckGAk+N&^wKyr zQNh13e;bXZl|zth|DnA!PD4}vFU%q1HnvucsQgFv(m3Tv2Y+D>8xNFL4n=1B&+esh zdWkxIVU8G2mwFCE=KJe27_!iRUoVZ*#*_ambHezj)N?qp)c2ffbfy39-l}Xkgtcr?7mdHKaSfrX{ms2qGoXwWTu_&dbJmoE z(3}0+daGu_7*;JoT{W&;<0_-Kv3^oj4oqRK4itOwx~4=%)Be&vDizFNg$mSl;~#5W zL+L;L@jj|ts7S73#RbN{)|7VElKDYZ!gNU(-jG2bIZzEUw>p zWlc#KUGKlUk7_o|OU`9U`NjupT*K+3{^mZaIZ&Nk=R-M(^d;f+N&mJ!s=07Qa=Q=Z zWWl>e(5L;~`l{x^lH@=><;1X;M9>ZX(!Q$sP@9~~r7QoHP?R?6~{dSF_ulZ~Gve_RxIZ!}3k=rFvwAFuiU)4gmH#t{8y)gdH zqOIxM{^q`_MX)}(PC&gf&S9z2^gaK!zN*FWWOBQJdShJ4@|@{M{%-wLOJGBCpfB~# zsAo~iw9Q}IPqh?YO3wAA-W&g5DaN$jAMdAH2Cd0;zSIZfUo1eFe(A63r&|Xko+Epa&uPTDQ zQ*wn=H`14dlhPa!-d|M=JyYt0RCjV1%LS!-i&Xtpt06z7T}ZL6a~8=-_ZMmUt4dhF zSD-)TM*haqFlkTG?*6J$I3y+4pXx>a&VnduFOiuwsX$puoj=uwoWmj@X}+kfziKUv zNoi+E!sJSpzDNs2ZXT*Kn357GqF7WL3qGU~k<>$_ff*^eB5DBn2aCd>B_iBIRSp#? zbs}mY`4`*Gq=$%99;$V4QcAms`jz~bEep^iL>dp37AjK$0p(3zDJx-#cB0)LDjm#A z$pw@T`Jl{|g~^G`?DPhzQ|bUEAYYf2uoyQ{n}^B(SERH9%8&d}=E@S(L~dM_5tgI` zB9uSbS5v~m%S2MHiiFyfT!aFouf~<-hKX>lih`9XbqFOUhiOV!WS2X*?va~FgF{Y|ye?g#_l9In^O44YBXg60?1^1@pimAcm?;2N@5hXHnRqJ7WN}ZS* zO3u-gq|=jF6q9NLJekririPO%HLe--RF;CIs)h|IfdNzysn?Wb&`K6Sq}m8CrQ`-s zA>9eyXi9#g)hujAwFSOR$sI&RlMghmqv$0p=SB4gbfnY`qGHL{nvzlU3YM^<+6up? zv=5@<$sZcm(ex@7Zlc--T~h-kR3h28yks<8!g57a+gZI+u7pY^eal^0G9-)iP}M@u z)H(^3N)9V8A!sd2(@>dUAwRWU!d@`OmAlGmBTKSS(JUJ-P)gzCZ{;O&x{`%RsCK|1 zsku@ro&3Grbqrn2@*7k;p)9pdN{uAvl$VU5H?!mf)h-y5+AgI=kt@qx74$Y1TA=z9 zrlj(kQVCL9u2vvUgaE!9rl)2F%EyqK%N+{D+1|(N;F#3fK>1j5Z@GFb;w;YNd*H;> z)QvDX`82ZkI`w$O*$l@2hU-$Z zhRCOr{B@4;h_jT7AA}XDwL|3D{6&0_Crc}{T zxr)qKr_My2El~V0+>@F$R6dKGw9b)+BF^d~egvLKZ5=9~ zORiYwn20!Ahxk!=E>$#4KA+UCQ%^#ig+KflyqKCbOum5Jyv{KRadz$S6zWW-r>!%x7wsjb80i^-Gg9Fq}e!wf$OpQMV0%a@Xu)~SC-oaHY36nv4IHC(=& zytmHrJL0TW;s3(-skOu91?0M4k`(S-j8zooVgmlu-X*Eyyj&N2~x8g@fPBjiP7 zZ>@SN;_UI@XJ9WhYlM6?$=5ojBF+j8eijZuYe&dS$stHd>uJS>&QZ!Z3+B53}M-WavhncRZmBp zMFsowLbzMWuUgwoIv#Z8Nu4=g zo{YPlbk`|!=w#r}lR6`|nKG`4Dd*4& zz;++0GbNQ7&OJf4=xlT7#o&yO)R|AJ3Fn?7+jYvh^m6dnN9v3QwS;s3BfsiwbLm3R zl`nOsa(EHkGo-s-Igefq{P|L6ASN?{dyeGkZS&~0AdxR^g@s%;dBSZZ1NF-J^g1w& zFMR@eZ0Lh~fsE4I=FK0M78G z&tNbcGT>e&bM?vv^d|6_FMST9*!e#9D!EK=TR?9GT?JA*OkzjtTo!?%SE^|fYm%3? z!89zB9buC-dYhWw1ri0)b|}Yc*y%A@r&lhd_kd{v=?j>NwXlO*^0?l%klqi91k#r< z3*)gfQ?gmFTtpuN+Xd2BFc-^Yr-c@xz2lb+j9CL=;|l^49{Y` zIF|fbZcwhEp8$V9=@;0HWyW!xx68H_^mCBtC;bW;tR{}@yx3J1&@aF=Kj}Bvg0;kP zop-9X0{RUo@{@jttr#z!>%7iXuB6|C?S9fu*p6k!bDbB7ww3fJaK=yi19o6F@!U?b zoxQN5zk$bo(x31v))LSCNq%MT%IKe}^xDF;u2ydKK-Cy+CG;TnNGD}o*{~*wJCLd~ zDog3X>}g8c3(sUNuiRg$<3?L4J)AwrNPFX1tT&PCO*I>pYv>SUJ9}Qhb75vOmo;m# z1}i!oIm14xcY2C;j7W-50ul}Xyl>2b&z7R14Bw>7EUp;RuZTt`nt z9<$YUcBgAee`P8}z0I(jZr1f+btm0dizkyJCO)YA))?LaEP z+u83emrWUvHa)!`Y({!5l88vz$5!_J;igi7l#-;^A=3~kzA6q}H{$o&AfaQqm~jAZ6eODe3JkcUWTfLjE92Iv09 z%%jRG6q}JJh+vRgByPs#zcKk#b%k{^@*IgBEBgSEY3u(o-BOaZAEI(x}l)2_>miY)4qxvBWJI_fBI*Gi#`bO6zu{pSVEc zmV*1Up9q#b4l5P4$N=#ciCZczO_LK$IW@l0T8sQDJ|%HO@t`z{V6;?DrNV@;sc4BC zhDW9`1f!>xR9a1lpSV-v2Jyr+uADKloO}h10I`5wzi>QFE@vpJy3$G`gT%2?w=|qc zqvT8_OKeu`Kn9B^OWo4(32BU+Sx+6UwC+HLiwmS~8Td5zW64xgO_hqBNQiig)NLfL zN|TRaYN*?l)}2VW_>|P`H++5?HHO(t*((*hkZAFJsoN-gX&N(z`GfjgY2Agyi#w%m zqw%6Nu7cS{ajF!5BFSPwpc{c}(&P%Jmh!5y{)u4X*g!WqPNq=`hNdJ{irq-McygfI z7<^+IqhNMY5mnaR$S83^pqm2U&VDzTKPgzHs6)nxw*E_?a~B|Cj?*S(Rch zGF>bf>^1>6r^)}v9HgqNtb37};@H7%nYbm5`X5uz;;a?>kXho%gWV?LchZ>uG5=6U ztE~Hwx#EJsZj=%?dN;Op}{z4Xrw+wchjJKu9$1%sL+f~-Tkj3ItgWZ0|-=$IG zn3I&fO0geVF1|n5Z3_M+jTy)MOMR}g?nes6orB$`;)Pw)x#OAB6lcBS0J2&v7~(b! zcT1O#XU#YZnwc^+zZdtfTIyIhYpd{-Re#YZo4dPQn+-BfG>C^<~5|y)FaR}KY zzCXlmCLWp2Okl21OV(QtAzQ_rL)>!k#B^>ZbB)T6O{k}9#oj~Pa&VZQmPs+x`t=R< z^iFZ)&^8r5I=wuTvQj(OryQp1#1n?L<>KShn=`4K)IaMR4%2^$mkw>4g-=a)pGe)N z&a+5x`k;8@(6&50Cq2!%eBE5%@DKfu_}I|4+4#Kl@`=e>2~BlL0couO@W@FnTZ z6R8K($Mp?I=zqmuhPKVcSEaj8q8?E_Hl!S-&x*Z=wavrJ($gkUt<*0Y8jjNE#gW6> z=Htfn@=5HP9k3zg7~L$MFsv;fuTF2CL_MRzHZ&ZguZWipYg>SCOLw14*(r2G%5j{ z;RO9a{AF0%V*GTv`|s3SYR!g}lXR=tdwAOtyeU2HcdCP0zoFqI{Y+f&?c7rQYI^bS zatFJY$W9^c;<3Zc%kW$2XMdM}r0O@+pF&=X7Y;Ws#~-GWYzU&kiG#6N0?XRei<&)YS~$Y8&EaET!IhENSY>h zXLpnOv&b(2hew!8@!=W8)8xG?l-06x2ruBq2=f{|Jmc&%dEW|kb^STSH{inv^IAMU z!zD}JzoMjC*1(EB1_qhSa4aJ!OU|vRtgdfB0s=yV%o==DMsXI)=cKD;jYwd?*dTK` zJ}%>Imi(8B`s(^dWLUt$AoDtWN`}jHxmQI)wd_0+98eWx*5WfWlBUaf?3Px49tjIL z9Awtvb2Eyk%lQ?yYS{%AIe#O_tj8B;oSiQBt#DM=UqIplJ_MN!cwvT%QZB4;-6(59 zk^%+>n~nI|j3lL8RN=X?z6n7CLW9jDZpbKB%8?3Wqs)w?1&j?gQ}~9Avr2hDg=}NJ z8Tl<>VX(OZ-G7$`Gu!+VZQ%8@5k zc-GWkL)HX@%FNsGej|%>jMtU%scSmBa2n?j0$Cq>^f2ta6@L^iHDCo ztCIg#p{}XFj{FhuL1x~C$B%T$m5;6{sgYTc+JJ$f=09<4WKynNUQt<7Z$)+ngoc`T zB}Y#MWJ<+(AMDB*ZNag#Nq{#$DJu zgmEYijC*5H#ibLp=uSQb@zAv%b&bnNuKZXyg#3J z85AjR%ETgcHF-6++>e3FC)tidiSm96uqa(~p6s@JHK?CtD~HnM0SmAgcFw7}?S2xh zon)(k7Rz%MU~#(6JlP+tt>D%q+c9XRylDZJpmXNc{Na8Md^gE<9NH-Fw-8IxaSq7t zxHo|#Cfh2ZZSsJHm{P|-P;*?# z@fPZo?EGc93*MY;JqewZ*Jbe*>9P-4?pq#$Zzfw$LFeS3vUp2$IS1SySe}5xr&v!z zm*mqI@v?Q~0m}nR7buxxJp)~nuUW)X>q-u|KeTj%VNUcRn_9fvK`JZ?_2UV~Ny*Dd1} z>U#4ne_8xJ-b}Mzhc*O%TE;8V^(%0HZkfy4FswJAt-;gPyb>L+!1CM@VZ8%g3ZA}#SFIxpEH5mn9+DZ>yU?}ZH7j^Ex{?C- zmlnb!Y=-q7bUV0i1@EM;y1??%lI5{U2zj<&|Z*N7W4LLulix;593G^|}uBWnfwDadU?C5%fN|ZYA%WuD8JQk7d2bn;F(W zp>M&TR`MEk{R-V*TQ;+<5bI;;hmh&3cuhK9q2;wD*F!SX`UDypvSt;pS?5;h{>Gy9 z2%Bl`fPN0CTgAJilNMUuSay3XooRgvjSu;>ig!g9Q0U%k+3&G`rnM6SL#D6hwd$}! zORq)mQ8m-r1$l?8SV(DK$&;PGar^)F~v$fwo3 z8@ilAcPFcZAMS5`4*ec7eGPl(CJQZ23+*BCw{}CpA#2v~9J-Q1_jeYnN0`602Z{)( zTf@7pt1h&>vs8F2^|$^FC4_uh!@HwvDs+Eusq)zGZ+!u!g-l<|yQgCcE$=P09##I< zm(Ze+HEVefbRFy)$#TZyroZ(Sv?8Q#E$@-8x6tyhrNQHkzx5wzL&&GKyvMqJ2i-qd zF0djM>uYFh$nHm1A)nUqp6db*x_`3VWDiJICsZ0TeLb&7haI$hvfT2hnq_?l9Sd2r zp7%nhWFKmlyB;@ZS>Hn^L+aM^Ug@$AT0UDIdc2us{TDhH@@YNqwQe24`{Mq@W5{gn z2dE`Px`BD4+jo%q;@;&Uo~``|wTEPHVBYGA4)VXccYDZZYd=A^LaH|~?{t+1sju!Y zJ+fwNKSK{gdN(lt>KYI7zq!Bh*gaeO1?mcsZe%{{ZXBe(xxe$Mn63Q^y$s3T$b8m4 zKFI&i{i8?wZ0$GbT}bst=Bw`YLF)Sh^5typf6&*E-i^$Ex;{nxK0sg3A#=2SV3$zo zCZ;bntcdCZxOj@^X#2uLLbEq9{iz8>{JsF!Q~upWGa|Hl6XQbp7EyhHA)Z-twEf|6 zp}m`!AE`M-{C>c2&)sv_gBuVk-OO;Q&?2fIFv7E9j@AVhhh}eP22n{x{Qkfw&-OXm zAK*!$)ti|i)RH2qKl_S(IY;{=>>t{@nfZy@RK({16FrB_)eeAzLZvy(aB5!>#Q^|M z@mwtzmWO8NFs@Wl5#NQ~Yslwn2g2c@)j7-vsEzB6|aS{JVV47$9T9{|kt91@@%2Iq!KHOxe6STQvK2=WvMXothQL$fu^ zZ`6chJ{N#I<=;g;`p{|(15m!j6c>lJ|?MS#KRJxUsQ2UCh!9c2~c%F6?+#Z^}mGPsBiupqT!c#s^I~u+f zTD_H-L{%13Lx3#Lta(~iQ6Jj7m6<{{7W0P!OFehb(~gBVc7;l}G1I6U#nez>xo5>Z z?Kt=)>uzRdP>+lGKLM*f+vjP=!|zz#F5^$VE~b8Bw+b)kX@7;kvVv4*Hr1zuKMdIH zIV4a!0d`@XmdspgSP3-@$n_KlYA3=&SPLODkD5@z9}Z|e<=-_%Bf_fLwn)migc=U) z_RI>@{sxZ=>t*#^)SMDN57_UyJ5cKm17T9us6>U9P&`2ISrMoOU~yQsmO-eb622>7 z_G}N-g7BoUYAqwDmXuJg>}um>pw`oQbMdUrC3RG&lqk-!DdA-`*V;oLCkE+&~8c8D4Yw0Met*Gk~sVcENw z6lwz7WChqf<=-7o`mpL=wc(G+G%imSoR)f8C7(MKMv^jl(We@ z_*Pi;9%eaJd59VZy!6asvr_QGu--k)N~-Y?e?0KUb2l4rfxE(_dzsbLjYHIU;GJg$ z8%Tg(hGp+%)>4lT@qYzAdba;h%W~gg)q9!s)aygkuk5Dj<^Qy^;jb)R%WS0jSojlw zzJek1wR2z>Ov*xx)G!M*0dNtB=WFM}L$K_9%ob{bg+CGC3gq7fSR=6NeM~Op%K|yT z5JA>_?L2rK*1M1OAJ1VK3ShWk_k3+23}DjzjFt*zuin52LB)J+5G=;B_cJ@GBn$sH z;QK*P`#U@dtKQG-rj}T!-`I86%lX>>!Twk;%Slt4EPNINW4G?w`EU>>C7J!yJ`3dz zeBYC5Ay|%OlZ=ikvhdlz2;}TK3J%ArNk&gqS||Vz3$h?B`{>7dNybPuTKFI!5$uMv zD4fc^tr##<%PbCBvo^F4vNf+4Ur6wbw@6jMwMqbW}yNFau_VeoD&n_>=86KFoWXBNo6+rji$ zHO0`BFHH#mxgZPH;&1`hOEHJ3IW%7guuVI#RsqwPRL@wcP?{0~;erZS8xB`s*?Q(E zl|=KsfM`KGtc`$cv1&b2K`o&vFLv?w64pk-4Op+9IZkb&`63`$Fa*&?!7Z57z*JHD zXi5a63dD#u8g9q34a^Cuh&5XPgg}mHW8hm@wSlRnDrrg#WC^kmZ7jU;A=YbPPEn0C z-y2ve*o|o8;4VyRWX@1GXv!N{E~r4X@$gG5+sK@y9@BguV6~tf(I&v}uxcaIK)t3Z zA9nrt6455YU$I^zbDru`%J&5}3x=TDB-jO)nwSgJuu{qw$Q6iDZ8AIr&o(g^sR^Zg z37{3ozq`{$;MFFkh4L+>B*1P#7OG8w$Kkyu<}x*>lrIJL3wEPg6%61~Gh?GdODQR! z7gV6yR9K8>o0+RrQYqgLFbmpIZ5liYuQoI7)RI!lkKKX3M78O#Ki+F*u2Y*z`7)qb zFhs7+fP-*p9-Fq^S4znMS|FBd30RJ2=P`DwsFXhmunOdIZ6+L!SLZResLE1m5>O$? zl4}>h@px|@^9R*f%AX8W33ki13*l5;dVsl0-6*9d1GRz*xi$;V!m|%B_o>IF{3*a0 zLAzYL2wsj?A7CC*uS=;Z>>~E1T)P-vkM|y6{-pXG=1&DK2!;e}m%zEWG@p4w4LeLt z1zH5+VC_vXSyig!_+jOU62*5T?QB6z4^>v)SSco z>A+3F?qIDNrg3Qj(@ljQrlte81Qo&BdL1Kk37h;|Kp3$HF@-cgl@DSzOlAS*<>7Ji8L7Bc@*jfeTO=smw_Lx^>7CoVil zex$A)*36=v0&57d9)5vm9wa|gj}CKY(;o!35Ml%D#LEtnU#VAzHM8k2g6*5zah%q|g>hY=vctvSMv7>1a?(U zY=;9C!V+@0eh(|9q}_#rFhUEXip&zyRbP07!&-lYP#CcTRw&9!$PxNuM>K2=y^sha zcEWLro)YpG{kbC?wii{X4I_5JDuwV6IZA)+h~{@%CbY6WO7KEO<{@&7{?QT6|LCbg zTNoScS1Za6k>m8Qj%fZz&lGlt5qsfviXJxEqW^Y;GoPNr?(2ztutp)YkQ4Pot(y6C zpisbqH}Eb+riJ`XKi5o}8a(bbVz=;F!14U0MIYocY$_b{I2(>to4|ghrhskOBYgSD#trl8wq5yuO z$UIEW&_A+rLg-aO8%`9$PDR;a(qI3|stKXj3A=IPApAwqbC{g1|7PWc(wo@R1yKZZ z!i7i3x%#1HnowFJ6ex&dcyM^;5pte>d>JQ<6$C*Fq6FrLmmML4^xkEfFnX7eP!NaU zvEe;O$p7hQm2oh7pHQnHEUea1XeA+iaG3^Uzx1sNf`&!mnN||fCzNq;+9b3oh*DS< zUS?%nS1v45<3PT!O935*XM}fHu@L>bGA9lc30=aWBk;U%w=yhDzpG5G04(ee2zydN z!j)whuFo%XDu5%xpm3-R#=}d>uyB2OnK~RO7p8_oN8#A;jxsD#Uti`72P%cR;ZQl8 z67F^si`KW5sUv_IAsr4?zzf2aN3mG_gED6Xa9Y?B4jqG+g_j(~;`IaPr$o|cg-^l- z$Kf^M_m1)s^`FW*BkA+Pz7c{-I468`IWJj1@Te+^z9^g+A*h0PgvXTgQuL#bc1F>c zg>xfVp%NLMSI$e-3y-Rz>8rxz2*C+BFZ^CPFI_+VXlFEiUAQ?yP{TS&M^~`GA$(L7 zL)(SL5rSH{EIg)yw?H3rv@?eOLwF%Va1yQ#&#T~N=`)Y2V(I(BClP{Ea9#Ml3f^M< znxmbu^q<1Mk%H53Q~2m(tUq(dQB@rMR5&qGa0YG-k2%I$rq4Ut8AtymoEs^qWA9CQ z$9T*2Wk*%<^xwkdNWoe7UiiIZyp{U8qn+{eKf=wCf_k_keDrbNYJKZbRRaB1SR5&6 zfP2DYj`P;)?;Y(-p#K$Kh!mWId&BdN^VaKoj;a#r&%!5>g7fgF@O#I38}*-#b|%vQ z3HwF~8sUBsqbqrv^#jXQNpyd&iBW@6 zjIL(oqC3h}Dz=VdVzj^p!x1snJe@wTyi-Mcc+HI#w6d4CylS3aUskS4rMPF&M^-p8fd6^<$8Z^Ve*{^>SlCyNf&dV&_!zDoHnSH&rc^(*!a)Eb zUh8AH4!AgiSIZpH&#zEq0=QQ}4EGjnjlgP|GJRBqBNK@9YKY<9hN~jTTBcl|QK47> z#Cko9;r;=iiC}7(WBOGUjs-xX*Vh>C9r!{7?<7;H-(I0u2&8z8i{;*hH`*ewlT5YV zRN+_%qA8<^u1lZy=E{^*I=0;+tnI`?nV~!=jHZT7; zZU;O(l03~c>phPtmI6Dy;^Vka;Zf{yl)0pzdd#sD*z2`Ej@t=OjO3kRuIT3X|sAG<7!06Qw$9)D%BFQsMn?B>1Vi|D2>tP)CFL+ucbB4L5Uv0^%Nz;Unj@!S`1awP98b4P#Wm|_KR!mA*j`w}K1v9ruQ z{hecu6~HO4hIsBPcxfbgmU*CmeoV0vIP3K=p8F5HI+8idJkozS=2!`w_xc*oeGPAp z1$rA6T08RMUev=*vs+n#{<{UjF&l~+X)|G=XbJ4A91{S4Sn0| zOhWfNxH57_1NlOqc06z`eb4J|Lic<4bYyD-`AWayc=KBNk=MtB?tkIN$bsj`*ZQr; z1J}_VUZWDbKfqTa;d5lK-f+Bm9sSH}T4MJ{_(tT8bEH#WdOUDF-QyLV*!>B<6WMx> ze6K%wym>wS%4>CE_hdGeF~_VMNo^n0%}iQQk} z*O5EUlV9}DjBMMZY28|ey(iZME@X~mek!B85Xspk>nV_%D~O^Kv8s3cRyrgRBI#ogJE)I z^JaReXmwI|e`G?`zzggN{rAei9GWLGCv|fW&nWl;InWSM*_=cFEIO0a?SlA5?YKY= zHl$SsZlOnu?k08rfJ}{Qy+95%tf*|>LXQ`HOzQp-nG-dzi5zCwS{bOJ-9)33**=~5 zQE(Hg4W0 zNK(|mi|if$5^G1Mr6O~3_h2L=3cg5=Hr%dk-bzmvok{Kjh-&L zo7_DVSryfKksNP$U)j8ko+bL2-2D@>DQaLdIl=HlRp53yKr~9(Jq+0%1vis!hM%jN zx6{9irYXCJBm1IuG?VP1tSV5;4hhl9ZXRNaYHcP#!{n-FEgdXct?YJ1ilPR#urR^z zRe?KbOk`Ge^N}O$V~`XYBC48q&=I0D%I*!qQe;T03fxJ@i0&%8e@0G6wYHGn zh80!KJLv?`M`iaf$i~K~ftN^M!`7<6U9?g(Dy4fQawQ7BL`n^Ys^(pEnrK=|_bB8> z)Q(G}%uretxSP%tMW=L+M(#wlULq$OPF6MVrWc7;r*w}&9!Cwl%pwh!Sa&p?Ei$Kc zk42tG!I#PDhTB!md*~ISGb!EUkk?T=E|W72&#D6V(rZL_Q@Y0^AEH_>ld}x(tD5)H z8$=&dx_?FbL=U_|&N2K@9k`Fq5sgxHPe2Al!&k@v!_U>7i+|WEnxwK#M1G3Sxq`9U z^J>|CV23DNWphJ*iEg@r{cey|*X#%Oh?c9^_K#nq``NJhhQMkW36LVa%I1!ELu8EeX+5C`$ z(f!)73`5`vSsuWM^l3I3ayUAm9m`~)lbSr>wx~AEHVHW%ozspjG^kF<4gmK=x6*8r zkyFu4?bsrN`b5nE;F0KEnr#YlKDysEY>7d0LY5D7h(@H_rXrW41Fo@a3hG2nKJZL5 zDcv>=xgMQ!4O6p#oU8!o5rwDQrXznuH(kS47-~+`6acS8%hPQ$kUyjQUB^}#nor0I zfnJe5-8K{XD>~phwuU8NY6^k(qS|zuKk`p>&UI{^q4R|7An-|aE8R8=`8T@hI<~>! zJW+EH_$GRnZkvt#7v1j$w#mS$kre^`#3M3nbC4fn0&ZYA27XOV5%7a}Qig3V@>5LC z4NPNjuaOl41I6JPwgBXpn5G-pR)egjrWhD1UY=o_hx{7T?3GHWW#Z39w^%21iXzDKJ@FOW5Sd z;+PyemT%~+ksSu6i*FIOU}R-XlN~EGIBRMS1GB{M2wMoUF{YmbD>86uWk-Mj@rX=Y zD6%akz=4$*__Z}hSQo{lOj{VTHzvn{Sq$#AGAp}h4$rhEo+Ys!EKac!n896277a|)fgWk90OK}YZut!k$+-x{=n)Dowc&#z#8$b1-1m_-H48DuAGs(?M>e?qXM1083WQ2CnoA zZ7RegHsCJSYEYc4sRj(<+J&}M#3wfAF4ksHos^vb^2E0m+R~6Iu}yceYXOBao2ToO_tVU^ywP z1 zlQk!Slj7PeTNbi7Hs?O}z|eV8b_%Ey-^#KrLRQ8$-NzmooF{8e0q4Z;vTTcyjj{b6 zV2=%)Q?k=QlX%1;+Y)43Y`_Do!@xgPa~ilLp0voe6xkb_^8o8KxSx`p0b0f3i)`75 zF}CRe_RJtVRdWWoCSJbCwhTEK+wURvoJG-ObpXR|A#7^oaBRRstjC}@RZ|Dt7S}Ga zEk};W<~+n+7*wZZXMua-TZ?QfkW;Zu53yGU^(ju`frsKZi>xb=bFrTu@?INqPPy09 zPsGC)TUQ~MVy8di^%}@imU_BNELm(_ja-Xe^N8m(l$>&Jpu5Fki>+&r+p%?zc<&9> zrz{QhOL3KX^IGI#Z0{rHUqj<5{yE@{c=uxMI;1OB`X}?zaN`tp4tOW7Sgc)-yo}BM zllg3Te2RabrRLffYd0Y8VypjTz8YSiqRsA2WT8!%kC;fQz?yiFPwGBrf|g)89DZH2(s?^_DNu<{%^Dsvk2hM&Hxa1z?DG z))MU&WL#YDW9CQWoYVX!V7T}0C2XJ%h?72HxW>@aR1+}5yJCqp7ZJy0KVb$LlTPz5 zvgls>675!GQe5>DW{7dgY3d>{&imyO?KZ?euJ;M^lX25&elsx9d&p8PYqpJ(b}++@ z`%Y8M0Q=pvRI5egaoHV=tFh=bzlFu0>alIYP zFUH2x{7Znud-qc9E+jQh`ji=Eym6Yk1WfX-SgPHPpzkxDl4FdIPIE5PQ@w3Vi9Luq zuIwo}&iLxI<}y8#MGA?%$hx?mr{u53Z>Kp|=sDipY+@gxi4%5`6OBX9Xs*zK-hymm zKe8(>vy)_Ntj=(3^n4b}CP;*eE9)cyqxTt&jYhqRY(j_R$MtlQ9>!T`IIVQ3w>F!g z*Z`!kixe1x&uCg{g|{`E&?6hm<1)KQFJrdhmRw#tmmUZFG`1cNt+qn&X7eNQrUJ8BH6l@)j&3%t%{Y<}=dISa^ohPG@*S z%ZNP05m)w%oMb$9M$=9&^d^=O2apGGJc1ClJ zR(o5Q5e3MLxXi!E8OBFvIM?Y_-nL~#A>xcH`-}89zB;41POoF>O5z~$C9dZ$a<=i? z8O{xQlQ&mQ6d{~=;d64XacG_92CeZHsEJ}^aD3)-a-MN~9p@(Nx`xz53Br#rdrk%! zz3VhLS&0gvCfEkt_@3wF|BSQhIBa;{TdQWHTki3~ZkD?XuG6qb6K|`Upb=4gW;cl# z6Y4m2+T?9h6QzhOzO0*+8yD4S>~ubhcoK(^nejc{WQcJ?9mhczd2^Q&N07jHVGkK* z+*7A<&=zmOa>9zB@tHj&ZY-?h+@g;;)>^ggS(OSgFwD~J`I(M>f?J}l8cQS&T<~mkG;7oiPK1Pyzmvd)VSxY<^kR5Em%pMLE7RoUy;jF3_iN}>*N#FxDymm80r)jXtMcoQp$v&e(^o>$~b5LctL#{Sn zJF9s_JH4$di3a3FeC9voTH~X$oImLg-nNy*Im8)X_7Az<`0A|YPx=c>+!E)JFY!J9 zkQp!?WN<>}YjTTmd_Ct0J-`QAMKmG& zgtFIUuF<<*^MoGkL#!e$B4ZPJUX$C5v+6k=^e`XoDxw*2PY}K#wZ`CjO$U3wvaTXp z5K%(r8*-;Hp`P=U9_eFSMO;E;31x4{-Nr@rny2(w7W*YGBQq0v-jI8Z8|pco^aLO7 zYT^nKm>}$BwM2XBHJvPXFIdeES7<_JFR3#Y)^oaOPakMC(TXS%%6dt?@mRg4ix&A1 ztBI>fTtZJTX*8az=RBi*eYC5IHbj*md`p^**XlLTXqk_7HPMbNOvrpo9xy(t=ln%a z^|7rct|97#vbSV`@m0O%FM6g=_iExgvM!ySQ063Qqj!U*n?`+zHH00>Pv~)yhmEru zI6ZWzk9G~=KsH(ugzreJF}Oj~Lo0l&YlvG&c|ztp@~APPf%7*V#p2GyZKNil>>XKQ zT-2cXn~wA8UPJtW)F<@3Baa(5G;m(fNj}`Q#2utLLHM4mGVW>6yr5M+g0;k5q%9%y zJ$b@d*uZ&7XZS#CiF=46q3k_bYdqGVc}XwyA=VQ2kp~Gq@5xifa}AtV^b#NKTH*oH znIQa^JY&4ppm{~BeXMJVhscYB%zw$V#zzgDf9O>#b4@%#oC#(Bk`2aJ4Vr)Gbw1r| zi9eAq2|fRk=Z)VQIIrnVKHPQ0V}z3^{6JnX4n3!NO>2Av>xd`F;Ka-i3 z>1D5>)^$V|B1+8sNM1E2oa4NuO)U6LJVRuOWgp3Q5v(V=5i~LL6KOXVp5wfukN80Ai5^6eSoVp$ zWjuCH^Nud}A=VRrBXNm6pU6Lq=gx87)0IBj^~4KAl_>m7-Zfr3r+H7;_*mByFOh|b znV-q~#z*Hk|I(*f`kZ)$s1wURlMjur&T0Oo>wUV{6aOIV5_>+Ae;U7?<9whSeYhKl z*N7%j_=S989C}{!fo}E@Y#`nsyAm_MkWY={&vQP~SA3uiL@z=mVk6Klqv*W)BhcoP zx&eBN97yc=g8gNjdEWUExZ#t#0dgXT65YOH-A44h`V-*rp*KM9kfVvpuh`$lxbx0W zz#X5K4bXe!L}JNT?4@zxdG%-Dflt?WD*kL@$5-qh4|BPSGJO2av__}O@`k?)j-1^{sO@kZNeL#*c zunFpm4oXt?!TX!WHah!&1AK!vLH$tIq>?_ki%Ha|?h6j~P2B|bN5>>}^ud2L&1`h` z1&8_OZh|=IZ%J-_ajpq%RQCh&T!o1h<1zoe4B_z=^= zMsIdg%6_=3 zDZkOl0X=<#HbVnZJgKA~KEhPqsCEHGzNwp`L1=7JM?d@*Q+=b;1@!gJ-3$#zQK=8iFoJQufEkm>x7be*mZYwrqxmqRWy>`s3qFFB;W9f-`-)zK0an zCUx}3e>Ht+bp8m=@pZ|8hM`-M+&K6|)8Gs00brmnkOK`zcP1%0_;04M7n}pY`MyCp zY~)RsRKmdlljwq)3!=WMIgl&5@jy}s2lp_|yx`=5p}x5}5Fb61h1enw9umAK$y)4~htL13J3*Y~Kz*`y8^+{d);f^!g< z>--Nv84( z>LK7l-_$M881#Np#}D`vQ~d?!5O9fa?iOe)`ZUSyM|_&8?SgtJsP?6|K;zKAlaxQ= zGfWRIIER9(d|S3a+~n(`fhM5+ zlidd3b4`Pr)WblHFQ9=YqJxr^1MqpKu}#il;CA024djNpCYKDrgG{0(^>A>PZ>om1 zK#fW6U@N>$Gn<^l!F|5D8ps{}E!mBWLngFI%>yZ4S_1*7SF)0eBc`|}Cl56FwrC&_ z^-C_{;&Ri%CbcV=@7wh~G%_Q(gNuil)-^d@!6ILmT*wohm+UqW4>Rp*Qu9Fz3w=NW z6iHSN#Bo!8lamh~@eRs_geaa|G7t|pl{cwJfaSiaxsVqco7^!Fk2KXcIY)q%zPY)O z2u(?L8-z!j+M3iqgEhW%E+j@5Br6Buv8D%2&Y!{4zAd?sH@YmjWDp*2deNl*1+4e& z`kq`_o7^!7Pc(gLa{dA~`nqg|e9JO`?nHQDB>I>Q=}PJ&@cn7*99NyyzSS-tf)c3dztz$!NBBkz6ta&oV8%s2&48@a_5@pE;Y{F$7<1 zT6fVo27K)6vJIMwUQBiyiZ3%|`778* z;<6o@gZ5Xt4Z}B^1~;oGfE)?19h!>{QYwexTTEk{ofE(TlA!HS0P3nN8HVSYM9u1n z;9yDWc4!_tM%gh8-)5TG?3@S=ljLrP0@2@;Zo_e{32j!pfqV(Q9STCdl*-}wPE%a7 z(+wOcY1z(dA^nsk!|~mwh0W^Uz_F69??Iv&%8udqUemf}=WpNyi3{5hhR##E@$mho zUCnBDw*3pxLJ*25l{{Q$%5QeMgPxKg78XNsWeE@0o64Kj04S2AY9R!TRd(=jqp7~x z34p$mTrGs6DM~k2+-z!VR)e5SLTe#8xb0IgIm;|V4wup0fnJEl}bK-$TYUa z=?Tu41npo0x*K)M5=LlU$Tib3tllArNf zQ+bP83@((U?u25|`^t`=@l&Sy7N;0oBFWte#i37?ZolAXOl>V{Z%{3vcS7;#-%8~# z_*v6~7NxGDH(~km_(P<5^$F!br+O^ zj!EeliC;F&yyTRC`y{!$AQk#sirXmMWQrv;d7w=@^awVXD96oCH=% za(6=u(UcUoG5B3m+a>j6utq}fhO*EFDatYUeba+W&dK0uNy~0%5xOj;WDNe$^x~3w z3Ro}c`W|sxo6<1`|I_s4l5+~!C~?^XEkU=WxQ)f1mJ=r0#*1p$Aer#^QgOW?ptq18+!j_dsg&P>S0) zyxW9cR!;{V5_%7`96g$%9EbmHio5Kb4&ITp?15IGCsInr;V(@KFRN#O4zDunATl(&Hx`vT=qh%(2FT<fAiQY&N<)!Y0y3>2X$4IOu${t zqATjT;9zO$K4=R%M%6I^|Is}2igPYFOq#n7(y%5&w~08{j9yU(fP5*v56VToRLY6? zAamRmX8<@-+OiMYiu$QaCgMZP3$Li>fn%jz-xG&3R2>uXpUms7IOl;Aq%QlR?dUv} zn;Smdyz7cO5OinZMwU@UR7yA8)trCD83=kxgZ4u^P+V2whL13pUr`5vB5CS=XeS!0 z>TttOw;M&@C!AcYLCGuuVN543q+F zhZVYUr%LIL|7IR*bIu3nOM^(ZPe7+CamN9($fjmT0%CgkJ4tLzcJk#ccz))!} z2~p@Fl^cKy%&1Kbg9<6lQcvhnm6D|_%yBj+3`R*?NXUSmP?Z3<*u0S4%7SsyuJ7T? zv#JgN_c5=tIT0{P>Y{_#Us;tKh)c}7Y-$u#NdX;XMz5-rAns?*w>eQTLmH%m@=&{~ z1jHwq%WY~oxKNs^gASngRUIHc#awT5%E2YlTpg5;K2^DS;M2@)Hg#~R|C}P7paAVr z#dx^RFyFIv2A8gsUeE~&(Oy-ahpWH2$EFG?T`PT}6C6Z8sqT5W&NhFtb%vB~l=h_r zMQkW^w5RJ_^T1YBXz3Q|L`qPM4or>lbe(4&-P##ix=lKl5|p64)I3kuAhWPl6;`@a znoO}G;nAu0SPPVSdTVD`>0aq(O2GE7rH*FXKg@8e3S*;N#gu^M?owj}u829NwG%5f zN-t1?Qug1SCvcUUGh0=7=>h2zN^lsRo_bH<8e(44+KHDQl=jsNjG~K{={S9qr{BZEkH{BQE&!rO^xw(U1rX^ z+8I~+mvpYNyQ=TaRMOk7HXpgFhzI|c#v8e3(YvXPw|#~A^i@YZ_>XkGkz0>GN#*(2 zSDCL|RV09Kr3FTA1KOR6`PkQ(?_6~xfd5JxjNEhRn^e-rzRvvosv;5mEPZI?o<~2X zGCuYV<_}jLiQs?IuSRYo+Bc2oYu{uZ(56TN`}>VEaW9};Hal$3F^_C>B!NHr`J1>+ z=F|(P@m-ZZMy2bEJYke(TNLHZ(bn=Vv#Wue2%BKtI0%Gq)Wj(l9@Jp7~CjBMqG5 z*I?#eLzkwJe)fFx^EO2~IK%IunR^{woyPdt3(X(e9O>X}zprNQ4Rqt?G@i^}WFFA2 z$N=a0jmzWSWPxx@W-l?1YNHb{B}hq`%P1j$GwGG)38bQGILbBBNL4DYsll?Myt}uN%nGc zM!RAG80+^ikNXFDCXJb7KW1Ll?pOdO`hCsg-a#*<@h00V&D+}*3&9k>aR<0}Q5&1( zwpW`??T&?Dx}X06?mhHo8adfsV?NTZ$O0Gm#UJ3_NAIRFlkF$Xr`sJ_;9|e^2e=Q= zCuzJX_S5Do?TSU6rrEEU z=U-D~gF3&0d~O#iNXMqxTg_3|9ND1JuOXlN43(sl)9h{LjBAQz-~qpf`P{$IY3a-~ z`!(~bYmQ~$LBFs0+~?@rbl!CP4fFPE3N?7hZ(ISl8->_ZyPYwct~u1;VL$%@ZVwuk zPENNw%tx*%mV-zA;tRNcqtWTibo*`d>1&SV;Bmk81>6^CayoB@{f_y{HN^_>gkM1c z_a#cCV>9gc%y+IiR)DAc8Vb0t(530*4EqD~^J|Kg;90+i1>Aqo)#=O(`y=y*YmSxR zdB3j(+}G&lblyz+WAlLPidEo6zj1}!H*C59n`!SbkG$?!1zz^^FXZ;3`_svp_D-|s zb;WA%s$YB|_bqBpXJ*=;nWtWNtOl?9tuN#{(c*NTzx}y+{&mF~(C$}I$bE-e(=mU0 zk2&hPV-5I+Uqd1HJzAAc`rBWaGp;Mvg7^I%7IObZ&!jW{_E+Xr*BxuYKmEQIazCIK z(s{G&ug%-9E7pNe{l*>Senf3-yx-nyHeGkD1OM{#Kgj)r-b^QF*`4Mi*A?r*zy0D5 zazCSY)0tWJ_vX{r9qYk={MH}jenFq4^Jd#Wn6F${YyjW-6&&P#MZ43n+4fK7JJ%f> zz<>Q34syStZ_>%x_Aln=*A*MV&wdXNa{og=rZcnc-^?GbJ2ry<`F%ae?IZ7-!JA|6 zlQ-aoViVY3Hm-=X%fPGlr_8STf z_?v88F_&%1VT%szygbtlhX(YJ`4@8s%EL0q06RbL$PGm<=p~CU<_?laXD|WwpYu-N zaO8qMvh~H>!Sdt`-aPxryel^pTR}frK{0oToXEiD*+=Kyx#8FfPLVYfbBD^8W{~sj zWAmQhP;3Kd$Q~ARf0A!pox#krkI(yX!?6vVE&E!`9VXwL!3(rc$Qy7|u^pTz8&|>| z&Sq7xK)YMs$eWJs;QwU)C0w3-e+C)IHYRx9RA@o=ELOs0M}iC{&<^HJz3I?`A+q%) zT)w-_q1tr`Oa%%<_WEbW|-E`~#BV`RG+@IxD8Dx-Ml$UW+u@j7y zJuKn=B0rPC1lhgwR^4>$1QTUnOSmKD7czLi+kNx4-&E`ZQ)J@~aYxB*Z0CbrnrFJ{ z*afD`{10(Q%Wr0otk*m5$W6s=aDgoT5O<9HZU)1ea`R5#bnFHf%hn&_j+H;j;Qh}& zHSfwz#U5~(tl$uLoV+^&`=5P!-W^s>2CkGf9O90bzsVr~XP=q(oYnn+Yh@1)aetM6 z%wYa!pOyFFrh^q#$i5!pPLTH{c=PRZ@&+)9ec%?^I187p3}=fc>;ZWr8OJ_wo6O(B zb(0Tg%Wmy~d7g}7Ke$sCZ{hwXA4M>%NH}jQ&4Hy5o^wMsFkY3)}n1QKRwu!i>TB8OI~ zTBTxTudu=%T0^x}6Rl=LF_*YS9N@;F1~(TF5D+3kE&>7u1O)t^e?opp^4vY2&--

FPf!@pdUDeO50z@`LXXf-xox@U~SKM!$Ag}Wi$?Iu+i?q8g z^k4cl_tq1X|3Mk#g%7?>I#Or+mwwBA;sj+ClppC#zNwTt;i1Rqcik6hD67fErx5bl zUpgN*9;4rP_tj9=K!+j^lc#Cvay;}n{h|9-4dr|2cx2tr_%5jz`Fl%$>YlBkxI-n8 z&Jey^x{mw=blhE`p?E;L$PfrW8oCkJOEnGdl^VhWIv-gKiDRU@aIQ?#?B1jyn9$Y8 zT1XrxO~v&xO`H3uhVX=LM^a&Nf^;v=m1|7y3oyb9dJq{5i<6{BalKsA<<7zg7W6E# z_;VUWgL4%ci~DAbU_*_OwXirPs{;JuU!}$=~^SVRO9S%TuX4F#HE%7515Je3UAC&Gja?3QX9t(8D zI%r)~aESOYInSjpBasVe z#81%nC@T5xOnT4A)o9ju9M=&)L%XAbQL$9oVAN|gOpi((0YSM5QN^fOF6}aMF%8?J zNk_m?dQ>ecR!WDBdQ9WzF{&dFC@+c{DppG!>$zG@pvQuf1RvTL6&xxqmCmczYc*>< zSSN|~(7~wUxAJy)j*_Sk%q2!W18)rN{SQf9qgr-3~(P7)}jj-qZ5Yo%-JxhFLt z9>-4-p^%nb$rGQHZmidz)NJ&qJV|VT&PNq*5TBOrs^^~4gn2ZbBsM}r;kIGZTR;WL!_!seYX;(e>oJQ!;bc)ypjYQS{BEBgduGgQ_olUghTVAYd4ASNtq4$3!1+@GENh}LrbEmo5c^LYa6)bX{6&j5P>kJVI@uQ1Bmvb}?+)J919-Ge)QP8I7 z+Awj0^m2p#lIE;O#u*|S+8#~)Rop1O*TB83Dfc*jhKPZ7M+g5ZZk9GQ=r3z7dsLnw zVxffS;$Our(yj*X6-|{#(-|TTN{_DnRoo^WZqQ%R-1HbdL&QUQ(bO&CcB$hF?o~~- z$AYs&0<BblODouk&Nat|bngr9b&bhm!8sxwdJrAF zO*|+)`a*wQ)8)ZBM`S?HqKiK_gfuU>H#8QH&F2Uq)EHg6O*|~U{6c?2)8~v@C zqp81%N2K>&aBpe`J&vCvvY_th;NQe!(uNoMo0?&d%5y|E)E`~^n|NH>^@4j#Gv?8B zj>v&VqHBK>PfCYh=x=EzJx0$FxsXi^b-UO`=Ge%+t+8V+I8Wq3PBFpT#dflJjr!Xf zM<(k$kq^y|DgGQXqBnByXsFE1=ZOMneoXClv7?OHsK29`$;>!U6hcd4s5`_?vbBxe zYK=4V_<5oTS{@U;Lrj%zY}8k4<}oYJ6MLXlF~vK?(`CCFxpy@#%%=0iUdSt^c87ST zEVWU8SF@NodYQ$! zt|@66xiuOFbMpnF7}^w58!nzFyWFU+(R|0uxIp{`ZI7Y;E}k#D*T}u6S;IVjf!Gh_ z?v4rmUF;%jXw=`+FqxGXhyzeUO!4pHg|er*U8%mF<{aw6RHr%McukmA! zULgK~@?xkv#Y<$4P22~XK<0vS;vlpyCU~dVRW`3l|3I^r$toufK?h@sKR2D|OIn353N2MxYE?Xj4Tk zeIxTyx#0+OE~X#?{Z_WK$^9Wcj9Fi9I0{{fsg6LulL?wC9@4ilhsq8ALbqbLQiF0x@b^9 z?_vu6Kz(GLP3}+WLT3F%gA)1>Q~d|(C$lzHJf-I_hb|ga(5D!?-DrT!zS;d5y?{Bl z!k~tx#s=<217)=4if8n_Opgjf2{bFVU^mJo>sId1>BY!++3Mv38N@IvKawy^emEd8EQX=8IwjBhg@4Xmdp!x%qIZ!f*om^Y@HMfV?wl zb|atIna?Y98i*NN6$!wyq~2werD~tj{Wu?t-^%@;>{UzN=Xk%o4odp`)=?F~Ba_nwMown#at<%esq@GIlHm*ewfu>DH`y z&OCHkR{?2a*|9*BEaYWbv! zT+cICMK#dMxTHkmuuH2P#F6poq9N$@p>EvldAg-NW-trRXsf z8<&)f%Vg~>v^R8?=i^Gz6DTFFJQ-KW`dcJ#=)Ru4m7=FmcHCexu9Dfb(z@v!&ly#s zXV9LwH3GauHoH~QO%L*1StWW79f(U3;Qz^%w9*JV&l9R5lP^c&$_4ld*{W6vL5DnJ zt3)`Yh#M5(m@J@`W+4-C2dYFy=tSI_6kI3cw@NJZ4W4JJMD@^_xTF;Plx%w|?Ja$? z=i@3-19T~_JOw`^OK6q6rEm4@trER}Zp00y;OAs{t+XEc4$m3aM2%2Q+?rJUg6v?c zq=z2ix$>H*33?otl!|s8QnuRnYIb|_u0=LO#<-KI;!Co#twp_>7|+OSkuRZ^I7_Pd zitJ{qeV-=5Q*J581+aV_!{G#+P36W@|ew%Ye=@;oWmBikYSct*PTj%;RIQNO0hbH(+@ z4rqFORJ!=CY;l|YfM%a3?|P&OqQ##~7uU$XYqO?(vEMV|I`1{~WxOdJy)W}=vkuS? zdhWT->x90Jcg;WzvY*=ogLH}KiR(Nw^i6zt2KrF8rOi4>KjvBUIjQ%3d}#*ySQgVJ zcu$vmny>TTK;H4D4D_iitIhhJuJ&}e!Rv;8h<6pD&t?1D1Xg+(8Ij--1evLN?(HFAoZPp?BdCxsJcsU@pCht8Yi#KJVZ)85NtfTa2o_lWctk8e)uGuId`}vh%jBfNi zag#R$or(|7M&HV|yt0nb8$D}2#|1COmu92AvY1zbkMtH#^G)6d=vurf8+|9sdS(4c zZ})V##T$X{#=GXA1G4?E1mpBh&t%y2TrV@c7akbVzpk zm0*J2;~8;__Yry-Z^}VG$gaP#PSE>3_uS%*L$BjqbJ0=RqgR4Sy4CZqU<*$7inz_2 z0)Lrc%0s8hecG+I;B2oww|VyP*9orqC`JBryTA^ld7Ze;bAZ1|2+v2S$+xsy?ZEk7 zHJ>w!-zSvjqch|&?Sd)bLND`e-c;B-!IY2Al4rGBr+`bm9PaR(;2#oP3(z_8{p|vK z(2XpT@F?(43E>4OAeXmW?ZM?%<-23^+O=yb%3T-qLP$1iid!KF1!D6G{uw zMe?3@!Bo)4%Y2773(ib16{26s$J?z_!2mCZYTj(PFu}D5T`Hg6A#eh@UdyU^bKt)c z!i&(a0*dB8v&%b_gio&t4JLJn~48U@AgExlf0c0`k4~RP$)? ze+jO8&=vBZI|NiP)ayhwZ!UZ)A$$+|jeJXol?rb1s`*@;yqHkB2VE(T=@3iEKSUWp{ZC;D-s}d(rRZ@($~C zaJN^`U7ib!CzS3*J>;i51T(-GuZX+6FX5L7roE`A{CbCV2AJTr=Pqv{{5rw)Pn0Ep z)FGG&3cOC-8WCWQZqddpintTVxMubR)<%l8STf1MtMfu+9SWyd3nrCGeC)*L^5QKHVgk4HkJV)AN?X(-On?p+CqMnyj$g@mV091Q9)bN(W zKP0*qqwD4SO#&KNM)qxZE8w3J!;4W=E;m_epw=s>hR1->#L{APgZ#8fFc&=S6;Z?c z2L3hCRE+*2zizV51A)o$Q zFdx)=ExX5C4gZxGz90QvzVNkmKKQ^Z=pJtkEKV%lk4DJ9eJxl3KJtpV$NL_ZC7Sl5 zf5?4aTNi-Oy!PDVxx@b@x*i}KBtO3vxPV5l6Zd!?@TtV`18B5-%WJC(*yvUBxk7p| zvGf2ME01|C_!4aKGT-BQ!q*Z_2hezV)@$pRV7r&YeV!M5H_`QPG*Q0)wO}FG>9y=W zj|D$W4F4NVmdjsT7lPehLHBuV7*8zy8%>d)el1u8_IO3y=Xt{~6HR}kY4Yo@t&6~Z zuRZs9KJe>A*MHCq`J>l@#h}&e#C@JG{5CQCA2d_m^4hu>9Pz68oI-t{So#l|E$?|P z_zE2NGT-OU`KpU3B16}|;CCT+5nlGQ;DOdv9vz9&Jao{-tn+^hn za+l7|CD>FJ=fR3V*f~jh5ZEJUbnaY=O=InNu;K^UC8_x!@TZ*J*|`*(#maxMf(yGQ zxf}vSWV2fWPEnot>`O7pyxER;-2BBuNhe2jo$mJHN)hWOY1P z@gvMm3XZw^w>-Vm_%%rC$`2^(U`~=VY5U3db%wfuU$Yh&C_FfqmlSdsKP*4iX>EI=wVk2Mz;9Sv4V0hY%}I5K@uTvqoyKM0|5(`u%FpnQBxf->^7EiG zl)TYoDGU?{j!X&><5GEJr;!eNvThhCFr1ilSd7c%-JPKz=*?<1P!KpHsZNY5I8B`DWvC@JE(!EhPj#lyq2vm&uo# zLsx)5v9{JyQ22OKodnmA-zeh>5MgE4QbOU9BW<)N<>_YQH{frqiCW4g_zoGc z#m~$4nM1z?e`hUvNZAY@@KeF!*`W;iGtkTx$+p2}ZNFKBW8# zHzm~_#V^aRnvE;Lcvkj9$`-gI$@yRWs{DaD^gA$_rFcl$3KK~o|Ke5hMzirdFpYKN zA!QppkaYN8{JOl`9Qr>nlhyi=@*6ywRQE4_Q$Aug{twJ$O+2J*hi#LckKwoFPF&7F>E_ikF z;p6xt`R=aJ??D->^%3O{n3Y_29DgED?=pT5s#p_`D7#?}nexS-$@g{H50;g(7Ca_) z!|RfRrDWarXqVm{)38{NiAb2ATr4HA3r!c-13Ssu{FsP>Hzn6f#SQYyU3w4fEGy$N z5e;uorpm;P@_SueCRWZm{+Nh?cP9tS#Le=CEw%&L4$#KH;5#WHb=ysL}riB++h z9uskJdUCBy+$JCH(tBbzS)-4McsMVaDi^oQ9p7-huxi$VCqx3gFF9CFCWhy|(R*R{ zSga>RB788pST63A)8BAeSS@Sw6Cw#dmRu_rcgdM=^epTNE8_`~46Bo=3URl5?Heu| zt79F1LI_|jnHLjV(V^A{D-xT&ocG$y49x zy|Fgd=o2ChzMV`}iu>hz-*9~}6Kla!A{~B^9IPZe$4B4jeXuST>nV`|KT9rFimh_Z z8?G;AVQqd&2;s)$TBUece))~w7wcnXJS8&W_GGF`JR-mMhUx?CR$JfVr@no)LRtFF|dIc%~w?TmJ*Lm_7Q8_!G_z5Kv3SvlV;0xm?VZz2G^q z4_+q-E+v!CN4xc0jLv2~Cqyt`P+TgeDKy>OAdJD@{G2F;HwkJ>#q$)GyY)fXckGPk z#9#1s0rfxee8s(P?pkaO`}lKWKg^jO_#f(`Fm_k01)1zi&kYCQctOE`=t4zjxBHJE zn_d6h@Hd<$sQwRKtgv=h{0RE7hn^e$fpZ0RW#|%xJ>k9%3}nx(BZ1aG1%YL#tAa*U ztOM7wJ?adH;C}=Kq)l`c8`+oY3`gK|f`Sw1w~C#FJGnv6uCFs3g|7&zPoUo^ z1VqJ8;5PP99l3FHOJJu#S1F1J_n*OV_FUX>48AW2)R5sj2~qJgxQp$98;-+I1qB+E zywW4wAux))0XInD7lLXHe$Ty}Ok`uR>_qaB7H$)aX#g)pHQ@$hiR?qTP7Zeo*ciYj zBYI^pmcqV(>lARGK!^c83KQXmU>WS^xK0TV3#u@{PccZ8Ay_th0N1JDNx>Kf1Ssq* zZhS1CJ;$h1!;UFzEfA=1wv_R)J?zy+T?srhMW_Y13RjEUdQ8M#Z`75-^HQp`z*@x` zveS(nU?&=N|G|q>#vS-jB0LF%DoQPG8!#PveZB4^yfLNfB(PCYPR7EqGwj5A-6?om%GgO@ zlcL(*rG65+clr^XE9g5j+C7Zw+_R0p) z1^7Tp(rNs6#gezQ&7gq|HHgaLBPr#l@d(ALx021^V|HwV=pw9089a@5a{S)fhhfjy zqK3!{xGaTnMjWYt-xh^oMzU)jc?k}^z5NUtt=RI`8U{A9Yd)vWFQ$~9L1Ps$Zw0@C zEo^fG?+Sb^#dHRZS7g1l{tC9U9bWLR!go_#&!UNn{ci?z73^V0yx?7fU#6JOqG^ijZ>?Lwe)gUhyzB7m6xVZT zhT_p%!8Xv!KJkKg1Adzleh$r4w7j)$14r03pL^@?Q%cXF*@~XGg5SV#w)q9`7W^^A zbPml`jK8)12HJQ#H1cl4Q&L^eqxp*IJ%a6^z4x+4-W_;aYWR7yP_eMbx*c@#4r=68 z!$4~3d32BB+aAFVaJqLyBkwN!Wvb~s`lrID$GQWY?Y*awr-#2zb-jR!6hHR}!a$YtNAT~dt{2hcibp+yU7)-7i6-7-I65`_ zA}Uj~^jLR+UfwmI)APxxr590!qNhjj2k7H%ZsI+KGgD0$QI%r6$NC2t;O)@Ndj=P# zx>leiis`+A-5}R{Su^iB{8wst1^S<2VXt*JxXwGMnKY@ysihU@3B|X)f=F`RC8C+U z>X)UOD#&k?Pp>r+pbuf+#T5`$RLZ9zK;CehEFL*wSl_0ylZr zeD2?0Of9{Ho>9c~3ZlU+-sWcB3;0^9=@NQQk=1LB2Df`VyyP{)cT-(2qZbtWdj&Dz zPVZ$ec}?(#_RW`piwbFPXAJg-H|OPwX41-%UIs2HPWJAM#iG4;yj<}TZcc5!3|vuE z^%lw8;=Qw9Qd;1SROc)BRmFqe&^R#JTk(?83KOXzSMVxDW3Mp|O!L0+lF|kbq#nM4 zUsrVZhQ@=L-mNbwui(+tx-0li#YnF)9?bQgcu8r8ZPT2u;rg0#A;_&tSJpD_{q$2+@) z(g`n3bFRc6DAx6bCV^saMGM6YuSg52#A_9s`ix27zuq@mC|&UCw8NG7BgO8%&}2~N z-P%HV1GCcVD)A?Z^gd%UsPdj@p>)F>(hIOqGa#ugM5OeWqY9|~Kuk_kG;+e|SetjXf*k`nzn2H3XQ>(?Zm3#ZSMVPD4 zf)2t7S(hGMO|lY4`}IW_-G|jdP!N84akZGH)bw-rU<{wl9RwBGlwMmco~OLruit}x z=abPvOhdM(Q}2rBEARDl_hM^&j&~5#k=^ORcf~HshJO8CjOkO^LCioB(u?nk7b?5@ zxqo78pQa9CCX$|BdsnruE&1cj^e1Y6fr`{8U$_tSus#?0F3iVd2RR-d6jN=AN5k|2i*S#lYEZ6 zHY`NmqzB$d*-GO;#ou76&!yLfMaaAKg8QhCvU9-wA5iF1|Jtw^`H)_HAN5mO2P*yn zb9{zg8@@t5rQ1C~1C;iI?gzmFpShieCCJo_zz1lck~Uaz5ZvqI(P>zU%*rTufO1Lr z!~GCg?6aZM;EH^aQT+g2t8^c%I0XLflhSGU8u=>2&Va5{atGZHgNJ>NbQ;_cFeA`_ z1}j4cD-MH4eJ*txmLdPkC@`QuD|ZgMi$SSReW!trcxF@^P*^D#tPq1rpP^0zi1=sN z)uMc5(V)8oEcKadHY`Ve%m}PSLzI%i3JIw3@h}@!AV@|*EgDMdBko7QlRg{F1_ts= zMs+Q^QCTrqaRfZ;lVUb}gZ!3Z_YmEr)DOBJ1R)8G(<`a3$@1#WC=nk4KkbHS$kJ!6S4hNuanN2Wx#cbQ#tl|7KJ_LU$?M z-&Y(5pZKJ78NNqU8Fr7+-Ae9zcPUusbEM1QP9C2FAEQyq(DxNmu)*h2m%#%$mr?K- zjZyA=?=AzIed@amOyo*N^u#p!T)lbk=WySjn1=#14^2Xqe zyvne9il!^|@7P=dogm);nBk#`vdPth!8=X-Y*IObFT z#^8s1$f$mb<|wW2D^%d5&(Ir#Kk_NV?ire=w70seK|9~M-G%^Usxa`G_~Y-+)-pBb z;Jdn8$3bQah0lN@rK{Dg1f%$_@74t(^MqB;fW68!qz{A5@J;O2{ea{y7LGjw_9+9c zZl%~9-$UIxE{o_c-TuQC_&)E}twor^s^`Gp z$|P&qf7l}5fo|Q8h@WumIdD)}V0A0Qmio>ibnB3{LUtW+Sb5l5R)#I}T}_aJ7AzE! zK!~!`>UIKK;k%yD1tS}URdv8oWx2KN1h&#QkEl`x)6K zWaEHTS!XTNU>?2~2pxpP357TySDLJD7{>B_PUv7HRak`sO68!n48weV2M8U4x^`6gO)D54aO83C;_aLDZ>w%+%UMHh-_gzS3Yq%vfv>?F3q_ku;Y0XZua z)&r-N;X`hxu+6^DExL`!Wnoo4a8{W#RCWs6>N{Z3{es*Sj@1L_l?6j?r?DNrbKdGU zA@_vr2B2Jdc&O|&7U8@4t!^{&L?~&i zt?pN(O*qy7Tvb*Nxt+xleGk3WZ9%$(>=!^48A>lZi>3Hpc&por^a+J8fa^-rklQ&d z!}s}H-8N)cSoH$9sT>?CJBMZa4!qUh$hOR}W}s17J?wTFyWo4M zR~Liq%4EL;nw53KWtXu_z889Ru}EB|@FmcqG!465!76>9_v+%1)Xb`vK$~)KxawOm-VEs670k>^f%lUHwj%fjr3+wgFaUDe2sj6RYdr>4Zo_ zW>p(7tSl!DC#=^u@trOcY0DgI14fk9AKY$Y1HOme>9UZnO!g~ajI8dL-Nc4`FTB%b zBYl~|SHQT^^ug^GHtPHQoh}C%&a8R`OezOIl-|h+^VrTeuw(W%sM@b-2qTlAtPng*j&F0{klEKcUi&?V7e-N z#O*G&!0&m#ZZE>js_Fn{s**;^?qZAl2Kse>B7Rw89l&f=!HAn4Tk1DwK(`NBo5eN( z&Z@&BWqPtqxOzY*Lf|Z+381M;N8D<#6@Kdnbj8TVtSS>QPgPEGR@h3v!~xx3$hNF8 z6EI&@J>qr`TkUsfK(`;+mBoGyxTxwz%I;wveisIG2avcd;cH-_$~5A3A7lAFAJF}c zq-IsU1{SLZN6PMFzJ3D(x_^+Itg+X?5|!Pk+XIZ_H)l|H5ZRl>?gU&_&ZA`yupqzH zgStb=-&w*=z)j^k>PB*ge(MKyhmoUMRh@->Sk#-5z3_{hkl%jv|+{s?5N5s-)4f zhuBuXfkEBB$jz)VGq6fkFzWUQ+hP6m$#2ek-7(}|7P|{rL(&UnkFW^8)$eu3ktbQg zF2G$?I_mZqi}YLnUMEEwvZ}fOrmCE@y|7rn#P>QG(v~&W1$e2dN8O%aiGGLP>*PpR z7W)mrR@IG`J;74^F1*(%kiIP88^A|p8g+Y$W%xaRuTvt!SygWUKh@x9*;6drZ{WR7 zg-m9Ry#WGLc4KbOuzbHcR-GDg%w~53fhy;*vS-*HztvV<2{JQV*bQ)1u48V`F_GVT ztF9E8mtEBjtW~WcfidiWU!qm_AF?=mtQ%OT3LJB*!w&f!vg*nZdN!K?f>j}7Wp&sQ zzYA8~3FNzMAp!iX3LkUBvEzQvtvU_D%&sB;Sd}zZhGPo90jmx}{IbUgfUhbTb2DNk zeshL&T4ZfD+X95B4v&=?u@io)hjcmw&K6pLP*v%eTRo=pTR)^biEPZSvH%-Z<)m_k zo$*T?(w#!KWsg~aO{(fKw+8Hj-=QJhX=GP6`z;Wrsv9e7z%Kb+7}A|V;p%yH$Z7-I~FR(*47t3gkrgnqE9o#s4U22J8LK42v!yXR?!e@o3fdkF=Lyli%ZE z(PiXPc6l!zt4jDNc?q`q^$v@!AUCoHd+~Tx-bY#s*x@(hgXk(!lf9-7PgEWJC}{!B zek(tSDv`(8Nqu;-O8t@63X&uCA4FA1eRg>to}xPcQPK+b`o(?_T|-*42mA0e)$Nb8 zHj;ol@IiDPF=wxNhi9msktPT@4)cF(7^j)QIsOMmM0#Xd z&YA&Sq~ecDOyFGqGb5rJWMxj$0RES1`#9}2xWNDMi0B^TkyAc^A5bNXOJ0ME{Ch`4 z_YvQm!2$dqRo*zQ6I|*)V^s723CdYBh#yiN9G7&0%luc4iVO&plQf8nRqAn?8C>BH zjf!fK4LRk5_z~6lami=;E_PJ(5ZRhDIEeqNx;;+o0$2MV7!^H2B68Ne$B(O?jZ3;f z5C1cxqQ^*VPSSf^rfMIjy#ZPNk4HsMkd&PA_qamUKQ4I#`ug{dik>3bIfL(UmC9y< z)(vv}XN-xSA$xMxSn(3o>Fr^IzJ)#i~z=d6um@R za|Va;E2`TQv_3G=|G-C43u4Y$^8v3^JtKt~Fvb7OM^P)%n^Svn&ox!sg#9}#-QVyr zvJJ83octiZq3W9`dWU8C6CWdAA>%og58_*@$qD;@EYF`Z9@&oA=Q2jbcT_Vci~32e zX2p192Qoc3YD9cjwRqBg0Ndx!8;>+0wA_;;;u_U=lSKpAe*ehv$k)iiT+4{~zKZK* z9RLsd?-}QHB46jaj-m$D&y#{dP(sQ#q*wS&ZultrP_<>!ItU)~uNmicA>Zegj-roM zF_VJ#pxoa)&U=G+=bA>*r>d+;>w8e`?=ZpZMt;b39Yde1_D>3|V44522_8Y#d&9?2 zTqU2hT0yOU&;-wdpt+@EXuaz6q+kdPdGb#80UiUXo@ZKSr zxu%ckE7ka<^#gd@-(iy1j}+#*j-wr_>7N86px%GkByRxuD>r-`eXUyf$vOf)@DG~g z4I<*)(s9(R`u3Ax6nx|#G0A(6$Z}2N=o^*KC+jH4{55ZKg%v5yb(sJNRq&^sV;JsV zGPz<1Ihh+a0lZa(ed-*;UijacTro^yj?xLBR~7YX=SS?Nf5+sC4@gyR^91ltCH&O+ z5qsrt`)S1pQl0BE2@I%;KkXdHUi&Zov|<#g%?+Cb-m9daI>)g${+v%M#*n&P=_D|u zI{9hm1oqZ{$EOt^k>=dyN#KL3>Qmd7**AN+Bu26_b>UhVgj+` zhJ6A)s+vD_PGTSY?|fP@Ns5xvPr!uA@@eNM?4y6jrxl-&vE1fQz$ew%r_N8YkZn+RL& zw16Epj46DVyk;BPR5jbiY^$9WkZ;4V=ey>)*wQHKV4Da#Ef7#*!*Jj;^1^Is)6`)$ zW;^W{0e5T|j-+cTwWZBaN7+P7(S8}wVZ)fpXXiEB(q^fJHs&eXuL5js8BYAbJQq9K z9CfixguV9bfTgyi$rPLyW=8|mQX8|q77XCnGN}BJJgFURuKJ`+goE~*fE~7sY5cIf zW;@y!>M9$vgZ6&``L>Md{O~-NDYOOZTAK(*?e_sCwu~A4sJyT#v@g}oHfBeyXTTj> z#!M2|lun^7Qd?{yrfR(dI&2xU_`afgTK@oBJH~8&L7s~}ZK;}S8{wq= zAz-N;V-CMKFU+3ywc5qj?4Uz+FQKwF^>wvC`_(SQ;=#$5i%yf6paH|j83GgbRbz#TirJW}|SI?z_C zqiiFlX@3pquw#6|ugYt7p#4uRv^7uD{uW?6g)yIBo#*06Tdgj(jhL?eJz(h+#sYqA zUYH~8d$rWoJYD-o0A~urg(#|}5j5?80ZSc7;43&kj7me*QadwEiv@5T7z}<$ zzLZMapgw6AF;{ykV21y2{QxS9>lX-+}QhKRn-M8f~+>)-Gb6_F_PZ z17jsWDnD!*?N@cPoq3-2O28cl#&;yRDxF5#so_k3x7^K{xDYW5WK0`1d)d`E^mzck-v291QKrbM`C@qiLXh6n#- ze%K6Jv^s2x*+u&z;Ep4MNvf~X8MIh+)Rc%XwJ!rY92uVcs{G~|w0N~}iup_Js{q@n z3@?6lzROHnqPloW#6s=sfTdFzEPic%*i2foS~|tNQ2Qo;GnK*S*X2uR(o)nXr$j8$ zz75zhmEp~A&TpPcOH)@(F)z}-3&@|!@Zp>CU1rfT)U{J07Hi)JluTv#@-6vcvuK&> z<|*dI+7AJDrZW6UdR97%maVo-iTFzUF`#2A!=Il!mft*!ma86{V*X0|DZti=5x}=A zaG6cZS5xgHmT0GNmO3#wd}=}1Y+9k(#ooL`JC(z6Vg&M?3#7AYd(;g3h^5+ToE=V# zANVc>&9iBLs@eADrP^7Xd?yB%?^@t8hbAIrt_W8xz$tNJ1o0UKVRLAIsl)8euG%j+ zcbph&NdQ+mhju_6Wgqdi_DfEO6XQodyP$av?H{$!-u$)pD~>INv5p^D;Nna>q%O9P zaMOOxSxO2lEOo!pWyHLiwcy zE^}!*b+AJOLyK}ssEiH#lLcXOX{Xd-4rYe-7tS3jV7X)x z;a3$j&!wGH3mwegXn*6_PGfB1R~NXthYU4Ti(->R$rUI8QXqD<( zhluaA$()jDjIDf2LD(0xYwBhP^LN@b&YfwDZKOvm{epHwZE=YBpEi@zF^%yXf2^SS z3)(I9n1lI$+FXw9bjEhRU7^c-+8s63F=Carkh63;V+WsF7&f1FSMB0xUZwq$!%U*F;!I4Z?&fm}on4I2)cYJaxG(>YvuFl2lFus)aWU4Zk2%)6FDKu6&Y(u|*B2gk zF&foc#|<9KPjR-+phojI7uLBL8`M`F>phmA<7CgE#_)F(I)75 z#M0JsHqW#q@sAbOE|hesnNw?6+9#ZhnU-X}x{$g^(yd-QHHfXP;~bx95%9Hz!HXmo z^~R|+Y;6Ola;7DPf4;DIk)%hxYif|Uwwcp3(~`=+T3EYC(x*JW0ob8-(E=lN;0CpH#NvlJIFac%aXfxz1{@O{-=qyVv-=>JVgruw; zoq__ic7Y3KTk`l$MZrrXb|v$iY67&5fvnkwiNQ06j7H-oJ!U@1qEuI1CP(P6!DiA1uvCQOEx;y1Zw96R?fEU z;jbzxUMiVhvdbyx2dztB(`?ILzE@H0QpwDcRHvFBw2K2rXIuW{2NY3VNl$yPQxI3{ z8n|GNWgmZCQLwATx#Xx*4OdGKWX-XN`23<`R|%~|;}jI6Wdv@XV=3luDynsr%qzL< zR1>8AE-+(`!M-6U=$bV?8@8U}85wjAakE2?#q&`X$<8lDyo z%pe7MzPgCIOtQRWEhQ*e8xnZj*&^XhoQYke*X}1NAI$Qqb-!7t(h0~I~l%Sur;eiVP%Q60gqF}m& zOh}PQB<-$17GOEfe^yjXm$;W`C_&`PdEjQiBIP$0)zT%*lFO7DNE;WJ0a#@G_97}d z5?^wU5(I0L0*?d#r|4e%VoLu2j@wq2aV=+3AxV=tqf${6B~xdbPPaLiiQKia%wlQP zD)-iOIbBqq>88?L&dfPyv9z*kv9>jqSuA$4vdhw9Ig7>0svq|E^dIz^UZ>7H&-eTO zh>e$YI`mhy>Gc1xhbOY|#wW<8q)hdPwXVu230?h(^sAJtGJ4#sCRMaEvko z5ltFTy+aOE#t4a>;3|}Ph-lXM>Ln;p8&wjFR>v0so-krn_Yb(lj1X|xA zdIklMIg(%$*P!f1M5|`D-hqNtW3I$b!JCvmM6_vQ^%4batQSf!nlmZAq#~zL>v07j zjWrTG4VU~QrSRHDqh}O=hWY`fcrovqCSK1{NC6 zNw9RTRaq|;4{Fxv@o8X@@w&vG&K*~_OT|N)je2GpSYmu2!4_~G%5JH6ShGd%mxYNo$sraR4mtGPGL+?X|V+*-XrI&|zL{kiNtsv9rG2Fh8JEx5F5RYmq^-LgG zWegaOWpEBfzX8?xwQaLp-iIp?3s<4aVHz_Ql+7WuJ%mwdRao z5)5neg~PEWT#wRgoOnWWUXKTZY-7!E`x5SfGIE^QuDPOTg285^bvU+^6O|d`#1733 zy(1Xp8jlaRFXf&p^TvtaXzu7G)4>+wx#8F{?uD{`oOn{R^PwJ}4)TrHhufENuaxcM z#8aAQdS*JyT^&AHLvxK>0pQP)o}ZA?wzu4ocOI~P@yCQHt&awz*cZB zabBL{8O_i_JOmUNJx179a6{rEJ;m=dV+xrNu*VoM0$a&R;xatNPK{@wBLoy0BSzR) za--t%JjL%dzJ-!dP;6938XdQuNaf-MMFVUe{Sm+1?HOAv3>{(oJT%MQsqh>{+WCo}+ zo*RL!=ECCYy~K-}HHG*L&|thi!oHe|h->!}FKIRwGBZG<@xch}BQ7eg+e^Hx*;443 z0h*1kM%X{%V&eL|#Gf>~3MDf^i*d+EYz?Q3^YRw2Xo_L{7nqD5BkgNARa~UEcvVwb z$jk&5W57skEljdzc#GFGb%l!7P~Y@3Yl$weadE>=jHFpXnv%m@CxsligE<3J%y!e~uVIe*XbQrIXv~S>Y zB>t;=Cq^cQiwb@Y$f#=rPK^kt>Wl^yK_q&FG?n+4NbX|ETay+{~+*2||y?qo`vx za?ThwirvJO$8DM*{GstF>Yt6AH!4TTvbn0bstLk9O+ZmpB;qiVqu6ZD5O;Kfa9mhssr!)NkSaZe^Pe`~fEy_n-@v$J6f72zYv$=!@STKidyG7?;Ar#8}j6r;@0~x z|7eV}f*ebsj_r!PqXv4 zA~6R1Ydk&LbqDt>u4Iz#jpkmFVjk!-ULLLA!M%**C+Xg59vA86f&UqAk9OV3y^gyu zN%v0kvPhT*-Ws2d*6-xr#66m%`%m+xNSp`$Grk+`x{DhWKg?G*h!|X~hz0MNMvc+$ zg6VD4SN9$6N<%H@PR32 zjB5cmCcea1H<<7*R?G*(OsQk^1soF3`|4bYz+&BeFx<3kjO*u|XZ!_U-4J4Cu`nNu zG;J88|D2l;|HxPO0Wr5&oDW8uc8qb|!}-P!o2(m3#1|`+z|B-KM!$#ik4Go#h7rld zIwgRE4P#vOoIE~pGQ@!BVnGR{ro&_OdM+@2?PT3>Vo9;61mjGn$G8@9A@L=Xbt8yX z#fmuKWx6~@U&zgj=O^n%66=a}abUdZ_88Y9Zg%{I$+}U*=3*fZOf)?mqc7s-#y^^@ z8%=C07URGq)4MUQ#awLsFhAWGVo$Lm9!xfka?=-c@$smi&W$K5*2RM6eT z=%;fhK(P=Hi-y5&`VuZVeyyKwEYVmj#>3g97&ljj)5Mqf=@7zFtiT{|n(C%!I69v9 z(@BYg#X1a3Gc9v-E#(%*U+~j;5XXuI3t1e4{DmnxAL3=PkN~1hPu=u;xoz=}rsyUSZ;HhPFwgYP&9#!-6+g^h=SvJOQK-Ru z(2DU8}g__(Xr5AAytzYJi!7-St&mS^Qdm-4tR%iKqq% zrWkkEYOXTA#9!x6_?IXWL82+uU0=EuLwi2?_Nslr_kI7>V#)1gFii4F%8 z9Gr1Aa9n(%Os62|5&;KV(_z?+<_^ZMmFcDtOG-o>EHIsRcdg-$#Fxl)fyAm3MH0v` zU3S;kaL3|#n8YL2mFSYdV$*GR*IMpG`~{gVnAlt*z&MfVsk^?GI~o5-rkhS|D-n~N zm5blF_twhK#JdLIA;jk;e#tZh^vCAZ$-+?lAbgg-f6(Q1Nk7#RgT9J?=yZH zdbeo=JdKkN#$46)}S`5p=m} z9g<^~Pr&ZW@dRQH(?igEP1}%Ov)mVRMR7F|$M}(Sm8lHLvB>=~8Hy(oNlYdQ9j`{D z*CLl=7>eTr#h6IiU^l3V=z7ydB*!YB ziLof2LVV0*Qczv_4e7PYXJd90u4I17m?*e$@)*gn$>(DCQ9O;<%Jfk50n;0#*Cvm} zT;Vw!@j2s1(?-(>X%74&ipdl>L6kC?G;KCbkoIzNHHIm05|U~rnr2~=HHVicV_6EE zBK9*qG;M>!mA$-NgXtAGO_&)!EzO&fr8$C}##jZeCB9%Xwe%OJCDL9&z7Vr3@O0uU z#-ybWnbt{jzK}1$?kn&G#5YWjmj2STP1^f~dFlB*vHaETn%h&GqO#DzC)!f%qz7N~vE4 zeZiFMk<%&%7#oOZ5z|UDGw2^pOFVj8xvSI81S(b&VWkZj$R(4`BkL<}KXx}z@evVK zdNTw0$&~ML|10hQ_Fj-;4WTUcUW8mVF&2d!!_ciuikYWRoUD~i1xn&YPvcBfN!R`hrJ|Xf;Z!SiDH}!km z|C;+2doNhAktitjUV_{;NycTJ;J(Ku2P-xaOzEN}$R8%}araMfKVb8N71=~}X~Poa zzDY4ItDXB1TN$kQlxQfuxdi#s6gBRCJNFZ|J6N%qFqL{QMIM>(aakSQ&zLb-kwXZj zi>!?(RxfwHF+Cio{Z{@z?ACP2PNJ{$+H&VR)9Z0v-^%~P-cFb7 zBHk-=U*Q~N9_pz*BY$5tF+{SP7* zb;`%7PK8JciO{m@mCh07HJ;k<<>OViLL@~*WZAWq&Qazqo?YL|C#l|sNQ#O1W$u~I zG3H`V?OFL0)x=Oq36WSvW;)%?b)H>k1jUisXZs3p=t`1loM;q$Pb;~<{O?}=j5|gr$Qwa z#HO<951kXt4?VR%$mghTg$hNZwv;(O1U}|hp7tNO7*$`WxR=;fCRqi1%|pDf^PE!U z6(&{^#bx*^;Ai&mvY+Qvs>m?0il{7ORsnx=fERXw!&Mn!uxMCU=2!(S(Fia51-P=8 z7bfl_TFN9@KyFrfVGfQ|)rX0I;L7kUpfIO<*&STEsy$3J5J$?GED&g3?S=iwEmC!d zi8aKDGDj8&Hs^ZTf8>^_`ohFo;!K%jH3%^mdSMs2OqJIRv5q)jhOY);<{B^iMQ*h! za)ww>Tq$E#gPCTl7j}u$sWN7W4aAKy$7&E^KJI0|#BEUJ%@Fq!cgiFmf!XGBUf5+W zTU9?pY$P6*;U9r0^K~!#WiD6MK0|CGo|Q2lfw|@fUf54uzN&kM*i5`Gb9@A1%&)xc zKXE%$eKW)Z#GrD?8W3w9;*DM53RGS*#TH^{Ilcxc%^u$ND_o%}a;9h`#*{N_K)gA? z8@tLes*IVUiSR6UtN|)>gtz@FSE0(ADVho2a>-htHmkg`Yh1OeekNS8l$Yac0d7wB zwqN6FRP8fGmIx_l*1~+jYH#dkXis*}6s^SUa>rT#SG>IKKXc8hzL}zph%J}sfW}^j$~st*?r5^KuwkHI4Ib#ME1?zpNwTs%ZqRbn`p*NPiX6rdnB~YcG!vO6$P8ka<%$Gtq{BfhsOZe5Ys|+cwB45nC*(vrek7JxxO__2na@rLe;^M_Fhn{o z5+7A4KcySY&IxS~M5`HF{EBZIXdwu0Z8GL*as-grZVXcVDK?E~7 zkrg>@!qF(9i#S}7pMxAUXHJy;#bqR1jS{XC$16H=ki+KeiR@q8(uC(x!Y{<>ivAqr zh`C^*>@l}8!EKIkgE&_am5a2Rt0%ILxvYfHIYKvaxgtLoIc7FZls)0r!Z%;xCUL!@ zBNsVtZk@jgiUjWKM0?_{acXpW~Gnp1y_|&HCMPt1niBiKU{sn)w#k0B4Tex9&*{7?Zf`VH6=WsEBr~s?CsA(u9ypaWG^{mf?KrkkWlT7 z%15r5t9{s)9Gegt4W-`Hz4`fw(`@pQ^>Tay5iN*B`reLwo)Z;&^S2@Q%zl$(ecXivB1U*Y)a>ophCDEbO=A1FO9`7|gnx+Uz5Uyehi2s@ zSwD9*p(;jr33Dq^+u;=nIf?D(x)P4Y2))GNz4_ab$L7pQvj1`230Gr;SH#Zadpoux zPtDnr*#B|25}wBh{}QM7_HRd?nF}V#-f(vl+~x_diF13Sb|5dz)sxsa-2H^mc|sp? zd2jv>+aeHt74&=4DbCT>G_dKC$ zp74fvus3Qa(r>;tiG9cQCLEn7yd|FQ&EJWK2P|Mc(=EICo;(5;wu}(zo&MK6$X(*Dx-EGE*5uRb`bBX4viJwBS%%{??MJ! z{Cs8a@x#P@l2`=n20|1M;xMd>SZ;oa0#vBF?7pfYMV zBC(LZtP3wyAB`1U$>7TT-N*<_rmyUM-b;NoRv1D?RCeq}Mp?3b+4uR0>gTb-2V_iT z|88WArNCDeQXx>!j7a)km z9R-MorPY^p<%86l<_p8gWtIH}h^M8~S2lzXRaeayMv$v3qdrHxE!TY6A$+*{=zL)$ zxuG)ub7X?0$5-|NAE~}NUl>K^R(5=j_*h=}vLEo#>gV%?(d3TG{?8F#i_2u$P=3DJ zO(~2a3oE1cAbuA2$?Q-bQ->-AH?pEKe-Gks@tZ6g#wV%?rQlB1RCervMZ>Vk>@Yq> zy-5ktgyzcrJ&4?*oGg>@gt|&8Af&Z2N{=WkyA`zC>$?Qmey?RreFrK_!*REAxwxxt5;EveA5=`f8luL%yo)C_-W^ zFDA32`R(fGal$0>U1fg}5^Hhsla1kbtKH%SUvfxQR57BoxcjkVc)dC_UYJads>&}$ z;w^rDGB>_NO~ea+q(@apF`}}B`LS+%xq4H)FopD~>Musr7Nwueov%_?#S8vqKvh%; zf?G&G)}1$~kH!l!GPo+g1WC4J`pL%f_3Eqf0>pExI!cgKOST_7mTyu&j~AwrF;)E~ zh{jUjCqsCn+6@!rq^c^4K}burAB*s;IusL7Xe#A12yHR>$)r55CNM!krdM?^NV=uf zkCpO=)SED28o8{hpFtK{I{joG{84okCIphJtD;JgMV4!RtOwtwK8guJp&zdh&L4s7eSWE2{F#kX07HDKanqf|^hXVPs8JM;Wr(5;leP;xDN;se~D1b5(yC zvc{sEBJ<|2s;gAOOww8vRgUN^b39xk{Ktp04UIM>bjtrpPAnchznQ!ff(fRa6C%ZK%;e|k0uDwZmG&v5@|( zA1_TjsunOZxH`WIDYIny%ck&NiC5Kvij1i4s6r|%+5YSleq!QtwU9u@RQFdQm6ig3 znLj@{(JfI>ld9^dYNXmy?a%u2vc%9tA(2e2&aXy*#pExO@n|BEDBxszbw@Q)V`=qg zWqeTLrbHo$oP)incGgcO$c^!}dArEm!;v0cd#Qp+qKyTwk5N&$-`n z$6q=XjZD0h$fS}v)vf!SO_pc=hN)Q;mEkY%IH zFb!Rr_ylJbkf*9&7@S{PcFClH=*q;ANz6jhUL98BJZh*_a}p0FF-yrO)!DVqZ!C9YQW#=Syp+T&BYUe`Yn`Vo&twLe&Q5%i#4IP@ zR==oqerp*NAPqqW3dPI{(sf^0o%1`(m;gfvI?$A5R+1z4W!E{sxA+D~L(zc@C_L?y z?rW`c+ASdghEQ}M+{k=LPTcpR&iR8SHb5GN4z%`|RitcRSiSRtMH65MLk)?CV9|^W z+LvAL{L!)^Ksp1hPrQ`Oz;4vO)_Uh9%fu z7#L^S7uMjsYN-q`%tYD5pcDr7G++b6`Lm@ZKpKwni76?Jj?}_xfwRkUB)|}k9!gxF z!hB3Fh0wh77t5IdX#{#Su{?!YM`rB{gPgbJN`N5(ZA(0q!mKCP@5|or{MB*?O2=q> z;-wU31DUh0b-(kL5c?G$xO{ zv@aX3Zd+QWN~6(7i79DJKH0Uewb|KgIWpA{jXp_SpT=w@Z|!^0?EKeqW~wv>eV$mJ z#%v?+?+ZKN?6X{%YKTF56Az^^+sP;UvJW`_XSo9xebBzdOKHpwvUgwW0q0xGv#Ex8 z=v%l0!R#d8?t5{-`JZKwTpEkMhmVAl=A&&i2!(#`n+8!MM8Q8&C?!|Wkta5l*~jMc~u zN>qv;(lB~52#%9DhqEi>(m2!$zocOb$#6IT;2gE;#B=-P?1;AGmRIldaag@|m zk!3(<0q~y*>YB)_@kCNrO#&cTfQ$`>&n%yeuO)T+$VMPqz*IH{e#*QCFCpOxj0GrI zm`_Ya^*B%CJgGB~2Z4?S)7WLGYZAW@zd-70$YVfYK@htE)hF>w@JFPsmOKeW7Qh-k z>YB{2z=u(~I`S+~SOLtVqWWZh6^>H6dh#OBS-}jp8Fhs@B|MSRHIPmqSV1^@7}cln z>+rP{^w)m_q7`z_r%~5bej{E&=^Dv}6D+%5TPbO4md_209y<)!14X2BTY2j>3yg?xVf~`>KY2JdPS{+X&8*~Do*k*;Rmgn$9txkY|upo z%OAwoYIR?bOAMj_7O*$ofok&EU`DiRrpl5*UPoAd3y2rqAFn;%n1&Uy(Z-4dOxY z5gRkjbrF9BFG<%OBP|BSA+VNBou*&JJ8?c;*G3*R=njF8*=5sQ7xOpp3+cM!%JyW8pK236L!Zm*CqTNeAoir3G%E#aTsi3E2im}@b_?Zfv%mr zXwV%7pR&!@x%qX4$^554uc%_@HG8W{xQCGf$kgfH-mT>e8!%h=DLi3hL-`;Ht@qdNjVXMF-) zSMo!X5*O+^Nu)+N0`zQfpnfGkB5CbH-S^~#8u17yVq*ebGx;$|B@1}KAIZfvJ*{*tdor*$3k^xqXW$pf4{Q9sqU+g)ukkxZ8`%*-Icw1PB-tYTDp^{Sd5ku*6M}lzpz0)S5q=GpmrTcKmJJNbS&Jqo zWi5h1u>CbX$Dq46H>h_ls!7r>!kzF4*{_Y}+2o)c9ZDy$i|{V;i<-?k~cBA-}2VX`{bnw*~cnjIKy>U5wu#zpwE-P9J5< zf^ycOtCD1k@ow_Rn#|+$S8QWY?>cl%61Et>N&Z}8I!?E-2ZM6fqwA8g7URE?zt;2| zr@v-T2KBB-Hzs98PyUVUsqy|AX=fclSsVCIlZ=ZMw@9&O(bvc~?9HJ28~D$XzFVxg zO}?mU_!>FIia}YQ@LQAaE>`?b_Sf9}8u^y(54!&ezccB*C5k(wORe__vNk#3VcH~EPQSkk2zBcK*C5i`RSZzZ) za*5RiXMM`=PrAEA@h2Hod$S$+iOmnb|0#bU>Aj_jhorLBy92q(GQnA!d2`a_rHV%+ zUfc8G57*eb;I7T6Eh&1bL?nsY>JF!q<$|?2=od*#mrDL3GitAOIIpuOg1d6iFO#+} zl{_X_*1CV=yuqFi*5;yLB{eOTJR#TClHWLQvNwXea?!7oPA!!@B{$Vpf8+d(eHg6$ z4E-kQ)>6seWM1vHZ=AQ;*TG$%q2DIGT`GA-?yhw|>Ab@Zovz)2exEdPndCWH0{>$< zdsxruU0cu}lA@PMUXVMhYO7B=@3Hdf+C22fq@~Lw|B&^y*G@Vgu(PLk<)J?%ZC@sN zNg8Y2PdOj5>gn2i^yj3eWs+W!uO&}8MRwuzu6*>Dq*KczugIgd)u)_~*)`L(ThU*W zZY`7iOSacuJLP=JZkgV-75zQw?J~)0^1E91)6QpX@pSDr^pB*8%O!o}gtZ2$Z&g>>yfrzS_Q zlt?I|uDa8SSh*0b9-WrFbfsiCl~H%C)9GP75z?hcrzdY;DH%bntaJa~>1jP5qAf&c zBsZ;;jHK4qk>5MLtv5ot3ej1~r&dZvQJdvXNt(1(W^6IXA@AR?0 z4(TdF=Ow>gDH%iUu5&-@^tBER)fS_1$rCdrZd3_;|8e?RJwv;S(S+paOo=;HRabr1 z>2H;XYD>_hfF!KO+XM8An zQ#a}y=Rkz@c&NRc-;kX5p*WtpQz!WW%(k8j#VYvhg|3zH*PiIb@@^~`w?Zw-J+cb-YkSS9*Vp7oCNKxK`9Wp=(I zId7FXh4QVJTmWjT3ij3c>g4)WkZ_gP;}-yKO^2Cuz9zYSl_;Y^>X{25*}6Im+s8K~ zcdrrysM+-ni04{!!|eO`=H$Lr;#4XY=E#A@S{Q}_INRctCCVvvJ?;RcwI<9Ccx!TG zmWWas7zYP5?EPT|UP#W!5*5@!*!TwN*5hGz1AjOtXg9{&;eGmKa2BgyC$k#QGo%tK~aj5?c(Ww$wX*1k0?i!tAyDX;`)v zr&GJ?B^SX8>yQ~(9p4E9)nW*>v$!6=2r{i6GwgNzIT)H2L#fJo<|0^S4VZz|^A6aH z7Q?8zddEeu+8Qy#Ue8~K*=KPE)lx6H1lCwpGq48!8jLrKGbyefzXadR(`VQl`0KF2 zEQV7@>X}PmoptpLY(IY!rj*49>O{Tc64+qPonhb4--bnGaTax^UUC_1v=+|58u=a= zHWp`7=j-vyAlq6q!`{e0fPG>ylDbmQTn3x1))`n6FT%{Q7)9NvcU%U!*5fnmP5e_> z3l`^4cj_fSfi2c^Gq7g<1&scRbE${*_)j3;dVPkynSTY_y<#-=te*J^Y_mR?fgRxc zVS-nTp3Ty66yP5Za z0ah`Aifxcw1C`donV5z5gB?{-O{p93YoOX%Gt+M217HqSOoV%g%ryY4)|nW~D`3@B z#Hoc1j%%RCdVHpx<%40IR7|2)G)R61b=Gq;F)JSi8>3<}wWb08873>Q&$PoJS4#U@ z*t6N#z(B1ZMnEx$5vO#o6;r7#4UV5dGi-UyBZ`;&;pa37{@D9ymX?5 zDsI4?zy!;hc8*u2MCwF>s%&7KzybrA7|-J=89I@q>KYtQ_>)G2+o61%lBW|XxLqvi z0`L+)920mlrCuk}a7701f?xB}!|ei}p3<%pwbYRYrVAW|?MUnkeo;!dPE4mxG&s7z zVVHolf59(H>C=e|s51?c>);41IbsL-%oMMW#f8-Q2K+i`g+WI9L4I{gTK}9XG%^Sk<#1}uVXb_1O8q)<70lk@-N0c@kFdA$H7V`u z#4K2^W4ggb>*@&XE50G6d!4u%M(7;f;IcJ0!u}QCoYJ>W{0O$>BsakoYheU-j5not ztryq8BpiMdT(j0h*pI>3TjY9iEv4Dd+yqYeTf^F5vn^x2sG}C{ciaTmt;Zwm1L?KA z_2S3Wiv5yb!42!V2yCFBR=-|cN3Ge9{|ateuSeJiLTK&l#r4$2{R~W;Ssz4TU-KO) z-Rs2-)Rz4YSSqu=im-pppHAspFMdMp+_hfg8q;vb|$ej?^kSN1cv z!Aq+ZHllbjCF2t@pSrQ%aT~m{9*3zX{%K0yC*oG>&VI@7;I;J}EH?2kQtCeuw^0xG zvb4j;$Nk-er7W-zVZu zYEYx(4j5z`0&7ORORCpKaThhT5x)amY#y`iXZRthksHO`)R;!*4j60;fNddOlA5tm zETB9a9e2PGTLdfw@uO1nHj1B9zKxQ*V5m(6LqEKGYW+rW4<&EJ?*fS}9rk*7kJR>! zqMizAWbT3yw$(7Z!+WQ8Zxjou*^Q37V3aKv)^m8D)V_^k5f$4g=>cPGg)oA{`=xqq z5{oHyBi;ktZ8flE!v~~BZW2o$sfSeb`Az&_^{OaO=20frV;-Gc-yYS&I=!r+P+CFr#3b+e}DY8_5{aGXm8o9YVkK1!vnasN<`HQ>&#O`+ zv&AZ^vXQw5{A~e|*aaSk*%Gmus%v!IgH7p(Nc#myx94Sx`>2*i$$cQVsUk54Pr`_Z z2q>-*zYkBK(j)B-J{`6|L<4oCk+}~7ZL1@(ANfTv=^@roCmJ31L9i`1(*7gA43;^> zTIx)rBo$D(eJUQH1~o|@f`L{8c7-oU_1Y}9 zP(z#WhhQMNV86l_rbccSjntSX<{=meCtz23CN*QTXreru91nrY77=B?%2%Z3Z5GY2 zY#@0A)HYQVc8#x2t=|mAe0dZ82;jE#DEl?OCbfOD$imJ5^9Ur{R!3n!^9`xpn?)-% zyUFng<_mJ8>;oZyzRjYIifxj>1yx&N6z1ejsa`oEN2!}|5s^k3?TAw2xq}DXy ze}P4|>yWJHkEgchh=-_+P0U|liR}TD=lPD*?i}$jwR208<1etx_6iE}{OQ!b9PvwP zSCiy1SYaCiA$Yzs)hky#LKQdRk3pu*1A6ZKxzxy9@hDZ<#5@M8YynVZ=N+jTxne6- z*W`E%R@)*VuFhXh&C3KCxGfk4GV56;Y4)z=01F>-N z1a-a%e+sf~HFNC0@eiQ+Ew)ovnwY0xv&}jOyTyx;?iM?!8%>U{0+9*9?Zdh=ldaR zEuN-cH#zqG z%g7TQkOg+U05!JbbM5!};IzCv@kglpO8xU4EzOc%z}ZyMmPR!w3l7>=M`Hu{YInYP zojTF%=mm#uxzY9kL$xno{DnHxEO`Zv*b1Yu$B;tx+A7|l&Nt()K&!1L+Wr`drjc94 zZt6-i^9mfZS);Kh5GKvoD&C}SG&^2_#t#b*EYKFF0X47mYoIOlbX9 z@i*#WGyX5=uw9R~4``q5Tg6+{vu5UBaMJc58XG`6ySIwBsn^Ypf5BmA1_({BZQ>nj=mGpS=(Ksn*q=cNGjf}Fml|_`c@56m0%EWM&oX11 z*h6_9aJ&ZRY!NZ`0hTgvoA?LidqC0$&f8Qm*nloszfHVH$q(Rtz+p>|u@4}T?c2or zRLB9Q4_vgZj==_?$L?+718VjGM<2Ls%Z<^`7~GuJyUp<@H4oy9^cCBlnDCdVDNVZF z@sLV@#v*;q24dP?qSmzV?T$xO8l)0wcy}BV-ir!p+U*XJS^&jE`nv5{Oj|E{I4x(p z<1cDCgbV2#wzDzeuh7;s!*<7G>Lch6(l>3+n6_8wfGp^ELhbzIK*9g$-)#3{!v94F zgh0nr>azo#|D$i)UdFWjiw;14j=!m$2VCCJcWi^_g}+8S)1*5b�mU$~Sb64Vl;W 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%0D1KjIpzTNiJsUX&TUN5 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

1D? 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 literal 0 HcmV?d00001 diff --git a/examples/rag/feature_repo/example_repo.py b/examples/rag/feature_repo/example_repo.py new file mode 100644 index 00000000000..e0a9be21452 --- /dev/null +++ b/examples/rag/feature_repo/example_repo.py @@ -0,0 +1,42 @@ +from datetime import timedelta + +from feast import ( + FeatureView, + Field, + FileSource, +) +from feast.data_format import ParquetFormat +from feast.types import Float32, Array, String, ValueType +from feast import Entity + +item = Entity( + name="item_id", + description="Item ID", + value_type=ValueType.INT64, +) + +parquet_file_path = "./data/city_wikipedia_summaries_with_embeddings.parquet" + +source = FileSource( + file_format=ParquetFormat(), + path=parquet_file_path, + timestamp_field="event_timestamp", +) + +city_embeddings_feature_view = FeatureView( + name="city_embeddings", + entities=[item], + schema=[ + Field( + name="vector", + dtype=Array(Float32), + vector_index=True, + vector_search_metric="COSINE", + ), + Field(name="state", dtype=String), + Field(name="sentence_chunks", dtype=String), + Field(name="wiki_summary", dtype=String), + ], + source=source, + ttl=timedelta(hours=2), +) \ No newline at end of file diff --git a/examples/rag/feature_repo/feature_store.yaml b/examples/rag/feature_repo/feature_store.yaml new file mode 100644 index 00000000000..223be052093 --- /dev/null +++ b/examples/rag/feature_repo/feature_store.yaml @@ -0,0 +1,17 @@ +project: rag +provider: local +registry: data/registry.db +online_store: + type: milvus + path: data/online_store.db + vector_enabled: true + embedding_dim: 384 + index_type: "IVF_FLAT" + + +offline_store: + type: file +entity_key_serialization_version: 3 +# By default, no_auth for authentication and authorization, other possible values kubernetes and oidc. Refer the documentation for more details. +auth: + type: no_auth diff --git a/examples/rag/feature_repo/test_workflow.py b/examples/rag/feature_repo/test_workflow.py new file mode 100644 index 00000000000..05cd554d823 --- /dev/null +++ b/examples/rag/feature_repo/test_workflow.py @@ -0,0 +1,74 @@ +import pandas as pd +import torch +import torch.nn.functional as F +from feast import FeatureStore +from transformers import AutoTokenizer, AutoModel +from example_repo import city_embeddings_feature_view, item + +TOKENIZER = "sentence-transformers/all-MiniLM-L6-v2" +MODEL = "sentence-transformers/all-MiniLM-L6-v2" + + +def mean_pooling(model_output, attention_mask): + token_embeddings = model_output[ + 0 + ] # First element of model_output contains all token embeddings + input_mask_expanded = ( + attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() + ) + return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp( + input_mask_expanded.sum(1), min=1e-9 + ) + + +def run_model(sentences, tokenizer, model): + encoded_input = tokenizer( + sentences, padding=True, truncation=True, return_tensors="pt" + ) + # Compute token embeddings + with torch.no_grad(): + model_output = model(**encoded_input) + + sentence_embeddings = mean_pooling(model_output, encoded_input["attention_mask"]) + sentence_embeddings = F.normalize(sentence_embeddings, p=2, dim=1) + return sentence_embeddings + +def run_demo(): + store = FeatureStore(repo_path=".") + df = pd.read_parquet("./data/city_wikipedia_summaries_with_embeddings.parquet") + embedding_length = len(df['vector'][0]) + print(f'embedding length = {embedding_length}') + + store.apply([city_embeddings_feature_view, item]) + fields = [ + f.name for f in city_embeddings_feature_view.features + ] + city_embeddings_feature_view.entities + [city_embeddings_feature_view.batch_source.timestamp_field] + print('\ndata=') + print(df[fields].head().T) + store.write_to_online_store("city_embeddings", df[fields][0:3]) + + + question = "the most populous city in the state of New York is New York" + tokenizer = AutoTokenizer.from_pretrained(TOKENIZER) + model = AutoModel.from_pretrained(MODEL) + query_embedding = run_model(question, tokenizer, model) + query = query_embedding.detach().cpu().numpy().tolist()[0] + + # Retrieve top k documents + features = store.retrieve_online_documents_v2( + features=[ + "city_embeddings:vector", + "city_embeddings:item_id", + "city_embeddings:state", + "city_embeddings:sentence_chunks", + "city_embeddings:wiki_summary", + ], + query=query, + top_k=3, + ) + print("features =") + print(features.to_df()) + store.teardown() + +if __name__ == "__main__": + run_demo() diff --git a/examples/rag/milvus-quickstart.ipynb b/examples/rag/milvus-quickstart.ipynb new file mode 100644 index 00000000000..2999d3ba43f --- /dev/null +++ b/examples/rag/milvus-quickstart.ipynb @@ -0,0 +1,1023 @@ +{ + "cells": [ + { + "cell_type": "raw", + "id": "f33a2f4a-48b5-4218-8b3f-fc884070145e", + "metadata": {}, + "source": [ + "!pip install torch\n", + "!pip install transformers\n", + "!pip install openai" + ] + }, + { + "cell_type": "markdown", + "id": "b19cb54f-e63f-4d9b-b7ff-d18a30635cd2", + "metadata": {}, + "source": [ + "# Overview\n", + "\n", + "In this tutorial, we'll use Feast to inject documents and structured data (i.e., features) into the context of an LLM (Large Language Model) to power a RAG Application (Retrieval Augmented Generation).\n", + "\n", + "Feast solves several common issues in this flow:\n", + "1. **Online retrieval:** At inference time, LLMs often need access to data that isn't readily \n", + " available and needs to be precomputed from other data sources.\n", + " * Feast manages deployment to a variety of online stores (e.g. Milvus, DynamoDB, Redis, Google Cloud Datastore) and \n", + " ensures necessary features are consistently _available_ and _freshly computed_ at inference time.\n", + "2. **Vector Search:** Feast has built support for vector similarity search that is easily configured declaritively so users can focus on their application.\n", + "3. **Richer structured data:** Along with vector search, users can query standard structured fields to inject into the LLM context for better user experiences.\n", + "4. **Feature/Context and versioning:** Different teams within an organization are often unable to reuse \n", + " data across projects and services, resulting in duplicate application logic. Models have data dependencies that need \n", + " to be versioned, for example when running A/B tests on model/prompt versions.\n", + " * Feast enables discovery of and collaboration on previously used documents, features, and enables versioning of sets of \n", + " data.\n", + "\n", + "We will:\n", + "1. Deploy a local feature store with a **Parquet file offline store** and **Sqlite online store**.\n", + "2. Write/materialize the data (i.e., feature values) from the offline store (a parquet file) into the online store (Sqlite).\n", + "3. Serve the features using the Feast SDK\n", + "4. Inject the document into the LLM's context to answer questions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "425cf2f7-70b5-423c-a4f2-f470d8638135", + "metadata": {}, + "outputs": [], + "source": [ + "%%sh\n", + "pip install feast -U -q\n", + "echo \"Please restart your runtime now (Runtime -> Restart runtime). This ensures that the correct dependencies are loaded.\"" + ] + }, + { + "cell_type": "markdown", + "id": "db162bb9-e262-4958-990d-fd8f3f1f1249", + "metadata": {}, + "source": [ + "**Reminder**: Please restart your runtime after installing Feast (Runtime -> Restart runtime). This ensures that the correct dependencies are loaded." + ] + }, + { + "cell_type": "markdown", + "id": "a25cf84f-c255-4bb3-a3d7-e5512c1ba10d", + "metadata": {}, + "source": [ + "## Step 2: Create a feature repository\n", + "\n", + "A feature repository is a directory that contains the configuration of the feature store and individual features. This configuration is written as code (Python/YAML) and it's highly recommended that teams track it centrally using git. See [Feature Repository](https://docs.feast.dev/reference/feature-repository) for a detailed explanation of feature repositories.\n", + "\n", + "The easiest way to create a new feature repository to use the `feast init` command. For this demo, you **do not** need to initialize a feast repo.\n", + "\n", + "\n", + "### Demo data scenario \n", + "- We data from Wikipedia about states that we have embedded into sentence embeddings to be used for vector retrieval in a RAG application.\n", + "- We want to generate predictions for driver satisfaction for the rest of the users so we can reach out to potentially dissatisfied users." + ] + }, + { + "cell_type": "raw", + "id": "61dfdc9d8732d5a6", + "metadata": {}, + "source": [ + "!feast init feature_repo" + ] + }, + { + "cell_type": "markdown", + "id": "c969b62f-4f58-49ed-ae23-ace1916de0c0", + "metadata": {}, + "source": [ + "### Step 2a: Inspecting the feature repository\n", + "\n", + "Let's take a look at the demo repo itself. It breaks down into\n", + "\n", + "\n", + "* `data/` contains raw demo parquet data\n", + "* `example_repo.py` contains demo feature definitions\n", + "* `feature_store.yaml` contains a demo setup configuring where data sources are\n", + "* `test_workflow.py` showcases how to run all key Feast commands, including defining, retrieving, and pushing features.\n", + " * You can run this with `python test_workflow.py`." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5d531836-5981-4a34-9367-51b09af18a8a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/Users/farceo/dev/feast/examples/rag/feature_repo\n", + "__init__.py \u001b[1m\u001b[36mdata\u001b[m\u001b[m feature_store.yaml test_milvus.py\n", + "\u001b[1m\u001b[36m__pycache__\u001b[m\u001b[m example_repo.py milvus_demo.db test_workflow.py\n", + "\n", + "./__pycache__:\n", + "example_repo.cpython-311.pyc\n", + "\n", + "./data:\n", + "city_wikipedia_summaries_with_embeddings.parquet\n", + "online_store.db\n", + "registry.db\n" + ] + } + ], + "source": [ + "%cd feature_repo/\n", + "!ls -R" + ] + }, + { + "cell_type": "markdown", + "id": "d14a8073-5030-4d35-9c96-f5360aeaf39f", + "metadata": {}, + "source": [ + "### Step 2b: Inspecting the project configuration\n", + "Let's inspect the setup of the project in `feature_store.yaml`. \n", + "\n", + "The key line defining the overall architecture of the feature store is the **provider**. \n", + "\n", + "The provider value sets default offline and online stores. \n", + "* The offline store provides the compute layer to process historical data (for generating training data & feature \n", + " values for serving). \n", + "* The online store is a low latency store of the latest feature values (for powering real-time inference).\n", + "\n", + "Valid values for `provider` in `feature_store.yaml` are:\n", + "\n", + "* local: use file source with Milvus Lite\n", + "* gcp: use BigQuery/Snowflake with Google Cloud Datastore/Redis\n", + "* aws: use Redshift/Snowflake with DynamoDB/Redis\n", + "\n", + "Note that there are many other offline / online stores Feast works with, including Azure, Hive, Trino, and PostgreSQL via community plugins. See https://docs.feast.dev/roadmap for all supported connectors.\n", + "\n", + "A custom setup can also be made by following [Customizing Feast](https://docs.feast.dev/v/master/how-to-guides/customizing-feast)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "14c830ef-f5a4-4867-ad5c-87e709df7057", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[94mproject\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mrag\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mprovider\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mlocal\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mregistry\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mdata/registry.db\u001b[37m\u001b[39;49;00m\n", + "\u001b[94monline_store\u001b[39;49;00m:\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mtype\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mmilvus\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mpath\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mdata/online_store.db\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mvector_enabled\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mtrue\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94membedding_dim\u001b[39;49;00m:\u001b[37m \u001b[39;49;00m384\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mindex_type\u001b[39;49;00m:\u001b[37m \u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m\u001b[33mIVF_FLAT\u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m\u001b[39;49;00m\n", + "\u001b[94moffline_store\u001b[39;49;00m:\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mtype\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mfile\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mentity_key_serialization_version\u001b[39;49;00m:\u001b[37m \u001b[39;49;00m3\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m# By default, no_auth for authentication and authorization, other possible values kubernetes and oidc. Refer the documentation for more details.\u001b[39;49;00m\u001b[37m\u001b[39;49;00m\n", + "\u001b[94mauth\u001b[39;49;00m:\u001b[37m\u001b[39;49;00m\n", + "\u001b[37m \u001b[39;49;00m\u001b[94mtype\u001b[39;49;00m:\u001b[37m \u001b[39;49;00mno_auth\u001b[37m\u001b[39;49;00m\n" + ] + } + ], + "source": [ + "!pygmentize feature_store.yaml" + ] + }, + { + "cell_type": "markdown", + "id": "5ce80d1a-05d3-434d-bd1e-1ade8abd1f9f", + "metadata": {}, + "source": [ + "### Inspecting the raw data\n", + "\n", + "The raw feature data we have in this demo is stored in a local parquet file. The dataset Wikipedia summaries of diferent cities." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "788a27ff-16a4-4b23-8c1c-ba27fd918aa5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "embedding length = 384\n" + ] + } + ], + "source": [ + "import pandas as pd \n", + "\n", + "df = pd.read_parquet(\"./data/city_wikipedia_summaries_with_embeddings.parquet\")\n", + "df['vector'] = df['vector'].apply(lambda x: x.tolist())\n", + "embedding_length = len(df['vector'][0])\n", + "print(f'embedding length = {embedding_length}')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e433178c-51e8-49a7-884c-c9573082ad6d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "

\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
iditem_idevent_timestampstatewiki_summarysentence_chunksvector
0002025-01-09 13:36:59.280589New York, New YorkNew York, often called New York City or simply...New York, often called New York City or simply...[0.1465730518102646, -0.07317650318145752, 0.0...
1112025-01-09 13:36:59.280589New York, New YorkNew York, often called New York City or simply...The city comprises five boroughs, each of whic...[0.05218901485204697, -0.08449874818325043, 0....
2222025-01-09 13:36:59.280589New York, New YorkNew York, often called New York City or simply...New York is a global center of finance and com...[0.06769222766160965, -0.07371102273464203, -0...
3332025-01-09 13:36:59.280589New York, New YorkNew York, often called New York City or simply...New York City is the epicenter of the world's ...[0.12095861881971359, -0.04279915615916252, 0....
4442025-01-09 13:36:59.280589New York, New YorkNew York, often called New York City or simply...With an estimated population in 2022 of 8,335,...[0.17943550646305084, -0.09458263963460922, 0....
\n", + "
" + ], + "text/plain": [ + " id item_id event_timestamp state \\\n", + "0 0 0 2025-01-09 13:36:59.280589 New York, New York \n", + "1 1 1 2025-01-09 13:36:59.280589 New York, New York \n", + "2 2 2 2025-01-09 13:36:59.280589 New York, New York \n", + "3 3 3 2025-01-09 13:36:59.280589 New York, New York \n", + "4 4 4 2025-01-09 13:36:59.280589 New York, New York \n", + "\n", + " wiki_summary \\\n", + "0 New York, often called New York City or simply... \n", + "1 New York, often called New York City or simply... \n", + "2 New York, often called New York City or simply... \n", + "3 New York, often called New York City or simply... \n", + "4 New York, often called New York City or simply... \n", + "\n", + " sentence_chunks \\\n", + "0 New York, often called New York City or simply... \n", + "1 The city comprises five boroughs, each of whic... \n", + "2 New York is a global center of finance and com... \n", + "3 New York City is the epicenter of the world's ... \n", + "4 With an estimated population in 2022 of 8,335,... \n", + "\n", + " vector \n", + "0 [0.1465730518102646, -0.07317650318145752, 0.0... \n", + "1 [0.05218901485204697, -0.08449874818325043, 0.... \n", + "2 [0.06769222766160965, -0.07371102273464203, -0... \n", + "3 [0.12095861881971359, -0.04279915615916252, 0.... \n", + "4 [0.17943550646305084, -0.09458263963460922, 0.... " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import display\n", + "\n", + "display(df.head())" + ] + }, + { + "cell_type": "markdown", + "id": "ec07d38d-d0ff-4dc3-b041-3bf24de9e7e3", + "metadata": {}, + "source": [ + "## Step 3: Register feature definitions and deploy your feature store\n", + "\n", + "`feast apply` scans python files in the current directory for feature/entity definitions and deploys infrastructure according to `feature_store.yaml`." + ] + }, + { + "cell_type": "markdown", + "id": "79409ca9-7552-4aa5-b95b-29f836a0d3a5", + "metadata": {}, + "source": [ + "### Step 3a: Inspecting feature definitions\n", + "Let's inspect what `example_repo.py` looks like:\n", + "\n", + "```python\n", + "from datetime import timedelta\n", + "\n", + "from feast import (\n", + " FeatureView,\n", + " Field,\n", + " FileSource,\n", + ")\n", + "from feast.data_format import ParquetFormat\n", + "from feast.types import Float32, Array, String, ValueType\n", + "from feast import Entity\n", + "\n", + "item = Entity(\n", + " name=\"item_id\",\n", + " description=\"Item ID\",\n", + " value_type=ValueType.INT64,\n", + ")\n", + "\n", + "parquet_file_path = \"./data/city_wikipedia_summaries_with_embeddings.parquet\"\n", + "\n", + "source = FileSource(\n", + " file_format=ParquetFormat(),\n", + " path=parquet_file_path,\n", + " timestamp_field=\"event_timestamp\",\n", + ")\n", + "\n", + "city_embeddings_feature_view = FeatureView(\n", + " name=\"city_embeddings\",\n", + " entities=[item],\n", + " schema=[\n", + " Field(\n", + " name=\"vector\",\n", + " dtype=Array(Float32),\n", + " vector_index=True,\n", + " vector_search_metric=\"COSINE\",\n", + " ),\n", + " Field(name=\"state\", dtype=String),\n", + " Field(name=\"sentence_chunks\", dtype=String),\n", + " Field(name=\"wiki_summary\", dtype=String),\n", + " ],\n", + " source=source,\n", + " ttl=timedelta(hours=2),\n", + ")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "76634929-c84a-4301-93d3-88292335bde0", + "metadata": {}, + "source": [ + "### Step 3b: Applying feature definitions\n", + "Now we run `feast apply` to register the feature views and entities defined in `example_repo.py`, and sets up SQLite online store tables. Note that we had previously specified SQLite as the online store in `feature_store.yaml` by specifying a `local` provider." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "837e1530-e863-4e5f-b206-b6b4b3ca2aa2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/Users/farceo/dev/feast/sdk/python/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/pymilvus/client/__init__.py:6: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html\n", + " from pkg_resources import DistributionNotFound, get_distribution\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/pkg_resources/__init__.py:3142: DeprecationWarning: Deprecated call to `pkg_resources.declare_namespace('sphinxcontrib')`.\n", + "Implementing implicit namespace packages (as specified in PEP 420) is preferred to `pkg_resources.declare_namespace`. See https://setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages\n", + " declare_namespace(pkg)\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/environs/__init__.py:58: DeprecationWarning: The '__version_info__' attribute is deprecated and will be removed in in a future version. Use feature detection or 'packaging.Version(importlib.metadata.version(\"marshmallow\")).release' instead.\n", + " _SUPPORTS_LOAD_DEFAULT = ma.__version_info__ >= (3, 13)\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"MilvusOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\n", + " warnings.warn(\n", + "No project found in the repository. Using project name rag defined in feature_store.yaml\n", + "Applying changes for project rag\n", + "/Users/farceo/dev/feast/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity 'item_id'.\n", + " entity = cls(\n", + "/Users/farceo/dev/feast/sdk/python/feast/entity.py:173: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " entity = cls(\n", + "Connecting to Milvus in local mode using /Users/farceo/dev/feast/examples/rag/feature_repo/data/online_store.db\n", + "01/29/2025 05:11:55 PM pymilvus.milvus_client.milvus_client DEBUG: Created new connection using: 9fe4c5dfbe434f1babbf9f2a0970fb87\n", + "Deploying infrastructure for \u001b[1m\u001b[32mcity_embeddings\u001b[0m\n" + ] + } + ], + "source": [ + "! feast apply" + ] + }, + { + "cell_type": "markdown", + "id": "ad7654cc-865c-4bb4-8c0f-d3086c5d9f7e", + "metadata": {}, + "source": [ + "## Step 5: Load features into your online store" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "34ded931-3de0-4951-aead-1e8ca1679cbe", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/farceo/dev/feast/sdk/python/feast/feature_view.py:48: DeprecationWarning: Entity value_type will be mandatory in the next release. Please specify a value_type for entity '__dummy'.\n", + " DUMMY_ENTITY = Entity(\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/pymilvus/client/__init__.py:6: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html\n", + " from pkg_resources import DistributionNotFound, get_distribution\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/pkg_resources/__init__.py:3142: DeprecationWarning: Deprecated call to `pkg_resources.declare_namespace('sphinxcontrib')`.\n", + "Implementing implicit namespace packages (as specified in PEP 420) is preferred to `pkg_resources.declare_namespace`. See https://setuptools.pypa.io/en/latest/references/keywords.html#keyword-namespace-packages\n", + " declare_namespace(pkg)\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/environs/__init__.py:58: DeprecationWarning: The '__version_info__' attribute is deprecated and will be removed in in a future version. Use feature detection or 'packaging.Version(importlib.metadata.version(\"marshmallow\")).release' instead.\n", + " _SUPPORTS_LOAD_DEFAULT = ma.__version_info__ >= (3, 13)\n", + "/Users/farceo/dev/feast/.venv/lib/python3.11/site-packages/pydantic/_internal/_fields.py:192: UserWarning: Field name \"vector_enabled\" in \"MilvusOnlineStoreConfig\" shadows an attribute in parent \"VectorStoreConfig\"\n", + " warnings.warn(\n" + ] + } + ], + "source": [ + "from datetime import datetime\n", + "from feast import FeatureStore\n", + "\n", + "store = FeatureStore(repo_path=\".\")" + ] + }, + { + "cell_type": "markdown", + "id": "4c784d77-e96c-455c-9f1f-9183bab58d72", + "metadata": {}, + "source": [ + "### Step 5a: Using `materialize_incremental`\n", + "\n", + "We now serialize the latest values of features since the beginning of time to prepare for serving. Note, `materialize_incremental` serializes all new features since the last `materialize` call, or since the time provided minus the `ttl` timedelta. In this case, this will be `CURRENT_TIME - 1 day` (`ttl` was set on the `FeatureView` instances in [feature_repo/feature_repo/example_repo.py](feature_repo/feature_repo/example_repo.py)). \n", + "\n", + "```bash\n", + "CURRENT_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%S\")\n", + "feast materialize-incremental $CURRENT_TIME\n", + "```\n", + "\n", + "An alternative to using the CLI command is to use Python:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a2655725-5cc4-4f07-ade4-dc5e705eed05", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Connecting to Milvus in local mode using data/online_store.db\n" + ] + } + ], + "source": [ + "store.write_to_online_store(feature_view_name='city_embeddings', df=df)" + ] + }, + { + "cell_type": "markdown", + "id": "b836e5b1-1fe2-4e9d-8c9a-bdc91da8254e", + "metadata": {}, + "source": [ + "### Step 5b: Inspect materialized features\n", + "\n", + "Note that now there are `online_store.db` and `registry.db`, which store the materialized features and schema information, respectively." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1307b1aa-fecf-4adf-aafc-f65d89ca735c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
item_id_pkcreated_tsevent_tsitem_idsentence_chunksstatevectorwiki_summary
00100000002000000070000006974656d5f696404000000...017364478192805890New York, often called New York City or simply...New York, New York0.146573New York, often called New York City or simply...
10100000002000000070000006974656d5f696404000000...017364478192805890New York, often called New York City or simply...New York, New York-0.073177New York, often called New York City or simply...
20100000002000000070000006974656d5f696404000000...017364478192805890New York, often called New York City or simply...New York, New York0.052114New York, often called New York City or simply...
30100000002000000070000006974656d5f696404000000...017364478192805890New York, often called New York City or simply...New York, New York0.033187New York, often called New York City or simply...
40100000002000000070000006974656d5f696404000000...017364478192805890New York, often called New York City or simply...New York, New York0.012013New York, often called New York City or simply...
\n", + "
" + ], + "text/plain": [ + " item_id_pk created_ts \\\n", + "0 0100000002000000070000006974656d5f696404000000... 0 \n", + "1 0100000002000000070000006974656d5f696404000000... 0 \n", + "2 0100000002000000070000006974656d5f696404000000... 0 \n", + "3 0100000002000000070000006974656d5f696404000000... 0 \n", + "4 0100000002000000070000006974656d5f696404000000... 0 \n", + "\n", + " event_ts item_id \\\n", + "0 1736447819280589 0 \n", + "1 1736447819280589 0 \n", + "2 1736447819280589 0 \n", + "3 1736447819280589 0 \n", + "4 1736447819280589 0 \n", + "\n", + " sentence_chunks state \\\n", + "0 New York, often called New York City or simply... New York, New York \n", + "1 New York, often called New York City or simply... New York, New York \n", + "2 New York, often called New York City or simply... New York, New York \n", + "3 New York, often called New York City or simply... New York, New York \n", + "4 New York, often called New York City or simply... New York, New York \n", + "\n", + " vector wiki_summary \n", + "0 0.146573 New York, often called New York City or simply... \n", + "1 -0.073177 New York, often called New York City or simply... \n", + "2 0.052114 New York, often called New York City or simply... \n", + "3 0.033187 New York, often called New York City or simply... \n", + "4 0.012013 New York, often called New York City or simply... " + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pymilvus_client = store._provider._online_store._connect(store.config)\n", + "COLLECTION_NAME = pymilvus_client.list_collections()[0]\n", + "\n", + "milvus_query_result = pymilvus_client.query(\n", + " collection_name=COLLECTION_NAME,\n", + " filter=\"item_id == '0'\",\n", + ")\n", + "pd.DataFrame(milvus_query_result[0]).head()" + ] + }, + { + "cell_type": "markdown", + "id": "5fbf3921-e775-46b7-9915-d18c6592586f", + "metadata": {}, + "source": [ + "### Quick note on entity keys\n", + "Note from the above command that the online store indexes by `entity_key`. \n", + "\n", + "[Entity keys](https://docs.feast.dev/getting-started/concepts/entity#entity-key) include a list of all entities needed (e.g. all relevant primary keys) to generate the feature vector. In this case, this is a serialized version of the `driver_id`. We use this later to fetch all features for a given driver at inference time." + ] + }, + { + "cell_type": "markdown", + "id": "516f6e4a-2d37-4428-8dba-81620a65c2ad", + "metadata": {}, + "source": [ + "## Step 6: Embedding a query using PyTorch and Sentence Transformers" + ] + }, + { + "cell_type": "markdown", + "id": "66b4e67d-6f94-4532-b107-abc4c0f002f1", + "metadata": {}, + "source": [ + "During inference (e.g., during when a user submits a chat message) we need to embed the input text. This can be thought of as a feature transformation of the input data. In this example, we'll do this with a small Sentence Transformer from Hugging Face." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "62da57be-316d-46ee-b8a7-bac54a7faf55", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import torch.nn.functional as F\n", + "from feast import FeatureStore\n", + "from pymilvus import MilvusClient, DataType, FieldSchema\n", + "from transformers import AutoTokenizer, AutoModel\n", + "from example_repo import city_embeddings_feature_view, item\n", + "\n", + "TOKENIZER = \"sentence-transformers/all-MiniLM-L6-v2\"\n", + "MODEL = \"sentence-transformers/all-MiniLM-L6-v2\"\n", + "\n", + "def mean_pooling(model_output, attention_mask):\n", + " token_embeddings = model_output[\n", + " 0\n", + " ] # First element of model_output contains all token embeddings\n", + " input_mask_expanded = (\n", + " attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()\n", + " )\n", + " return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(\n", + " input_mask_expanded.sum(1), min=1e-9\n", + " )\n", + "\n", + "def run_model(sentences, tokenizer, model):\n", + " encoded_input = tokenizer(\n", + " sentences, padding=True, truncation=True, return_tensors=\"pt\"\n", + " )\n", + " # Compute token embeddings\n", + " with torch.no_grad():\n", + " model_output = model(**encoded_input)\n", + "\n", + " sentence_embeddings = mean_pooling(model_output, encoded_input[\"attention_mask\"])\n", + " sentence_embeddings = F.normalize(sentence_embeddings, p=2, dim=1)\n", + " return sentence_embeddings" + ] + }, + { + "cell_type": "markdown", + "id": "67868cdf-04e9-4086-bed8-050e4902ed71", + "metadata": {}, + "source": [ + "## Step 7: Fetching real-time vectors and data for online inference" + ] + }, + { + "cell_type": "markdown", + "id": "29b9ae94-7daa-4d56-8bca-9339d09cd1ed", + "metadata": {}, + "source": [ + "At inference time, we need to use vector similarity search through the document embeddings from the online feature store using `retrieve_online_documents_v2()` while passing the embedded query. These feature vectors can then be fed into the context of the LLM." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "0c76a526-35dc-4af5-bd46-d181e3a8c23a", + "metadata": {}, + "outputs": [], + "source": [ + "question = \"Which city has the largest population in New York?\"\n", + "\n", + "tokenizer = AutoTokenizer.from_pretrained(TOKENIZER)\n", + "model = AutoModel.from_pretrained(MODEL)\n", + "query_embedding = run_model(question, tokenizer, model)\n", + "query = query_embedding.detach().cpu().numpy().tolist()[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "d3099708-409b-4d9e-b1d6-8ad86de6fde2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vectoritem_idstatesentence_chunkswiki_summarydistance
0[0.15548758208751678, -0.08017724752426147, -0...0New York, New YorkNew York, often called New York City or simply...New York, often called New York City or simply...0.743023
\n", + "
" + ], + "text/plain": [ + " vector item_id \\\n", + "0 [0.15548758208751678, -0.08017724752426147, -0... 0 \n", + "\n", + " state sentence_chunks \\\n", + "0 New York, New York New York, often called New York City or simply... \n", + "\n", + " wiki_summary distance \n", + "0 New York, often called New York City or simply... 0.743023 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import display\n", + "\n", + "# Retrieve top k documents\n", + "context_data = store.retrieve_online_documents_v2(\n", + " features=[\n", + " \"city_embeddings:vector\",\n", + " \"city_embeddings:item_id\",\n", + " \"city_embeddings:state\",\n", + " \"city_embeddings:sentence_chunks\",\n", + " \"city_embeddings:wiki_summary\",\n", + " ],\n", + " query=query,\n", + " top_k=3,\n", + " distance_metric='COSINE',\n", + ").to_df()\n", + "display(context_data)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "0d56cf77-b09c-4ed7-b26e-3950d351953e", + "metadata": {}, + "outputs": [], + "source": [ + "def format_documents(context_df):\n", + " output_context = \"\"\n", + " unique_documents = context_df.drop_duplicates().apply(\n", + " lambda x: \"City & State = {\" + x['state'] +\"}\\nSummary = {\" + x['wiki_summary'].strip()+\"}\",\n", + " axis=1,\n", + " )\n", + " for i, document_text in enumerate(unique_documents):\n", + " output_context+= f\"****START DOCUMENT {i}****\\n{document_text.strip()}\\n****END DOCUMENT {i}****\"\n", + " return output_context" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "595adf60-54bd-4ec7-966e-5ac08f643f25", + "metadata": {}, + "outputs": [], + "source": [ + "RAG_CONTEXT = format_documents(context_data[['state', 'wiki_summary']])" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "3978561a-79a0-48bb-86ca-d81293a0e618", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "****START DOCUMENT 0****\n", + "City & State = {New York, New York}\n", + "Summary = {New York, often called New York City or simply NYC, is the most populous city in the United States, located at the southern tip of New York State on one of the world's largest natural harbors. The city comprises five boroughs, each of which is coextensive with a respective county. New York is a global center of finance and commerce, culture and technology, entertainment and media, academics and scientific output, and the arts and fashion, and, as home to the headquarters of the United Nations, is an important center for international diplomacy. New York City is the epicenter of the world's principal metropolitan economy.\n", + "With an estimated population in 2022 of 8,335,897 distributed over 300.46 square miles (778.2 km2), the city is the most densely populated major city in the United States. New York has more than double the population of Los Angeles, the nation's second-most populous city. New York is the geographical and demographic center of both the Northeast megalopolis and the New York metropolitan area, the largest metropolitan area in the U.S. by both population and urban area. With more than 20.1 million people in its metropolitan statistical area and 23.5 million in its combined statistical area as of 2020, New York City is one of the world's most populous megacities. The city and its metropolitan area are the premier gateway for legal immigration to the United States. As many as 800 languages are spoken in New York, making it the most linguistically diverse city in the world. In 2021, the city was home to nearly 3.1 million residents born outside the U.S., the largest foreign-born population of any city in the world.\n", + "New York City traces its origins to Fort Amsterdam and a trading post founded on the southern tip of Manhattan Island by Dutch colonists in approximately 1624. The settlement was named New Amsterdam (Dutch: Nieuw Amsterdam) in 1626 and was chartered as a city in 1653. The city came under English control in 1664 and was temporarily renamed New York after King Charles II granted the lands to his brother, the Duke of York. before being permanently renamed New York in November 1674. New York City was the capital of the United States from 1785 until 1790. The modern city was formed by the 1898 consolidation of its five boroughs: Manhattan, Brooklyn, Queens, The Bronx, and Staten Island, and has been the largest U.S. city ever since.\n", + "Anchored by Wall Street in the Financial District of Lower Manhattan, New York City has been called both the world's premier financial and fintech center and the most economically powerful city in the world. As of 2022, the New York metropolitan area is the largest metropolitan economy in the world with a gross metropolitan product of over US$2.16 trillion. If the New York metropolitan area were its own country, it would have the tenth-largest economy in the world. The city is home to the world's two largest stock exchanges by market capitalization of their listed companies: the New York Stock Exchange and Nasdaq. New York City is an established safe haven for global investors. As of 2023, New York City is the most expensive city in the world for expatriates to live. New York City is home to the highest number of billionaires, individuals of ultra-high net worth (greater than US$30 million), and millionaires of any city in the world.}\n", + "****END DOCUMENT 0****\n" + ] + } + ], + "source": [ + "print(RAG_CONTEXT)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "09cad16f-4078-42de-80ee-2672dae5608a", + "metadata": {}, + "outputs": [], + "source": [ + "FULL_PROMPT = f\"\"\"\n", + "You are an assistant for answering questions about states. You will be provided documentation from Wikipedia. Provide a conversational answer.\n", + "If you don't know the answer, just say \"I do not know.\" Don't make up an answer.\n", + "\n", + "Here are document(s) you should use when answer the users question:\n", + "{RAG_CONTEXT}\n", + "\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "7bb4a000-8ef3-4006-9c61-7d76fa865d28", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from openai import OpenAI\n", + "\n", + "client = OpenAI(\n", + " api_key=os.environ.get(\"OPENAI_API_KEY\"),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "da814147-9c78-4906-a84a-78fc88c2fc49", + "metadata": {}, + "outputs": [], + "source": [ + "response = client.chat.completions.create(\n", + " model=\"gpt-4o-mini\",\n", + " messages=[\n", + " {\"role\": \"system\", \"content\": FULL_PROMPT},\n", + " {\"role\": \"user\", \"content\": question}\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "68cbd8df-af73-4dbe-97a9-f3cd89f36f3d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The largest city in New York is New York City, often referred to as NYC. It is the most populous city in the United States, with an estimated population of 8,335,897 in 2022.\n" + ] + } + ], + "source": [ + "print('\\n'.join([c.message.content for c in response.choices]))" + ] + }, + { + "cell_type": "markdown", + "id": "d4f01627-533b-49b0-9814-292360d064c6", + "metadata": {}, + "source": [ + "# End" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/go.mod b/go.mod index 61063a0cdaf..05305c1e6c1 100644 --- a/go.mod +++ b/go.mod @@ -1,50 +1,54 @@ module github.com/feast-dev/feast -go 1.17 +go 1.22.0 -replace github.com/go-python/gopy v0.4.4 => github.com/feast-dev/gopy v0.4.1-0.20220714211711-252048177d85 +toolchain go1.22.5 require ( - github.com/apache/arrow/go/v8 v8.0.0 + github.com/apache/arrow/go/v17 v17.0.0 github.com/ghodss/yaml v1.0.0 - github.com/go-redis/redis/v8 v8.11.4 - github.com/golang/protobuf v1.5.3 - github.com/google/uuid v1.3.0 - github.com/mattn/go-sqlite3 v1.14.12 + github.com/golang/protobuf v1.5.4 + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.23 github.com/pkg/errors v0.9.1 + github.com/redis/go-redis/v9 v9.6.1 + github.com/rs/zerolog v1.33.0 github.com/spaolacci/murmur3 v1.1.0 - github.com/stretchr/testify v1.7.0 - google.golang.org/grpc v1.56.3 - google.golang.org/protobuf v1.33.0 + github.com/stretchr/testify v1.9.0 + google.golang.org/grpc v1.67.0 + google.golang.org/protobuf v1.34.2 ) require ( github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect - github.com/andybalholm/brotli v1.0.4 // indirect - github.com/apache/thrift v0.15.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/apache/thrift v0.21.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/goccy/go-json v0.9.6 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/flatbuffers v2.0.6+incompatible // indirect + github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/klauspost/asmfmt v1.3.2 // indirect - github.com/klauspost/compress v1.15.1 // indirect - github.com/klauspost/cpuid/v2 v2.0.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect - github.com/pierrec/lz4/v4 v4.1.14 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - golang.org/x/exp v0.0.0-20220407100705-7b9b53b0aca4 // indirect - golang.org/x/mod v0.8.0 // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.6.0 // indirect - golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect - google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + golang.org/x/tools v0.25.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 83bbc041c5a..41abd905c44 100644 --- a/go.sum +++ b/go.sum @@ -1,1910 +1,107 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= -cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= -cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= -cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= -cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= -cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= -cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= -cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= -cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= -cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= -cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= -cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= -cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= -cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= -cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= -cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o= -cloud.google.com/go/accesscontextmanager v1.4.0/go.mod h1:/Kjh7BBu/Gh83sv+K60vN9QE5NJcd80sU33vIe2IFPE= -cloud.google.com/go/accesscontextmanager v1.6.0/go.mod h1:8XCvZWfYw3K/ji0iVnp+6pu7huxoQTLmxAbVjbloTtM= -cloud.google.com/go/accesscontextmanager v1.7.0/go.mod h1:CEGLewx8dwa33aDAZQujl7Dx+uYhS0eay198wB/VumQ= -cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= -cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= -cloud.google.com/go/aiplatform v1.27.0/go.mod h1:Bvxqtl40l0WImSb04d0hXFU7gDOiq9jQmorivIiWcKg= -cloud.google.com/go/aiplatform v1.35.0/go.mod h1:7MFT/vCaOyZT/4IIFfxH4ErVg/4ku6lKv3w0+tFTgXQ= -cloud.google.com/go/aiplatform v1.36.1/go.mod h1:WTm12vJRPARNvJ+v6P52RDHCNe4AhvjcIZ/9/RRHy/k= -cloud.google.com/go/aiplatform v1.37.0/go.mod h1:IU2Cv29Lv9oCn/9LkFiiuKfwrRTq+QQMbW+hPCxJGZw= -cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= -cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= -cloud.google.com/go/analytics v0.17.0/go.mod h1:WXFa3WSym4IZ+JiKmavYdJwGG/CvpqiqczmL59bTD9M= -cloud.google.com/go/analytics v0.18.0/go.mod h1:ZkeHGQlcIPkw0R/GW+boWHhCOR43xz9RN/jn7WcqfIE= -cloud.google.com/go/analytics v0.19.0/go.mod h1:k8liqf5/HCnOUkbawNtrWWc+UAzyDlW89doe8TtoDsE= -cloud.google.com/go/apigateway v1.3.0/go.mod h1:89Z8Bhpmxu6AmUxuVRg/ECRGReEdiP3vQtk4Z1J9rJk= -cloud.google.com/go/apigateway v1.4.0/go.mod h1:pHVY9MKGaH9PQ3pJ4YLzoj6U5FUDeDFBllIz7WmzJoc= -cloud.google.com/go/apigateway v1.5.0/go.mod h1:GpnZR3Q4rR7LVu5951qfXPJCHquZt02jf7xQx7kpqN8= -cloud.google.com/go/apigeeconnect v1.3.0/go.mod h1:G/AwXFAKo0gIXkPTVfZDd2qA1TxBXJ3MgMRBQkIi9jc= -cloud.google.com/go/apigeeconnect v1.4.0/go.mod h1:kV4NwOKqjvt2JYR0AoIWo2QGfoRtn/pkS3QlHp0Ni04= -cloud.google.com/go/apigeeconnect v1.5.0/go.mod h1:KFaCqvBRU6idyhSNyn3vlHXc8VMDJdRmwDF6JyFRqZ8= -cloud.google.com/go/apigeeregistry v0.4.0/go.mod h1:EUG4PGcsZvxOXAdyEghIdXwAEi/4MEaoqLMLDMIwKXY= -cloud.google.com/go/apigeeregistry v0.5.0/go.mod h1:YR5+s0BVNZfVOUkMa5pAR2xGd0A473vA5M7j247o1wM= -cloud.google.com/go/apigeeregistry v0.6.0/go.mod h1:BFNzW7yQVLZ3yj0TKcwzb8n25CFBri51GVGOEUcgQsc= -cloud.google.com/go/apikeys v0.4.0/go.mod h1:XATS/yqZbaBK0HOssf+ALHp8jAlNHUgyfprvNcBIszU= -cloud.google.com/go/apikeys v0.5.0/go.mod h1:5aQfwY4D+ewMMWScd3hm2en3hCj+BROlyrt3ytS7KLI= -cloud.google.com/go/apikeys v0.6.0/go.mod h1:kbpXu5upyiAlGkKrJgQl8A0rKNNJ7dQ377pdroRSSi8= -cloud.google.com/go/appengine v1.4.0/go.mod h1:CS2NhuBuDXM9f+qscZ6V86m1MIIqPj3WC/UoEuR1Sno= -cloud.google.com/go/appengine v1.5.0/go.mod h1:TfasSozdkFI0zeoxW3PTBLiNqRmzraodCWatWI9Dmak= -cloud.google.com/go/appengine v1.6.0/go.mod h1:hg6i0J/BD2cKmDJbaFSYHFyZkgBEfQrDg/X0V5fJn84= -cloud.google.com/go/appengine v1.7.0/go.mod h1:eZqpbHFCqRGa2aCdope7eC0SWLV1j0neb/QnMJVWx6A= -cloud.google.com/go/appengine v1.7.1/go.mod h1:IHLToyb/3fKutRysUlFO0BPt5j7RiQ45nrzEJmKTo6E= -cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= -cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= -cloud.google.com/go/area120 v0.7.0/go.mod h1:a3+8EUD1SX5RUcCs3MY5YasiO1z6yLiNLRiFrykbynY= -cloud.google.com/go/area120 v0.7.1/go.mod h1:j84i4E1RboTWjKtZVWXPqvK5VHQFJRF2c1Nm69pWm9k= -cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= -cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= -cloud.google.com/go/artifactregistry v1.8.0/go.mod h1:w3GQXkJX8hiKN0v+at4b0qotwijQbYUqF2GWkZzAhC0= -cloud.google.com/go/artifactregistry v1.9.0/go.mod h1:2K2RqvA2CYvAeARHRkLDhMDJ3OXy26h3XW+3/Jh2uYc= -cloud.google.com/go/artifactregistry v1.11.1/go.mod h1:lLYghw+Itq9SONbCa1YWBoWs1nOucMH0pwXN1rOBZFI= -cloud.google.com/go/artifactregistry v1.11.2/go.mod h1:nLZns771ZGAwVLzTX/7Al6R9ehma4WUEhZGWV6CeQNQ= -cloud.google.com/go/artifactregistry v1.12.0/go.mod h1:o6P3MIvtzTOnmvGagO9v/rOjjA0HmhJ+/6KAXrmYDCI= -cloud.google.com/go/artifactregistry v1.13.0/go.mod h1:uy/LNfoOIivepGhooAUpL1i30Hgee3Cu0l4VTWHUC08= -cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= -cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= -cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= -cloud.google.com/go/asset v1.9.0/go.mod h1:83MOE6jEJBMqFKadM9NLRcs80Gdw76qGuHn8m3h8oHQ= -cloud.google.com/go/asset v1.10.0/go.mod h1:pLz7uokL80qKhzKr4xXGvBQXnzHn5evJAEAtZiIb0wY= -cloud.google.com/go/asset v1.11.1/go.mod h1:fSwLhbRvC9p9CXQHJ3BgFeQNM4c9x10lqlrdEUYXlJo= -cloud.google.com/go/asset v1.12.0/go.mod h1:h9/sFOa4eDIyKmH6QMpm4eUK3pDojWnUhTgJlk762Hg= -cloud.google.com/go/asset v1.13.0/go.mod h1:WQAMyYek/b7NBpYq/K4KJWcRqzoalEsxz/t/dTk4THw= -cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= -cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= -cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= -cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= -cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= -cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= -cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= -cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= -cloud.google.com/go/automl v1.7.0/go.mod h1:RL9MYCCsJEOmt0Wf3z9uzG0a7adTT1fe+aObgSpkCt8= -cloud.google.com/go/automl v1.8.0/go.mod h1:xWx7G/aPEe/NP+qzYXktoBSDfjO+vnKMGgsApGJJquM= -cloud.google.com/go/automl v1.12.0/go.mod h1:tWDcHDp86aMIuHmyvjuKeeHEGq76lD7ZqfGLN6B0NuU= -cloud.google.com/go/baremetalsolution v0.3.0/go.mod h1:XOrocE+pvK1xFfleEnShBlNAXf+j5blPPxrhjKgnIFc= -cloud.google.com/go/baremetalsolution v0.4.0/go.mod h1:BymplhAadOO/eBa7KewQ0Ppg4A4Wplbn+PsFKRLo0uI= -cloud.google.com/go/baremetalsolution v0.5.0/go.mod h1:dXGxEkmR9BMwxhzBhV0AioD0ULBmuLZI8CdwalUxuss= -cloud.google.com/go/batch v0.3.0/go.mod h1:TR18ZoAekj1GuirsUsR1ZTKN3FC/4UDnScjT8NXImFE= -cloud.google.com/go/batch v0.4.0/go.mod h1:WZkHnP43R/QCGQsZ+0JyG4i79ranE2u8xvjq/9+STPE= -cloud.google.com/go/batch v0.7.0/go.mod h1:vLZN95s6teRUqRQ4s3RLDsH8PvboqBK+rn1oevL159g= -cloud.google.com/go/beyondcorp v0.2.0/go.mod h1:TB7Bd+EEtcw9PCPQhCJtJGjk/7TC6ckmnSFS+xwTfm4= -cloud.google.com/go/beyondcorp v0.3.0/go.mod h1:E5U5lcrcXMsCuoDNyGrpyTm/hn7ne941Jz2vmksAxW8= -cloud.google.com/go/beyondcorp v0.4.0/go.mod h1:3ApA0mbhHx6YImmuubf5pyW8srKnCEPON32/5hj+RmM= -cloud.google.com/go/beyondcorp v0.5.0/go.mod h1:uFqj9X+dSfrheVp7ssLTaRHd2EHqSL4QZmH4e8WXGGU= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= -cloud.google.com/go/bigquery v1.43.0/go.mod h1:ZMQcXHsl+xmU1z36G2jNGZmKp9zNY5BUua5wDgmNCfw= -cloud.google.com/go/bigquery v1.44.0/go.mod h1:0Y33VqXTEsbamHJvJHdFmtqHvMIY28aK1+dFsvaChGc= -cloud.google.com/go/bigquery v1.47.0/go.mod h1:sA9XOgy0A8vQK9+MWhEQTY6Tix87M/ZurWFIxmF9I/E= -cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac= -cloud.google.com/go/bigquery v1.49.0/go.mod h1:Sv8hMmTFFYBlt/ftw2uN6dFdQPzBlREY9yBh7Oy7/4Q= -cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU= -cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= -cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= -cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI= -cloud.google.com/go/billing v1.7.0/go.mod h1:q457N3Hbj9lYwwRbnlD7vUpyjq6u5U1RAOArInEiD5Y= -cloud.google.com/go/billing v1.12.0/go.mod h1:yKrZio/eu+okO/2McZEbch17O5CB5NpZhhXG6Z766ss= -cloud.google.com/go/billing v1.13.0/go.mod h1:7kB2W9Xf98hP9Sr12KfECgfGclsH3CQR0R08tnRlRbc= -cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= -cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= -cloud.google.com/go/binaryauthorization v1.3.0/go.mod h1:lRZbKgjDIIQvzYQS1p99A7/U1JqvqeZg0wiI5tp6tg0= -cloud.google.com/go/binaryauthorization v1.4.0/go.mod h1:tsSPQrBd77VLplV70GUhBf/Zm3FsKmgSqgm4UmiDItk= -cloud.google.com/go/binaryauthorization v1.5.0/go.mod h1:OSe4OU1nN/VswXKRBmciKpo9LulY41gch5c68htf3/Q= -cloud.google.com/go/certificatemanager v1.3.0/go.mod h1:n6twGDvcUBFu9uBgt4eYvvf3sQ6My8jADcOVwHmzadg= -cloud.google.com/go/certificatemanager v1.4.0/go.mod h1:vowpercVFyqs8ABSmrdV+GiFf2H/ch3KyudYQEMM590= -cloud.google.com/go/certificatemanager v1.6.0/go.mod h1:3Hh64rCKjRAX8dXgRAyOcY5vQ/fE1sh8o+Mdd6KPgY8= -cloud.google.com/go/channel v1.8.0/go.mod h1:W5SwCXDJsq/rg3tn3oG0LOxpAo6IMxNa09ngphpSlnk= -cloud.google.com/go/channel v1.9.0/go.mod h1:jcu05W0my9Vx4mt3/rEHpfxc9eKi9XwsdDL8yBMbKUk= -cloud.google.com/go/channel v1.11.0/go.mod h1:IdtI0uWGqhEeatSB62VOoJ8FSUhJ9/+iGkJVqp74CGE= -cloud.google.com/go/channel v1.12.0/go.mod h1:VkxCGKASi4Cq7TbXxlaBezonAYpp1GCnKMY6tnMQnLU= -cloud.google.com/go/cloudbuild v1.3.0/go.mod h1:WequR4ULxlqvMsjDEEEFnOG5ZSRSgWOywXYDb1vPE6U= -cloud.google.com/go/cloudbuild v1.4.0/go.mod h1:5Qwa40LHiOXmz3386FrjrYM93rM/hdRr7b53sySrTqA= -cloud.google.com/go/cloudbuild v1.6.0/go.mod h1:UIbc/w9QCbH12xX+ezUsgblrWv+Cv4Tw83GiSMHOn9M= -cloud.google.com/go/cloudbuild v1.7.0/go.mod h1:zb5tWh2XI6lR9zQmsm1VRA+7OCuve5d8S+zJUul8KTg= -cloud.google.com/go/cloudbuild v1.9.0/go.mod h1:qK1d7s4QlO0VwfYn5YuClDGg2hfmLZEb4wQGAbIgL1s= -cloud.google.com/go/clouddms v1.3.0/go.mod h1:oK6XsCDdW4Ib3jCCBugx+gVjevp2TMXFtgxvPSee3OM= -cloud.google.com/go/clouddms v1.4.0/go.mod h1:Eh7sUGCC+aKry14O1NRljhjyrr0NFC0G2cjwX0cByRk= -cloud.google.com/go/clouddms v1.5.0/go.mod h1:QSxQnhikCLUw13iAbffF2CZxAER3xDGNHjsTAkQJcQA= -cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= -cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= -cloud.google.com/go/cloudtasks v1.7.0/go.mod h1:ImsfdYWwlWNJbdgPIIGJWC+gemEGTBK/SunNQQNCAb4= -cloud.google.com/go/cloudtasks v1.8.0/go.mod h1:gQXUIwCSOI4yPVK7DgTVFiiP0ZW/eQkydWzwVMdHxrI= -cloud.google.com/go/cloudtasks v1.9.0/go.mod h1:w+EyLsVkLWHcOaqNEyvcKAsWp9p29dL6uL9Nst1cI7Y= -cloud.google.com/go/cloudtasks v1.10.0/go.mod h1:NDSoTLkZ3+vExFEWu2UJV1arUyzVDAiZtdWcsUyNwBs= -cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= -cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= -cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= -cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= -cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= -cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= -cloud.google.com/go/compute v1.12.0/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= -cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= -cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE= -cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= -cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= -cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= -cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= -cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= -cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= -cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= -cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= -cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= -cloud.google.com/go/container v1.6.0/go.mod h1:Xazp7GjJSeUYo688S+6J5V+n/t+G5sKBTFkKNudGRxg= -cloud.google.com/go/container v1.7.0/go.mod h1:Dp5AHtmothHGX3DwwIHPgq45Y8KmNsgN3amoYfxVkLo= -cloud.google.com/go/container v1.13.1/go.mod h1:6wgbMPeQRw9rSnKBCAJXnds3Pzj03C4JHamr8asWKy4= -cloud.google.com/go/container v1.14.0/go.mod h1:3AoJMPhHfLDxLvrlVWaK57IXzaPnLaZq63WX59aQBfM= -cloud.google.com/go/container v1.15.0/go.mod h1:ft+9S0WGjAyjDggg5S06DXj+fHJICWg8L7isCQe9pQA= -cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= -cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= -cloud.google.com/go/containeranalysis v0.7.0/go.mod h1:9aUL+/vZ55P2CXfuZjS4UjQ9AgXoSw8Ts6lemfmxBxI= -cloud.google.com/go/containeranalysis v0.9.0/go.mod h1:orbOANbwk5Ejoom+s+DUCTTJ7IBdBQJDcSylAx/on9s= -cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= -cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= -cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= -cloud.google.com/go/datacatalog v1.7.0/go.mod h1:9mEl4AuDYWw81UGc41HonIHH7/sn52H0/tc8f8ZbZIE= -cloud.google.com/go/datacatalog v1.8.0/go.mod h1:KYuoVOv9BM8EYz/4eMFxrr4DUKhGIOXxZoKYF5wdISM= -cloud.google.com/go/datacatalog v1.8.1/go.mod h1:RJ58z4rMp3gvETA465Vg+ag8BGgBdnRPEMMSTr5Uv+M= -cloud.google.com/go/datacatalog v1.12.0/go.mod h1:CWae8rFkfp6LzLumKOnmVh4+Zle4A3NXLzVJ1d1mRm0= -cloud.google.com/go/datacatalog v1.13.0/go.mod h1:E4Rj9a5ZtAxcQJlEBTLgMTphfP11/lNaAshpoBgemX8= -cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= -cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= -cloud.google.com/go/dataflow v0.8.0/go.mod h1:Rcf5YgTKPtQyYz8bLYhFoIV/vP39eL7fWNcSOyFfLJE= -cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= -cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= -cloud.google.com/go/dataform v0.5.0/go.mod h1:GFUYRe8IBa2hcomWplodVmUx/iTL0FrsauObOM3Ipr0= -cloud.google.com/go/dataform v0.6.0/go.mod h1:QPflImQy33e29VuapFdf19oPbE4aYTJxr31OAPV+ulA= -cloud.google.com/go/dataform v0.7.0/go.mod h1:7NulqnVozfHvWUBpMDfKMUESr+85aJsC/2O0o3jWPDE= -cloud.google.com/go/datafusion v1.4.0/go.mod h1:1Zb6VN+W6ALo85cXnM1IKiPw+yQMKMhB9TsTSRDo/38= -cloud.google.com/go/datafusion v1.5.0/go.mod h1:Kz+l1FGHB0J+4XF2fud96WMmRiq/wj8N9u007vyXZ2w= -cloud.google.com/go/datafusion v1.6.0/go.mod h1:WBsMF8F1RhSXvVM8rCV3AeyWVxcC2xY6vith3iw3S+8= -cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= -cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= -cloud.google.com/go/datalabeling v0.7.0/go.mod h1:WPQb1y08RJbmpM3ww0CSUAGweL0SxByuW2E+FU+wXcM= -cloud.google.com/go/dataplex v1.3.0/go.mod h1:hQuRtDg+fCiFgC8j0zV222HvzFQdRd+SVX8gdmFcZzA= -cloud.google.com/go/dataplex v1.4.0/go.mod h1:X51GfLXEMVJ6UN47ESVqvlsRplbLhcsAt0kZCCKsU0A= -cloud.google.com/go/dataplex v1.5.2/go.mod h1:cVMgQHsmfRoI5KFYq4JtIBEUbYwc3c7tXmIDhRmNNVQ= -cloud.google.com/go/dataplex v1.6.0/go.mod h1:bMsomC/aEJOSpHXdFKFGQ1b0TDPIeL28nJObeO1ppRs= -cloud.google.com/go/dataproc v1.7.0/go.mod h1:CKAlMjII9H90RXaMpSxQ8EU6dQx6iAYNPcYPOkSbi8s= -cloud.google.com/go/dataproc v1.8.0/go.mod h1:5OW+zNAH0pMpw14JVrPONsxMQYMBqJuzORhIBfBn9uI= -cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= -cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= -cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= -cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZWwP12Qq3c= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= -cloud.google.com/go/datastore v1.11.0/go.mod h1:TvGxBIHCS50u8jzG+AW/ppf87v1of8nwzFNgEZU1D3c= -cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= -cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= -cloud.google.com/go/datastream v1.4.0/go.mod h1:h9dpzScPhDTs5noEMQVWP8Wx8AFBRyS0s8KWPx/9r0g= -cloud.google.com/go/datastream v1.5.0/go.mod h1:6TZMMNPwjUqZHBKPQ1wwXpb0d5VDVPl2/XoS5yi88q4= -cloud.google.com/go/datastream v1.6.0/go.mod h1:6LQSuswqLa7S4rPAOZFVjHIG3wJIjZcZrw8JDEDJuIs= -cloud.google.com/go/datastream v1.7.0/go.mod h1:uxVRMm2elUSPuh65IbZpzJNMbuzkcvu5CjMqVIUHrww= -cloud.google.com/go/deploy v1.4.0/go.mod h1:5Xghikd4VrmMLNaF6FiRFDlHb59VM59YoDQnOUdsH/c= -cloud.google.com/go/deploy v1.5.0/go.mod h1:ffgdD0B89tToyW/U/D2eL0jN2+IEV/3EMuXHA0l4r+s= -cloud.google.com/go/deploy v1.6.0/go.mod h1:f9PTHehG/DjCom3QH0cntOVRm93uGBDt2vKzAPwpXQI= -cloud.google.com/go/deploy v1.8.0/go.mod h1:z3myEJnA/2wnB4sgjqdMfgxCA0EqC3RBTNcVPs93mtQ= -cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= -cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= -cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= -cloud.google.com/go/dialogflow v1.18.0/go.mod h1:trO7Zu5YdyEuR+BhSNOqJezyFQ3aUzz0njv7sMx/iek= -cloud.google.com/go/dialogflow v1.19.0/go.mod h1:JVmlG1TwykZDtxtTXujec4tQ+D8SBFMoosgy+6Gn0s0= -cloud.google.com/go/dialogflow v1.29.0/go.mod h1:b+2bzMe+k1s9V+F2jbJwpHPzrnIyHihAdRFMtn2WXuM= -cloud.google.com/go/dialogflow v1.31.0/go.mod h1:cuoUccuL1Z+HADhyIA7dci3N5zUssgpBJmCzI6fNRB4= -cloud.google.com/go/dialogflow v1.32.0/go.mod h1:jG9TRJl8CKrDhMEcvfcfFkkpp8ZhgPz3sBGmAUYJ2qE= -cloud.google.com/go/dlp v1.6.0/go.mod h1:9eyB2xIhpU0sVwUixfBubDoRwP+GjeUoxxeueZmqvmM= -cloud.google.com/go/dlp v1.7.0/go.mod h1:68ak9vCiMBjbasxeVD17hVPxDEck+ExiHavX8kiHG+Q= -cloud.google.com/go/dlp v1.9.0/go.mod h1:qdgmqgTyReTz5/YNSSuueR8pl7hO0o9bQ39ZhtgkWp4= -cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= -cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= -cloud.google.com/go/documentai v1.9.0/go.mod h1:FS5485S8R00U10GhgBC0aNGrJxBP8ZVpEeJ7PQDZd6k= -cloud.google.com/go/documentai v1.10.0/go.mod h1:vod47hKQIPeCfN2QS/jULIvQTugbmdc0ZvxxfQY1bg4= -cloud.google.com/go/documentai v1.16.0/go.mod h1:o0o0DLTEZ+YnJZ+J4wNfTxmDVyrkzFvttBXXtYRMHkM= -cloud.google.com/go/documentai v1.18.0/go.mod h1:F6CK6iUH8J81FehpskRmhLq/3VlwQvb7TvwOceQ2tbs= -cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= -cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= -cloud.google.com/go/domains v0.8.0/go.mod h1:M9i3MMDzGFXsydri9/vW+EWz9sWb4I6WyHqdlAk0idE= -cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= -cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= -cloud.google.com/go/edgecontainer v0.3.0/go.mod h1:FLDpP4nykgwwIfcLt6zInhprzw0lEi2P1fjO6Ie0qbc= -cloud.google.com/go/edgecontainer v1.0.0/go.mod h1:cttArqZpBB2q58W/upSG++ooo6EsblxDIolxa3jSjbY= -cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= -cloud.google.com/go/essentialcontacts v1.3.0/go.mod h1:r+OnHa5jfj90qIfZDO/VztSFqbQan7HV75p8sA+mdGI= -cloud.google.com/go/essentialcontacts v1.4.0/go.mod h1:8tRldvHYsmnBCHdFpvU+GL75oWiBKl80BiqlFh9tp+8= -cloud.google.com/go/essentialcontacts v1.5.0/go.mod h1:ay29Z4zODTuwliK7SnX8E86aUF2CTzdNtvv42niCX0M= -cloud.google.com/go/eventarc v1.7.0/go.mod h1:6ctpF3zTnaQCxUjHUdcfgcA1A2T309+omHZth7gDfmc= -cloud.google.com/go/eventarc v1.8.0/go.mod h1:imbzxkyAU4ubfsaKYdQg04WS1NvncblHEup4kvF+4gw= -cloud.google.com/go/eventarc v1.10.0/go.mod h1:u3R35tmZ9HvswGRBnF48IlYgYeBcPUCjkr4BTdem2Kw= -cloud.google.com/go/eventarc v1.11.0/go.mod h1:PyUjsUKPWoRBCHeOxZd/lbOOjahV41icXyUY5kSTvVY= -cloud.google.com/go/filestore v1.3.0/go.mod h1:+qbvHGvXU1HaKX2nD0WEPo92TP/8AQuCVEBXNY9z0+w= -cloud.google.com/go/filestore v1.4.0/go.mod h1:PaG5oDfo9r224f8OYXURtAsY+Fbyq/bLYoINEK8XQAI= -cloud.google.com/go/filestore v1.5.0/go.mod h1:FqBXDWBp4YLHqRnVGveOkHDf8svj9r5+mUDLupOWEDs= -cloud.google.com/go/filestore v1.6.0/go.mod h1:di5unNuss/qfZTw2U9nhFqo8/ZDSc466dre85Kydllg= -cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= -cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= -cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= -cloud.google.com/go/functions v1.8.0/go.mod h1:RTZ4/HsQjIqIYP9a9YPbU+QFoQsAlYgrwOXJWHn1POY= -cloud.google.com/go/functions v1.9.0/go.mod h1:Y+Dz8yGguzO3PpIjhLTbnqV1CWmgQ5UwtlpzoyquQ08= -cloud.google.com/go/functions v1.10.0/go.mod h1:0D3hEOe3DbEvCXtYOZHQZmD+SzYsi1YbI7dGvHfldXw= -cloud.google.com/go/functions v1.12.0/go.mod h1:AXWGrF3e2C/5ehvwYo/GH6O5s09tOPksiKhz+hH8WkA= -cloud.google.com/go/functions v1.13.0/go.mod h1:EU4O007sQm6Ef/PwRsI8N2umygGqPBS/IZQKBQBcJ3c= -cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= -cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= -cloud.google.com/go/gaming v1.7.0/go.mod h1:LrB8U7MHdGgFG851iHAfqUdLcKBdQ55hzXy9xBJz0+w= -cloud.google.com/go/gaming v1.8.0/go.mod h1:xAqjS8b7jAVW0KFYeRUxngo9My3f33kFmua++Pi+ggM= -cloud.google.com/go/gaming v1.9.0/go.mod h1:Fc7kEmCObylSWLO334NcO+O9QMDyz+TKC4v1D7X+Bc0= -cloud.google.com/go/gkebackup v0.2.0/go.mod h1:XKvv/4LfG829/B8B7xRkk8zRrOEbKtEam6yNfuQNH60= -cloud.google.com/go/gkebackup v0.3.0/go.mod h1:n/E671i1aOQvUxT541aTkCwExO/bTer2HDlj4TsBRAo= -cloud.google.com/go/gkebackup v0.4.0/go.mod h1:byAyBGUwYGEEww7xsbnUTBHIYcOPy/PgUWUtOeRm9Vg= -cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= -cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= -cloud.google.com/go/gkeconnect v0.7.0/go.mod h1:SNfmVqPkaEi3bF/B3CNZOAYPYdg7sU+obZ+QTky2Myw= -cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= -cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= -cloud.google.com/go/gkehub v0.11.0/go.mod h1:JOWHlmN+GHyIbuWQPl47/C2RFhnFKH38jH9Ascu3n0E= -cloud.google.com/go/gkehub v0.12.0/go.mod h1:djiIwwzTTBrF5NaXCGv3mf7klpEMcST17VBTVVDcuaw= -cloud.google.com/go/gkemulticloud v0.3.0/go.mod h1:7orzy7O0S+5kq95e4Hpn7RysVA7dPs8W/GgfUtsPbrA= -cloud.google.com/go/gkemulticloud v0.4.0/go.mod h1:E9gxVBnseLWCk24ch+P9+B2CoDFJZTyIgLKSalC7tuI= -cloud.google.com/go/gkemulticloud v0.5.0/go.mod h1:W0JDkiyi3Tqh0TJr//y19wyb1yf8llHVto2Htf2Ja3Y= -cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= -cloud.google.com/go/gsuiteaddons v1.3.0/go.mod h1:EUNK/J1lZEZO8yPtykKxLXI6JSVN2rg9bN8SXOa0bgM= -cloud.google.com/go/gsuiteaddons v1.4.0/go.mod h1:rZK5I8hht7u7HxFQcFei0+AtfS9uSushomRlg+3ua1o= -cloud.google.com/go/gsuiteaddons v1.5.0/go.mod h1:TFCClYLd64Eaa12sFVmUyG62tk4mdIsI7pAnSXRkcFo= -cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= -cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= -cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc= -cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHDMFMc= -cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg= -cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= -cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY= -cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= -cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= -cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= -cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= -cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= -cloud.google.com/go/iap v1.7.0/go.mod h1:beqQx56T9O1G1yNPph+spKpNibDlYIiIixiqsQXxLIo= -cloud.google.com/go/iap v1.7.1/go.mod h1:WapEwPc7ZxGt2jFGB/C/bm+hP0Y6NXzOYGjpPnmMS74= -cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM= -cloud.google.com/go/ids v1.2.0/go.mod h1:5WXvp4n25S0rA/mQWAg1YEEBBq6/s+7ml1RDCW1IrcY= -cloud.google.com/go/ids v1.3.0/go.mod h1:JBdTYwANikFKaDP6LtW5JAi4gubs57SVNQjemdt6xV4= -cloud.google.com/go/iot v1.3.0/go.mod h1:r7RGh2B61+B8oz0AGE+J72AhA0G7tdXItODWsaA2oLs= -cloud.google.com/go/iot v1.4.0/go.mod h1:dIDxPOn0UvNDUMD8Ger7FIaTuvMkj+aGk94RPP0iV+g= -cloud.google.com/go/iot v1.5.0/go.mod h1:mpz5259PDl3XJthEmh9+ap0affn/MqNSP4My77Qql9o= -cloud.google.com/go/iot v1.6.0/go.mod h1:IqdAsmE2cTYYNO1Fvjfzo9po179rAtJeVGUvkLN3rLE= -cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= -cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg= -cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0= -cloud.google.com/go/kms v1.8.0/go.mod h1:4xFEhYFqvW+4VMELtZyxomGSYtSQKzM178ylFW4jMAg= -cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w= -cloud.google.com/go/kms v1.10.0/go.mod h1:ng3KTUtQQU9bPX3+QGLsflZIHlkbn8amFAMY63m8d24= -cloud.google.com/go/kms v1.10.1/go.mod h1:rIWk/TryCkR59GMC3YtHtXeLzd634lBbKenvyySAyYI= -cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= -cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= -cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE= -cloud.google.com/go/language v1.8.0/go.mod h1:qYPVHf7SPoNNiCL2Dr0FfEFNil1qi3pQEyygwpgVKB8= -cloud.google.com/go/language v1.9.0/go.mod h1:Ns15WooPM5Ad/5no/0n81yUetis74g3zrbeJBE+ptUY= -cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= -cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= -cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= -cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= -cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= -cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= -cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= -cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= -cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE= -cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM= -cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= -cloud.google.com/go/maps v0.1.0/go.mod h1:BQM97WGyfw9FWEmQMpZ5T6cpovXXSd1cGmFma94eubI= -cloud.google.com/go/maps v0.6.0/go.mod h1:o6DAMMfb+aINHz/p/jbcY+mYeXBoZoxTfdSQ8VAJaCw= -cloud.google.com/go/maps v0.7.0/go.mod h1:3GnvVl3cqeSvgMcpRlQidXsPYuDGQ8naBis7MVzpXsY= -cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= -cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= -cloud.google.com/go/mediatranslation v0.7.0/go.mod h1:LCnB/gZr90ONOIQLgSXagp8XUW1ODs2UmUMvcgMfI2I= -cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= -cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= -cloud.google.com/go/memcache v1.6.0/go.mod h1:XS5xB0eQZdHtTuTF9Hf8eJkKtR3pVRCcvJwtm68T3rA= -cloud.google.com/go/memcache v1.7.0/go.mod h1:ywMKfjWhNtkQTxrWxCkCFkoPjLHPW6A7WOTVI8xy3LY= -cloud.google.com/go/memcache v1.9.0/go.mod h1:8oEyzXCu+zo9RzlEaEjHl4KkgjlNDaXbCQeQWlzNFJM= -cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= -cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= -cloud.google.com/go/metastore v1.7.0/go.mod h1:s45D0B4IlsINu87/AsWiEVYbLaIMeUSoxlKKDqBGFS8= -cloud.google.com/go/metastore v1.8.0/go.mod h1:zHiMc4ZUpBiM7twCIFQmJ9JMEkDSyZS9U12uf7wHqSI= -cloud.google.com/go/metastore v1.10.0/go.mod h1:fPEnH3g4JJAk+gMRnrAnoqyv2lpUCqJPWOodSaf45Eo= -cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhIsnmlA53dvEk= -cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4= -cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= -cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw= -cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= -cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= -cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM= -cloud.google.com/go/networkconnectivity v1.7.0/go.mod h1:RMuSbkdbPwNMQjB5HBWD5MpTBnNm39iAVpC3TmsExt8= -cloud.google.com/go/networkconnectivity v1.10.0/go.mod h1:UP4O4sWXJG13AqrTdQCD9TnLGEbtNRqjuaaA7bNjF5E= -cloud.google.com/go/networkconnectivity v1.11.0/go.mod h1:iWmDD4QF16VCDLXUqvyspJjIEtBR/4zq5hwnY2X3scM= -cloud.google.com/go/networkmanagement v1.4.0/go.mod h1:Q9mdLLRn60AsOrPc8rs8iNV6OHXaGcDdsIQe1ohekq8= -cloud.google.com/go/networkmanagement v1.5.0/go.mod h1:ZnOeZ/evzUdUsnvRt792H0uYEnHQEMaz+REhhzJRcf4= -cloud.google.com/go/networkmanagement v1.6.0/go.mod h1:5pKPqyXjB/sgtvB5xqOemumoQNB7y95Q7S+4rjSOPYY= -cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= -cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= -cloud.google.com/go/networksecurity v0.7.0/go.mod h1:mAnzoxx/8TBSyXEeESMy9OOYwo1v+gZ5eMRnsT5bC8k= -cloud.google.com/go/networksecurity v0.8.0/go.mod h1:B78DkqsxFG5zRSVuwYFRZ9Xz8IcQ5iECsNrPn74hKHU= -cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= -cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= -cloud.google.com/go/notebooks v1.4.0/go.mod h1:4QPMngcwmgb6uw7Po99B2xv5ufVoIQ7nOGDyL4P8AgA= -cloud.google.com/go/notebooks v1.5.0/go.mod h1:q8mwhnP9aR8Hpfnrc5iN5IBhrXUy8S2vuYs+kBJ/gu0= -cloud.google.com/go/notebooks v1.7.0/go.mod h1:PVlaDGfJgj1fl1S3dUwhFMXFgfYGhYQt2164xOMONmE= -cloud.google.com/go/notebooks v1.8.0/go.mod h1:Lq6dYKOYOWUCTvw5t2q1gp1lAp0zxAxRycayS0iJcqQ= -cloud.google.com/go/optimization v1.1.0/go.mod h1:5po+wfvX5AQlPznyVEZjGJTMr4+CAkJf2XSTQOOl9l4= -cloud.google.com/go/optimization v1.2.0/go.mod h1:Lr7SOHdRDENsh+WXVmQhQTrzdu9ybg0NecjHidBq6xs= -cloud.google.com/go/optimization v1.3.1/go.mod h1:IvUSefKiwd1a5p0RgHDbWCIbDFgKuEdB+fPPuP0IDLI= -cloud.google.com/go/orchestration v1.3.0/go.mod h1:Sj5tq/JpWiB//X/q3Ngwdl5K7B7Y0KZ7bfv0wL6fqVA= -cloud.google.com/go/orchestration v1.4.0/go.mod h1:6W5NLFWs2TlniBphAViZEVhrXRSMgUGDfW7vrWKvsBk= -cloud.google.com/go/orchestration v1.6.0/go.mod h1:M62Bevp7pkxStDfFfTuCOaXgaaqRAga1yKyoMtEoWPQ= -cloud.google.com/go/orgpolicy v1.4.0/go.mod h1:xrSLIV4RePWmP9P3tBl8S93lTmlAxjm06NSm2UTmKvE= -cloud.google.com/go/orgpolicy v1.5.0/go.mod h1:hZEc5q3wzwXJaKrsx5+Ewg0u1LxJ51nNFlext7Tanwc= -cloud.google.com/go/orgpolicy v1.10.0/go.mod h1:w1fo8b7rRqlXlIJbVhOMPrwVljyuW5mqssvBtU18ONc= -cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= -cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= -cloud.google.com/go/osconfig v1.9.0/go.mod h1:Yx+IeIZJ3bdWmzbQU4fxNl8xsZ4amB+dygAwFPlvnNo= -cloud.google.com/go/osconfig v1.10.0/go.mod h1:uMhCzqC5I8zfD9zDEAfvgVhDS8oIjySWh+l4WK6GnWw= -cloud.google.com/go/osconfig v1.11.0/go.mod h1:aDICxrur2ogRd9zY5ytBLV89KEgT2MKB2L/n6x1ooPw= -cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= -cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= -cloud.google.com/go/oslogin v1.6.0/go.mod h1:zOJ1O3+dTU8WPlGEkFSh7qeHPPSoxrcMbbK1Nm2iX70= -cloud.google.com/go/oslogin v1.7.0/go.mod h1:e04SN0xO1UNJ1M5GP0vzVBFicIe4O53FOfcixIqTyXo= -cloud.google.com/go/oslogin v1.9.0/go.mod h1:HNavntnH8nzrn8JCTT5fj18FuJLFJc4NaZJtBnQtKFs= -cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= -cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= -cloud.google.com/go/phishingprotection v0.7.0/go.mod h1:8qJI4QKHoda/sb/7/YmMQ2omRLSLYSu9bU0EKCNI+Lk= -cloud.google.com/go/policytroubleshooter v1.3.0/go.mod h1:qy0+VwANja+kKrjlQuOzmlvscn4RNsAc0e15GGqfMxg= -cloud.google.com/go/policytroubleshooter v1.4.0/go.mod h1:DZT4BcRw3QoO8ota9xw/LKtPa8lKeCByYeKTIf/vxdE= -cloud.google.com/go/policytroubleshooter v1.5.0/go.mod h1:Rz1WfV+1oIpPdN2VvvuboLVRsB1Hclg3CKQ53j9l8vw= -cloud.google.com/go/policytroubleshooter v1.6.0/go.mod h1:zYqaPTsmfvpjm5ULxAyD/lINQxJ0DDsnWOP/GZ7xzBc= -cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= -cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= -cloud.google.com/go/privatecatalog v0.7.0/go.mod h1:2s5ssIFO69F5csTXcwBP7NPFTZvps26xGzvQ2PQaBYg= -cloud.google.com/go/privatecatalog v0.8.0/go.mod h1:nQ6pfaegeDAq/Q5lrfCQzQLhubPiZhSaNhIgfJlnIXs= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcdcPRnFIRI= -cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0= -cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= -cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= -cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= -cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= -cloud.google.com/go/pubsublite v1.7.0/go.mod h1:8hVMwRXfDfvGm3fahVbtDbiLePT3gpoiJYJY+vxWxVM= -cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= -cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= -cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= -cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= -cloud.google.com/go/recaptchaenterprise/v2 v2.4.0/go.mod h1:Am3LHfOuBstrLrNCBrlI5sbwx9LBg3te2N6hGvHn2mE= -cloud.google.com/go/recaptchaenterprise/v2 v2.5.0/go.mod h1:O8LzcHXN3rz0j+LBC91jrwI3R+1ZSZEWrfL7XHgNo9U= -cloud.google.com/go/recaptchaenterprise/v2 v2.6.0/go.mod h1:RPauz9jeLtB3JVzg6nCbe12qNoaa8pXc4d/YukAmcnA= -cloud.google.com/go/recaptchaenterprise/v2 v2.7.0/go.mod h1:19wVj/fs5RtYtynAPJdDTb69oW0vNHYDBTbB4NvMD9c= -cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= -cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= -cloud.google.com/go/recommendationengine v0.7.0/go.mod h1:1reUcE3GIu6MeBz/h5xZJqNLuuVjNg1lmWMPyjatzac= -cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= -cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= -cloud.google.com/go/recommender v1.7.0/go.mod h1:XLHs/W+T8olwlGOgfQenXBTbIseGclClff6lhFVe9Bs= -cloud.google.com/go/recommender v1.8.0/go.mod h1:PkjXrTT05BFKwxaUxQmtIlrtj0kph108r02ZZQ5FE70= -cloud.google.com/go/recommender v1.9.0/go.mod h1:PnSsnZY7q+VL1uax2JWkt/UegHssxjUVVCrX52CuEmQ= -cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= -cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= -cloud.google.com/go/redis v1.9.0/go.mod h1:HMYQuajvb2D0LvMgZmLDZW8V5aOC/WxstZHiy4g8OiA= -cloud.google.com/go/redis v1.10.0/go.mod h1:ThJf3mMBQtW18JzGgh41/Wld6vnDDc/F/F35UolRZPM= -cloud.google.com/go/redis v1.11.0/go.mod h1:/X6eicana+BWcUda5PpwZC48o37SiFVTFSs0fWAJ7uQ= -cloud.google.com/go/resourcemanager v1.3.0/go.mod h1:bAtrTjZQFJkiWTPDb1WBjzvc6/kifjj4QBYuKCCoqKA= -cloud.google.com/go/resourcemanager v1.4.0/go.mod h1:MwxuzkumyTX7/a3n37gmsT3py7LIXwrShilPh3P1tR0= -cloud.google.com/go/resourcemanager v1.5.0/go.mod h1:eQoXNAiAvCf5PXxWxXjhKQoTMaUSNrEfg+6qdf/wots= -cloud.google.com/go/resourcemanager v1.6.0/go.mod h1:YcpXGRs8fDzcUl1Xw8uOVmI8JEadvhRIkoXXUNVYcVo= -cloud.google.com/go/resourcemanager v1.7.0/go.mod h1:HlD3m6+bwhzj9XCouqmeiGuni95NTrExfhoSrkC/3EI= -cloud.google.com/go/resourcesettings v1.3.0/go.mod h1:lzew8VfESA5DQ8gdlHwMrqZs1S9V87v3oCnKCWoOuQU= -cloud.google.com/go/resourcesettings v1.4.0/go.mod h1:ldiH9IJpcrlC3VSuCGvjR5of/ezRrOxFtpJoJo5SmXg= -cloud.google.com/go/resourcesettings v1.5.0/go.mod h1:+xJF7QSG6undsQDfsCJyqWXyBwUoJLhetkRMDRnIoXA= -cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= -cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= -cloud.google.com/go/retail v1.10.0/go.mod h1:2gDk9HsL4HMS4oZwz6daui2/jmKvqShXKQuB2RZ+cCc= -cloud.google.com/go/retail v1.11.0/go.mod h1:MBLk1NaWPmh6iVFSz9MeKG/Psyd7TAgm6y/9L2B4x9Y= -cloud.google.com/go/retail v1.12.0/go.mod h1:UMkelN/0Z8XvKymXFbD4EhFJlYKRx1FGhQkVPU5kF14= -cloud.google.com/go/run v0.2.0/go.mod h1:CNtKsTA1sDcnqqIFR3Pb5Tq0usWxJJvsWOCPldRU3Do= -cloud.google.com/go/run v0.3.0/go.mod h1:TuyY1+taHxTjrD0ZFk2iAR+xyOXEA0ztb7U3UNA0zBo= -cloud.google.com/go/run v0.8.0/go.mod h1:VniEnuBwqjigv0A7ONfQUaEItaiCRVujlMqerPPiktM= -cloud.google.com/go/run v0.9.0/go.mod h1:Wwu+/vvg8Y+JUApMwEDfVfhetv30hCG4ZwDR/IXl2Qg= -cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= -cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= -cloud.google.com/go/scheduler v1.6.0/go.mod h1:SgeKVM7MIwPn3BqtcBntpLyrIJftQISRrYB5ZtT+KOk= -cloud.google.com/go/scheduler v1.7.0/go.mod h1:jyCiBqWW956uBjjPMMuX09n3x37mtyPJegEWKxRsn44= -cloud.google.com/go/scheduler v1.8.0/go.mod h1:TCET+Y5Gp1YgHT8py4nlg2Sew8nUHMqcpousDgXJVQc= -cloud.google.com/go/scheduler v1.9.0/go.mod h1:yexg5t+KSmqu+njTIh3b7oYPheFtBWGcbVUYF1GGMIc= -cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= -cloud.google.com/go/secretmanager v1.8.0/go.mod h1:hnVgi/bN5MYHd3Gt0SPuTPPp5ENina1/LxM+2W9U9J4= -cloud.google.com/go/secretmanager v1.9.0/go.mod h1:b71qH2l1yHmWQHt9LC80akm86mX8AL6X1MA01dW8ht4= -cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= -cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= -cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= -cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= -cloud.google.com/go/security v1.9.0/go.mod h1:6Ta1bO8LXI89nZnmnsZGp9lVoVWXqsVbIq/t9dzI+2Q= -cloud.google.com/go/security v1.10.0/go.mod h1:QtOMZByJVlibUT2h9afNDWRZ1G96gVywH8T5GUSb9IA= -cloud.google.com/go/security v1.12.0/go.mod h1:rV6EhrpbNHrrxqlvW0BWAIawFWq3X90SduMJdFwtLB8= -cloud.google.com/go/security v1.13.0/go.mod h1:Q1Nvxl1PAgmeW0y3HTt54JYIvUdtcpYKVfIB8AOMZ+0= -cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= -cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= -cloud.google.com/go/securitycenter v1.15.0/go.mod h1:PeKJ0t8MoFmmXLXWm41JidyzI3PJjd8sXWaVqg43WWk= -cloud.google.com/go/securitycenter v1.16.0/go.mod h1:Q9GMaLQFUD+5ZTabrbujNWLtSLZIZF7SAR0wWECrjdk= -cloud.google.com/go/securitycenter v1.18.1/go.mod h1:0/25gAzCM/9OL9vVx4ChPeM/+DlfGQJDwBy/UC8AKK0= -cloud.google.com/go/securitycenter v1.19.0/go.mod h1:LVLmSg8ZkkyaNy4u7HCIshAngSQ8EcIRREP3xBnyfag= -cloud.google.com/go/servicecontrol v1.4.0/go.mod h1:o0hUSJ1TXJAmi/7fLJAedOovnujSEvjKCAFNXPQ1RaU= -cloud.google.com/go/servicecontrol v1.5.0/go.mod h1:qM0CnXHhyqKVuiZnGKrIurvVImCs8gmqWsDoqe9sU1s= -cloud.google.com/go/servicecontrol v1.10.0/go.mod h1:pQvyvSRh7YzUF2efw7H87V92mxU8FnFDawMClGCNuAA= -cloud.google.com/go/servicecontrol v1.11.0/go.mod h1:kFmTzYzTUIuZs0ycVqRHNaNhgR+UMUpw9n02l/pY+mc= -cloud.google.com/go/servicecontrol v1.11.1/go.mod h1:aSnNNlwEFBY+PWGQ2DoM0JJ/QUXqV5/ZD9DOLB7SnUk= -cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= -cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= -cloud.google.com/go/servicedirectory v1.6.0/go.mod h1:pUlbnWsLH9c13yGkxCmfumWEPjsRs1RlmJ4pqiNjVL4= -cloud.google.com/go/servicedirectory v1.7.0/go.mod h1:5p/U5oyvgYGYejufvxhgwjL8UVXjkuw7q5XcG10wx1U= -cloud.google.com/go/servicedirectory v1.8.0/go.mod h1:srXodfhY1GFIPvltunswqXpVxFPpZjf8nkKQT7XcXaY= -cloud.google.com/go/servicedirectory v1.9.0/go.mod h1:29je5JjiygNYlmsGz8k6o+OZ8vd4f//bQLtvzkPPT/s= -cloud.google.com/go/servicemanagement v1.4.0/go.mod h1:d8t8MDbezI7Z2R1O/wu8oTggo3BI2GKYbdG4y/SJTco= -cloud.google.com/go/servicemanagement v1.5.0/go.mod h1:XGaCRe57kfqu4+lRxaFEAuqmjzF0r+gWHjWqKqBvKFo= -cloud.google.com/go/servicemanagement v1.6.0/go.mod h1:aWns7EeeCOtGEX4OvZUWCCJONRZeFKiptqKf1D0l/Jc= -cloud.google.com/go/servicemanagement v1.8.0/go.mod h1:MSS2TDlIEQD/fzsSGfCdJItQveu9NXnUniTrq/L8LK4= -cloud.google.com/go/serviceusage v1.3.0/go.mod h1:Hya1cozXM4SeSKTAgGXgj97GlqUvF5JaoXacR1JTP/E= -cloud.google.com/go/serviceusage v1.4.0/go.mod h1:SB4yxXSaYVuUBYUml6qklyONXNLt83U0Rb+CXyhjEeU= -cloud.google.com/go/serviceusage v1.5.0/go.mod h1:w8U1JvqUqwJNPEOTQjrMHkw3IaIFLoLsPLvsE3xueec= -cloud.google.com/go/serviceusage v1.6.0/go.mod h1:R5wwQcbOWsyuOfbP9tGdAnCAc6B9DRwPG1xtWMDeuPA= -cloud.google.com/go/shell v1.3.0/go.mod h1:VZ9HmRjZBsjLGXusm7K5Q5lzzByZmJHf1d0IWHEN5X4= -cloud.google.com/go/shell v1.4.0/go.mod h1:HDxPzZf3GkDdhExzD/gs8Grqk+dmYcEjGShZgYa9URw= -cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+qE2f9A= -cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos= -cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= -cloud.google.com/go/spanner v1.45.0/go.mod h1:FIws5LowYz8YAE1J8fOS7DJup8ff7xJeetWEo5REA2M= -cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= -cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= -cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0= -cloud.google.com/go/speech v1.9.0/go.mod h1:xQ0jTcmnRFFM2RfX/U+rk6FQNUF6DQlydUSyoooSpco= -cloud.google.com/go/speech v1.14.1/go.mod h1:gEosVRPJ9waG7zqqnsHpYTOoAS4KouMRLDFMekpJ0J0= -cloud.google.com/go/speech v1.15.0/go.mod h1:y6oH7GhqCaZANH7+Oe0BhgIogsNInLlz542tg3VqeYI= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= -cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= -cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= -cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= -cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= -cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= -cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= -cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= -cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= -cloud.google.com/go/storagetransfer v1.8.0/go.mod h1:JpegsHHU1eXg7lMHkvf+KE5XDJ7EQu0GwNJbbVGanEw= -cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= -cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= -cloud.google.com/go/talent v1.3.0/go.mod h1:CmcxwJ/PKfRgd1pBjQgU6W3YBwiewmUzQYH5HHmSCmM= -cloud.google.com/go/talent v1.4.0/go.mod h1:ezFtAgVuRf8jRsvyE6EwmbTK5LKciD4KVnHuDEFmOOA= -cloud.google.com/go/talent v1.5.0/go.mod h1:G+ODMj9bsasAEJkQSzO2uHQWXHHXUomArjWQQYkqK6c= -cloud.google.com/go/texttospeech v1.4.0/go.mod h1:FX8HQHA6sEpJ7rCMSfXuzBcysDAuWusNNNvN9FELDd8= -cloud.google.com/go/texttospeech v1.5.0/go.mod h1:oKPLhR4n4ZdQqWKURdwxMy0uiTS1xU161C8W57Wkea4= -cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= -cloud.google.com/go/tpu v1.3.0/go.mod h1:aJIManG0o20tfDQlRIej44FcwGGl/cD0oiRyMKG19IQ= -cloud.google.com/go/tpu v1.4.0/go.mod h1:mjZaX8p0VBgllCzF6wcU2ovUXN9TONFLd7iz227X2Xg= -cloud.google.com/go/tpu v1.5.0/go.mod h1:8zVo1rYDFuW2l4yZVY0R0fb/v44xLh3llq7RuV61fPM= -cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg6N0G28= -cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= -cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= -cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= -cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= -cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= -cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= -cloud.google.com/go/translate v1.6.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= -cloud.google.com/go/translate v1.7.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= -cloud.google.com/go/video v1.8.0/go.mod h1:sTzKFc0bUSByE8Yoh8X0mn8bMymItVGPfTuUBUyRgxk= -cloud.google.com/go/video v1.9.0/go.mod h1:0RhNKFRF5v92f8dQt0yhaHrEuH95m068JYOvLZYnJSw= -cloud.google.com/go/video v1.12.0/go.mod h1:MLQew95eTuaNDEGriQdcYn0dTwf9oWiA4uYebxM5kdg= -cloud.google.com/go/video v1.13.0/go.mod h1:ulzkYlYgCp15N2AokzKjy7MQ9ejuynOJdf1tR5lGthk= -cloud.google.com/go/video v1.14.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= -cloud.google.com/go/video v1.15.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= -cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= -cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= -cloud.google.com/go/videointelligence v1.8.0/go.mod h1:dIcCn4gVDdS7yte/w+koiXn5dWVplOZkE+xwG9FgK+M= -cloud.google.com/go/videointelligence v1.9.0/go.mod h1:29lVRMPDYHikk3v8EdPSaL8Ku+eMzDljjuvRs105XoU= -cloud.google.com/go/videointelligence v1.10.0/go.mod h1:LHZngX1liVtUhZvi2uNS0VQuOzNi2TkY1OakiuoUOjU= -cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= -cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= -cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= -cloud.google.com/go/vision/v2 v2.4.0/go.mod h1:VtI579ll9RpVTrdKdkMzckdnwMyX2JILb+MhPqRbPsY= -cloud.google.com/go/vision/v2 v2.5.0/go.mod h1:MmaezXOOE+IWa+cS7OhRRLK2cNv1ZL98zhqFFZaaH2E= -cloud.google.com/go/vision/v2 v2.6.0/go.mod h1:158Hes0MvOS9Z/bDMSFpjwsUrZ5fPrdwuyyvKSGAGMY= -cloud.google.com/go/vision/v2 v2.7.0/go.mod h1:H89VysHy21avemp6xcf9b9JvZHVehWbET0uT/bcuY/0= -cloud.google.com/go/vmmigration v1.2.0/go.mod h1:IRf0o7myyWFSmVR1ItrBSFLFD/rJkfDCUTO4vLlJvsE= -cloud.google.com/go/vmmigration v1.3.0/go.mod h1:oGJ6ZgGPQOFdjHuocGcLqX4lc98YQ7Ygq8YQwHh9A7g= -cloud.google.com/go/vmmigration v1.5.0/go.mod h1:E4YQ8q7/4W9gobHjQg4JJSgXXSgY21nA5r8swQV+Xxc= -cloud.google.com/go/vmmigration v1.6.0/go.mod h1:bopQ/g4z+8qXzichC7GW1w2MjbErL54rk3/C843CjfY= -cloud.google.com/go/vmwareengine v0.1.0/go.mod h1:RsdNEf/8UDvKllXhMz5J40XxDrNJNN4sagiox+OI208= -cloud.google.com/go/vmwareengine v0.2.2/go.mod h1:sKdctNJxb3KLZkE/6Oui94iw/xs9PRNC2wnNLXsHvH8= -cloud.google.com/go/vmwareengine v0.3.0/go.mod h1:wvoyMvNWdIzxMYSpH/R7y2h5h3WFkx6d+1TIsP39WGY= -cloud.google.com/go/vpcaccess v1.4.0/go.mod h1:aQHVbTWDYUR1EbTApSVvMq1EnT57ppDmQzZ3imqIk4w= -cloud.google.com/go/vpcaccess v1.5.0/go.mod h1:drmg4HLk9NkZpGfCmZ3Tz0Bwnm2+DKqViEpeEpOq0m8= -cloud.google.com/go/vpcaccess v1.6.0/go.mod h1:wX2ILaNhe7TlVa4vC5xce1bCnqE3AeH27RV31lnmZes= -cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= -cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= -cloud.google.com/go/webrisk v1.6.0/go.mod h1:65sW9V9rOosnc9ZY7A7jsy1zoHS5W9IAXv6dGqhMQMc= -cloud.google.com/go/webrisk v1.7.0/go.mod h1:mVMHgEYH0r337nmt1JyLthzMr6YxwN1aAIEc2fTcq7A= -cloud.google.com/go/webrisk v1.8.0/go.mod h1:oJPDuamzHXgUc+b8SiHRcVInZQuybnvEW72PqTc7sSg= -cloud.google.com/go/websecurityscanner v1.3.0/go.mod h1:uImdKm2wyeXQevQJXeh8Uun/Ym1VqworNDlBXQevGMo= -cloud.google.com/go/websecurityscanner v1.4.0/go.mod h1:ebit/Fp0a+FWu5j4JOmJEV8S8CzdTkAS77oDsiSqYWQ= -cloud.google.com/go/websecurityscanner v1.5.0/go.mod h1:Y6xdCPy81yi0SQnDY1xdNTNpfY1oAgXUlcfN3B3eSng= -cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= -cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= -cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= -cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= -cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= -git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c h1:RGWPOewvKIROun94nF7v2cua9qP+thov/7M50KEoeSU= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= -github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= -github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= -github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= -github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= -github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= -github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= -github.com/apache/arrow/go/v8 v8.0.0 h1:mG1dDlq8aQO4a/PB00T9H19Ga2imvqoFPHI5cykpibs= -github.com/apache/arrow/go/v8 v8.0.0/go.mod h1:63co72EKYQT9WKr8Y1Yconk4dysC0t79wNDauYO1ZGg= -github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/apache/thrift v0.15.0 h1:aGvdaR0v1t9XLgjtBYwxcBvBOTMqClzwE26CHOgjW1Y= -github.com/apache/thrift v0.15.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= -github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY= -github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= -github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= -github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= -github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54= +github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= -github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= -github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= -github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= -github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= -github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= -github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= -github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= -github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= -github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= -github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= -github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= -github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= -github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= -github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg= -github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= -github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/goccy/go-json v0.9.6 h1:5/4CtRQdtsX0sal8fdVhTaiMN01Ri8BExZZ8iRmHQ6E= -github.com/goccy/go-json v0.9.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= -github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= -github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= -github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/flatbuffers v2.0.5+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/flatbuffers v2.0.6+incompatible h1:XHFReMv7nFFusa+CEokzWbzaYocKXI6C7hdU5Kgh9Lw= -github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/flatbuffers v2.0.8+incompatible h1:ivUb1cGomAB101ZM1T0nOiWz9pSrTMoa9+EiY7igmkM= -github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= -github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= -github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= -github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= -github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= -github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= -github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= -github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= -github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= -github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= -github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= -github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= -github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= -github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/asmfmt v1.3.1/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= +github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= -github.com/klauspost/compress v1.14.2/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A= -github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= -github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= -github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= -github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= -github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= -github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= -github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= -github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= -github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= -github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= -github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= -github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= -github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= -github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= -github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= -github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= -github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= -github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= -github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= -github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= -github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= -github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= -github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= -github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pierrec/lz4/v4 v4.1.12/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= -github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= -github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.21.0/go.mod h1:ZPhntP/xmq1nnND05hhpAh2QMhSsA4UN3MGZ6O2J3hM= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= -github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.0.1/go.mod h1:8VHV24/3AZLn3b6Mlp/KuC33LWH687Wq6EnziEB+rsA= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= -go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= -go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU= -go.opentelemetry.io/otel/oteltest v0.20.0/go.mod h1:L7bgKf9ZB7qCwT9Up7i9/pn0PWIa9FqQ2IQ8LoxiGnw= -go.opentelemetry.io/otel/sdk v0.20.0/go.mod h1:g/IcepuwNsoiX5Byy2nNV0ySUF1em498m7hBWC279Yc= -go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20211216164055-b2b84827b756/go.mod h1:b9TAUYHmRtqA6klRHApnXMnj+OyLce4yF5cZCUbk2ps= -golang.org/x/exp v0.0.0-20220407100705-7b9b53b0aca4 h1:K3x+yU+fbot38x5bQbU2QqUAVyYLEktdNH2GxZLnM3U= -golang.org/x/exp v0.0.0-20220407100705-7b9b53b0aca4/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20210216034530-4410531fe030/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= -golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= -golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= -golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/gonum v0.9.3 h1:DnoIG+QAMaF5NvxnGe/oKsgKcAc6PcUyl8q0VetfQ8s= -gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= -gonum.org/v1/gonum v0.11.0 h1:f1IJhK4Km5tBJmaiJXtk/PkL4cdVX6J+tGiM187uT5E= -gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= -gonum.org/v1/plot v0.10.1/go.mod h1:VZW5OlhkL1mysU9vaqNHnsy86inf6Ot+jB3r+BczCEo= -google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= -google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= -google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= -google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= -google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= -google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= -google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= -google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= -google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= -google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= -google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= -google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= -google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= -google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= -google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= -google.golang.org/api v0.99.0/go.mod h1:1YOf74vkVndF7pG6hIHuINsM7eWwpVTAfNMNiL91A08= -google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= -google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo= -google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0= -google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= -google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= -google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= -google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= -google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= -google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= -google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= -google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= -google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= -google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= -google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= -google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= -google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= -google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= -google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= -google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= -google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= -google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= -google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= -google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= -google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= -google.golang.org/genproto v0.0.0-20221024153911-1573dae28c9c/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= -google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= -google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= -google.golang.org/genproto v0.0.0-20221109142239-94d6d90a7d66/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221114212237-e4508ebdbee1/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221117204609-8f9c96812029/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= -google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd/go.mod h1:cTsE614GARnxrLsqKREzmNYJACSWWpAWdNMwnD7c2BE= -google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230112194545-e10362b5ecf9/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230124163310-31e0e69b6fc2/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= -google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA= -google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= -google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= -google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA= -google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= -google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= -google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= -google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= -google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= -google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= -google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= -google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= -google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= -google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc= -google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ= +gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= +google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= -modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= -modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= -modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= -modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= -modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= -modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= -modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= -modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= -modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= -modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= -modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= -modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= -modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= -modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= -modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= -modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= -modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= -modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= -sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/go/README.md b/go/README.md index 0bca470919f..d18b75815fc 100644 --- a/go/README.md +++ b/go/README.md @@ -1,109 +1,12 @@ -This directory contains the Go logic that's executed by the `EmbeddedOnlineFeatureServer` from Python. - -## Building and Linking -[gopy](https://github.com/go-python/gopy) generates (and compiles) a CPython extension module from a Go package. That's what we're using here, as visible in [setup.py](../setup.py). - -Under the hood, gopy invokes `go build`, and then templates `cgo` stubs for the Go module that exposes the public functions from the Go module as C functions. -For our project, this stuff can be found at `sdk/python/feast/embedded_go/lib/embedded.go` & `sdk/python/feast/embedded_go/lib/embedded_go.h` after running `make compile-go-lib`. - -## Arrow memory management -Understanding this is the trickiest part of this integration. - -At a high level, when using the Python<>Go integration, the Python layer exports request data into an [Arrow Record batch](https://arrow.apache.org/docs/python/data.html) which is transferred to Go using Arrow's zero copy mechanism. -Similarly, the Go layer converts feature values read from the online store into a Record Batch that's exported to Python using the same mechanics. - -The first thing to note is that from the Python perspective, all the export logic assumes that we're exporting to & importing from C, not Go. This is because pyarrow only interops with C, and the fact we're using Go is an implementation detail not relevant to the Python layer. - -### Export Entities & Request data from Python to Go -The code exporting to C is this, in [online_feature_service.py](../sdk/python/feast/embedded_go/online_features_service.py) -``` -( - entities_c_schema, - entities_ptr_schema, - entities_c_array, - entities_ptr_array, -) = allocate_schema_and_array() -( - req_data_c_schema, - req_data_ptr_schema, - req_data_c_array, - req_data_ptr_array, -) = allocate_schema_and_array() - -batch, schema = map_to_record_batch(entities, join_keys_types) -schema._export_to_c(entities_ptr_schema) -batch._export_to_c(entities_ptr_array) - -batch, schema = map_to_record_batch(request_data) -schema._export_to_c(req_data_ptr_schema) -batch._export_to_c(req_data_ptr_array) -``` - -Under the hood, `allocate_schema_and_array` allocates a pointer (`struct ArrowSchema*` and `struct ArrowArray*`) in native memory (i.e. the C layer) using `cffi`. -Next, the RecordBatch exports to this pointer using [`_export_to_c`](https://github.com/apache/arrow/blob/master/python/pyarrow/table.pxi#L2509), which uses [`ExportRecordBatch`](https://arrow.apache.org/docs/cpp/api/c_abi.html#_CPPv417ExportRecordBatchRK11RecordBatchP10ArrowArrayP11ArrowSchema) under the hood. - -As per the documentation for ExportRecordBatch: -> Status ExportRecordBatch(const RecordBatch &batch, struct ArrowArray *out, struct ArrowSchema *out_schema = NULLPTR) -> Export C++ RecordBatch using the C data interface format. -> -> The record batch is exported as if it were a struct array. The resulting ArrowArray struct keeps the record batch data and buffers alive until its release callback is called by the consumer. +[Update 10/31/2024] This Go feature server code is updated from the Expedia Group's forked Feast branch (https://github.com/EXPEbdodla/feast) on 10/22/2024. Thanks the engineers of the Expedia Groups who contributed and improved the Go feature server. -This is why `GetOnlineFeatures()` in `online_features.go` calls `record.Release()` as below: -``` -entitiesRecord, err := readArrowRecord(entities) -if err != nil { - return err -} -defer entitiesRecord.Release() -... -requestDataRecords, err := readArrowRecord(requestData) -if err != nil { - return err -} -defer requestDataRecords.Release() -``` -Additionally, we need to pass in a pair of pointers to `GetOnlineFeatures()` that are populated by the Go layer, and the resultant feature values can be passed back to Python (via the C layer) using zero-copy semantics. -That happens as follows: -``` -( - features_c_schema, - features_ptr_schema, - features_c_array, - features_ptr_array, -) = allocate_schema_and_array() - -... - -record_batch = pa.RecordBatch._import_from_c( - features_ptr_array, features_ptr_schema -) -``` - -The corresponding Go code that exports this data is: -``` -result := array.NewRecord(arrow.NewSchema(outputFields, nil), outputColumns, int64(numRows)) - -cdata.ExportArrowRecordBatch(result, - cdata.ArrayFromPtr(output.DataPtr), - cdata.SchemaFromPtr(output.SchemaPtr)) -``` - -The documentation for `ExportArrowRecordBatch` is great. It has this super useful caveat: - -> // The release function on the populated CArrowArray will properly decrease the reference counts, -> // and release the memory if the record has already been released. But since this must be explicitly -> // done, make sure it is released so that you do not create a memory leak. - -This implies that the reciever is on the hook for explicitly releasing this memory. - -However, we're using `_import_from_c`, which uses [`ImportRecordBatch`](https://arrow.apache.org/docs/cpp/api/c_abi.html#_CPPv417ImportRecordBatchP10ArrowArrayP11ArrowSchema), which implies that the receiver of the RecordBatch is the new owner of the data. -This is wrapped by pyarrow - and when the corresponding python object goes out of scope, it should clean up the underlying record batch. - -Another thing to note (which I'm not sure may be the source of issues) is that Arrow has the concept of [Memory Pools](https://arrow.apache.org/docs/python/api/memory.html#memory-pools). -Memory pools can be set in python as well as in Go. I *believe* that if we use the CGoArrowAllocator, that uses whatever pool C++ uses, which should be the same as the one used by PyArrow. But this should be vetted. +This directory contains the Go logic that's executed by the `EmbeddedOnlineFeatureServer` from Python. +## Build and Run +To build and run the Go Feature Server locally, create a feature_store.yaml file with necessary configurations and run below commands: -### References -- https://arrow.apache.org/docs/format/CDataInterface.html#memory-management -- https://arrow.apache.org/docs/python/memory.html \ No newline at end of file +```bash + go build -o feast ./go/main.go + ./feast --type=http --port=8080 +``` \ No newline at end of file diff --git a/go/embedded/online_features.go b/go/embedded/online_features.go index 3c470e4b244..3cbd47ae5b7 100644 --- a/go/embedded/online_features.go +++ b/go/embedded/online_features.go @@ -7,13 +7,16 @@ import ( "net" "os" "os/signal" + //"strings" "syscall" "time" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/cdata" - "github.com/apache/arrow/go/v8/arrow/memory" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/cdata" + "github.com/apache/arrow/go/v17/arrow/memory" "google.golang.org/grpc" "github.com/feast-dev/feast/go/internal/feast" @@ -26,6 +29,10 @@ import ( "github.com/feast-dev/feast/go/protos/feast/serving" prototypes "github.com/feast-dev/feast/go/protos/feast/types" "github.com/feast-dev/feast/go/types" + jsonlog "github.com/rs/zerolog/log" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + //grpctrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/google.golang.org/grpc" ) type OnlineFeatureService struct { @@ -63,6 +70,7 @@ type LoggingOptions struct { func NewOnlineFeatureService(conf *OnlineFeatureServiceConfig, transformationCallback transformation.TransformationCallback) *OnlineFeatureService { repoConfig, err := registry.NewRepoConfigFromJSON(conf.RepoPath, conf.RepoConfig) if err != nil { + jsonlog.Error().Stack().Err(err).Msg("Failed to convert to RepoConfig") return &OnlineFeatureService{ err: err, } @@ -70,6 +78,7 @@ func NewOnlineFeatureService(conf *OnlineFeatureServiceConfig, transformationCal fs, err := feast.NewFeatureStore(repoConfig, transformationCallback) if err != nil { + jsonlog.Error().Stack().Err(err).Msg("Failed to create NewFeatureStore") return &OnlineFeatureService{ err: err, } @@ -205,7 +214,7 @@ func (s *OnlineFeatureService) GetOnlineFeatures( outputFields := make([]arrow.Field, 0) outputColumns := make([]arrow.Array, 0) - pool := memory.NewCgoArrowAllocator() + pool := memory.NewGoAllocator() for _, featureVector := range resp { outputFields = append(outputFields, arrow.Field{ @@ -254,7 +263,7 @@ func (s *OnlineFeatureService) GetOnlineFeatures( // StartGprcServer starts gRPC server with disabled feature logging and blocks the thread func (s *OnlineFeatureService) StartGprcServer(host string, port int) error { - return s.StartGprcServerWithLogging(host, port, nil, LoggingOptions{}) + return s.StartGrpcServerWithLogging(host, port, nil, LoggingOptions{}) } // StartGprcServerWithLoggingDefaultOpts starts gRPC server with enabled feature logging but default configuration for logging @@ -266,7 +275,7 @@ func (s *OnlineFeatureService) StartGprcServerWithLoggingDefaultOpts(host string WriteInterval: logging.DefaultOptions.WriteInterval, FlushInterval: logging.DefaultOptions.FlushInterval, } - return s.StartGprcServerWithLogging(host, port, writeLoggedFeaturesCallback, defaultOpts) + return s.StartGrpcServerWithLogging(host, port, writeLoggedFeaturesCallback, defaultOpts) } func (s *OnlineFeatureService) constructLoggingService(writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts LoggingOptions) (*logging.LoggingService, error) { @@ -290,9 +299,14 @@ func (s *OnlineFeatureService) constructLoggingService(writeLoggedFeaturesCallba return loggingService, nil } -// StartGprcServerWithLogging starts gRPC server with enabled feature logging +// StartGrpcServerWithLogging starts gRPC server with enabled feature logging // Caller of this function must provide Python callback to flush buffered logs as well as logging configuration (loggingOpts) -func (s *OnlineFeatureService) StartGprcServerWithLogging(host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts LoggingOptions) error { +func (s *OnlineFeatureService) StartGrpcServerWithLogging(host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts LoggingOptions) error { + //if strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" { + // tracer.Start(tracer.WithRuntimeMetrics()) + // defer tracer.Stop() + //} + loggingService, err := s.constructLoggingService(writeLoggedFeaturesCallback, loggingOpts) if err != nil { return err @@ -304,8 +318,12 @@ func (s *OnlineFeatureService) StartGprcServerWithLogging(host string, port int, return err } + //grpcServer := grpc.NewServer(grpc.UnaryInterceptor(grpctrace.UnaryServerInterceptor())) grpcServer := grpc.NewServer() + serving.RegisterServingServiceServer(grpcServer, ser) + healthService := health.NewServer() + grpc_health_v1.RegisterHealthServer(grpcServer, healthService) go func() { // As soon as these signals are received from OS, try to gracefully stop the gRPC server diff --git a/go/infra/docker/feature-server/Dockerfile b/go/infra/docker/feature-server/Dockerfile new file mode 100644 index 00000000000..cf63bb45594 --- /dev/null +++ b/go/infra/docker/feature-server/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.22.5 + +# Update the package list and install the ca-certificates package +RUN apt-get update && apt-get install -y ca-certificates +RUN apt install -y protobuf-compiler + +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.31.0 +RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0 + +# Set the current working directory inside the container +WORKDIR /app + +# Copy the source code into the container +COPY go/ ./go/ +COPY go.mod go.sum ./ + +# Compile Protobuf files +COPY protos/ ./protos/ +RUN mkdir -p go/protos +RUN find ./protos -name "*.proto" \ + -exec protoc --proto_path=protos --go_out=go/protos --go_opt=module=github.com/feast-dev/feast/go/protos --go-grpc_out=go/protos --go-grpc_opt=module=github.com/feast-dev/feast/go/protos {} \; + +# Build the Go application +RUN go build -o feast ./go/main.go + +# Expose ports +EXPOSE 8080 + +# Command to run the executable +# Pass arguments to the executable (Ex: ./feast --type=grpc) +CMD ["./feast"] \ No newline at end of file diff --git a/go/internal/feast/errors.go b/go/internal/feast/errors.go new file mode 100644 index 00000000000..f42b4aad82d --- /dev/null +++ b/go/internal/feast/errors.go @@ -0,0 +1,22 @@ +package feast + +import ( + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type FeastTransformationServiceNotConfigured struct{} + +func (FeastTransformationServiceNotConfigured) GRPCStatus() *status.Status { + errorStatus := status.New(codes.Internal, "No transformation service configured") + ds, err := errorStatus.WithDetails(&errdetails.LocalizedMessage{Message: "No transformation service configured, required for on-demand feature transformations"}) + if err != nil { + return errorStatus + } + return ds +} + +func (e FeastTransformationServiceNotConfigured) Error() string { + return e.GRPCStatus().Err().Error() +} diff --git a/go/internal/feast/featurestore.go b/go/internal/feast/featurestore.go index ed38411460a..abe1d195def 100644 --- a/go/internal/feast/featurestore.go +++ b/go/internal/feast/featurestore.go @@ -4,7 +4,9 @@ import ( "context" "errors" - "github.com/apache/arrow/go/v8/arrow/memory" + "github.com/apache/arrow/go/v17/arrow/memory" + + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" "github.com/feast-dev/feast/go/internal/feast/model" "github.com/feast-dev/feast/go/internal/feast/onlineserving" @@ -20,6 +22,7 @@ type FeatureStore struct { registry *registry.Registry onlineStore onlinestore.OnlineStore transformationCallback transformation.TransformationCallback + transformationService *transformation.GrpcTransformationService } // A Features struct specifies a list of features to be retrieved from the online store. These features @@ -45,18 +48,33 @@ func NewFeatureStore(config *registry.RepoConfig, callback transformation.Transf if err != nil { return nil, err } - - registry, err := registry.NewRegistry(config.GetRegistryConfig(), config.RepoPath) + registryConfig, err := config.GetRegistryConfig() if err != nil { return nil, err } - registry.InitializeRegistry() + registry, err := registry.NewRegistry(registryConfig, config.RepoPath, config.Project) + if err != nil { + return nil, err + } + err = registry.InitializeRegistry() + if err != nil { + return nil, err + } + + var transformationService *transformation.GrpcTransformationService + if transformationServerEndpoint, ok := config.FeatureServer["transformation_service_endpoint"]; ok { + // Use a scalable transformation service like Python Transformation Service. + // Assume the user will define the "transformation_service_endpoint" in the feature_store.yaml file + // under the "feature_server" section. + transformationService, _ = transformation.NewGrpcTransformationService(config, transformationServerEndpoint.(string)) + } return &FeatureStore{ config: config, registry: registry, onlineStore: onlineStore, transformationCallback: callback, + transformationService: transformationService, }, nil } @@ -92,6 +110,10 @@ func (fs *FeatureStore) GetOnlineFeatures( return nil, err } + if len(requestedOnDemandFeatureViews) > 0 && fs.transformationService == nil { + return nil, FeastTransformationServiceNotConfigured{} + } + entityNameToJoinKeyMap, expectedJoinKeysSet, err := onlineserving.GetEntityMaps(requestedFeatureViews, entities) if err != nil { return nil, err @@ -113,7 +135,7 @@ func (fs *FeatureStore) GetOnlineFeatures( } result := make([]*onlineserving.FeatureVector, 0) - arrowMemory := memory.NewCgoArrowAllocator() + arrowMemory := memory.NewGoAllocator() featureViews := make([]*model.FeatureView, len(requestedFeatureViews)) index := 0 for _, featuresAndView := range requestedFeatureViews { @@ -161,13 +183,15 @@ func (fs *FeatureStore) GetOnlineFeatures( result = append(result, vectors...) } - if fs.transformationCallback != nil { + if fs.transformationCallback != nil || fs.transformationService != nil { onDemandFeatures, err := transformation.AugmentResponseWithOnDemandTransforms( + ctx, requestedOnDemandFeatureViews, requestData, joinKeyToEntityValues, result, fs.transformationCallback, + fs.transformationService, arrowMemory, numRows, fullFeatureNames, @@ -297,6 +321,10 @@ func (fs *FeatureStore) readFromOnlineStore(ctx context.Context, entityRows []*p requestedFeatureViewNames []string, requestedFeatureNames []string, ) ([][]onlinestore.FeatureData, error) { + // Create a Datadog span from context + //span, _ := tracer.StartSpanFromContext(ctx, "fs.readFromOnlineStore") + //defer span.Finish() + numRows := len(entityRows) entityRowsValue := make([]*prototypes.EntityKey, numRows) for index, entityKey := range entityRows { diff --git a/go/internal/feast/featurestore_test.go b/go/internal/feast/featurestore_test.go index dd08bc287e9..e1f908b9062 100644 --- a/go/internal/feast/featurestore_test.go +++ b/go/internal/feast/featurestore_test.go @@ -2,71 +2,241 @@ package feast import ( "context" + "log" + "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/feast-dev/feast/go/internal/feast/onlinestore" "github.com/feast-dev/feast/go/internal/feast/registry" + "github.com/feast-dev/feast/go/internal/test" + "github.com/feast-dev/feast/go/protos/feast/serving" "github.com/feast-dev/feast/go/protos/feast/types" ) -// Return absolute path to the test_repo registry regardless of the working directory -func getRegistryPath() map[string]interface{} { +var featureRepoBasePath string +var featureRepoRegistryFile string + +func TestMain(m *testing.M) { // Get the file path of this source file, regardless of the working directory _, filename, _, ok := runtime.Caller(0) if !ok { - panic("couldn't find file path of the test file") + log.Print("couldn't find file path of the test file") + os.Exit(1) } - registry := map[string]interface{}{ - "path": filepath.Join(filename, "..", "..", "..", "feature_repo/data/registry.db"), + featureRepoBasePath = filepath.Join(filename, "..", "..", "test") + featureRepoRegistryFile = filepath.Join(featureRepoBasePath, "feature_repo", "data", "registry.db") + if err := test.SetupInitializedRepo(featureRepoBasePath); err != nil { + log.Print("Could not initialize test repo: ", err) + os.Exit(1) } - return registry + os.Exit(m.Run()) } func TestNewFeatureStore(t *testing.T) { - t.Skip("@todo(achals): feature_repo isn't checked in yet") - config := registry.RepoConfig{ - Project: "feature_repo", - Registry: getRegistryPath(), - Provider: "local", - OnlineStore: map[string]interface{}{ - "type": "redis", + tests := []struct { + name string + config *registry.RepoConfig + expectOnlineStoreType interface{} + errMessage string + }{ + { + name: "valid config", + config: ®istry.RepoConfig{ + Project: "feature_repo", + Registry: map[string]interface{}{ + "path": featureRepoRegistryFile, + }, + Provider: "local", + OnlineStore: map[string]interface{}{ + "type": "redis", + }, + }, + expectOnlineStoreType: &onlinestore.RedisOnlineStore{}, + }, + { + name: "valid config with transformation service endpoint", + config: ®istry.RepoConfig{ + Project: "feature_repo", + Registry: map[string]interface{}{ + "path": featureRepoRegistryFile, + }, + Provider: "local", + OnlineStore: map[string]interface{}{ + "type": "redis", + }, + FeatureServer: map[string]interface{}{ + "transformation_service_endpoint": "localhost:50051", + }, + }, + expectOnlineStoreType: &onlinestore.RedisOnlineStore{}, + }, + { + name: "invalid online store config", + config: ®istry.RepoConfig{ + Project: "feature_repo", + Registry: map[string]interface{}{ + "path": featureRepoRegistryFile, + }, + Provider: "local", + OnlineStore: map[string]interface{}{ + "type": "invalid_store", + }, + }, + errMessage: "invalid_store online store type is currently not supported", }, } - fs, err := NewFeatureStore(&config, nil) - assert.Nil(t, err) - assert.IsType(t, &onlinestore.RedisOnlineStore{}, fs.onlineStore) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := NewFeatureStore(test.config, nil) + if test.errMessage != "" { + assert.Nil(t, got) + require.Error(t, err) + assert.ErrorContains(t, err, test.errMessage) + + } else { + require.NoError(t, err) + assert.NotNil(t, got) + assert.IsType(t, test.expectOnlineStoreType, got.onlineStore) + } + }) + } + +} + +type MockRedis struct { + mock.Mock +} + +func (m *MockRedis) Destruct() {} +func (m *MockRedis) OnlineRead(ctx context.Context, entityKeys []*types.EntityKey, featureViewNames []string, featureNames []string) ([][]onlinestore.FeatureData, error) { + args := m.Called(ctx, entityKeys, featureViewNames, featureNames) + var fd [][]onlinestore.FeatureData + if args.Get(0) != nil { + fd = args.Get(0).([][]onlinestore.FeatureData) + } + return fd, args.Error(1) +} + +func TestGetOnlineFeatures(t *testing.T) { + tests := []struct { + name string + config *registry.RepoConfig + fn func(*testing.T, *FeatureStore) + }{ + { + name: "redis with simple features", + config: ®istry.RepoConfig{ + Project: "feature_repo", + Registry: map[string]interface{}{ + "path": featureRepoRegistryFile, + }, + Provider: "local", + OnlineStore: map[string]interface{}{ + "type": "redis", + "connection_string": "localhost:6379", + }, + }, + fn: testRedisSimpleFeatures, + }, + { + name: "redis with On-demand feature views, no transformation service endpoint", + config: ®istry.RepoConfig{ + Project: "feature_repo", + Registry: map[string]interface{}{ + "path": featureRepoRegistryFile, + }, + Provider: "local", + OnlineStore: map[string]interface{}{ + "type": "redis", + "connection_string": "localhost:6379", + }, + }, + fn: testRedisODFVNoTransformationService, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + fs, err := NewFeatureStore(test.config, nil) + require.Nil(t, err) + fs.onlineStore = new(MockRedis) + test.fn(t, fs) + }) + + } } -func TestGetOnlineFeaturesRedis(t *testing.T) { - t.Skip("@todo(achals): feature_repo isn't checked in yet") - config := registry.RepoConfig{ - Project: "feature_repo", - Registry: getRegistryPath(), - Provider: "local", - OnlineStore: map[string]interface{}{ - "type": "redis", - "connection_string": "localhost:6379", +func testRedisSimpleFeatures(t *testing.T, fs *FeatureStore) { + + featureNames := []string{"driver_hourly_stats:conv_rate", + "driver_hourly_stats:acc_rate", + "driver_hourly_stats:avg_daily_trips", + } + entities := map[string]*types.RepeatedValue{"driver_id": {Val: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 1001}}, + {Val: &types.Value_Int64Val{Int64Val: 1002}}, + }}} + + results := [][]onlinestore.FeatureData{ + { + { + Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_hourly_stats", FeatureName: "conv_rate"}, + Value: types.Value{Val: &types.Value_FloatVal{FloatVal: 12.0}}, + }, + { + Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_hourly_stats", FeatureName: "acc_rate"}, + Value: types.Value{Val: &types.Value_FloatVal{FloatVal: 1.0}}, + }, + { + Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_hourly_stats", FeatureName: "avg_daily_trips"}, + Value: types.Value{Val: &types.Value_Int64Val{Int64Val: 100}}, + }, + }, + { + + { + Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_hourly_stats", FeatureName: "conv_rate"}, + Value: types.Value{Val: &types.Value_FloatVal{FloatVal: 24.0}}, + }, + { + Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_hourly_stats", FeatureName: "acc_rate"}, + Value: types.Value{Val: &types.Value_FloatVal{FloatVal: 2.0}}, + }, + { + Reference: serving.FeatureReferenceV2{FeatureViewName: "driver_hourly_stats", FeatureName: "avg_daily_trips"}, + Value: types.Value{Val: &types.Value_Int64Val{Int64Val: 130}}, + }, }, } + ctx := context.Background() + mr := fs.onlineStore.(*MockRedis) + mr.On("OnlineRead", ctx, mock.Anything, mock.Anything, mock.Anything).Return(results, nil) + response, err := fs.GetOnlineFeatures(ctx, featureNames, nil, entities, map[string]*types.RepeatedValue{}, true) + require.Nil(t, err) + assert.Len(t, response, 4) // 3 Features + 1 entity = 4 columns (feature vectors) in response +} +func testRedisODFVNoTransformationService(t *testing.T, fs *FeatureStore) { featureNames := []string{"driver_hourly_stats:conv_rate", "driver_hourly_stats:acc_rate", "driver_hourly_stats:avg_daily_trips", + "transformed_conv_rate:conv_rate_plus_val1", } entities := map[string]*types.RepeatedValue{"driver_id": {Val: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 1001}}, {Val: &types.Value_Int64Val{Int64Val: 1002}}, {Val: &types.Value_Int64Val{Int64Val: 1003}}}}, } - fs, err := NewFeatureStore(&config, nil) - assert.Nil(t, err) ctx := context.Background() - response, err := fs.GetOnlineFeatures( - ctx, featureNames, nil, entities, map[string]*types.RepeatedValue{}, true) - assert.Nil(t, err) - assert.Len(t, response, 4) // 3 Features + 1 entity = 4 columns (feature vectors) in response + mr := fs.onlineStore.(*MockRedis) + mr.On("OnlineRead", ctx, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + response, err := fs.GetOnlineFeatures(ctx, featureNames, nil, entities, map[string]*types.RepeatedValue{}, true) + assert.Nil(t, response) + assert.ErrorAs(t, err, &FeastTransformationServiceNotConfigured{}) + } diff --git a/go/internal/feast/onlineserving/serving.go b/go/internal/feast/onlineserving/serving.go index dc7124fc8b8..2ae733b62bb 100644 --- a/go/internal/feast/onlineserving/serving.go +++ b/go/internal/feast/onlineserving/serving.go @@ -7,9 +7,9 @@ import ( "sort" "strings" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/memory" - "github.com/golang/protobuf/proto" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/memory" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" diff --git a/go/internal/feast/onlinestore/onlinestore.go b/go/internal/feast/onlinestore/onlinestore.go index 88cd3dbd9b5..2f30e16d674 100644 --- a/go/internal/feast/onlinestore/onlinestore.go +++ b/go/internal/feast/onlinestore/onlinestore.go @@ -5,11 +5,9 @@ import ( "fmt" "github.com/feast-dev/feast/go/internal/feast/registry" - - "github.com/golang/protobuf/ptypes/timestamp" - "github.com/feast-dev/feast/go/protos/feast/serving" "github.com/feast-dev/feast/go/protos/feast/types" + "github.com/golang/protobuf/ptypes/timestamp" ) type FeatureData struct { diff --git a/go/internal/feast/onlinestore/redisonlinestore.go b/go/internal/feast/onlinestore/redisonlinestore.go index 8fb85085d43..df47deceecf 100644 --- a/go/internal/feast/onlinestore/redisonlinestore.go +++ b/go/internal/feast/onlinestore/redisonlinestore.go @@ -6,18 +6,23 @@ import ( "encoding/binary" "errors" "fmt" - "github.com/feast-dev/feast/go/internal/feast/registry" + //"os" "sort" "strconv" "strings" - "github.com/go-redis/redis/v8" - "github.com/golang/protobuf/proto" + "github.com/feast-dev/feast/go/internal/feast/registry" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "github.com/redis/go-redis/v9" "github.com/spaolacci/murmur3" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/feast-dev/feast/go/protos/feast/serving" "github.com/feast-dev/feast/go/protos/feast/types" + "github.com/rs/zerolog/log" + //redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/redis/go-redis.v9" ) type redisType int @@ -39,6 +44,9 @@ type RedisOnlineStore struct { // Redis client connector client *redis.Client + // Redis cluster client connector + clusterClient *redis.ClusterClient + config *registry.RepoConfig } @@ -53,11 +61,12 @@ func NewRedisOnlineStore(project string, config *registry.RepoConfig, onlineStor var tlsConfig *tls.Config var db int // Default to 0 - // Parse redis_type and write it into conf.t - t, err := getRedisType(onlineStoreConfig) + // Parse redis_type and write it into conf.redisStoreType + redisStoreType, err := getRedisType(onlineStoreConfig) if err != nil { return nil, err } + store.t = redisStoreType // Parse connection_string and write it into conf.address, conf.password, and conf.ssl redisConnJson, ok := onlineStoreConfig["connection_string"] @@ -66,7 +75,7 @@ func NewRedisOnlineStore(project string, config *registry.RepoConfig, onlineStor redisConnJson = "localhost:6379" } if redisConnStr, ok := redisConnJson.(string); !ok { - return nil, errors.New(fmt.Sprintf("failed to convert connection_string to string: %+v", redisConnJson)) + return nil, fmt.Errorf("failed to convert connection_string to string: %+v", redisConnJson) } else { parts := strings.Split(redisConnStr, ",") for _, part := range parts { @@ -89,23 +98,42 @@ func NewRedisOnlineStore(project string, config *registry.RepoConfig, onlineStor return nil, err } } else { - return nil, errors.New(fmt.Sprintf("unrecognized option in connection_string: %s. Must be one of 'password', 'ssl'", kv[0])) + return nil, fmt.Errorf("unrecognized option in connection_string: %s. Must be one of 'password', 'ssl'", kv[0]) } } else { - return nil, errors.New(fmt.Sprintf("unable to parse a part of connection_string: %s. Must contain either ':' (addresses) or '=' (options", part)) + return nil, fmt.Errorf("unable to parse a part of connection_string: %s. Must contain either ':' (addresses) or '=' (options", part) } } } - if t == redisNode { + // Metrics are not showing up when the service name is set to DD_SERVICE + //redisTraceServiceName := os.Getenv("DD_SERVICE") + "-redis" + //if redisTraceServiceName == "" { + // redisTraceServiceName = "redis.client" // default service name if DD_SERVICE is not set + //} + + if redisStoreType == redisNode { + log.Info().Msgf("Using Redis: %s", address[0]) store.client = redis.NewClient(&redis.Options{ Addr: address[0], - Password: password, // No password set + Password: password, DB: db, TLSConfig: tlsConfig, }) - } else { - return nil, errors.New("only single node Redis is supported at this time") + //if strings.ToLower(os.Getenv("ENABLE_DATADOG_REDIS_TRACING")) == "true" { + // redistrace.WrapClient(store.client, redistrace.WithServiceName(redisTraceServiceName)) + //} + } else if redisStoreType == redisCluster { + log.Info().Msgf("Using Redis Cluster: %s", address) + store.clusterClient = redis.NewClusterClient(&redis.ClusterOptions{ + Addrs: address, + Password: password, + TLSConfig: tlsConfig, + ReadOnly: true, + }) + //if strings.ToLower(os.Getenv("ENABLE_DATADOG_REDIS_TRACING")) == "true" { + // redistrace.WrapClient(store.clusterClient, redistrace.WithServiceName(redisTraceServiceName)) + //} } return &store, nil @@ -119,24 +147,23 @@ func getRedisType(onlineStoreConfig map[string]interface{}) (redisType, error) { // Default to "redis" redisTypeJson = "redis" } else if redisTypeStr, ok := redisTypeJson.(string); !ok { - return -1, errors.New(fmt.Sprintf("failed to convert redis_type to string: %+v", redisTypeJson)) + return -1, fmt.Errorf("failed to convert redis_type to string: %+v", redisTypeJson) } else { if redisTypeStr == "redis" { t = redisNode } else if redisTypeStr == "redis_cluster" { t = redisCluster } else { - return -1, errors.New(fmt.Sprintf("failed to convert redis_type to enum: %s. Must be one of 'redis', 'redis_cluster'", redisTypeStr)) + return -1, fmt.Errorf("failed to convert redis_type to enum: %s. Must be one of 'redis', 'redis_cluster'", redisTypeStr) } } return t, nil } -func (r *RedisOnlineStore) OnlineRead(ctx context.Context, entityKeys []*types.EntityKey, featureViewNames []string, featureNames []string) ([][]FeatureData, error) { - featureCount := len(featureNames) - index := featureCount +func (r *RedisOnlineStore) buildFeatureViewIndices(featureViewNames []string, featureNames []string) (map[string]int, map[int]string, int) { featureViewIndices := make(map[string]int) indicesFeatureView := make(map[int]string) + index := len(featureNames) for _, featureViewName := range featureViewNames { if _, ok := featureViewIndices[featureViewName]; !ok { featureViewIndices[featureViewName] = index @@ -144,6 +171,11 @@ func (r *RedisOnlineStore) OnlineRead(ctx context.Context, entityKeys []*types.E index += 1 } } + return featureViewIndices, indicesFeatureView, index +} + +func (r *RedisOnlineStore) buildRedisHashSetKeys(featureViewNames []string, featureNames []string, indicesFeatureView map[int]string, index int) ([]string, []string) { + featureCount := len(featureNames) var hsetKeys = make([]string, index) h := murmur3.New32() intBuffer := h.Sum32() @@ -162,36 +194,59 @@ func (r *RedisOnlineStore) OnlineRead(ctx context.Context, entityKeys []*types.E hsetKeys[i] = tsKey featureNames = append(featureNames, tsKey) } + return hsetKeys, featureNames +} +func (r *RedisOnlineStore) buildRedisKeys(entityKeys []*types.EntityKey) ([]*[]byte, map[string]int, error) { redisKeys := make([]*[]byte, len(entityKeys)) redisKeyToEntityIndex := make(map[string]int) for i := 0; i < len(entityKeys); i++ { - var key, err = buildRedisKey(r.project, entityKeys[i], r.config.EntityKeySerializationVersion) if err != nil { - return nil, err + return nil, nil, err } redisKeys[i] = key redisKeyToEntityIndex[string(*key)] = i } + return redisKeys, redisKeyToEntityIndex, nil +} - // Retrieve features from Redis - // TODO: Move context object out - - results := make([][]FeatureData, len(entityKeys)) - pipe := r.client.Pipeline() - commands := map[string]*redis.SliceCmd{} - - for _, redisKey := range redisKeys { - keyString := string(*redisKey) - commands[keyString] = pipe.HMGet(ctx, keyString, hsetKeys...) - } +func (r *RedisOnlineStore) OnlineRead(ctx context.Context, entityKeys []*types.EntityKey, featureViewNames []string, featureNames []string) ([][]FeatureData, error) { + //span, _ := tracer.StartSpanFromContext(ctx, "redis.OnlineRead") + //defer span.Finish() - _, err := pipe.Exec(ctx) + featureCount := len(featureNames) + featureViewIndices, indicesFeatureView, index := r.buildFeatureViewIndices(featureViewNames, featureNames) + hsetKeys, featureNamesWithTimeStamps := r.buildRedisHashSetKeys(featureViewNames, featureNames, indicesFeatureView, index) + redisKeys, redisKeyToEntityIndex, err := r.buildRedisKeys(entityKeys) if err != nil { return nil, err } + results := make([][]FeatureData, len(entityKeys)) + commands := map[string]*redis.SliceCmd{} + + if r.t == redisNode { + pipe := r.client.Pipeline() + for _, redisKey := range redisKeys { + keyString := string(*redisKey) + commands[keyString] = pipe.HMGet(ctx, keyString, hsetKeys...) + } + _, err = pipe.Exec(ctx) + if err != nil { + return nil, err + } + } else if r.t == redisCluster { + pipe := r.clusterClient.Pipeline() + for _, redisKey := range redisKeys { + keyString := string(*redisKey) + commands[keyString] = pipe.HMGet(ctx, keyString, hsetKeys...) + } + _, err = pipe.Exec(ctx) + if err != nil { + return nil, err + } + } var entityIndex int var resContainsNonNil bool for redisKey, values := range commands { @@ -214,7 +269,7 @@ func (r *RedisOnlineStore) OnlineRead(ctx context.Context, entityKeys []*types.E if resString == nil { // TODO (Ly): Can there be nil result within each feature or they will all be returned as string proto of types.Value_NullVal proto? - featureName := featureNames[featureIndex] + featureName := featureNamesWithTimeStamps[featureIndex] featureViewName := featureViewNames[featureIndex] timeStampIndex := featureViewIndices[featureViewName] timeStampInterface := res[timeStampIndex] @@ -241,7 +296,7 @@ func (r *RedisOnlineStore) OnlineRead(ctx context.Context, entityKeys []*types.E if err := proto.Unmarshal([]byte(valueString), &value); err != nil { return nil, errors.New("error converting parsed redis Value to types.Value") } else { - featureName := featureNames[featureIndex] + featureName := featureNamesWithTimeStamps[featureIndex] featureViewName := featureViewNames[featureIndex] timeStampIndex := featureViewIndices[featureViewName] timeStampInterface := res[timeStampIndex] @@ -290,7 +345,7 @@ func serializeEntityKey(entityKey *types.EntityKey, entityKeySerializationVersio // Ensure that we have the right amount of join keys and entity values if len(entityKey.JoinKeys) != len(entityKey.EntityValues) { - return nil, errors.New(fmt.Sprintf("the amount of join key names and entity values don't match: %s vs %s", entityKey.JoinKeys, entityKey.EntityValues)) + return nil, fmt.Errorf("the amount of join key names and entity values don't match: %s vs %s", entityKey.JoinKeys, entityKey.EntityValues) } // Make sure that join keys are sorted so that we have consistent key building diff --git a/go/internal/feast/onlinestore/redisonlinestore_test.go b/go/internal/feast/onlinestore/redisonlinestore_test.go index ad9ef1e1e44..34adee191e8 100644 --- a/go/internal/feast/onlinestore/redisonlinestore_test.go +++ b/go/internal/feast/onlinestore/redisonlinestore_test.go @@ -1,9 +1,11 @@ package onlinestore import ( - "github.com/feast-dev/feast/go/internal/feast/registry" "testing" + "github.com/feast-dev/feast/go/internal/feast/registry" + "github.com/feast-dev/feast/go/protos/feast/types" + "github.com/stretchr/testify/assert" ) @@ -68,3 +70,125 @@ func TestNewRedisOnlineStoreWithSsl(t *testing.T) { assert.Equal(t, opts.Addr, "redis://localhost:6379") assert.NotNil(t, opts.TLSConfig) } + +func TestBuildFeatureViewIndices(t *testing.T) { + r := &RedisOnlineStore{} + + t.Run("test with empty featureViewNames and featureNames", func(t *testing.T) { + featureViewIndices, indicesFeatureView, index := r.buildFeatureViewIndices([]string{}, []string{}) + assert.Equal(t, 0, len(featureViewIndices)) + assert.Equal(t, 0, len(indicesFeatureView)) + assert.Equal(t, 0, index) + }) + + t.Run("test with non-empty featureNames and empty featureViewNames", func(t *testing.T) { + featureViewIndices, indicesFeatureView, index := r.buildFeatureViewIndices([]string{}, []string{"feature1", "feature2"}) + assert.Equal(t, 0, len(featureViewIndices)) + assert.Equal(t, 0, len(indicesFeatureView)) + assert.Equal(t, 2, index) + }) + + t.Run("test with non-empty featureViewNames and featureNames", func(t *testing.T) { + featureViewIndices, indicesFeatureView, index := r.buildFeatureViewIndices([]string{"view1", "view2"}, []string{"feature1", "feature2"}) + assert.Equal(t, 2, len(featureViewIndices)) + assert.Equal(t, 2, len(indicesFeatureView)) + assert.Equal(t, 4, index) + assert.Equal(t, "view1", indicesFeatureView[2]) + assert.Equal(t, "view2", indicesFeatureView[3]) + }) + + t.Run("test with duplicate featureViewNames", func(t *testing.T) { + featureViewIndices, indicesFeatureView, index := r.buildFeatureViewIndices([]string{"view1", "view1"}, []string{"feature1", "feature2"}) + assert.Equal(t, 1, len(featureViewIndices)) + assert.Equal(t, 1, len(indicesFeatureView)) + assert.Equal(t, 3, index) + assert.Equal(t, "view1", indicesFeatureView[2]) + }) +} + +func TestBuildHsetKeys(t *testing.T) { + r := &RedisOnlineStore{} + + t.Run("test with empty featureViewNames and featureNames", func(t *testing.T) { + hsetKeys, featureNames := r.buildRedisHashSetKeys([]string{}, []string{}, map[int]string{}, 0) + assert.Equal(t, 0, len(hsetKeys)) + assert.Equal(t, 0, len(featureNames)) + }) + + t.Run("test with non-empty featureViewNames and featureNames", func(t *testing.T) { + hsetKeys, featureNames := r.buildRedisHashSetKeys([]string{"view1", "view2"}, []string{"feature1", "feature2"}, map[int]string{2: "view1", 3: "view2"}, 4) + assert.Equal(t, 4, len(hsetKeys)) + assert.Equal(t, 4, len(featureNames)) + assert.Equal(t, "_ts:view1", hsetKeys[2]) + assert.Equal(t, "_ts:view2", hsetKeys[3]) + assert.Contains(t, featureNames, "_ts:view1") + assert.Contains(t, featureNames, "_ts:view2") + }) + + t.Run("test with more featureViewNames than featureNames", func(t *testing.T) { + hsetKeys, featureNames := r.buildRedisHashSetKeys([]string{"view1", "view2", "view3"}, []string{"feature1", "feature2", "feature3"}, map[int]string{3: "view1", 4: "view2", 5: "view3"}, 6) + assert.Equal(t, 6, len(hsetKeys)) + assert.Equal(t, 6, len(featureNames)) + assert.Equal(t, "_ts:view1", hsetKeys[3]) + assert.Equal(t, "_ts:view2", hsetKeys[4]) + assert.Equal(t, "_ts:view3", hsetKeys[5]) + assert.Contains(t, featureNames, "_ts:view1") + assert.Contains(t, featureNames, "_ts:view2") + assert.Contains(t, featureNames, "_ts:view3") + }) +} + +func TestBuildRedisKeys(t *testing.T) { + r := &RedisOnlineStore{ + project: "test_project", + config: ®istry.RepoConfig{ + EntityKeySerializationVersion: 2, + }, + } + + entity_key1 := types.EntityKey{ + JoinKeys: []string{"driver_id"}, + EntityValues: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 1005}}}, + } + + entity_key2 := types.EntityKey{ + JoinKeys: []string{"driver_id"}, + EntityValues: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 1001}}}, + } + + error_entity_key1 := types.EntityKey{ + JoinKeys: []string{"driver_id", "vehicle_id"}, + EntityValues: []*types.Value{{Val: &types.Value_Int64Val{Int64Val: 1005}}}, + } + + t.Run("test with empty entityKeys", func(t *testing.T) { + redisKeys, redisKeyToEntityIndex, err := r.buildRedisKeys([]*types.EntityKey{}) + assert.Nil(t, err) + assert.Equal(t, 0, len(redisKeys)) + assert.Equal(t, 0, len(redisKeyToEntityIndex)) + }) + + t.Run("test with single entityKey", func(t *testing.T) { + entityKeys := []*types.EntityKey{&entity_key1} + redisKeys, redisKeyToEntityIndex, err := r.buildRedisKeys(entityKeys) + assert.Nil(t, err) + assert.Equal(t, 1, len(redisKeys)) + assert.Equal(t, 1, len(redisKeyToEntityIndex)) + }) + + t.Run("test with multiple entityKeys", func(t *testing.T) { + entityKeys := []*types.EntityKey{ + &entity_key1, &entity_key2, + } + redisKeys, redisKeyToEntityIndex, err := r.buildRedisKeys(entityKeys) + assert.Nil(t, err) + assert.Equal(t, 2, len(redisKeys)) + assert.Equal(t, 2, len(redisKeyToEntityIndex)) + }) + + t.Run("test with error in buildRedisKey", func(t *testing.T) { + entityKeys := []*types.EntityKey{&error_entity_key1} + _, _, err := r.buildRedisKeys(entityKeys) + assert.NotNil(t, err) + }) +} diff --git a/go/internal/feast/onlinestore/sqliteonlinestore_test.go b/go/internal/feast/onlinestore/sqliteonlinestore_test.go index 9a56f4df1a4..929af6d16b4 100644 --- a/go/internal/feast/onlinestore/sqliteonlinestore_test.go +++ b/go/internal/feast/onlinestore/sqliteonlinestore_test.go @@ -21,9 +21,10 @@ func TestSqliteAndFeatureRepoSetup(t *testing.T) { err := test.SetupCleanFeatureRepo(dir) assert.Nil(t, err) config, err := registry.NewRepoConfigFromFile(feature_repo_path) + registryConfig, err := config.GetRegistryConfig() assert.Nil(t, err) assert.Equal(t, "my_project", config.Project) - assert.Equal(t, "data/registry.db", config.GetRegistryConfig().Path) + assert.Equal(t, "data/registry.db", registryConfig.Path) assert.Equal(t, "local", config.Provider) assert.Equal(t, map[string]interface{}{ "path": "data/online_store.db", diff --git a/go/internal/feast/registry/local.go b/go/internal/feast/registry/local.go index 124fcba3ed9..e5343cd75cd 100644 --- a/go/internal/feast/registry/local.go +++ b/go/internal/feast/registry/local.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/golang/protobuf/proto" "github.com/google/uuid" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/feast-dev/feast/go/protos/feast/core" diff --git a/go/internal/feast/registry/registry.go b/go/internal/feast/registry/registry.go index 9d0684d0230..a383dc42c07 100644 --- a/go/internal/feast/registry/registry.go +++ b/go/internal/feast/registry/registry.go @@ -8,6 +8,7 @@ import ( "time" "github.com/feast-dev/feast/go/internal/feast/model" + "github.com/rs/zerolog/log" "github.com/feast-dev/feast/go/protos/feast/core" ) @@ -26,6 +27,7 @@ var REGISTRY_STORE_CLASS_FOR_SCHEME map[string]string = map[string]string{ */ type Registry struct { + project string registryStore RegistryStore cachedFeatureServices map[string]map[string]*core.FeatureService cachedEntities map[string]map[string]*core.Entity @@ -35,24 +37,25 @@ type Registry struct { cachedRegistry *core.Registry cachedRegistryProtoLastUpdated time.Time cachedRegistryProtoTtl time.Duration - mu sync.Mutex + mu sync.RWMutex } -func NewRegistry(registryConfig *RegistryConfig, repoPath string) (*Registry, error) { +func NewRegistry(registryConfig *RegistryConfig, repoPath string, project string) (*Registry, error) { registryStoreType := registryConfig.RegistryStoreType registryPath := registryConfig.Path r := &Registry{ - cachedRegistryProtoTtl: time.Duration(registryConfig.CacheTtlSeconds), + project: project, + cachedRegistryProtoTtl: time.Duration(registryConfig.CacheTtlSeconds) * time.Second, } if len(registryStoreType) == 0 { - registryStore, err := getRegistryStoreFromScheme(registryPath, registryConfig, repoPath) + registryStore, err := getRegistryStoreFromScheme(registryPath, registryConfig, repoPath, project) if err != nil { return nil, err } r.registryStore = registryStore } else { - registryStore, err := getRegistryStoreFromType(registryStoreType, registryConfig, repoPath) + registryStore, err := getRegistryStoreFromType(registryStoreType, registryConfig, repoPath, project) if err != nil { return nil, err } @@ -62,26 +65,30 @@ func NewRegistry(registryConfig *RegistryConfig, repoPath string) (*Registry, er return r, nil } -func (r *Registry) InitializeRegistry() { +func (r *Registry) InitializeRegistry() error { _, err := r.getRegistryProto() if err != nil { + if _, ok := r.registryStore.(*FileRegistryStore); ok { + log.Error().Err(err).Msg("Registry Initialization Failed") + return err + } registryProto := &core.Registry{RegistrySchemaVersion: REGISTRY_SCHEMA_VERSION} r.registryStore.UpdateRegistryProto(registryProto) - go r.refreshRegistryOnInterval() } + go r.RefreshRegistryOnInterval() + return nil } -func (r *Registry) refreshRegistryOnInterval() { +func (r *Registry) RefreshRegistryOnInterval() { ticker := time.NewTicker(r.cachedRegistryProtoTtl) for ; true; <-ticker.C { err := r.refresh() if err != nil { - return + log.Error().Stack().Err(err).Msg("Registry refresh Failed") } } } -// TODO: Add a goroutine and automatically refresh every cachedRegistryProtoTtl func (r *Registry) refresh() error { _, err := r.getRegistryProto() return err @@ -94,7 +101,7 @@ func (r *Registry) getRegistryProto() (*core.Registry, error) { } registryProto, err := r.registryStore.GetRegistryProto() if err != nil { - return registryProto, err + return nil, err } r.load(registryProto) return registryProto, nil @@ -120,50 +127,50 @@ func (r *Registry) load(registry *core.Registry) { func (r *Registry) loadEntities(registry *core.Registry) { entities := registry.Entities for _, entity := range entities { - if _, ok := r.cachedEntities[entity.Spec.Project]; !ok { - r.cachedEntities[entity.Spec.Project] = make(map[string]*core.Entity) + if _, ok := r.cachedEntities[r.project]; !ok { + r.cachedEntities[r.project] = make(map[string]*core.Entity) } - r.cachedEntities[entity.Spec.Project][entity.Spec.Name] = entity + r.cachedEntities[r.project][entity.Spec.Name] = entity } } func (r *Registry) loadFeatureServices(registry *core.Registry) { featureServices := registry.FeatureServices for _, featureService := range featureServices { - if _, ok := r.cachedFeatureServices[featureService.Spec.Project]; !ok { - r.cachedFeatureServices[featureService.Spec.Project] = make(map[string]*core.FeatureService) + if _, ok := r.cachedFeatureServices[r.project]; !ok { + r.cachedFeatureServices[r.project] = make(map[string]*core.FeatureService) } - r.cachedFeatureServices[featureService.Spec.Project][featureService.Spec.Name] = featureService + r.cachedFeatureServices[r.project][featureService.Spec.Name] = featureService } } func (r *Registry) loadFeatureViews(registry *core.Registry) { featureViews := registry.FeatureViews for _, featureView := range featureViews { - if _, ok := r.cachedFeatureViews[featureView.Spec.Project]; !ok { - r.cachedFeatureViews[featureView.Spec.Project] = make(map[string]*core.FeatureView) + if _, ok := r.cachedFeatureViews[r.project]; !ok { + r.cachedFeatureViews[r.project] = make(map[string]*core.FeatureView) } - r.cachedFeatureViews[featureView.Spec.Project][featureView.Spec.Name] = featureView + r.cachedFeatureViews[r.project][featureView.Spec.Name] = featureView } } func (r *Registry) loadStreamFeatureViews(registry *core.Registry) { streamFeatureViews := registry.StreamFeatureViews for _, streamFeatureView := range streamFeatureViews { - if _, ok := r.cachedStreamFeatureViews[streamFeatureView.Spec.Project]; !ok { - r.cachedStreamFeatureViews[streamFeatureView.Spec.Project] = make(map[string]*core.StreamFeatureView) + if _, ok := r.cachedStreamFeatureViews[r.project]; !ok { + r.cachedStreamFeatureViews[r.project] = make(map[string]*core.StreamFeatureView) } - r.cachedStreamFeatureViews[streamFeatureView.Spec.Project][streamFeatureView.Spec.Name] = streamFeatureView + r.cachedStreamFeatureViews[r.project][streamFeatureView.Spec.Name] = streamFeatureView } } func (r *Registry) loadOnDemandFeatureViews(registry *core.Registry) { onDemandFeatureViews := registry.OnDemandFeatureViews for _, onDemandFeatureView := range onDemandFeatureViews { - if _, ok := r.cachedOnDemandFeatureViews[onDemandFeatureView.Spec.Project]; !ok { - r.cachedOnDemandFeatureViews[onDemandFeatureView.Spec.Project] = make(map[string]*core.OnDemandFeatureView) + if _, ok := r.cachedOnDemandFeatureViews[r.project]; !ok { + r.cachedOnDemandFeatureViews[r.project] = make(map[string]*core.OnDemandFeatureView) } - r.cachedOnDemandFeatureViews[onDemandFeatureView.Spec.Project][onDemandFeatureView.Spec.Name] = onDemandFeatureView + r.cachedOnDemandFeatureViews[r.project][onDemandFeatureView.Spec.Name] = onDemandFeatureView } } @@ -173,6 +180,8 @@ func (r *Registry) loadOnDemandFeatureViews(registry *core.Registry) { */ func (r *Registry) ListEntities(project string) ([]*model.Entity, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedEntities, ok := r.cachedEntities[project]; !ok { return []*model.Entity{}, nil } else { @@ -192,6 +201,8 @@ func (r *Registry) ListEntities(project string) ([]*model.Entity, error) { */ func (r *Registry) ListFeatureViews(project string) ([]*model.FeatureView, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedFeatureViews, ok := r.cachedFeatureViews[project]; !ok { return []*model.FeatureView{}, nil } else { @@ -211,6 +222,8 @@ func (r *Registry) ListFeatureViews(project string) ([]*model.FeatureView, error */ func (r *Registry) ListStreamFeatureViews(project string) ([]*model.FeatureView, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedStreamFeatureViews, ok := r.cachedStreamFeatureViews[project]; !ok { return []*model.FeatureView{}, nil } else { @@ -230,6 +243,8 @@ func (r *Registry) ListStreamFeatureViews(project string) ([]*model.FeatureView, */ func (r *Registry) ListFeatureServices(project string) ([]*model.FeatureService, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedFeatureServices, ok := r.cachedFeatureServices[project]; !ok { return []*model.FeatureService{}, nil } else { @@ -249,6 +264,8 @@ func (r *Registry) ListFeatureServices(project string) ([]*model.FeatureService, */ func (r *Registry) ListOnDemandFeatureViews(project string) ([]*model.OnDemandFeatureView, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedOnDemandFeatureViews, ok := r.cachedOnDemandFeatureViews[project]; !ok { return []*model.OnDemandFeatureView{}, nil } else { @@ -263,6 +280,8 @@ func (r *Registry) ListOnDemandFeatureViews(project string) ([]*model.OnDemandFe } func (r *Registry) GetEntity(project, entityName string) (*model.Entity, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedEntities, ok := r.cachedEntities[project]; !ok { return nil, fmt.Errorf("no cached entities found for project %s", project) } else { @@ -275,6 +294,8 @@ func (r *Registry) GetEntity(project, entityName string) (*model.Entity, error) } func (r *Registry) GetFeatureView(project, featureViewName string) (*model.FeatureView, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedFeatureViews, ok := r.cachedFeatureViews[project]; !ok { return nil, fmt.Errorf("no cached feature views found for project %s", project) } else { @@ -287,6 +308,8 @@ func (r *Registry) GetFeatureView(project, featureViewName string) (*model.Featu } func (r *Registry) GetStreamFeatureView(project, streamFeatureViewName string) (*model.FeatureView, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedStreamFeatureViews, ok := r.cachedStreamFeatureViews[project]; !ok { return nil, fmt.Errorf("no cached stream feature views found for project %s", project) } else { @@ -299,6 +322,8 @@ func (r *Registry) GetStreamFeatureView(project, streamFeatureViewName string) ( } func (r *Registry) GetFeatureService(project, featureServiceName string) (*model.FeatureService, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedFeatureServices, ok := r.cachedFeatureServices[project]; !ok { return nil, fmt.Errorf("no cached feature services found for project %s", project) } else { @@ -311,6 +336,8 @@ func (r *Registry) GetFeatureService(project, featureServiceName string) (*model } func (r *Registry) GetOnDemandFeatureView(project, onDemandFeatureViewName string) (*model.OnDemandFeatureView, error) { + r.mu.RLock() + defer r.mu.RUnlock() if cachedOnDemandFeatureViews, ok := r.cachedOnDemandFeatureViews[project]; !ok { return nil, fmt.Errorf("no cached on demand feature views found for project %s", project) } else { @@ -322,18 +349,18 @@ func (r *Registry) GetOnDemandFeatureView(project, onDemandFeatureViewName strin } } -func getRegistryStoreFromScheme(registryPath string, registryConfig *RegistryConfig, repoPath string) (RegistryStore, error) { +func getRegistryStoreFromScheme(registryPath string, registryConfig *RegistryConfig, repoPath string, project string) (RegistryStore, error) { uri, err := url.Parse(registryPath) if err != nil { return nil, err } if registryStoreType, ok := REGISTRY_STORE_CLASS_FOR_SCHEME[uri.Scheme]; ok { - return getRegistryStoreFromType(registryStoreType, registryConfig, repoPath) + return getRegistryStoreFromType(registryStoreType, registryConfig, repoPath, project) } return nil, fmt.Errorf("registry path %s has unsupported scheme %s. Supported schemes are file, s3 and gs", registryPath, uri.Scheme) } -func getRegistryStoreFromType(registryStoreType string, registryConfig *RegistryConfig, repoPath string) (RegistryStore, error) { +func getRegistryStoreFromType(registryStoreType string, registryConfig *RegistryConfig, repoPath string, project string) (RegistryStore, error) { switch registryStoreType { case "FileRegistryStore": return NewFileRegistryStore(registryConfig, repoPath), nil diff --git a/go/internal/feast/registry/repoconfig.go b/go/internal/feast/registry/repoconfig.go index b034b632dc0..f70310f261c 100644 --- a/go/internal/feast/registry/repoconfig.go +++ b/go/internal/feast/registry/repoconfig.go @@ -2,14 +2,18 @@ package registry import ( "encoding/json" - "io/ioutil" + "fmt" + "os" "path/filepath" + "time" + "github.com/feast-dev/feast/go/internal/feast/server/logging" "github.com/ghodss/yaml" ) const ( - defaultCacheTtlSeconds = 600 + defaultCacheTtlSeconds = int64(600) + defaultClientID = "Unknown" ) type RepoConfig struct { @@ -37,6 +41,7 @@ type RepoConfig struct { type RegistryConfig struct { RegistryStoreType string `json:"registry_store_type"` Path string `json:"path"` + ClientId string `json:"client_id" default:"Unknown"` CacheTtlSeconds int64 `json:"cache_ttl_seconds" default:"600"` } @@ -57,7 +62,7 @@ func NewRepoConfigFromJSON(repoPath, configJSON string) (*RepoConfig, error) { // NewRepoConfigFromFile reads the `feature_store.yaml` file in the repo path and converts it // into a RepoConfig struct. func NewRepoConfigFromFile(repoPath string) (*RepoConfig, error) { - data, err := ioutil.ReadFile(filepath.Join(repoPath, "feature_store.yaml")) + data, err := os.ReadFile(filepath.Join(repoPath, "feature_store.yaml")) if err != nil { return nil, err } @@ -66,17 +71,47 @@ func NewRepoConfigFromFile(repoPath string) (*RepoConfig, error) { return nil, err } + repoConfigWithEnv := os.ExpandEnv(string(data)) + config := RepoConfig{} - if err = yaml.Unmarshal(data, &config); err != nil { + if err = yaml.Unmarshal([]byte(repoConfigWithEnv), &config); err != nil { return nil, err } config.RepoPath = repoPath return &config, nil } -func (r *RepoConfig) GetRegistryConfig() *RegistryConfig { +func (r *RepoConfig) GetLoggingOptions() (*logging.LoggingOptions, error) { + loggingOptions := logging.LoggingOptions{} + if loggingOptionsMap, ok := r.FeatureServer["feature_logging"].(map[string]interface{}); ok { + loggingOptions = logging.DefaultOptions + for k, v := range loggingOptionsMap { + switch k { + case "queue_capacity": + if value, ok := v.(int); ok { + loggingOptions.ChannelCapacity = value + } + case "emit_timeout_micro_secs": + if value, ok := v.(int); ok { + loggingOptions.EmitTimeout = time.Duration(value) * time.Microsecond + } + case "write_to_disk_interval_secs": + if value, ok := v.(int); ok { + loggingOptions.WriteInterval = time.Duration(value) * time.Second + } + case "flush_interval_secs": + if value, ok := v.(int); ok { + loggingOptions.FlushInterval = time.Duration(value) * time.Second + } + } + } + } + return &loggingOptions, nil +} + +func (r *RepoConfig) GetRegistryConfig() (*RegistryConfig, error) { if registryConfigMap, ok := r.Registry.(map[string]interface{}); ok { - registryConfig := RegistryConfig{CacheTtlSeconds: defaultCacheTtlSeconds} + registryConfig := RegistryConfig{CacheTtlSeconds: defaultCacheTtlSeconds, ClientId: defaultClientID} for k, v := range registryConfigMap { switch k { case "path": @@ -87,14 +122,28 @@ func (r *RepoConfig) GetRegistryConfig() *RegistryConfig { if value, ok := v.(string); ok { registryConfig.RegistryStoreType = value } + case "client_id": + if value, ok := v.(string); ok { + registryConfig.ClientId = value + } case "cache_ttl_seconds": - if value, ok := v.(int64); ok { + // cache_ttl_seconds defaulted to type float64. Ex: "cache_ttl_seconds": 60 in registryConfigMap + switch value := v.(type) { + case float64: + registryConfig.CacheTtlSeconds = int64(value) + case int: + registryConfig.CacheTtlSeconds = int64(value) + case int32: + registryConfig.CacheTtlSeconds = int64(value) + case int64: registryConfig.CacheTtlSeconds = value + default: + return nil, fmt.Errorf("unexpected type %T for CacheTtlSeconds", v) } } } - return ®istryConfig + return ®istryConfig, nil } else { - return &RegistryConfig{Path: r.Registry.(string), CacheTtlSeconds: defaultCacheTtlSeconds} + return &RegistryConfig{Path: r.Registry.(string), ClientId: defaultClientID, CacheTtlSeconds: defaultCacheTtlSeconds}, nil } } diff --git a/go/internal/feast/registry/repoconfig_test.go b/go/internal/feast/registry/repoconfig_test.go index 848977886c9..4d30bf7bca0 100644 --- a/go/internal/feast/registry/repoconfig_test.go +++ b/go/internal/feast/registry/repoconfig_test.go @@ -3,8 +3,11 @@ package registry import ( "os" "path/filepath" + "strings" "testing" + "time" + "github.com/feast-dev/feast/go/internal/feast/server/logging" "github.com/stretchr/testify/assert" ) @@ -26,10 +29,11 @@ online_store: err = os.WriteFile(filePath, data, 0666) assert.Nil(t, err) config, err := NewRepoConfigFromFile(dir) + registryConfig, err := config.GetRegistryConfig() assert.Nil(t, err) assert.Equal(t, "feature_repo", config.Project) assert.Equal(t, dir, config.RepoPath) - assert.Equal(t, "data/registry.db", config.GetRegistryConfig().Path) + assert.Equal(t, "data/registry.db", registryConfig.Path) assert.Equal(t, "local", config.Provider) assert.Equal(t, map[string]interface{}{ "type": "redis", @@ -40,6 +44,40 @@ online_store: assert.Empty(t, config.Flags) } +func TestNewRepoConfigWithEnvironmentVariables(t *testing.T) { + dir, err := os.MkdirTemp("", "feature_repo_*") + assert.Nil(t, err) + defer func() { + assert.Nil(t, os.RemoveAll(dir)) + }() + filePath := filepath.Join(dir, "feature_store.yaml") + data := []byte(` +project: feature_repo +registry: "data/registry.db" +provider: local +online_store: + type: redis + connection_string: ${REDIS_CONNECTION_STRING} +`) + err = os.WriteFile(filePath, data, 0666) + assert.Nil(t, err) + os.Setenv("REDIS_CONNECTION_STRING", "localhost:6380") + config, err := NewRepoConfigFromFile(dir) + registryConfig, err := config.GetRegistryConfig() + assert.Nil(t, err) + assert.Equal(t, "feature_repo", config.Project) + assert.Equal(t, dir, config.RepoPath) + assert.Equal(t, "data/registry.db", registryConfig.Path) + assert.Equal(t, "local", config.Provider) + assert.Equal(t, map[string]interface{}{ + "type": "redis", + "connection_string": "localhost:6380", + }, config.OnlineStore) + assert.Empty(t, config.OfflineStore) + assert.Empty(t, config.FeatureServer) + assert.Empty(t, config.Flags) +} + func TestNewRepoConfigRegistryMap(t *testing.T) { dir, err := os.MkdirTemp("", "feature_repo_*") assert.Nil(t, err) @@ -50,6 +88,7 @@ func TestNewRepoConfigRegistryMap(t *testing.T) { data := []byte(` registry: path: data/registry.db + client_id: "test_client_id" project: feature_repo provider: local online_store: @@ -59,10 +98,12 @@ online_store: err = os.WriteFile(filePath, data, 0666) assert.Nil(t, err) config, err := NewRepoConfigFromFile(dir) + registryConfig, err := config.GetRegistryConfig() assert.Nil(t, err) assert.Equal(t, "feature_repo", config.Project) assert.Equal(t, dir, config.RepoPath) - assert.Equal(t, "data/registry.db", config.GetRegistryConfig().Path) + assert.Equal(t, "data/registry.db", registryConfig.Path) + assert.Equal(t, "test_client_id", registryConfig.ClientId) assert.Equal(t, "local", config.Provider) assert.Equal(t, map[string]interface{}{ "type": "redis", @@ -83,6 +124,7 @@ func TestNewRepoConfigRegistryConfig(t *testing.T) { data := []byte(` registry: path: data/registry.db + client_id: "test_client_id" project: feature_repo provider: local online_store: @@ -92,7 +134,206 @@ online_store: err = os.WriteFile(filePath, data, 0666) assert.Nil(t, err) config, err := NewRepoConfigFromFile(dir) + registryConfig, err := config.GetRegistryConfig() assert.Nil(t, err) assert.Equal(t, dir, config.RepoPath) - assert.Equal(t, "data/registry.db", config.GetRegistryConfig().Path) + assert.Equal(t, "data/registry.db", registryConfig.Path) + assert.Equal(t, "test_client_id", registryConfig.ClientId) +} +func TestNewRepoConfigFromJSON(t *testing.T) { + // Create a temporary directory for the test + dir, err := os.MkdirTemp("", "feature_repo_*") + assert.Nil(t, err) + defer func() { + assert.Nil(t, os.RemoveAll(dir)) + }() + + // Define a JSON string for the test + registry_path := filepath.Join(dir, "data/registry.db") + + configJSON := `{ + "project": "feature_repo", + "registry": "$REGISTRY_PATH", + "provider": "local", + "online_store": { + "type": "redis", + "connection_string": "localhost:6379" + } + }` + + replacements := map[string]string{ + "$REGISTRY_PATH": registry_path, + } + + // Replace the variables in the JSON string + for variable, replacement := range replacements { + configJSON = strings.ReplaceAll(configJSON, variable, replacement) + } + + // Call the function under test + config, err := NewRepoConfigFromJSON(dir, configJSON) + registryConfig, err := config.GetRegistryConfig() + // Assert that there was no error and that the config was correctly parsed + assert.Nil(t, err) + assert.Equal(t, "feature_repo", config.Project) + assert.Equal(t, filepath.Join(dir, "data/registry.db"), registryConfig.Path) + assert.Equal(t, "local", config.Provider) + assert.Equal(t, map[string]interface{}{ + "type": "redis", + "connection_string": "localhost:6379", + }, config.OnlineStore) + assert.Empty(t, config.OfflineStore) + assert.Empty(t, config.FeatureServer) + assert.Empty(t, config.Flags) +} + +func TestGetRegistryConfig_Map(t *testing.T) { + // Create a RepoConfig with a map Registry + config := &RepoConfig{ + Registry: map[string]interface{}{ + "path": "data/registry.db", + "registry_store_type": "local", + "client_id": "test_client_id", + "cache_ttl_seconds": 60, + }, + } + + // Call the method under test + registryConfig, _ := config.GetRegistryConfig() + + // Assert that the method correctly processed the map + assert.Equal(t, "data/registry.db", registryConfig.Path) + assert.Equal(t, "local", registryConfig.RegistryStoreType) + assert.Equal(t, int64(60), registryConfig.CacheTtlSeconds) + assert.Equal(t, "test_client_id", registryConfig.ClientId) +} + +func TestGetRegistryConfig_String(t *testing.T) { + // Create a RepoConfig with a string Registry + config := &RepoConfig{ + Registry: "data/registry.db", + } + + // Call the method under test + registryConfig, _ := config.GetRegistryConfig() + + // Assert that the method correctly processed the string + assert.Equal(t, "data/registry.db", registryConfig.Path) + assert.Equal(t, defaultClientID, registryConfig.ClientId) + println(registryConfig.CacheTtlSeconds) + assert.Empty(t, registryConfig.RegistryStoreType) + assert.Equal(t, defaultCacheTtlSeconds, registryConfig.CacheTtlSeconds) +} + +func TestGetRegistryConfig_CacheTtlSecondsTypes(t *testing.T) { + // Create RepoConfigs with different types for cache_ttl_seconds + configs := []*RepoConfig{ + { + Registry: map[string]interface{}{ + "cache_ttl_seconds": float64(60), + }, + }, + { + Registry: map[string]interface{}{ + "cache_ttl_seconds": int32(60), + }, + }, + { + Registry: map[string]interface{}{ + "cache_ttl_seconds": int64(60), + }, + }, + } + + for _, config := range configs { + // Call the method under test + registryConfig, _ := config.GetRegistryConfig() + + // Assert that the method correctly processed cache_ttl_seconds + assert.Equal(t, int64(60), registryConfig.CacheTtlSeconds) + } +} + +func TestGetLoggingOptions_Defaults(t *testing.T) { + config := RepoConfig{ + FeatureServer: map[string]interface{}{ + "feature_logging": map[string]interface{}{}, + }, + } + options, err := config.GetLoggingOptions() + assert.Nil(t, err) + assert.Equal(t, logging.DefaultOptions, *options) +} + +func TestGetLoggingOptions_QueueCapacity(t *testing.T) { + config := RepoConfig{ + FeatureServer: map[string]interface{}{ + "feature_logging": map[string]interface{}{ + "queue_capacity": 100, + }, + }, + } + expected := logging.DefaultOptions + expected.ChannelCapacity = 100 + options, err := config.GetLoggingOptions() + assert.Nil(t, err) + assert.Equal(t, expected, *options) +} + +func TestGetLoggingOptions_EmitTimeoutMicroSecs(t *testing.T) { + config := RepoConfig{ + FeatureServer: map[string]interface{}{ + "feature_logging": map[string]interface{}{ + "emit_timeout_micro_secs": 500, + }, + }, + } + expected := logging.DefaultOptions + expected.EmitTimeout = 500 * time.Microsecond + options, err := config.GetLoggingOptions() + assert.Nil(t, err) + assert.Equal(t, expected, *options) +} + +func TestGetLoggingOptions_WriteToDiskIntervalSecs(t *testing.T) { + config := RepoConfig{ + FeatureServer: map[string]interface{}{ + "feature_logging": map[string]interface{}{ + "write_to_disk_interval_secs": 10, + }, + }, + } + expected := logging.DefaultOptions + expected.WriteInterval = 10 * time.Second + options, err := config.GetLoggingOptions() + assert.Nil(t, err) + assert.Equal(t, expected, *options) +} + +func TestGetLoggingOptions_FlushIntervalSecs(t *testing.T) { + config := RepoConfig{ + FeatureServer: map[string]interface{}{ + "feature_logging": map[string]interface{}{ + "flush_interval_secs": 15, + }, + }, + } + expected := logging.DefaultOptions + expected.FlushInterval = 15 * time.Second + options, err := config.GetLoggingOptions() + assert.Nil(t, err) + assert.Equal(t, expected, *options) +} + +func TestGetLoggingOptions_InvalidType(t *testing.T) { + config := RepoConfig{ + FeatureServer: map[string]interface{}{ + "feature_logging": map[string]interface{}{ + "queue_capacity": "invalid", + }, + }, + } + options, err := config.GetLoggingOptions() + assert.Nil(t, err) + assert.Equal(t, logging.DefaultOptions, *options) } diff --git a/go/internal/feast/server/grpc_server.go b/go/internal/feast/server/grpc_server.go index c47d185d6c1..d5e18b1c9ef 100644 --- a/go/internal/feast/server/grpc_server.go +++ b/go/internal/feast/server/grpc_server.go @@ -3,14 +3,13 @@ package server import ( "context" "fmt" - - "github.com/google/uuid" - "github.com/feast-dev/feast/go/internal/feast" "github.com/feast-dev/feast/go/internal/feast/server/logging" "github.com/feast-dev/feast/go/protos/feast/serving" prototypes "github.com/feast-dev/feast/go/protos/feast/types" "github.com/feast-dev/feast/go/types" + "github.com/google/uuid" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) const feastServerVersion = "0.0.1" @@ -31,15 +30,23 @@ func (s *grpcServingServiceServer) GetFeastServingInfo(ctx context.Context, requ }, nil } -// Returns an object containing the response to GetOnlineFeatures. -// Metadata contains featurenames that corresponds to the number of rows in response.Results. +// GetOnlineFeatures Returns an object containing the response to GetOnlineFeatures. +// Metadata contains feature names that corresponds to the number of rows in response.Results. // Results contains values including the value of the feature, the event timestamp, and feature status in a columnar format. func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, request *serving.GetOnlineFeaturesRequest) (*serving.GetOnlineFeaturesResponse, error) { + //span, ctx := tracer.StartSpanFromContext(ctx, "getOnlineFeatures", tracer.ResourceName("ServingService/GetOnlineFeatures")) + //defer span.Finish() + + //logSpanContext := LogWithSpanContext(span) + requestId := GenerateRequestId() featuresOrService, err := s.fs.ParseFeatures(request.GetKind()) + if err != nil { + //logSpanContext.Error().Err(err).Msg("Error parsing feature service or feature list from request") return nil, err } + featureVectors, err := s.fs.GetOnlineFeatures( ctx, featuresOrService.FeaturesRefs, @@ -47,7 +54,9 @@ func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, reques request.GetEntities(), request.GetRequestContext(), request.GetFullFeatureNames()) + if err != nil { + //logSpanContext.Error().Err(err).Msg("Error getting online features") return nil, err } @@ -66,6 +75,7 @@ func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, reques featureNames[idx] = vector.Name values, err := types.ArrowValuesToProtoValues(vector.Values) if err != nil { + //logSpanContext.Error().Err(err).Msg("Error converting Arrow values to proto values") return nil, err } if _, ok := request.Entities[vector.Name]; ok { @@ -83,11 +93,13 @@ func (s *grpcServingServiceServer) GetOnlineFeatures(ctx context.Context, reques if featureService != nil && featureService.LoggingConfig != nil && s.loggingService != nil { logger, err := s.loggingService.GetOrCreateLogger(featureService) if err != nil { + //logSpanContext.Error().Err(err).Msg("Error to instantiating logger for feature service: " + featuresOrService.FeatureService.Name) fmt.Printf("Couldn't instantiate logger for feature service %s: %+v", featuresOrService.FeatureService.Name, err) } err = logger.Log(request.Entities, resp.Results[len(request.Entities):], resp.Metadata.FeatureNames.Val[len(request.Entities):], request.RequestContext, requestId) if err != nil { + //logSpanContext.Error().Err(err).Msg("Error to logging to feature service: " + featuresOrService.FeatureService.Name) fmt.Printf("LoggerImpl error[%s]: %+v", featuresOrService.FeatureService.Name, err) } } diff --git a/go/internal/feast/server/grpc_server_test.go b/go/internal/feast/server/grpc_server_test.go index 52960321319..3ef7a6aa8a3 100644 --- a/go/internal/feast/server/grpc_server_test.go +++ b/go/internal/feast/server/grpc_server_test.go @@ -15,10 +15,10 @@ import ( "github.com/feast-dev/feast/go/internal/feast/registry" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/memory" - "github.com/apache/arrow/go/v8/parquet/file" - "github.com/apache/arrow/go/v8/parquet/pqarrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/memory" + "github.com/apache/arrow/go/v17/parquet/file" + "github.com/apache/arrow/go/v17/parquet/pqarrow" "github.com/stretchr/testify/assert" "google.golang.org/grpc" "google.golang.org/grpc/test/bufconn" diff --git a/go/internal/feast/server/http_server.go b/go/internal/feast/server/http_server.go index 7ebab429e7e..def58aedb88 100644 --- a/go/internal/feast/server/http_server.go +++ b/go/internal/feast/server/http_server.go @@ -5,6 +5,10 @@ import ( "encoding/json" "fmt" "net/http" + //"os" + "runtime" + "strconv" + //"strings" "time" "github.com/feast-dev/feast/go/internal/feast" @@ -14,6 +18,9 @@ import ( "github.com/feast-dev/feast/go/protos/feast/serving" prototypes "github.com/feast-dev/feast/go/protos/feast/types" "github.com/feast-dev/feast/go/types" + "github.com/rs/zerolog/log" + //httptrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) type httpServer struct { @@ -140,23 +147,45 @@ func NewHttpServer(fs *feast.FeatureStore, loggingService *logging.LoggingServic } func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { + var err error + + ctx := r.Context() + //span, ctx := tracer.StartSpanFromContext(r.Context(), "getOnlineFeatures", tracer.ResourceName("/get-online-features")) + //defer span.Finish(tracer.WithError(err)) + + //logSpanContext := LogWithSpanContext(span) + if r.Method != "POST" { http.NotFound(w, r) return } + statusQuery := r.URL.Query().Get("status") + + status := false + if statusQuery != "" { + status, err = strconv.ParseBool(statusQuery) + if err != nil { + //logSpanContext.Error().Err(err).Msg("Error parsing status query parameter") + writeJSONError(w, fmt.Errorf("Error parsing status query parameter: %+v", err), http.StatusBadRequest) + return + } + } + decoder := json.NewDecoder(r.Body) var request getOnlineFeaturesRequest - err := decoder.Decode(&request) + err = decoder.Decode(&request) if err != nil { - http.Error(w, fmt.Sprintf("Error decoding JSON request data: %+v", err), http.StatusInternalServerError) + //logSpanContext.Error().Err(err).Msg("Error decoding JSON request data") + writeJSONError(w, fmt.Errorf("Error decoding JSON request data: %+v", err), http.StatusInternalServerError) return } var featureService *model.FeatureService if request.FeatureService != nil { featureService, err = s.fs.GetFeatureService(*request.FeatureService) if err != nil { - http.Error(w, fmt.Sprintf("Error getting feature service from registry: %+v", err), http.StatusInternalServerError) + //logSpanContext.Error().Err(err).Msg("Error getting feature service from registry") + writeJSONError(w, fmt.Errorf("Error getting feature service from registry: %+v", err), http.StatusInternalServerError) return } } @@ -170,7 +199,7 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { } featureVectors, err := s.fs.GetOnlineFeatures( - r.Context(), + ctx, request.Features, featureService, entitiesProto, @@ -178,7 +207,8 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { request.FullFeatureNames) if err != nil { - http.Error(w, fmt.Sprintf("Error getting feature vector: %+v", err), http.StatusInternalServerError) + //logSpanContext.Error().Err(err).Msg("Error getting feature vector") + writeJSONError(w, fmt.Errorf("Error getting feature vector: %+v", err), http.StatusInternalServerError) return } @@ -187,17 +217,19 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { for _, vector := range featureVectors { featureNames = append(featureNames, vector.Name) result := make(map[string]interface{}) - var statuses []string - for _, status := range vector.Statuses { - statuses = append(statuses, status.String()) - } - var timestamps []string - for _, timestamp := range vector.Timestamps { - timestamps = append(timestamps, timestamp.AsTime().Format(time.RFC3339)) - } + if status { + var statuses []string + for _, status := range vector.Statuses { + statuses = append(statuses, status.String()) + } + var timestamps []string + for _, timestamp := range vector.Timestamps { + timestamps = append(timestamps, timestamp.AsTime().Format(time.RFC3339)) + } - result["statuses"] = statuses - result["event_timestamps"] = timestamps + result["statuses"] = statuses + result["event_timestamps"] = timestamps + } // Note, that vector.Values is an Arrow Array, but this type implements JSON Marshaller. // So, it's not necessary to pre-process it in any way. result["values"] = vector.Values @@ -217,14 +249,16 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { err = json.NewEncoder(w).Encode(response) if err != nil { - http.Error(w, fmt.Sprintf("Error encoding response: %+v", err), http.StatusInternalServerError) + //logSpanContext.Error().Err(err).Msg("Error encoding response") + writeJSONError(w, fmt.Errorf("Error encoding response: %+v", err), http.StatusInternalServerError) return } if featureService != nil && featureService.LoggingConfig != nil && s.loggingService != nil { logger, err := s.loggingService.GetOrCreateLogger(featureService) if err != nil { - http.Error(w, fmt.Sprintf("Couldn't instantiate logger for feature service %s: %+v", featureService.Name, err), http.StatusInternalServerError) + //logSpanContext.Error().Err(err).Msgf("Couldn't instantiate logger for feature service %s", featureService.Name) + writeJSONError(w, fmt.Errorf("Couldn't instantiate logger for feature service %s: %+v", featureService.Name, err), http.StatusInternalServerError) return } @@ -236,7 +270,8 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { for _, vector := range featureVectors[len(request.Entities):] { values, err := types.ArrowValuesToProtoValues(vector.Values) if err != nil { - http.Error(w, fmt.Sprintf("Couldn't convert arrow values into protobuf: %+v", err), http.StatusInternalServerError) + //logSpanContext.Error().Err(err).Msg("Couldn't convert arrow values into protobuf") + writeJSONError(w, fmt.Errorf("Couldn't convert arrow values into protobuf: %+v", err), http.StatusInternalServerError) return } featureVectorProtos = append(featureVectorProtos, &serving.GetOnlineFeaturesResponse_FeatureVector{ @@ -248,10 +283,11 @@ func (s *httpServer) getOnlineFeatures(w http.ResponseWriter, r *http.Request) { err = logger.Log(entitiesProto, featureVectorProtos, featureNames[len(request.Entities):], requestContextProto, requestId) if err != nil { - http.Error(w, fmt.Sprintf("LoggerImpl error[%s]: %+v", featureService.Name, err), http.StatusInternalServerError) + writeJSONError(w, fmt.Errorf("LoggerImpl error[%s]: %+v", featureService.Name, err), http.StatusInternalServerError) return } } + go releaseCGOMemory(featureVectors) } @@ -261,15 +297,64 @@ func releaseCGOMemory(featureVectors []*onlineserving.FeatureVector) { } } +func logStackTrace() { + // Start with a small buffer and grow it until the full stack trace fits. + buf := make([]byte, 1024) + for { + stackSize := runtime.Stack(buf, false) + if stackSize < len(buf) { + // The stack trace fits in the buffer, so we can log it now. + log.Error().Str("stack_trace", string(buf[:stackSize])).Msg("") + return + } + // The stack trace doesn't fit in the buffer, so we need to grow the buffer and try again. + buf = make([]byte, 2*len(buf)) + } +} + +func writeJSONError(w http.ResponseWriter, err error, statusCode int) { + errMap := map[string]interface{}{ + "error": fmt.Sprintf("%+v", err), + "status_code": statusCode, + } + errJSON, _ := json.Marshal(errMap) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(errJSON) +} + +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if r := recover(); r != nil { + log.Error().Err(fmt.Errorf("Panic recovered: %v", r)).Msg("A panic occurred in the server") + // Log the stack trace + logStackTrace() + + writeJSONError(w, fmt.Errorf("Internal Server Error: %v", r), http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + func (s *httpServer) Serve(host string, port int) error { - s.server = &http.Server{Addr: fmt.Sprintf("%s:%d", host, port), Handler: nil} - http.HandleFunc("/get-online-features", s.getOnlineFeatures) - http.HandleFunc("/health", healthCheckHandler) + // DD + //if strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" { + // tracer.Start(tracer.WithRuntimeMetrics()) + // defer tracer.Stop() + //} + mux := http.NewServeMux() + mux.Handle("/get-online-features", recoverMiddleware(http.HandlerFunc(s.getOnlineFeatures))) + mux.HandleFunc("/health", healthCheckHandler) + s.server = &http.Server{Addr: fmt.Sprintf("%s:%d", host, port), Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 15 * time.Second} err := s.server.ListenAndServe() // Don't return the error if it's caused by graceful shutdown using Stop() if err == http.ErrServerClosed { return nil } + log.Fatal().Stack().Err(err).Msg("Failed to start HTTP server") return err } @@ -277,7 +362,6 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Healthy") } - func (s *httpServer) Stop() error { if s.server != nil { return s.server.Shutdown(context.Background()) diff --git a/go/internal/feast/server/http_server_test.go b/go/internal/feast/server/http_server_test.go index 67ba1c60f96..e0d474a9f34 100644 --- a/go/internal/feast/server/http_server_test.go +++ b/go/internal/feast/server/http_server_test.go @@ -1,8 +1,13 @@ package server import ( - "github.com/stretchr/testify/assert" + "encoding/json" "testing" + + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/memory" + "github.com/stretchr/testify/assert" ) func TestUnmarshalJSON(t *testing.T) { @@ -38,3 +43,36 @@ func TestUnmarshalJSON(t *testing.T) { assert.Nil(t, u.UnmarshalJSON([]byte("[[true, false, true], [false, true, false]]"))) assert.Equal(t, [][]bool{{true, false, true}, {false, true, false}}, u.boolListVal) } +func TestMarshalInt32JSON(t *testing.T) { + var arrowArray arrow.Array + memoryPool := memory.NewGoAllocator() + builder := array.NewInt32Builder(memoryPool) + defer builder.Release() + builder.AppendValues([]int32{1, 2, 3, 4}, nil) + arrowArray = builder.NewArray() + defer arrowArray.Release() + expectedJSON := `[1,2,3,4]` + + jsonData, err := json.Marshal(arrowArray) + assert.NoError(t, err, "Error marshaling Arrow array") + + assert.Equal(t, expectedJSON, string(jsonData), "JSON output does not match expected") + assert.IsType(t, &array.Int32{}, arrowArray, "arrowArray is not of type *array.Int32") +} + +func TestMarshalInt64JSON(t *testing.T) { + var arrowArray arrow.Array + memoryPool := memory.NewGoAllocator() + builder := array.NewInt64Builder(memoryPool) + defer builder.Release() + builder.AppendValues([]int64{-9223372036854775808, 9223372036854775807}, nil) + arrowArray = builder.NewArray() + defer arrowArray.Release() + expectedJSON := `[-9223372036854775808,9223372036854775807]` + + jsonData, err := json.Marshal(arrowArray) + assert.NoError(t, err, "Error marshaling Arrow array") + + assert.Equal(t, expectedJSON, string(jsonData), "JSON output does not match expected") + assert.IsType(t, &array.Int64{}, arrowArray, "arrowArray is not of type *array.Int64") +} diff --git a/go/internal/feast/server/logging/filelogsink.go b/go/internal/feast/server/logging/filelogsink.go index d9796d69d10..ae33e61a658 100644 --- a/go/internal/feast/server/logging/filelogsink.go +++ b/go/internal/feast/server/logging/filelogsink.go @@ -8,12 +8,12 @@ import ( "github.com/pkg/errors" - "github.com/apache/arrow/go/v8/arrow" + "github.com/apache/arrow/go/v17/arrow" "github.com/google/uuid" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/parquet" - "github.com/apache/arrow/go/v8/parquet/pqarrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/parquet" + "github.com/apache/arrow/go/v17/parquet/pqarrow" ) type FileLogSink struct { diff --git a/go/internal/feast/server/logging/logger.go b/go/internal/feast/server/logging/logger.go index 0e4f230f5ad..edea8aa1abb 100644 --- a/go/internal/feast/server/logging/logger.go +++ b/go/internal/feast/server/logging/logger.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/apache/arrow/go/v8/arrow" + "github.com/apache/arrow/go/v17/arrow" "github.com/pkg/errors" "google.golang.org/protobuf/types/known/timestamppb" diff --git a/go/internal/feast/server/logging/logger_test.go b/go/internal/feast/server/logging/logger_test.go index ddc1902b7d1..b81179f2d29 100644 --- a/go/internal/feast/server/logging/logger_test.go +++ b/go/internal/feast/server/logging/logger_test.go @@ -7,11 +7,11 @@ import ( "testing" "time" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/memory" - "github.com/apache/arrow/go/v8/parquet/file" - "github.com/apache/arrow/go/v8/parquet/pqarrow" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/memory" + "github.com/apache/arrow/go/v17/parquet/file" + "github.com/apache/arrow/go/v17/parquet/pqarrow" "github.com/stretchr/testify/require" "github.com/feast-dev/feast/go/protos/feast/types" diff --git a/go/internal/feast/server/logging/memorybuffer.go b/go/internal/feast/server/logging/memorybuffer.go index c9f00218dfc..cd97327a4aa 100644 --- a/go/internal/feast/server/logging/memorybuffer.go +++ b/go/internal/feast/server/logging/memorybuffer.go @@ -2,9 +2,10 @@ package logging import ( "fmt" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/memory" + + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/memory" "github.com/feast-dev/feast/go/protos/feast/types" gotypes "github.com/feast-dev/feast/go/types" @@ -128,7 +129,7 @@ func getArrowSchema(schema *FeatureServiceSchema) (*arrow.Schema, error) { // and writes them to arrow table. // Returns arrow table that contains all of the logs in columnar format. func (b *MemoryBuffer) convertToArrowRecord() (arrow.Record, error) { - arrowMemory := memory.NewCgoArrowAllocator() + arrowMemory := memory.NewGoAllocator() numRows := len(b.logs) columns := make(map[string][]*types.Value) diff --git a/go/internal/feast/server/logging/memorybuffer_test.go b/go/internal/feast/server/logging/memorybuffer_test.go index ec83680f4ff..6c6db8fc880 100644 --- a/go/internal/feast/server/logging/memorybuffer_test.go +++ b/go/internal/feast/server/logging/memorybuffer_test.go @@ -5,9 +5,9 @@ import ( "testing" "time" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/memory" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/memory" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/types/known/timestamppb" @@ -118,7 +118,7 @@ func TestSerializeToArrowTable(t *testing.T) { LogTimestamp: time.Now(), }) - pool := memory.NewCgoArrowAllocator() + pool := memory.NewGoAllocator() builder := array.NewRecordBuilder(pool, b.arrowSchema) defer builder.Release() diff --git a/go/internal/feast/server/logging/offlinestoresink.go b/go/internal/feast/server/logging/offlinestoresink.go index 632039baa43..b0f247ce6e1 100644 --- a/go/internal/feast/server/logging/offlinestoresink.go +++ b/go/internal/feast/server/logging/offlinestoresink.go @@ -8,10 +8,10 @@ import ( "os" "path/filepath" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/parquet" - "github.com/apache/arrow/go/v8/parquet/pqarrow" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/parquet" + "github.com/apache/arrow/go/v17/parquet/pqarrow" "github.com/google/uuid" ) diff --git a/go/internal/feast/server/server_commons.go b/go/internal/feast/server/server_commons.go new file mode 100644 index 00000000000..140269d5c1c --- /dev/null +++ b/go/internal/feast/server/server_commons.go @@ -0,0 +1,31 @@ +package server + +import ( + "github.com/rs/zerolog" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "os" +) + +func LogWiwithSpanContext() zerolog.Logger { + var logger = zerolog.New(os.Stderr).With(). + Timestamp(). + Logger() + + return logger +} + +/* +func LogWithSpanContext(span tracer.Span) zerolog.Logger { + spanContext := span.Context() + + var logger = zerolog.New(os.Stderr).With(). + Timestamp(). + Logger() + //Int64("trace_id", int64(spanContext.TraceID())). + //Int64("span_id", int64(spanContext.SpanID())). + //Timestamp(). + //Logger() + + return logger +} +*/ diff --git a/go/internal/feast/transformation/transformation.go b/go/internal/feast/transformation/transformation.go index 7e63aec2243..d6df03039d7 100644 --- a/go/internal/feast/transformation/transformation.go +++ b/go/internal/feast/transformation/transformation.go @@ -1,20 +1,18 @@ package transformation import ( - "errors" + "context" "fmt" + "runtime" "strings" - "unsafe" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/cdata" - "github.com/apache/arrow/go/v8/arrow/memory" - "google.golang.org/protobuf/types/known/timestamppb" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/memory" + "github.com/rs/zerolog/log" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" "github.com/feast-dev/feast/go/internal/feast/model" "github.com/feast-dev/feast/go/internal/feast/onlineserving" - "github.com/feast-dev/feast/go/protos/feast/serving" prototypes "github.com/feast-dev/feast/go/protos/feast/types" "github.com/feast-dev/feast/go/types" ) @@ -24,20 +22,27 @@ TransformationCallback is a Python callback function's expected signature. The function should accept name of the on demand feature view and pointers to input & output record batches. Each record batch is being passed as two pointers: pointer to array (data) and pointer to schema. Python function is expected to return number of rows added to the output record batch. + +[11-20-2024] Use a Transformation GRPC service, like the Python version one, for better scalability. */ type TransformationCallback func(ODFVName string, inputArrPtr, inputSchemaPtr, outArrPtr, outSchemaPtr uintptr, fullFeatureNames bool) int func AugmentResponseWithOnDemandTransforms( + ctx context.Context, onDemandFeatureViews []*model.OnDemandFeatureView, requestData map[string]*prototypes.RepeatedValue, entityRows map[string]*prototypes.RepeatedValue, features []*onlineserving.FeatureVector, transformationCallback TransformationCallback, + transformationService *GrpcTransformationService, arrowMemory memory.Allocator, numRows int, fullFeatureNames bool, ) ([]*onlineserving.FeatureVector, error) { + //span, _ := tracer.StartSpanFromContext(ctx, "transformation.AugmentResponseWithOnDemandTransforms") + //defer span.Finish() + result := make([]*onlineserving.FeatureVector, 0) var err error @@ -64,17 +69,20 @@ func AugmentResponseWithOnDemandTransforms( retrievedFeatures[vector.Name] = vector.Values } - onDemandFeatures, err := CallTransformations( - odfv, - retrievedFeatures, - requestContextArrow, - transformationCallback, - numRows, - fullFeatureNames, - ) - if err != nil { - ReleaseArrowContext(requestContextArrow) - return nil, err + var onDemandFeatures []*onlineserving.FeatureVector + if transformationService != nil { + onDemandFeatures, err = transformationService.GetTransformation( + ctx, + odfv, + retrievedFeatures, + requestContextArrow, + numRows, + fullFeatureNames, + ) + if err != nil { + ReleaseArrowContext(requestContextArrow) + return nil, err + } } result = append(result, onDemandFeatures...) @@ -91,103 +99,6 @@ func ReleaseArrowContext(requestContextArrow map[string]arrow.Array) { } } -func CallTransformations( - featureView *model.OnDemandFeatureView, - retrievedFeatures map[string]arrow.Array, - requestContext map[string]arrow.Array, - callback TransformationCallback, - numRows int, - fullFeatureNames bool, -) ([]*onlineserving.FeatureVector, error) { - - inputArr := cdata.CArrowArray{} - inputSchema := cdata.CArrowSchema{} - - outArr := cdata.CArrowArray{} - outSchema := cdata.CArrowSchema{} - - defer cdata.ReleaseCArrowArray(&inputArr) - defer cdata.ReleaseCArrowArray(&outArr) - defer cdata.ReleaseCArrowSchema(&inputSchema) - defer cdata.ReleaseCArrowSchema(&outSchema) - - inputArrPtr := uintptr(unsafe.Pointer(&inputArr)) - inputSchemaPtr := uintptr(unsafe.Pointer(&inputSchema)) - - outArrPtr := uintptr(unsafe.Pointer(&outArr)) - outSchemaPtr := uintptr(unsafe.Pointer(&outSchema)) - - inputFields := make([]arrow.Field, 0) - inputColumns := make([]arrow.Array, 0) - for name, arr := range retrievedFeatures { - inputFields = append(inputFields, arrow.Field{Name: name, Type: arr.DataType()}) - inputColumns = append(inputColumns, arr) - } - for name, arr := range requestContext { - inputFields = append(inputFields, arrow.Field{Name: name, Type: arr.DataType()}) - inputColumns = append(inputColumns, arr) - } - - inputRecord := array.NewRecord(arrow.NewSchema(inputFields, nil), inputColumns, int64(numRows)) - defer inputRecord.Release() - - cdata.ExportArrowRecordBatch(inputRecord, &inputArr, &inputSchema) - - ret := callback(featureView.Base.Name, inputArrPtr, inputSchemaPtr, outArrPtr, outSchemaPtr, fullFeatureNames) - - if ret != numRows { - return nil, errors.New("python transformation callback failed") - } - - outRecord, err := cdata.ImportCRecordBatch(&outArr, &outSchema) - if err != nil { - return nil, err - } - - result := make([]*onlineserving.FeatureVector, 0) - for idx, field := range outRecord.Schema().Fields() { - dropFeature := true - - if featureView.Base.Projection != nil { - var featureName string - if fullFeatureNames { - featureName = strings.Split(field.Name, "__")[1] - } else { - featureName = field.Name - } - - for _, feature := range featureView.Base.Projection.Features { - if featureName == feature.Name { - dropFeature = false - } - } - } else { - dropFeature = false - } - - if dropFeature { - continue - } - - statuses := make([]serving.FieldStatus, numRows) - timestamps := make([]*timestamppb.Timestamp, numRows) - - for idx := 0; idx < numRows; idx++ { - statuses[idx] = serving.FieldStatus_PRESENT - timestamps[idx] = timestamppb.Now() - } - - result = append(result, &onlineserving.FeatureVector{ - Name: field.Name, - Values: outRecord.Column(idx), - Statuses: statuses, - Timestamps: timestamps, - }) - } - - return result, nil -} - func EnsureRequestedDataExist(requestedOnDemandFeatureViews []*model.OnDemandFeatureView, requestDataFeatures map[string]*prototypes.RepeatedValue) error { @@ -220,3 +131,15 @@ func getNeededRequestData(requestedOnDemandFeatureViews []*model.OnDemandFeature return neededRequestData, nil } + +func logStackTrace() { + // Create a buffer for storing the stack trace + const size = 4096 + buf := make([]byte, size) + + // Retrieve the stack trace and write it to the buffer + stackSize := runtime.Stack(buf, false) + + // Log the stack trace using zerolog + log.Error().Str("stack_trace", string(buf[:stackSize])).Msg("") +} diff --git a/go/internal/feast/transformation/transformation_service.go b/go/internal/feast/transformation/transformation_service.go new file mode 100644 index 00000000000..0595d463b37 --- /dev/null +++ b/go/internal/feast/transformation/transformation_service.go @@ -0,0 +1,205 @@ +package transformation + +import ( + "bytes" + "context" + "fmt" + "strings" + + "io" + + "github.com/feast-dev/feast/go/internal/feast/registry" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/ipc" + "github.com/apache/arrow/go/v17/arrow/memory" + "github.com/feast-dev/feast/go/internal/feast/model" + "github.com/feast-dev/feast/go/internal/feast/onlineserving" + "github.com/feast-dev/feast/go/protos/feast/serving" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type GrpcTransformationService struct { + project string + conn *grpc.ClientConn + client *serving.TransformationServiceClient +} + +func NewGrpcTransformationService(config *registry.RepoConfig, endpoint string) (*GrpcTransformationService, error) { + opts := make([]grpc.DialOption, 0) + opts = append(opts, grpc.WithDefaultCallOptions(), grpc.WithTransportCredentials(insecure.NewCredentials())) + + conn, err := grpc.Dial(endpoint, opts...) + if err != nil { + return nil, err + } + client := serving.NewTransformationServiceClient(conn) + return &GrpcTransformationService{config.Project, conn, &client}, nil +} + +func (s *GrpcTransformationService) Close() error { + return s.conn.Close() +} + +func (s *GrpcTransformationService) GetTransformation( + ctx context.Context, + featureView *model.OnDemandFeatureView, + retrievedFeatures map[string]arrow.Array, + requestContext map[string]arrow.Array, + numRows int, + fullFeatureNames bool, +) ([]*onlineserving.FeatureVector, error) { + var err error + + inputFields := make([]arrow.Field, 0) + inputColumns := make([]arrow.Array, 0) + for name, arr := range retrievedFeatures { + inputFields = append(inputFields, arrow.Field{Name: name, Type: arr.DataType()}) + inputColumns = append(inputColumns, arr) + } + for name, arr := range requestContext { + inputFields = append(inputFields, arrow.Field{Name: name, Type: arr.DataType()}) + inputColumns = append(inputColumns, arr) + } + + inputSchema := arrow.NewSchema(inputFields, nil) + inputRecord := array.NewRecord(inputSchema, inputColumns, int64(numRows)) + defer inputRecord.Release() + + recordValueWriter := new(ByteSliceWriter) + arrowWriter, err := ipc.NewFileWriter(recordValueWriter, ipc.WithSchema(inputSchema)) + if err != nil { + return nil, err + } + + err = arrowWriter.Write(inputRecord) + if err != nil { + return nil, err + } + + err = arrowWriter.Close() + if err != nil { + return nil, err + } + + arrowInput := serving.ValueType_ArrowValue{ArrowValue: recordValueWriter.buf} + transformationInput := serving.ValueType{Value: &arrowInput} + + req := serving.TransformFeaturesRequest{ + OnDemandFeatureViewName: featureView.Base.Name, + Project: s.project, + TransformationInput: &transformationInput, + } + + res, err := (*s.client).TransformFeatures(ctx, &req) + if err != nil { + return nil, err + } + + arrowBytes := res.TransformationOutput.GetArrowValue() + return ExtractTransformationResponse(featureView, arrowBytes, numRows, false) +} + +func ExtractTransformationResponse( + featureView *model.OnDemandFeatureView, + arrowBytes []byte, + numRows int, + fullFeatureNames bool, +) ([]*onlineserving.FeatureVector, error) { + arrowMemory := memory.NewGoAllocator() + arrowReader, err := ipc.NewFileReader(bytes.NewReader(arrowBytes), ipc.WithAllocator(arrowMemory)) + if err != nil { + return nil, err + } + + outRecord, err := arrowReader.Read() + if err != nil { + return nil, err + } + result := make([]*onlineserving.FeatureVector, 0) + for idx, field := range outRecord.Schema().Fields() { + dropFeature := true + + featureName := strings.Split(field.Name, "__")[1] + if featureView.Base.Projection != nil { + + for _, feature := range featureView.Base.Projection.Features { + if featureName == feature.Name { + dropFeature = false + } + } + } else { + dropFeature = false + } + + if dropFeature { + continue + } + + statuses := make([]serving.FieldStatus, numRows) + timestamps := make([]*timestamppb.Timestamp, numRows) + + for idx := 0; idx < numRows; idx++ { + statuses[idx] = serving.FieldStatus_PRESENT + timestamps[idx] = timestamppb.Now() + } + + result = append(result, &onlineserving.FeatureVector{ + Name: featureName, + Values: outRecord.Column(idx), + Statuses: statuses, + Timestamps: timestamps, + }) + } + + return result, nil +} + +type ByteSliceWriter struct { + buf []byte + offset int64 +} + +func (w *ByteSliceWriter) Write(p []byte) (n int, err error) { + minCap := int(w.offset) + len(p) + if minCap > cap(w.buf) { // Make sure buf has enough capacity: + buf2 := make([]byte, len(w.buf), minCap+len(p)) // add some extra + copy(buf2, w.buf) + w.buf = buf2 + } + if minCap > len(w.buf) { + w.buf = w.buf[:minCap] + } + copy(w.buf[w.offset:], p) + w.offset += int64(len(p)) + return len(p), nil +} + +func (w *ByteSliceWriter) Seek(offset int64, whence int) (int64, error) { + switch whence { + case io.SeekStart: + if w.offset != offset && (offset < 0 || offset > int64(len(w.buf))) { + return 0, fmt.Errorf("invalid seek: new offset %d out of range [0 %d]", offset, len(w.buf)) + } + w.offset = offset + return offset, nil + case io.SeekCurrent: + newOffset := w.offset + offset + if newOffset != offset && (newOffset < 0 || newOffset > int64(len(w.buf))) { + return 0, fmt.Errorf("invalid seek: new offset %d out of range [0 %d]", offset, len(w.buf)) + } + w.offset += offset + return w.offset, nil + case io.SeekEnd: + newOffset := int64(len(w.buf)) + offset + if newOffset != offset && (newOffset < 0 || newOffset > int64(len(w.buf))) { + return 0, fmt.Errorf("invalid seek: new offset %d out of range [0 %d]", offset, len(w.buf)) + } + w.offset = offset + return w.offset, nil + } + return 0, fmt.Errorf("unsupported seek mode %d", whence) +} diff --git a/go/internal/test/feature_repo/example.py b/go/internal/test/feature_repo/example.py index 70843610075..a814b58913b 100644 --- a/go/internal/test/feature_repo/example.py +++ b/go/internal/test/feature_repo/example.py @@ -2,10 +2,12 @@ from datetime import timedelta -from feast import Entity, Feature, FeatureView, Field, FileSource, FeatureService +from feast import Entity, Feature, FeatureView, Field, FileSource, FeatureService, RequestSource from feast.feature_logging import LoggingConfig from feast.infra.offline_stores.file_source import FileLoggingDestination -from feast.types import Float32, Int64 +from feast.types import Float32, Float64, Int64, PrimitiveFeastType +from feast.on_demand_feature_view import on_demand_feature_view +import pandas as pd # Read data from parquet files. Parquet is convenient for local development mode. For # production, you can use your favorite DWH, such as BigQuery. See Feast documentation @@ -41,4 +43,32 @@ name="test_service", features=[driver_hourly_stats_view], logging_config=LoggingConfig(destination=FileLoggingDestination(path="")) -) \ No newline at end of file +) + + +# Define a request data source which encodes features / information only +# available at request time (e.g. part of the user initiated HTTP request) +input_request = RequestSource( + name="vals_to_add", + schema=[ + Field(name="val_to_add", dtype=PrimitiveFeastType.INT64), + Field(name="val_to_add_2", dtype=PrimitiveFeastType.INT64), + ] +) + +# Use the input data and feature view features to create new features +@on_demand_feature_view( + sources=[ + driver_hourly_stats_view, + input_request + ], + schema=[ + Field(name='conv_rate_plus_val1', dtype=Float64), + Field(name='conv_rate_plus_val2', dtype=Float64) + ] +) +def transformed_conv_rate(features_df: pd.DataFrame) -> pd.DataFrame: + df = pd.DataFrame() + df['conv_rate_plus_val1'] = (features_df['conv_rate'] + features_df['val_to_add']) + df['conv_rate_plus_val2'] = (features_df['conv_rate'] + features_df['val_to_add_2']) + return df diff --git a/go/internal/test/flexible_coyote/feature_repo/data/online_store_for_pg.db b/go/internal/test/flexible_coyote/feature_repo/data/online_store_for_pg.db new file mode 100644 index 00000000000..e69de29bb2d diff --git a/go/internal/test/go_integration_test_utils.go b/go/internal/test/go_integration_test_utils.go index 3ec9aa2a4cd..5068f405063 100644 --- a/go/internal/test/go_integration_test_utils.go +++ b/go/internal/test/go_integration_test_utils.go @@ -5,20 +5,20 @@ import ( "fmt" "log" - "github.com/apache/arrow/go/v8/arrow/memory" + "github.com/apache/arrow/go/v17/arrow/memory" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/parquet/file" - "github.com/apache/arrow/go/v8/parquet/pqarrow" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/parquet/file" + "github.com/apache/arrow/go/v17/parquet/pqarrow" "os" "os/exec" "path/filepath" "time" - "github.com/apache/arrow/go/v8/arrow/array" + "github.com/apache/arrow/go/v17/arrow/array" "github.com/feast-dev/feast/go/internal/feast/model" "github.com/feast-dev/feast/go/protos/feast/types" @@ -107,7 +107,10 @@ func SetupCleanFeatureRepo(basePath string) error { return err } applyCommand.Dir = featureRepoPath - applyCommand.Run() + err = applyCommand.Run() + if err != nil { + return err + } t := time.Now() formattedTime := fmt.Sprintf("%d-%02d-%02dT%02d:%02d:%02d", @@ -120,7 +123,6 @@ func SetupCleanFeatureRepo(basePath string) error { if err != nil { return err } - return nil } diff --git a/go/main.go b/go/main.go new file mode 100644 index 00000000000..feb54faa2e0 --- /dev/null +++ b/go/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + "os/signal" + //"strings" + "syscall" + + "github.com/feast-dev/feast/go/internal/feast" + "github.com/feast-dev/feast/go/internal/feast/registry" + "github.com/feast-dev/feast/go/internal/feast/server" + "github.com/feast-dev/feast/go/internal/feast/server/logging" + "github.com/feast-dev/feast/go/protos/feast/serving" + "github.com/rs/zerolog/log" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + //grpctrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/google.golang.org/grpc" + //"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +type ServerStarter interface { + StartHttpServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error + StartGrpcServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error +} + +type RealServerStarter struct{} + +func (s *RealServerStarter) StartHttpServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error { + return StartHttpServer(fs, host, port, writeLoggedFeaturesCallback, loggingOpts) +} + +func (s *RealServerStarter) StartGrpcServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error { + return StartGrpcServer(fs, host, port, writeLoggedFeaturesCallback, loggingOpts) +} + +func main() { + // Default values + serverType := "http" + host := "" + port := 8080 + server := RealServerStarter{} + // Current Directory + repoPath, err := os.Getwd() + if err != nil { + log.Error().Stack().Err(err).Msg("Failed to get current directory") + } + + flag.StringVar(&serverType, "type", serverType, "Specify the server type (http or grpc)") + flag.StringVar(&repoPath, "chdir", repoPath, "Repository path where feature store yaml file is stored") + + flag.StringVar(&host, "host", host, "Specify a host for the server") + flag.IntVar(&port, "port", port, "Specify a port for the server") + flag.Parse() + + repoConfig, err := registry.NewRepoConfigFromFile(repoPath) + if err != nil { + log.Fatal().Stack().Err(err).Msg("Failed to convert to RepoConfig") + } + + fs, err := feast.NewFeatureStore(repoConfig, nil) + if err != nil { + log.Fatal().Stack().Err(err).Msg("Failed to create NewFeatureStore") + } + + loggingOptions, err := repoConfig.GetLoggingOptions() + if err != nil { + log.Fatal().Stack().Err(err).Msg("Failed to get LoggingOptions") + } + + // TODO: writeLoggedFeaturesCallback is defaulted to nil. write_logged_features functionality needs to be + // implemented in Golang specific to OfflineStoreSink. Python Feature Server doesn't support this. + if serverType == "http" { + err = server.StartHttpServer(fs, host, port, nil, loggingOptions) + } else if serverType == "grpc" { + err = server.StartGrpcServer(fs, host, port, nil, loggingOptions) + } else { + fmt.Println("Unknown server type. Please specify 'http' or 'grpc'.") + } + + if err != nil { + log.Fatal().Stack().Err(err).Msg("Failed to start server") + } + +} + +func constructLoggingService(fs *feast.FeatureStore, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) (*logging.LoggingService, error) { + var loggingService *logging.LoggingService = nil + if writeLoggedFeaturesCallback != nil { + sink, err := logging.NewOfflineStoreSink(writeLoggedFeaturesCallback) + if err != nil { + return nil, err + } + + loggingService, err = logging.NewLoggingService(fs, sink, logging.LoggingOptions{ + ChannelCapacity: loggingOpts.ChannelCapacity, + EmitTimeout: loggingOpts.EmitTimeout, + WriteInterval: loggingOpts.WriteInterval, + FlushInterval: loggingOpts.FlushInterval, + }) + if err != nil { + return nil, err + } + } + return loggingService, nil +} + +// StartGprcServerWithLogging starts gRPC server with enabled feature logging +func StartGrpcServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error { + // #DD + //if strings.ToLower(os.Getenv("ENABLE_DATADOG_TRACING")) == "true" { + // tracer.Start(tracer.WithRuntimeMetrics()) + // defer tracer.Stop() + //} + loggingService, err := constructLoggingService(fs, writeLoggedFeaturesCallback, loggingOpts) + if err != nil { + return err + } + ser := server.NewGrpcServingServiceServer(fs, loggingService) + log.Info().Msgf("Starting a gRPC server on host %s port %d", host, port) + lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return err + } + + grpcServer := grpc.NewServer() + serving.RegisterServingServiceServer(grpcServer, ser) + healthService := health.NewServer() + grpc_health_v1.RegisterHealthServer(grpcServer, healthService) + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + go func() { + // As soon as these signals are received from OS, try to gracefully stop the gRPC server + <-stop + log.Info().Msg("Stopping the gRPC server...") + grpcServer.GracefulStop() + if loggingService != nil { + loggingService.Stop() + } + log.Info().Msg("gRPC server terminated") + }() + + return grpcServer.Serve(lis) +} + +// StartHttpServerWithLogging starts HTTP server with enabled feature logging +// Go does not allow direct assignment to package-level functions as a way to +// mock them for tests +func StartHttpServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error { + loggingService, err := constructLoggingService(fs, writeLoggedFeaturesCallback, loggingOpts) + if err != nil { + return err + } + ser := server.NewHttpServer(fs, loggingService) + log.Info().Msgf("Starting a HTTP server on host %s, port %d", host, port) + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + go func() { + // As soon as these signals are received from OS, try to gracefully stop the gRPC server + <-stop + log.Info().Msg("Stopping the HTTP server...") + err := ser.Stop() + if err != nil { + log.Error().Err(err).Msg("Error when stopping the HTTP server") + } + if loggingService != nil { + loggingService.Stop() + } + log.Info().Msg("HTTP server terminated") + }() + + return ser.Serve(host, port) +} diff --git a/go/main_test.go b/go/main_test.go new file mode 100644 index 00000000000..567a6cf5af4 --- /dev/null +++ b/go/main_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "testing" + + "github.com/feast-dev/feast/go/internal/feast" + "github.com/feast-dev/feast/go/internal/feast/server/logging" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockServerStarter is a mock of ServerStarter interface for testing +type MockServerStarter struct { + mock.Mock +} + +func (m *MockServerStarter) StartHttpServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error { + args := m.Called(fs, host, port, writeLoggedFeaturesCallback, loggingOpts) + return args.Error(0) +} + +func (m *MockServerStarter) StartGrpcServer(fs *feast.FeatureStore, host string, port int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error { + args := m.Called(fs, host, port, writeLoggedFeaturesCallback, loggingOpts) + return args.Error(0) +} + +// TestStartHttpServer tests the StartHttpServer function +func TestStartHttpServer(t *testing.T) { + mockServerStarter := new(MockServerStarter) + fs := &feast.FeatureStore{} + host := "localhost" + port := 8080 + var writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback + + loggingOpts := &logging.LoggingOptions{} + + mockServerStarter.On("StartHttpServer", fs, host, port, mock.AnythingOfType("logging.OfflineStoreWriteCallback"), loggingOpts).Return(nil) + + err := mockServerStarter.StartHttpServer(fs, host, port, writeLoggedFeaturesCallback, loggingOpts) + assert.NoError(t, err) + mockServerStarter.AssertExpectations(t) +} + +// TestStartGrpcServer tests the StartGrpcServer function +func TestStartGrpcServer(t *testing.T) { + mockServerStarter := new(MockServerStarter) + fs := &feast.FeatureStore{} + host := "localhost" + port := 9090 + var writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback + loggingOpts := &logging.LoggingOptions{} + + mockServerStarter.On("StartGrpcServer", fs, host, port, mock.AnythingOfType("logging.OfflineStoreWriteCallback"), loggingOpts).Return(nil) + + err := mockServerStarter.StartGrpcServer(fs, host, port, writeLoggedFeaturesCallback, loggingOpts) + assert.NoError(t, err) + mockServerStarter.AssertExpectations(t) +} + +// TestConstructLoggingService tests the constructLoggingService function +func TestConstructLoggingService(t *testing.T) { + fs := &feast.FeatureStore{} + var writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback + loggingOpts := &logging.LoggingOptions{} + + _, err := constructLoggingService(fs, writeLoggedFeaturesCallback, loggingOpts) + assert.NoError(t, err) + // Further assertions can be added here based on the expected behavior of constructLoggingService +} + +// Note: Additional tests can be written for other functions and error scenarios. diff --git a/go/types/typeconversion.go b/go/types/typeconversion.go index 18b4769b4d7..1864fe600ab 100644 --- a/go/types/typeconversion.go +++ b/go/types/typeconversion.go @@ -3,9 +3,9 @@ package types import ( "fmt" - "github.com/apache/arrow/go/v8/arrow" - "github.com/apache/arrow/go/v8/arrow/array" - "github.com/apache/arrow/go/v8/arrow/memory" + "github.com/apache/arrow/go/v17/arrow" + "github.com/apache/arrow/go/v17/arrow/array" + "github.com/apache/arrow/go/v17/arrow/memory" "github.com/feast-dev/feast/go/protos/feast/types" ) diff --git a/go/types/typeconversion_test.go b/go/types/typeconversion_test.go index 4869369c186..c9676cf59f4 100644 --- a/go/types/typeconversion_test.go +++ b/go/types/typeconversion_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/apache/arrow/go/v8/arrow/memory" + "github.com/apache/arrow/go/v17/arrow/memory" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" @@ -47,8 +47,8 @@ var ( {Val: &types.Value_Int32ListVal{&types.Int32List{Val: []int32{3, 4, 5}}}}, }, { - {Val: &types.Value_Int64ListVal{&types.Int64List{Val: []int64{0, 1, 2}}}}, - {Val: &types.Value_Int64ListVal{&types.Int64List{Val: []int64{3, 4, 5}}}}, + {Val: &types.Value_Int64ListVal{&types.Int64List{Val: []int64{0, 1, 2, 553248634761893728}}}}, + {Val: &types.Value_Int64ListVal{&types.Int64List{Val: []int64{3, 4, 5, 553248634761893729}}}}, }, { {Val: &types.Value_FloatListVal{&types.FloatList{Val: []float32{0.5, 1.5, 2}}}}, diff --git a/infra/charts/feast-feature-server/Chart.yaml b/infra/charts/feast-feature-server/Chart.yaml index dd547843d10..d8ed41d2782 100644 --- a/infra/charts/feast-feature-server/Chart.yaml +++ b/infra/charts/feast-feature-server/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: feast-feature-server description: Feast Feature Server in Go or Python type: application -version: 0.41.0 +version: 0.46.0 keywords: - machine learning - big data diff --git a/infra/charts/feast-feature-server/README.md b/infra/charts/feast-feature-server/README.md index a36f59d85ea..dc907ab8acf 100644 --- a/infra/charts/feast-feature-server/README.md +++ b/infra/charts/feast-feature-server/README.md @@ -1,6 +1,6 @@ # Feast Python / Go Feature Server Helm Charts -Current chart version is `0.41.0` +Current chart version is `0.46.0` ## Installation @@ -40,7 +40,7 @@ See [here](https://github.com/feast-dev/feast/tree/master/examples/python-helm-d | fullnameOverride | string | `""` | | | image.pullPolicy | string | `"IfNotPresent"` | | | image.repository | string | `"feastdev/feature-server"` | Docker image for Feature Server repository | -| image.tag | string | `"0.41.0"` | The Docker image tag (can be overwritten if custom feature server deps are needed for on demand transforms) | +| image.tag | string | `"0.46.0"` | The Docker image tag (can be overwritten if custom feature server deps are needed for on demand transforms) | | imagePullSecrets | list | `[]` | | | livenessProbe.initialDelaySeconds | int | `30` | | | livenessProbe.periodSeconds | int | `30` | | @@ -56,6 +56,7 @@ See [here](https://github.com/feast-dev/feast/tree/master/examples/python-helm-d | readinessProbe.periodSeconds | int | `10` | | | replicaCount | int | `1` | | | resources | object | `{}` | | +| route.enabled | bool | `false` | | | securityContext | object | `{}` | | | service.port | int | `80` | | | service.type | string | `"ClusterIP"` | | diff --git a/infra/charts/feast-feature-server/templates/route.yaml b/infra/charts/feast-feature-server/templates/route.yaml new file mode 100644 index 00000000000..2f4d36d9e5a --- /dev/null +++ b/infra/charts/feast-feature-server/templates/route.yaml @@ -0,0 +1,18 @@ +{{- if and (.Values.route.enabled) (eq .Values.feast_mode "ui") }} +--- +kind: Route +apiVersion: route.openshift.io/v1 +metadata: + name: {{ include "feast-feature-server.fullname" . }} + labels: + {{- include "feast-feature-server.labels" . | nindent 4 }} +spec: + to: + kind: Service + name: {{ include "feast-feature-server.fullname" . }} + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect +{{- end}} \ No newline at end of file diff --git a/infra/charts/feast-feature-server/values.yaml b/infra/charts/feast-feature-server/values.yaml index d894177558a..db2fbfb28bb 100644 --- a/infra/charts/feast-feature-server/values.yaml +++ b/infra/charts/feast-feature-server/values.yaml @@ -9,7 +9,7 @@ image: repository: feastdev/feature-server pullPolicy: IfNotPresent # image.tag -- The Docker image tag (can be overwritten if custom feature server deps are needed for on demand transforms) - tag: 0.41.0 + tag: 0.46.0 logLevel: "WARNING" # Set log level DEBUG, INFO, WARNING, ERROR, and CRITICAL (case-insensitive) @@ -74,3 +74,7 @@ livenessProbe: readinessProbe: initialDelaySeconds: 20 periodSeconds: 10 + +# to create OpenShift Route object for UI +route: + enabled: false \ No newline at end of file diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index a192da89116..d0dfebbf2b5 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 description: Feature store for machine learning name: feast -version: 0.41.0 +version: 0.46.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index e49fbf6d967..e89c8331d30 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -8,7 +8,7 @@ This repo contains Helm charts for Feast Java components that are being installe ## Chart: Feast -Feature store for machine learning Current chart version is `0.41.0` +Feature store for machine learning Current chart version is `0.46.0` ## Installation @@ -65,8 +65,8 @@ See [here](https://github.com/feast-dev/feast/tree/master/examples/java-demo) fo | Repository | Name | Version | |------------|------|---------| | https://charts.helm.sh/stable | redis | 10.5.6 | -| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.41.0 | -| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.41.0 | +| https://feast-helm-charts.storage.googleapis.com | feature-server(feature-server) | 0.46.0 | +| https://feast-helm-charts.storage.googleapis.com | transformation-service(transformation-service) | 0.46.0 | ## Values diff --git a/infra/charts/feast/charts/feature-server/Chart.yaml b/infra/charts/feast/charts/feature-server/Chart.yaml index 69748a362f0..a4c10bdd5b4 100644 --- a/infra/charts/feast/charts/feature-server/Chart.yaml +++ b/infra/charts/feast/charts/feature-server/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Feast Feature Server: Online feature serving service for Feast" name: feature-server -version: 0.41.0 -appVersion: v0.41.0 +version: 0.46.0 +appVersion: v0.46.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/feature-server/README.md b/infra/charts/feast/charts/feature-server/README.md index ab77911a8f8..697596b2eb2 100644 --- a/infra/charts/feast/charts/feature-server/README.md +++ b/infra/charts/feast/charts/feature-server/README.md @@ -1,6 +1,6 @@ # feature-server -![Version: 0.41.0](https://img.shields.io/badge/Version-0.41.0-informational?style=flat-square) ![AppVersion: v0.41.0](https://img.shields.io/badge/AppVersion-v0.41.0-informational?style=flat-square) +![Version: 0.46.0](https://img.shields.io/badge/Version-0.46.0-informational?style=flat-square) ![AppVersion: v0.46.0](https://img.shields.io/badge/AppVersion-v0.46.0-informational?style=flat-square) Feast Feature Server: Online feature serving service for Feast @@ -17,7 +17,7 @@ Feast Feature Server: Online feature serving service for Feast | envOverrides | object | `{}` | Extra environment variables to set | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"feastdev/feature-server-java"` | Docker image for Feature Server repository | -| image.tag | string | `"0.41.0"` | Image tag | +| image.tag | string | `"0.46.0"` | Image tag | | ingress.grpc.annotations | object | `{}` | Extra annotations for the ingress | | ingress.grpc.auth.enabled | bool | `false` | Flag to enable auth | | ingress.grpc.class | string | `"nginx"` | Which ingress controller to use | diff --git a/infra/charts/feast/charts/feature-server/values.yaml b/infra/charts/feast/charts/feature-server/values.yaml index 646d735ef85..48681f83ca0 100644 --- a/infra/charts/feast/charts/feature-server/values.yaml +++ b/infra/charts/feast/charts/feature-server/values.yaml @@ -5,7 +5,7 @@ image: # image.repository -- Docker image for Feature Server repository repository: feastdev/feature-server-java # image.tag -- Image tag - tag: 0.41.0 + tag: 0.46.0 # image.pullPolicy -- Image pull policy pullPolicy: IfNotPresent diff --git a/infra/charts/feast/charts/transformation-service/Chart.yaml b/infra/charts/feast/charts/transformation-service/Chart.yaml index 6c450852cbf..12123e505e3 100644 --- a/infra/charts/feast/charts/transformation-service/Chart.yaml +++ b/infra/charts/feast/charts/transformation-service/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 description: "Transformation service: to compute on-demand features" name: transformation-service -version: 0.41.0 -appVersion: v0.41.0 +version: 0.46.0 +appVersion: v0.46.0 keywords: - machine learning - big data diff --git a/infra/charts/feast/charts/transformation-service/README.md b/infra/charts/feast/charts/transformation-service/README.md index a00a21f034e..4dfd213bf2e 100644 --- a/infra/charts/feast/charts/transformation-service/README.md +++ b/infra/charts/feast/charts/transformation-service/README.md @@ -1,6 +1,6 @@ # transformation-service -![Version: 0.41.0](https://img.shields.io/badge/Version-0.41.0-informational?style=flat-square) ![AppVersion: v0.41.0](https://img.shields.io/badge/AppVersion-v0.41.0-informational?style=flat-square) +![Version: 0.46.0](https://img.shields.io/badge/Version-0.46.0-informational?style=flat-square) ![AppVersion: v0.46.0](https://img.shields.io/badge/AppVersion-v0.46.0-informational?style=flat-square) Transformation service: to compute on-demand features @@ -13,7 +13,7 @@ Transformation service: to compute on-demand features | envOverrides | object | `{}` | Extra environment variables to set | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"feastdev/feature-transformation-server"` | Docker image for Transformation Server repository | -| image.tag | string | `"0.41.0"` | Image tag | +| image.tag | string | `"0.46.0"` | Image tag | | nodeSelector | object | `{}` | Node labels for pod assignment | | podLabels | object | `{}` | Labels to be added to Feast Serving pods | | replicaCount | int | `1` | Number of pods that will be created | diff --git a/infra/charts/feast/charts/transformation-service/values.yaml b/infra/charts/feast/charts/transformation-service/values.yaml index 51cd72d6592..3d056d5b25f 100644 --- a/infra/charts/feast/charts/transformation-service/values.yaml +++ b/infra/charts/feast/charts/transformation-service/values.yaml @@ -5,7 +5,7 @@ image: # image.repository -- Docker image for Transformation Server repository repository: feastdev/feature-transformation-server # image.tag -- Image tag - tag: 0.41.0 + tag: 0.46.0 # image.pullPolicy -- Image pull policy pullPolicy: IfNotPresent diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index bb69ee9ed30..11d9026f2df 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,12 +1,12 @@ dependencies: - name: feature-server alias: feature-server - version: 0.41.0 + version: 0.46.0 condition: feature-server.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: transformation-service alias: transformation-service - version: 0.41.0 + version: 0.46.0 condition: transformation-service.enabled repository: https://feast-helm-charts.storage.googleapis.com - name: redis diff --git a/infra/feast-helm-operator/Makefile b/infra/feast-helm-operator/Makefile index 733bf7bc3dd..76614ae37af 100644 --- a/infra/feast-helm-operator/Makefile +++ b/infra/feast-helm-operator/Makefile @@ -3,7 +3,7 @@ # To re-generate a bundle for another specific version without changing the standard setup, you can: # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) -VERSION ?= 0.41.0 +VERSION ?= 0.46.0 # CHANNELS define the bundle channels used in the bundle. # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") diff --git a/infra/feast-helm-operator/README.md b/infra/feast-helm-operator/README.md index ba9fe17fa3c..e6d9caee9f5 100644 --- a/infra/feast-helm-operator/README.md +++ b/infra/feast-helm-operator/README.md @@ -1,4 +1,4 @@ -# Feast Feature Server Helm-based Operator +# Feast Feature Server Helm-based Operator (Deprecated replaced by [feast-operator](../feast-operator/README.md)) This Operator was built with the [operator-sdk](https://github.com/operator-framework/operator-sdk) and leverages the [feast-feature-server helm chart](/infra/charts/feast-feature-server). diff --git a/infra/feast-helm-operator/config/manager/kustomization.yaml b/infra/feast-helm-operator/config/manager/kustomization.yaml index decb714a200..bc970e7b408 100644 --- a/infra/feast-helm-operator/config/manager/kustomization.yaml +++ b/infra/feast-helm-operator/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: feastdev/feast-helm-operator - newTag: 0.41.0 + newTag: 0.46.0 diff --git a/infra/feast-operator/.gitignore b/infra/feast-operator/.gitignore index 72df211c0ca..c0c53d8e17e 100644 --- a/infra/feast-operator/.gitignore +++ b/infra/feast-operator/.gitignore @@ -27,4 +27,8 @@ go.work *~ # Installer file generated by Kustomize - skip 'dist/' directories within the Feast project except this one. -!dist/ \ No newline at end of file +!dist/ +bin +bin/ +/bin/ + diff --git a/infra/feast-operator/.golangci.yml b/infra/feast-operator/.golangci.yml index ca69a11f6fd..6c104980d43 100644 --- a/infra/feast-operator/.golangci.yml +++ b/infra/feast-operator/.golangci.yml @@ -16,12 +16,20 @@ issues: linters: - dupl - lll + - path: "test/*" + linters: + - lll + - path: "upgrade/*" + linters: + - lll + - path: "previous-version/*" + linters: + - lll linters: disable-all: true enable: - dupl - errcheck - - exportloopref - goconst - gocyclo - gofmt @@ -32,9 +40,16 @@ linters: - lll - misspell - nakedret + - ginkgolinter - prealloc + - revive - staticcheck - typecheck - unconvert - unparam - unused + +linters-settings: + revive: + rules: + - name: comment-spacings diff --git a/infra/feast-operator/Dockerfile b/infra/feast-operator/Dockerfile index aca26f92295..c7d4f6b696a 100644 --- a/infra/feast-operator/Dockerfile +++ b/infra/feast-operator/Dockerfile @@ -1,9 +1,8 @@ # Build the manager binary -FROM golang:1.21 AS builder +FROM registry.access.redhat.com/ubi8/go-toolset:1.22.9 AS builder ARG TARGETOS ARG TARGETARCH -WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum @@ -23,11 +22,9 @@ COPY internal/controller/ internal/controller/ # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go -# Use distroless as minimal base image to package the manager binary -# Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM registry.access.redhat.com/ubi8/ubi-micro:8.10 WORKDIR / -COPY --from=builder /workspace/manager . +COPY --from=builder /opt/app-root/src/manager . USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/infra/feast-operator/Makefile b/infra/feast-operator/Makefile index 54786eb5f1a..cde9f87982a 100644 --- a/infra/feast-operator/Makefile +++ b/infra/feast-operator/Makefile @@ -3,7 +3,7 @@ # To re-generate a bundle for another specific version without changing the standard setup, you can: # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) -VERSION ?= 0.41.0 +VERSION ?= 0.46.0 # CHANNELS define the bundle channels used in the bundle. # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") @@ -48,11 +48,12 @@ endif # Set the Operator SDK version to use. By default, what is installed on the system is used. # This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. -OPERATOR_SDK_VERSION ?= v1.37.0 +OPERATOR_SDK_VERSION ?= v1.38.0 # Image URL to use all building/pushing image targets IMG ?= $(IMAGE_TAG_BASE):$(VERSION) +FS_IMG ?= docker.io/feastdev/feature-server:$(VERSION) # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. -ENVTEST_K8S_VERSION = 1.29.0 +ENVTEST_K8S_VERSION = 1.30.0 # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -96,7 +97,7 @@ help: ## Display this help. .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. - $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + $(CONTROLLER_GEN) rbac:roleName=manager-role crd:maxDescLen=120 webhook paths="./..." output:crd:artifacts:config=config/crd/bases .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. @@ -111,13 +112,27 @@ vet: ## Run go vet against code. go vet ./... .PHONY: test -test: build-installer fmt vet lint envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out +test: build-installer vet lint envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v test/e2e | grep -v test/data-source-types | grep -v test/upgrade | grep -v test/previous-version) -coverprofile cover.out # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. .PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. test-e2e: - go test ./test/e2e/ -v -ginkgo.v + go test -timeout 60m ./test/e2e/ -v -ginkgo.v + +.PHONY: test-upgrade # Run the upgrade tests against a Kind k8s instance that is spun up. +test-upgrade: + go test -timeout 60m ./test/upgrade/ -v -ginkgo.v + +.PHONY: test-previous-version # Run e2e tests against previous version in a Kind k8s instance that is spun up. +test-previous-version: + go test -timeout 60m ./test/previous-version/ -v -ginkgo.v + +# Requires python3 +.PHONY: test-datasources +test-datasources: + python3 test/data-source-types/data-source-types.py + go test ./test/data-source-types/ .PHONY: lint lint: golangci-lint ## Run golangci-lint linter & yamllint @@ -142,7 +157,12 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . + $(CONTAINER_TOOL) build -t ${IMG} --load . + +## Build feast docker image. +.PHONY: feast-ci-dev-docker-img +feast-ci-dev-docker-img: + cd ./../.. && make build-feature-server-dev .PHONY: docker-push docker-push: ## Push docker image with the manager. @@ -161,12 +181,12 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name project-v3-builder $(CONTAINER_TOOL) buildx use project-v3-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross --load . - $(CONTAINER_TOOL) buildx rm project-v3-builder rm Dockerfile.cross .PHONY: build-installer -build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. +build-installer: manifests generate-ref kustomize related-image-fs ## Generate a consolidated YAML with CRDs and deployment. mkdir -p dist cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default > dist/install.yaml @@ -186,7 +206,7 @@ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy -deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. +deploy: manifests kustomize related-image-fs ## Deploy controller to the K8s cluster specified in ~/.kube/config. cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - @@ -197,22 +217,27 @@ undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/. ##@ Dependencies ## Location to install dependencies to -LOCALBIN ?= $(shell pwd)/bin +LOCALDIR ?= $(shell pwd) +LOCALBIN ?= $(LOCALDIR)/bin $(LOCALBIN): mkdir -p $(LOCALBIN) ## Tool Binaries KUBECTL ?= kubectl -KUSTOMIZE ?= $(LOCALBIN)/kustomize-$(KUSTOMIZE_VERSION) -CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen-$(CONTROLLER_TOOLS_VERSION) -ENVTEST ?= $(LOCALBIN)/setup-envtest-$(ENVTEST_VERSION) -GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) +KUSTOMIZE ?= $(LOCALBIN)/kustomize +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +CRD_REF_DOCS ?= $(LOCALBIN)/crd-ref-docs +ENVTEST ?= $(LOCALBIN)/setup-envtest +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint +ENVSUBST = $(LOCALBIN)/envsubst ## Tool Versions -KUSTOMIZE_VERSION ?= v5.3.0 -CONTROLLER_TOOLS_VERSION ?= v0.14.0 -ENVTEST_VERSION ?= release-0.17 -GOLANGCI_LINT_VERSION ?= v1.57.2 +KUSTOMIZE_VERSION ?= v5.4.2 +CONTROLLER_TOOLS_VERSION ?= v0.15.0 +CRD_REF_DOCS_VERSION ?= v0.1.0 +ENVTEST_VERSION ?= release-0.18 +GOLANGCI_LINT_VERSION ?= v1.59.1 +ENVSUBST_VERSION ?= v1.4.2 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. @@ -232,20 +257,28 @@ $(ENVTEST): $(LOCALBIN) .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) - $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,${GOLANGCI_LINT_VERSION}) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + +.PHONY: envsubst +envsubst: $(ENVSUBST) ## Download envsubst locally if necessary. +$(ENVSUBST): $(LOCALBIN) + $(call go-install-tool,$(ENVSUBST),github.com/a8m/envsubst/cmd/envsubst,$(ENVSUBST_VERSION)) # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary (ideally with version) # $2 - package url which can be installed # $3 - specific version of package define go-install-tool -@[ -f $(1) ] || { \ +@[ -f "$(1)-$(3)" ] || { \ + echo "Downloading $${package}" ;\ +rm -f $(1) || true ;\ set -e; \ package=$(2)@$(3) ;\ echo "Downloading $${package}" ;\ GOBIN=$(LOCALBIN) go install $${package} ;\ -mv "$$(echo "$(1)" | sed "s/-$(3)$$//")" $(1) ;\ -} +mv $(1) $(1)-$(3) ;\ +} ;\ +ln -sf $(1)-$(3) $(1) endef .PHONY: operator-sdk @@ -265,8 +298,17 @@ OPERATOR_SDK = $(shell which operator-sdk) endif endif +.PHONY: crd-ref-docs +crd-ref-docs: $(CRD_REF_DOCS) ## Download crd-ref-docs locally if necessary. +$(CRD_REF_DOCS): $(LOCALBIN) + $(call go-install-tool,$(CRD_REF_DOCS),github.com/elastic/crd-ref-docs,$(CRD_REF_DOCS_VERSION)) + +.PHONY: generate-ref +generate-ref: generate fmt crd-ref-docs + $(CRD_REF_DOCS) --log-level=WARN --max-depth=30 --config=$(LOCALDIR)/docs/crd-ref-templates/config.yaml --source-path=$(LOCALDIR)/api/v1alpha1 --renderer=markdown --templates-dir=$(LOCALDIR)/docs/crd-ref-templates/markdown --output-path=$(LOCALDIR)/docs/api/markdown/ref.md + .PHONY: bundle -bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metadata, then validate generated files. +bundle: manifests kustomize related-image-fs operator-sdk ## Generate bundle manifests and metadata, then validate generated files. $(OPERATOR_SDK) generate kustomize manifests -q cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) @@ -320,3 +362,7 @@ catalog-build: opm ## Build a catalog image. .PHONY: catalog-push catalog-push: ## Push a catalog image. $(MAKE) docker-push IMG=$(CATALOG_IMG) + +.PHONY: related-image-fs +related-image-fs: envsubst + FS_IMG=$(FS_IMG) $(ENVSUBST) < config/default/related_image_fs_patch.tmpl > config/default/related_image_fs_patch.yaml diff --git a/infra/feast-operator/README.md b/infra/feast-operator/README.md index 32e2ef11b53..b4c3ed6565b 100644 --- a/infra/feast-operator/README.md +++ b/infra/feast-operator/README.md @@ -1,10 +1,12 @@ # Feast Operator This is a K8s Operator that can be used to deploy and manage **Feast**, an open source feature store for machine learning. +### **[FeatureStore CR API Reference](docs/api/markdown/ref.md)** + ## Getting Started ### Prerequisites -- go version v1.21.0+ +- go version v1.22 - docker version 17.03+. - kubectl version v1.11.3+. - Access to a Kubernetes v1.11.3+ cluster. @@ -108,8 +110,8 @@ make deploy IMG=/feast-operator: ``` ### Prerequisites -- go version v1.21 -- operator-sdk version v1.37.0 +- go version v1.22 +- operator-sdk version v1.38.0 **NOTE:** Run `make help` for more information on all potential `make` targets @@ -131,3 +133,25 @@ 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. + + +## Running End-to-End integration tests on local(dev) environment +You need a kind cluster to run the e2e tests on local(dev) environment. + +```shell +# Default kind cluster configuration is not enough to run all the pods. In my case i was using docker with colima. kind uses the cpi and memory assigned to docker. +# below memory configuration worked well but if you are using other docker runtime then please increase the cpu and memory. +colima start --cpu 10 --memory 15 --disk 100 + +# create the kind cluster +kind create cluster + +# set kubernetes context to the recently created kind cluster +kubectl cluster-info --context kind-kind + +# run the command from operator directory to run e2e tests. +make test-e2e + +# delete cluster once you are done. +kind delete cluster +``` diff --git a/infra/feast-operator/api/feastversion/version.go b/infra/feast-operator/api/feastversion/version.go index 77a9db1d57f..a0e99c98056 100644 --- a/infra/feast-operator/api/feastversion/version.go +++ b/infra/feast-operator/api/feastversion/version.go @@ -16,5 +16,5 @@ limitations under the License. package feastversion -// Feast release version -const FeastVersion = "0.41.0" +// Feast release version. Keep on line #20, this is critical to release CI +const FeastVersion = "0.46.0" diff --git a/infra/feast-operator/api/v1alpha1/featurestore_types.go b/infra/feast-operator/api/v1alpha1/featurestore_types.go index 87e1cd64841..1d00163d61b 100644 --- a/infra/feast-operator/api/v1alpha1/featurestore_types.go +++ b/infra/feast-operator/api/v1alpha1/featurestore_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha1 import ( + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -28,26 +29,34 @@ const ( FailedPhase = "Failed" // Feast condition types: - ClientReadyType = "Client" - OfflineStoreReadyType = "OfflineStore" - OnlineStoreReadyType = "OnlineStore" - RegistryReadyType = "Registry" - ReadyType = "FeatureStore" + ClientReadyType = "Client" + OfflineStoreReadyType = "OfflineStore" + OnlineStoreReadyType = "OnlineStore" + RegistryReadyType = "Registry" + UIReadyType = "UI" + ReadyType = "FeatureStore" + AuthorizationReadyType = "Authorization" // Feast condition reasons: - ReadyReason = "Ready" - FailedReason = "FeatureStoreFailed" - OfflineStoreFailedReason = "OfflineStoreDeploymentFailed" - OnlineStoreFailedReason = "OnlineStoreDeploymentFailed" - RegistryFailedReason = "RegistryDeploymentFailed" - ClientFailedReason = "ClientDeploymentFailed" + ReadyReason = "Ready" + FailedReason = "FeatureStoreFailed" + DeploymentNotAvailableReason = "DeploymentNotAvailable" + OfflineStoreFailedReason = "OfflineStoreDeploymentFailed" + OnlineStoreFailedReason = "OnlineStoreDeploymentFailed" + RegistryFailedReason = "RegistryDeploymentFailed" + UIFailedReason = "UIDeploymentFailed" + ClientFailedReason = "ClientDeploymentFailed" + KubernetesAuthzFailedReason = "KubernetesAuthorizationDeploymentFailed" // Feast condition messages: - ReadyMessage = "FeatureStore installation complete" - OfflineStoreReadyMessage = "Offline Store installation complete" - OnlineStoreReadyMessage = "Online Store installation complete" - RegistryReadyMessage = "Registry installation complete" - ClientReadyMessage = "Client installation complete" + ReadyMessage = "FeatureStore installation complete" + OfflineStoreReadyMessage = "Offline Store installation complete" + OnlineStoreReadyMessage = "Online Store installation complete" + RegistryReadyMessage = "Registry installation complete" + UIReadyMessage = "UI installation complete" + ClientReadyMessage = "Client installation complete" + KubernetesAuthzReadyMessage = "Kubernetes authorization installation complete" + DeploymentNotAvailableMessage = "Deployment is not available" // entity_key_serialization_version SerializationVersion = 3 @@ -57,30 +66,231 @@ const ( type FeatureStoreSpec struct { // +kubebuilder:validation:Pattern="^[A-Za-z0-9][A-Za-z0-9_]*$" // FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an underscore. Required. - FeastProject string `json:"feastProject"` - Services *FeatureStoreServices `json:"services,omitempty"` + FeastProject string `json:"feastProject"` + FeastProjectDir *FeastProjectDir `json:"feastProjectDir,omitempty"` + Services *FeatureStoreServices `json:"services,omitempty"` + AuthzConfig *AuthzConfig `json:"authz,omitempty"` } -// FeatureStoreServices defines the desired feast service deployments. ephemeral registry is deployed by default. +// FeastProjectDir defines how to create the feast project directory. +// +kubebuilder:validation:XValidation:rule="[has(self.git), has(self.init)].exists_one(c, c)",message="One selection required between init or git." +type FeastProjectDir struct { + Git *GitCloneOptions `json:"git,omitempty"` + Init *FeastInitOptions `json:"init,omitempty"` +} + +// GitCloneOptions describes how a clone should be performed. +// +kubebuilder:validation:XValidation:rule="has(self.featureRepoPath) ? !self.featureRepoPath.startsWith('/') : true",message="RepoPath must be a file name only, with no slashes." +type GitCloneOptions struct { + // The repository URL to clone from. + URL string `json:"url"` + // Reference to a branch / tag / commit + Ref string `json:"ref,omitempty"` + // Configs passed to git via `-c` + // e.g. http.sslVerify: 'false' + // OR 'url."https://api:\${TOKEN}@github.com/".insteadOf': 'https://github.com/' + Configs map[string]string `json:"configs,omitempty"` + // FeatureRepoPath is the relative path to the feature repo subdirectory. Default is 'feature_repo'. + FeatureRepoPath string `json:"featureRepoPath,omitempty"` + Env *[]corev1.EnvVar `json:"env,omitempty"` + EnvFrom *[]corev1.EnvFromSource `json:"envFrom,omitempty"` +} + +// FeastInitOptions defines how to run a `feast init`. +type FeastInitOptions struct { + Minimal bool `json:"minimal,omitempty"` + // Template for the created project + // +kubebuilder:validation:Enum=local;gcp;aws;snowflake;spark;postgres;hbase;cassandra;hazelcast;ikv;couchbase + Template string `json:"template,omitempty"` +} + +// FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. type FeatureStoreServices struct { OfflineStore *OfflineStore `json:"offlineStore,omitempty"` OnlineStore *OnlineStore `json:"onlineStore,omitempty"` Registry *Registry `json:"registry,omitempty"` + // Creates a UI server container + UI *ServerConfigs `json:"ui,omitempty"` + DeploymentStrategy *appsv1.DeploymentStrategy `json:"deploymentStrategy,omitempty"` + // Disable the 'feast repo initialization' initContainer + DisableInitContainers bool `json:"disableInitContainers,omitempty"` + // Volumes specifies the volumes to mount in the FeatureStore deployment. A corresponding `VolumeMount` should be added to whichever feast service(s) require access to said volume(s). + Volumes []corev1.Volume `json:"volumes,omitempty"` } -// OfflineStore configures the deployed offline store service +// OfflineStore configures the offline store service type OfflineStore struct { - ServiceConfigs `json:",inline"` + // Creates a remote offline server container + Server *ServerConfigs `json:"server,omitempty"` + Persistence *OfflineStorePersistence `json:"persistence,omitempty"` +} + +// OfflineStorePersistence configures the persistence settings for the offline store service +// +kubebuilder:validation:XValidation:rule="[has(self.file), has(self.store)].exists_one(c, c)",message="One selection required between file or store." +type OfflineStorePersistence struct { + FilePersistence *OfflineStoreFilePersistence `json:"file,omitempty"` + DBPersistence *OfflineStoreDBStorePersistence `json:"store,omitempty"` +} + +// OfflineStoreFilePersistence configures the file-based persistence for the offline store service +type OfflineStoreFilePersistence struct { + // +kubebuilder:validation:Enum=file;dask;duckdb + Type string `json:"type,omitempty"` + PvcConfig *PvcConfig `json:"pvc,omitempty"` +} + +var ValidOfflineStoreFilePersistenceTypes = []string{ + "dask", + "duckdb", + "file", +} + +// OfflineStoreDBStorePersistence configures the DB store persistence for the offline store service +type OfflineStoreDBStorePersistence struct { + // Type of the persistence type you want to use. + // +kubebuilder:validation:Enum=snowflake.offline;bigquery;redshift;spark;postgres;trino;athena;mssql;couchbase.offline + Type string `json:"type"` + // Data store parameters should be placed as-is from the "feature_store.yaml" under the secret key. "registry_type" & "type" fields should be removed. + SecretRef corev1.LocalObjectReference `json:"secretRef"` + // By default, the selected store "type" is used as the SecretKeyName + SecretKeyName string `json:"secretKeyName,omitempty"` +} + +var ValidOfflineStoreDBStorePersistenceTypes = []string{ + "snowflake.offline", + "bigquery", + "redshift", + "spark", + "postgres", + "trino", + "athena", + "mssql", + "couchbase.offline", } -// OnlineStore configures the deployed online store service +// OnlineStore configures the online store service type OnlineStore struct { - ServiceConfigs `json:",inline"` + // Creates a feature server container + Server *ServerConfigs `json:"server,omitempty"` + Persistence *OnlineStorePersistence `json:"persistence,omitempty"` +} + +// OnlineStorePersistence configures the persistence settings for the online store service +// +kubebuilder:validation:XValidation:rule="[has(self.file), has(self.store)].exists_one(c, c)",message="One selection required between file or store." +type OnlineStorePersistence struct { + FilePersistence *OnlineStoreFilePersistence `json:"file,omitempty"` + DBPersistence *OnlineStoreDBStorePersistence `json:"store,omitempty"` +} + +// OnlineStoreFilePersistence configures the file-based persistence for the online store service +// +kubebuilder:validation:XValidation:rule="(!has(self.pvc) && has(self.path)) ? self.path.startsWith('/') : true",message="Ephemeral stores must have absolute paths." +// +kubebuilder:validation:XValidation:rule="(has(self.pvc) && has(self.path)) ? !self.path.startsWith('/') : true",message="PVC path must be a file name only, with no slashes." +// +kubebuilder:validation:XValidation:rule="has(self.path) ? !(self.path.startsWith('s3://') || self.path.startsWith('gs://')) : true",message="Online store does not support S3 or GS buckets." +type OnlineStoreFilePersistence struct { + Path string `json:"path,omitempty"` + PvcConfig *PvcConfig `json:"pvc,omitempty"` } -// LocalRegistryConfig configures the deployed registry service +// OnlineStoreDBStorePersistence configures the DB store persistence for the online store service +type OnlineStoreDBStorePersistence struct { + // Type of the persistence type you want to use. + // +kubebuilder:validation:Enum=snowflake.online;redis;ikv;datastore;dynamodb;bigtable;postgres;cassandra;mysql;hazelcast;singlestore;hbase;elasticsearch;qdrant;couchbase.online;milvus + Type string `json:"type"` + // Data store parameters should be placed as-is from the "feature_store.yaml" under the secret key. "registry_type" & "type" fields should be removed. + SecretRef corev1.LocalObjectReference `json:"secretRef"` + // By default, the selected store "type" is used as the SecretKeyName + SecretKeyName string `json:"secretKeyName,omitempty"` +} + +var ValidOnlineStoreDBStorePersistenceTypes = []string{ + "snowflake.online", + "redis", + "ikv", + "datastore", + "dynamodb", + "bigtable", + "postgres", + "cassandra", + "mysql", + "hazelcast", + "singlestore", + "hbase", + "elasticsearch", + "qdrant", + "couchbase.online", + "milvus", +} + +// LocalRegistryConfig configures the registry service type LocalRegistryConfig struct { - ServiceConfigs `json:",inline"` + // Creates a registry server container + Server *ServerConfigs `json:"server,omitempty"` + Persistence *RegistryPersistence `json:"persistence,omitempty"` +} + +// RegistryPersistence configures the persistence settings for the registry service +// +kubebuilder:validation:XValidation:rule="[has(self.file), has(self.store)].exists_one(c, c)",message="One selection required between file or store." +type RegistryPersistence struct { + FilePersistence *RegistryFilePersistence `json:"file,omitempty"` + DBPersistence *RegistryDBStorePersistence `json:"store,omitempty"` +} + +// RegistryFilePersistence configures the file-based persistence for the registry service +// +kubebuilder:validation:XValidation:rule="(!has(self.pvc) && has(self.path)) ? (self.path.startsWith('/') || self.path.startsWith('s3://') || self.path.startsWith('gs://')) : true",message="Registry files must use absolute paths or be S3 ('s3://') or GS ('gs://') object store URIs." +// +kubebuilder:validation:XValidation:rule="(has(self.pvc) && has(self.path)) ? !self.path.startsWith('/') : true",message="PVC path must be a file name only, with no slashes." +// +kubebuilder:validation:XValidation:rule="(has(self.pvc) && has(self.path)) ? !(self.path.startsWith('s3://') || self.path.startsWith('gs://')) : true",message="PVC persistence does not support S3 or GS object store URIs." +// +kubebuilder:validation:XValidation:rule="(has(self.s3_additional_kwargs) && has(self.path)) ? self.path.startsWith('s3://') : true",message="Additional S3 settings are available only for S3 object store URIs." +type RegistryFilePersistence struct { + Path string `json:"path,omitempty"` + PvcConfig *PvcConfig `json:"pvc,omitempty"` + S3AdditionalKwargs *map[string]string `json:"s3_additional_kwargs,omitempty"` +} + +// RegistryDBStorePersistence configures the DB store persistence for the registry service +type RegistryDBStorePersistence struct { + // Type of the persistence type you want to use. + // +kubebuilder:validation:Enum=sql;snowflake.registry + Type string `json:"type"` + // Data store parameters should be placed as-is from the "feature_store.yaml" under the secret key. "registry_type" & "type" fields should be removed. + SecretRef corev1.LocalObjectReference `json:"secretRef"` + // By default, the selected store "type" is used as the SecretKeyName + SecretKeyName string `json:"secretKeyName,omitempty"` +} + +var ValidRegistryDBStorePersistenceTypes = []string{ + "sql", + "snowflake.registry", +} + +// PvcConfig defines the settings for a persistent file store based on PVCs. +// We can refer to an existing PVC using the `Ref` field, or create a new one using the `Create` field. +// +kubebuilder:validation:XValidation:rule="[has(self.ref), has(self.create)].exists_one(c, c)",message="One selection is required between ref and create." +// +kubebuilder:validation:XValidation:rule="self.mountPath.matches('^/[^:]*$')",message="Mount path must start with '/' and must not contain ':'" +type PvcConfig struct { + // Reference to an existing field + Ref *corev1.LocalObjectReference `json:"ref,omitempty"` + // Settings for creating a new PVC + Create *PvcCreate `json:"create,omitempty"` + // MountPath within the container at which the volume should be mounted. + // Must start by "/" and cannot contain ':'. + MountPath string `json:"mountPath"` +} + +// PvcCreate defines the immutable settings to create a new PVC mounted at the given path. +// The PVC name is the same as the associated deployment & feast service name. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="PvcCreate is immutable" +type PvcCreate struct { + // AccessModes k8s persistent volume access modes. Defaults to ["ReadWriteOnce"]. + AccessModes []corev1.PersistentVolumeAccessMode `json:"accessModes,omitempty"` + // StorageClassName is the name of an existing StorageClass to which this persistent volume belongs. Empty value + // means that this volume does not belong to any StorageClass and the cluster default will be used. + StorageClassName *string `json:"storageClassName,omitempty"` + // Resources describes the storage resource requirements for a volume. + // Default requested storage size depends on the associated service: + // - 10Gi for offline store + // - 5Gi for online store + // - 5Gi for registry + Resources corev1.VolumeResourceRequirements `json:"resources,omitempty"` } // Registry configures the registry service. One selection is required. Local is the default setting. @@ -97,7 +307,8 @@ type RemoteRegistryConfig struct { // Host address of the remote registry service - :, e.g. `registry..svc.cluster.local:80` Hostname *string `json:"hostname,omitempty"` // Reference to an existing `FeatureStore` CR in the same k8s cluster. - FeastRef *FeatureStoreRef `json:"feastRef,omitempty"` + FeastRef *FeatureStoreRef `json:"feastRef,omitempty"` + TLS *TlsRemoteRegistryConfigs `json:"tls,omitempty"` } // FeatureStoreRef defines which existing FeatureStore's registry should be used @@ -108,35 +319,114 @@ type FeatureStoreRef struct { Namespace string `json:"namespace,omitempty"` } -// ServiceConfigs k8s container settings -type ServiceConfigs struct { - DefaultConfigs `json:",inline"` - OptionalConfigs `json:",inline"` +// ServerConfigs creates a server for the feast service, with specified container configurations. +type ServerConfigs struct { + ContainerConfigs `json:",inline"` + TLS *TlsConfigs `json:"tls,omitempty"` + // LogLevel sets the logging level for the server + // Allowed values: "debug", "info", "warning", "error", "critical". + // +kubebuilder:validation:Enum=debug;info;warning;error;critical + LogLevel *string `json:"logLevel,omitempty"` + // VolumeMounts defines the list of volumes that should be mounted into the feast container. + // This allows attaching persistent storage, config files, secrets, or other resources + // required by the Feast components. Ensure that each volume mount has a corresponding + // volume definition in the Volumes field. + VolumeMounts []corev1.VolumeMount `json:"volumeMounts,omitempty"` } -// DefaultConfigs k8s container settings that are applied by default -type DefaultConfigs struct { +// ContainerConfigs k8s container settings for the server +type ContainerConfigs struct { + DefaultCtrConfigs `json:",inline"` + OptionalCtrConfigs `json:",inline"` +} + +// DefaultCtrConfigs k8s container settings that are applied by default +type DefaultCtrConfigs struct { Image *string `json:"image,omitempty"` } -// OptionalConfigs k8s container settings that are optional -type OptionalConfigs struct { +// OptionalCtrConfigs k8s container settings that are optional +type OptionalCtrConfigs struct { Env *[]corev1.EnvVar `json:"env,omitempty"` + EnvFrom *[]corev1.EnvFromSource `json:"envFrom,omitempty"` ImagePullPolicy *corev1.PullPolicy `json:"imagePullPolicy,omitempty"` Resources *corev1.ResourceRequirements `json:"resources,omitempty"` } +// AuthzConfig defines the authorization settings for the deployed Feast services. +// +kubebuilder:validation:XValidation:rule="[has(self.kubernetes), has(self.oidc)].exists_one(c, c)",message="One selection required between kubernetes or oidc." +type AuthzConfig struct { + KubernetesAuthz *KubernetesAuthz `json:"kubernetes,omitempty"` + OidcAuthz *OidcAuthz `json:"oidc,omitempty"` +} + +// KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. +// https://kubernetes.io/docs/reference/access-authn-authz/rbac/ +type KubernetesAuthz struct { + // The Kubernetes RBAC roles to be deployed in the same namespace of the FeatureStore. + // Roles are managed by the operator and created with an empty list of rules. + // See the Feast permission model at https://docs.feast.dev/getting-started/concepts/permission + // The feature store admin is not obligated to manage roles using the Feast operator, roles can be managed independently. + // This configuration option is only providing a way to automate this procedure. + // Important note: the operator cannot ensure that these roles will match the ones used in the configured Feast permissions. + Roles []string `json:"roles,omitempty"` +} + +// OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. +// https://auth0.com/docs/authenticate/protocols/openid-connect-protocol +type OidcAuthz struct { + SecretRef corev1.LocalObjectReference `json:"secretRef"` +} + +// TlsConfigs configures server TLS for a feast service. in an openshift cluster, this is configured by default using service serving certificates. +// +kubebuilder:validation:XValidation:rule="(!has(self.disable) || !self.disable) ? has(self.secretRef) : true",message="`secretRef` required if `disable` is false." +type TlsConfigs struct { + // references the local k8s secret where the TLS key and cert reside + SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` + SecretKeyNames SecretKeyNames `json:"secretKeyNames,omitempty"` + // will disable TLS for the feast service. useful in an openshift cluster, for example, where TLS is configured by default + Disable *bool `json:"disable,omitempty"` +} + +// `secretRef` required if `disable` is false. +func (tls *TlsConfigs) IsTLS() bool { + if tls != nil { + if tls.Disable != nil && *tls.Disable { + return false + } else if tls.SecretRef == nil { + return false + } + return true + } + return false +} + +// TlsRemoteRegistryConfigs configures client TLS for a remote feast registry. in an openshift cluster, this is configured by default when the remote feast registry is using service serving certificates. +type TlsRemoteRegistryConfigs struct { + // references the local k8s configmap where the TLS cert resides + ConfigMapRef corev1.LocalObjectReference `json:"configMapRef"` + // defines the configmap key name for the client TLS cert. + CertName string `json:"certName"` +} + +// SecretKeyNames defines the secret key names for the TLS key and cert. +type SecretKeyNames struct { + // defaults to "tls.crt" + TlsCrt string `json:"tlsCrt,omitempty"` + // defaults to "tls.key" + TlsKey string `json:"tlsKey,omitempty"` +} + // FeatureStoreStatus defines the observed state of FeatureStore type FeatureStoreStatus struct { // Shows the currently applied feast configuration, including any pertinent defaults Applied FeatureStoreSpec `json:"applied,omitempty"` // ConfigMap in this namespace containing a client `feature_store.yaml` for this feast deployment - ClientConfigMap string `json:"clientConfigMap,omitempty"` - Conditions []metav1.Condition `json:"conditions,omitempty"` - // Version of feast that's currently deployed - FeastVersion string `json:"feastVersion,omitempty"` - Phase string `json:"phase,omitempty"` - ServiceHostnames ServiceHostnames `json:"serviceHostnames,omitempty"` + ClientConfigMap string `json:"clientConfigMap,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + FeastVersion string `json:"feastVersion,omitempty"` + Phase string `json:"phase,omitempty"` + ServiceHostnames ServiceHostnames `json:"serviceHostnames,omitempty"` } // ServiceHostnames defines the service hostnames in the format of :, e.g. example.svc.cluster.local:80 @@ -144,13 +434,14 @@ type ServiceHostnames struct { OfflineStore string `json:"offlineStore,omitempty"` OnlineStore string `json:"onlineStore,omitempty"` Registry string `json:"registry,omitempty"` + UI string `json:"ui,omitempty"` } -//+kubebuilder:object:root=true -//+kubebuilder:subresource:status -//+kubebuilder:resource:shortName=feast -//+kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` -//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=feast +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // FeatureStore is the Schema for the featurestores API type FeatureStore struct { @@ -161,7 +452,7 @@ type FeatureStore struct { Status FeatureStoreStatus `json:"status,omitempty"` } -//+kubebuilder:object:root=true +// +kubebuilder:object:root=true // FeatureStoreList contains a list of FeatureStore type FeatureStoreList struct { diff --git a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go index f37c8942ad2..87e5b7164af 100644 --- a/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/infra/feast-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -21,13 +21,56 @@ limitations under the License. package v1alpha1 import ( + appsv1 "k8s.io/api/apps/v1" "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DefaultConfigs) DeepCopyInto(out *DefaultConfigs) { +func (in *AuthzConfig) DeepCopyInto(out *AuthzConfig) { + *out = *in + if in.KubernetesAuthz != nil { + in, out := &in.KubernetesAuthz, &out.KubernetesAuthz + *out = new(KubernetesAuthz) + (*in).DeepCopyInto(*out) + } + if in.OidcAuthz != nil { + in, out := &in.OidcAuthz, &out.OidcAuthz + *out = new(OidcAuthz) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthzConfig. +func (in *AuthzConfig) DeepCopy() *AuthzConfig { + if in == nil { + return nil + } + out := new(AuthzConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContainerConfigs) DeepCopyInto(out *ContainerConfigs) { + *out = *in + in.DefaultCtrConfigs.DeepCopyInto(&out.DefaultCtrConfigs) + in.OptionalCtrConfigs.DeepCopyInto(&out.OptionalCtrConfigs) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerConfigs. +func (in *ContainerConfigs) DeepCopy() *ContainerConfigs { + if in == nil { + return nil + } + out := new(ContainerConfigs) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DefaultCtrConfigs) DeepCopyInto(out *DefaultCtrConfigs) { *out = *in if in.Image != nil { in, out := &in.Image, &out.Image @@ -36,12 +79,52 @@ func (in *DefaultConfigs) DeepCopyInto(out *DefaultConfigs) { } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DefaultConfigs. -func (in *DefaultConfigs) DeepCopy() *DefaultConfigs { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DefaultCtrConfigs. +func (in *DefaultCtrConfigs) DeepCopy() *DefaultCtrConfigs { if in == nil { return nil } - out := new(DefaultConfigs) + out := new(DefaultCtrConfigs) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeastInitOptions) DeepCopyInto(out *FeastInitOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeastInitOptions. +func (in *FeastInitOptions) DeepCopy() *FeastInitOptions { + if in == nil { + return nil + } + out := new(FeastInitOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeastProjectDir) DeepCopyInto(out *FeastProjectDir) { + *out = *in + if in.Git != nil { + in, out := &in.Git, &out.Git + *out = new(GitCloneOptions) + (*in).DeepCopyInto(*out) + } + if in.Init != nil { + in, out := &in.Init, &out.Init + *out = new(FeastInitOptions) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeastProjectDir. +func (in *FeastProjectDir) DeepCopy() *FeastProjectDir { + if in == nil { + return nil + } + out := new(FeastProjectDir) in.DeepCopyInto(out) return out } @@ -138,6 +221,23 @@ func (in *FeatureStoreServices) DeepCopyInto(out *FeatureStoreServices) { *out = new(Registry) (*in).DeepCopyInto(*out) } + if in.UI != nil { + in, out := &in.UI, &out.UI + *out = new(ServerConfigs) + (*in).DeepCopyInto(*out) + } + if in.DeploymentStrategy != nil { + in, out := &in.DeploymentStrategy, &out.DeploymentStrategy + *out = new(appsv1.DeploymentStrategy) + (*in).DeepCopyInto(*out) + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = make([]v1.Volume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureStoreServices. @@ -153,11 +253,21 @@ func (in *FeatureStoreServices) DeepCopy() *FeatureStoreServices { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FeatureStoreSpec) DeepCopyInto(out *FeatureStoreSpec) { *out = *in + if in.FeastProjectDir != nil { + in, out := &in.FeastProjectDir, &out.FeastProjectDir + *out = new(FeastProjectDir) + (*in).DeepCopyInto(*out) + } if in.Services != nil { in, out := &in.Services, &out.Services *out = new(FeatureStoreServices) (*in).DeepCopyInto(*out) } + if in.AuthzConfig != nil { + in, out := &in.AuthzConfig, &out.AuthzConfig + *out = new(AuthzConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureStoreSpec. @@ -194,10 +304,83 @@ func (in *FeatureStoreStatus) DeepCopy() *FeatureStoreStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitCloneOptions) DeepCopyInto(out *GitCloneOptions) { + *out = *in + if in.Configs != nil { + in, out := &in.Configs, &out.Configs + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = new([]v1.EnvVar) + if **in != nil { + in, out := *in, *out + *out = make([]v1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + } + if in.EnvFrom != nil { + in, out := &in.EnvFrom, &out.EnvFrom + *out = new([]v1.EnvFromSource) + if **in != nil { + in, out := *in, *out + *out = make([]v1.EnvFromSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCloneOptions. +func (in *GitCloneOptions) DeepCopy() *GitCloneOptions { + if in == nil { + return nil + } + out := new(GitCloneOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesAuthz) DeepCopyInto(out *KubernetesAuthz) { + *out = *in + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesAuthz. +func (in *KubernetesAuthz) DeepCopy() *KubernetesAuthz { + if in == nil { + return nil + } + out := new(KubernetesAuthz) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LocalRegistryConfig) DeepCopyInto(out *LocalRegistryConfig) { *out = *in - in.ServiceConfigs.DeepCopyInto(&out.ServiceConfigs) + if in.Server != nil { + in, out := &in.Server, &out.Server + *out = new(ServerConfigs) + (*in).DeepCopyInto(*out) + } + if in.Persistence != nil { + in, out := &in.Persistence, &out.Persistence + *out = new(RegistryPersistence) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalRegistryConfig. @@ -213,7 +396,16 @@ func (in *LocalRegistryConfig) DeepCopy() *LocalRegistryConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OfflineStore) DeepCopyInto(out *OfflineStore) { *out = *in - in.ServiceConfigs.DeepCopyInto(&out.ServiceConfigs) + if in.Server != nil { + in, out := &in.Server, &out.Server + *out = new(ServerConfigs) + (*in).DeepCopyInto(*out) + } + if in.Persistence != nil { + in, out := &in.Persistence, &out.Persistence + *out = new(OfflineStorePersistence) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OfflineStore. @@ -226,10 +418,96 @@ func (in *OfflineStore) DeepCopy() *OfflineStore { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OfflineStoreDBStorePersistence) DeepCopyInto(out *OfflineStoreDBStorePersistence) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OfflineStoreDBStorePersistence. +func (in *OfflineStoreDBStorePersistence) DeepCopy() *OfflineStoreDBStorePersistence { + if in == nil { + return nil + } + out := new(OfflineStoreDBStorePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OfflineStoreFilePersistence) DeepCopyInto(out *OfflineStoreFilePersistence) { + *out = *in + if in.PvcConfig != nil { + in, out := &in.PvcConfig, &out.PvcConfig + *out = new(PvcConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OfflineStoreFilePersistence. +func (in *OfflineStoreFilePersistence) DeepCopy() *OfflineStoreFilePersistence { + if in == nil { + return nil + } + out := new(OfflineStoreFilePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OfflineStorePersistence) DeepCopyInto(out *OfflineStorePersistence) { + *out = *in + if in.FilePersistence != nil { + in, out := &in.FilePersistence, &out.FilePersistence + *out = new(OfflineStoreFilePersistence) + (*in).DeepCopyInto(*out) + } + if in.DBPersistence != nil { + in, out := &in.DBPersistence, &out.DBPersistence + *out = new(OfflineStoreDBStorePersistence) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OfflineStorePersistence. +func (in *OfflineStorePersistence) DeepCopy() *OfflineStorePersistence { + if in == nil { + return nil + } + out := new(OfflineStorePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OidcAuthz) DeepCopyInto(out *OidcAuthz) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OidcAuthz. +func (in *OidcAuthz) DeepCopy() *OidcAuthz { + if in == nil { + return nil + } + out := new(OidcAuthz) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OnlineStore) DeepCopyInto(out *OnlineStore) { *out = *in - in.ServiceConfigs.DeepCopyInto(&out.ServiceConfigs) + if in.Server != nil { + in, out := &in.Server, &out.Server + *out = new(ServerConfigs) + (*in).DeepCopyInto(*out) + } + if in.Persistence != nil { + in, out := &in.Persistence, &out.Persistence + *out = new(OnlineStorePersistence) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OnlineStore. @@ -243,7 +521,68 @@ func (in *OnlineStore) DeepCopy() *OnlineStore { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *OptionalConfigs) DeepCopyInto(out *OptionalConfigs) { +func (in *OnlineStoreDBStorePersistence) DeepCopyInto(out *OnlineStoreDBStorePersistence) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OnlineStoreDBStorePersistence. +func (in *OnlineStoreDBStorePersistence) DeepCopy() *OnlineStoreDBStorePersistence { + if in == nil { + return nil + } + out := new(OnlineStoreDBStorePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OnlineStoreFilePersistence) DeepCopyInto(out *OnlineStoreFilePersistence) { + *out = *in + if in.PvcConfig != nil { + in, out := &in.PvcConfig, &out.PvcConfig + *out = new(PvcConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OnlineStoreFilePersistence. +func (in *OnlineStoreFilePersistence) DeepCopy() *OnlineStoreFilePersistence { + if in == nil { + return nil + } + out := new(OnlineStoreFilePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OnlineStorePersistence) DeepCopyInto(out *OnlineStorePersistence) { + *out = *in + if in.FilePersistence != nil { + in, out := &in.FilePersistence, &out.FilePersistence + *out = new(OnlineStoreFilePersistence) + (*in).DeepCopyInto(*out) + } + if in.DBPersistence != nil { + in, out := &in.DBPersistence, &out.DBPersistence + *out = new(OnlineStoreDBStorePersistence) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OnlineStorePersistence. +func (in *OnlineStorePersistence) DeepCopy() *OnlineStorePersistence { + if in == nil { + return nil + } + out := new(OnlineStorePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OptionalCtrConfigs) DeepCopyInto(out *OptionalCtrConfigs) { *out = *in if in.Env != nil { in, out := &in.Env, &out.Env @@ -256,6 +595,17 @@ func (in *OptionalConfigs) DeepCopyInto(out *OptionalConfigs) { } } } + if in.EnvFrom != nil { + in, out := &in.EnvFrom, &out.EnvFrom + *out = new([]v1.EnvFromSource) + if **in != nil { + in, out := *in, *out + *out = make([]v1.EnvFromSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + } if in.ImagePullPolicy != nil { in, out := &in.ImagePullPolicy, &out.ImagePullPolicy *out = new(v1.PullPolicy) @@ -268,12 +618,63 @@ func (in *OptionalConfigs) DeepCopyInto(out *OptionalConfigs) { } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptionalConfigs. -func (in *OptionalConfigs) DeepCopy() *OptionalConfigs { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptionalCtrConfigs. +func (in *OptionalCtrConfigs) DeepCopy() *OptionalCtrConfigs { + if in == nil { + return nil + } + out := new(OptionalCtrConfigs) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PvcConfig) DeepCopyInto(out *PvcConfig) { + *out = *in + if in.Ref != nil { + in, out := &in.Ref, &out.Ref + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.Create != nil { + in, out := &in.Create, &out.Create + *out = new(PvcCreate) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PvcConfig. +func (in *PvcConfig) DeepCopy() *PvcConfig { + if in == nil { + return nil + } + out := new(PvcConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PvcCreate) DeepCopyInto(out *PvcCreate) { + *out = *in + if in.AccessModes != nil { + in, out := &in.AccessModes, &out.AccessModes + *out = make([]v1.PersistentVolumeAccessMode, len(*in)) + copy(*out, *in) + } + if in.StorageClassName != nil { + in, out := &in.StorageClassName, &out.StorageClassName + *out = new(string) + **out = **in + } + in.Resources.DeepCopyInto(&out.Resources) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PvcCreate. +func (in *PvcCreate) DeepCopy() *PvcCreate { if in == nil { return nil } - out := new(OptionalConfigs) + out := new(PvcCreate) in.DeepCopyInto(out) return out } @@ -303,6 +704,78 @@ func (in *Registry) DeepCopy() *Registry { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryDBStorePersistence) DeepCopyInto(out *RegistryDBStorePersistence) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryDBStorePersistence. +func (in *RegistryDBStorePersistence) DeepCopy() *RegistryDBStorePersistence { + if in == nil { + return nil + } + out := new(RegistryDBStorePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryFilePersistence) DeepCopyInto(out *RegistryFilePersistence) { + *out = *in + if in.PvcConfig != nil { + in, out := &in.PvcConfig, &out.PvcConfig + *out = new(PvcConfig) + (*in).DeepCopyInto(*out) + } + if in.S3AdditionalKwargs != nil { + in, out := &in.S3AdditionalKwargs, &out.S3AdditionalKwargs + *out = new(map[string]string) + if **in != nil { + in, out := *in, *out + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryFilePersistence. +func (in *RegistryFilePersistence) DeepCopy() *RegistryFilePersistence { + if in == nil { + return nil + } + out := new(RegistryFilePersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryPersistence) DeepCopyInto(out *RegistryPersistence) { + *out = *in + if in.FilePersistence != nil { + in, out := &in.FilePersistence, &out.FilePersistence + *out = new(RegistryFilePersistence) + (*in).DeepCopyInto(*out) + } + if in.DBPersistence != nil { + in, out := &in.DBPersistence, &out.DBPersistence + *out = new(RegistryDBStorePersistence) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryPersistence. +func (in *RegistryPersistence) DeepCopy() *RegistryPersistence { + if in == nil { + return nil + } + out := new(RegistryPersistence) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemoteRegistryConfig) DeepCopyInto(out *RemoteRegistryConfig) { *out = *in @@ -316,6 +789,11 @@ func (in *RemoteRegistryConfig) DeepCopyInto(out *RemoteRegistryConfig) { *out = new(FeatureStoreRef) **out = **in } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TlsRemoteRegistryConfigs) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteRegistryConfig. @@ -329,18 +807,49 @@ func (in *RemoteRegistryConfig) DeepCopy() *RemoteRegistryConfig { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ServiceConfigs) DeepCopyInto(out *ServiceConfigs) { +func (in *SecretKeyNames) DeepCopyInto(out *SecretKeyNames) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyNames. +func (in *SecretKeyNames) DeepCopy() *SecretKeyNames { + if in == nil { + return nil + } + out := new(SecretKeyNames) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerConfigs) DeepCopyInto(out *ServerConfigs) { *out = *in - in.DefaultConfigs.DeepCopyInto(&out.DefaultConfigs) - in.OptionalConfigs.DeepCopyInto(&out.OptionalConfigs) + in.ContainerConfigs.DeepCopyInto(&out.ContainerConfigs) + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TlsConfigs) + (*in).DeepCopyInto(*out) + } + if in.LogLevel != nil { + in, out := &in.LogLevel, &out.LogLevel + *out = new(string) + **out = **in + } + if in.VolumeMounts != nil { + in, out := &in.VolumeMounts, &out.VolumeMounts + *out = make([]v1.VolumeMount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceConfigs. -func (in *ServiceConfigs) DeepCopy() *ServiceConfigs { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerConfigs. +func (in *ServerConfigs) DeepCopy() *ServerConfigs { if in == nil { return nil } - out := new(ServiceConfigs) + out := new(ServerConfigs) in.DeepCopyInto(out) return out } @@ -359,3 +868,45 @@ func (in *ServiceHostnames) DeepCopy() *ServiceHostnames { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TlsConfigs) DeepCopyInto(out *TlsConfigs) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } + out.SecretKeyNames = in.SecretKeyNames + if in.Disable != nil { + in, out := &in.Disable, &out.Disable + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TlsConfigs. +func (in *TlsConfigs) DeepCopy() *TlsConfigs { + if in == nil { + return nil + } + out := new(TlsConfigs) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TlsRemoteRegistryConfigs) DeepCopyInto(out *TlsRemoteRegistryConfigs) { + *out = *in + out.ConfigMapRef = in.ConfigMapRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TlsRemoteRegistryConfigs. +func (in *TlsRemoteRegistryConfigs) DeepCopy() *TlsRemoteRegistryConfigs { + if in == nil { + return nil + } + out := new(TlsRemoteRegistryConfigs) + in.DeepCopyInto(out) + return out +} diff --git a/infra/feast-operator/bundle.Dockerfile b/infra/feast-operator/bundle.Dockerfile index ab3f14a9da4..685b137b92a 100644 --- a/infra/feast-operator/bundle.Dockerfile +++ b/infra/feast-operator/bundle.Dockerfile @@ -6,7 +6,7 @@ LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ LABEL operators.operatorframework.io.bundle.package.v1=feast-operator LABEL operators.operatorframework.io.bundle.channels.v1=alpha -LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.37.0 +LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.38.0 LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v4 diff --git a/infra/feast-operator/bundle/manifests/feast-operator-controller-manager-metrics-service_v1_service.yaml b/infra/feast-operator/bundle/manifests/feast-operator-controller-manager-metrics-service_v1_service.yaml index e0cd9dc2545..913517e198a 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator-controller-manager-metrics-service_v1_service.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator-controller-manager-metrics-service_v1_service.yaml @@ -12,7 +12,7 @@ spec: - name: https port: 8443 protocol: TCP - targetPort: https + targetPort: 8443 selector: control-plane: controller-manager status: diff --git a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml index 245db443581..734508cfecb 100644 --- a/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml +++ b/infra/feast-operator/bundle/manifests/feast-operator.clusterserviceversion.yaml @@ -13,13 +13,34 @@ metadata: "spec": { "feastProject": "my_project" } + }, + { + "apiVersion": "feast.dev/v1alpha1", + "kind": "FeatureStore", + "metadata": { + "name": "sample-all-servers" + }, + "spec": { + "feastProject": "my_project", + "services": { + "offlineStore": { + "server": {} + }, + "registry": { + "local": { + "server": {} + } + }, + "ui": {} + } + } } ] capabilities: Basic Install - createdAt: "2024-11-01T13:05:11Z" - operators.operatorframework.io/builder: operator-sdk-v1.37.0 + createdAt: "2025-02-17T22:19:00Z" + operators.operatorframework.io/builder: operator-sdk-v1.38.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 - name: feast-operator.v0.41.0 + name: feast-operator.v0.46.0 namespace: placeholder spec: apiservicedefinitions: {} @@ -54,6 +75,8 @@ spec: - "" resources: - configmaps + - persistentvolumeclaims + - serviceaccounts - services verbs: - create @@ -62,6 +85,13 @@ spec: - list - update - watch + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list - apiGroups: - feast.dev resources: @@ -88,6 +118,29 @@ spec: - get - patch - update + - apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - update + - watch + - apiGroups: + - route.openshift.io + resources: + - routes + verbs: + - create + - delete + - get + - list + - update + - watch - apiGroups: - authentication.k8s.io resources: @@ -122,35 +175,15 @@ spec: spec: containers: - args: - - --secure-listen-address=0.0.0.0:8443 - - --upstream=http://127.0.0.1:8080/ - - --logtostderr=true - - --v=0 - image: gcr.io/kubebuilder/kube-rbac-proxy:v0.16.0 - name: kube-rbac-proxy - ports: - - containerPort: 8443 - name: https - protocol: TCP - resources: - limits: - cpu: 500m - memory: 128Mi - requests: - cpu: 5m - memory: 64Mi - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - - args: - - --health-probe-bind-address=:8081 - - --metrics-bind-address=127.0.0.1:8080 + - --metrics-bind-address=:8443 - --leader-elect + - --health-probe-bind-address=:8081 command: - /manager - image: feastdev/feast-operator:0.41.0 + env: + - name: RELATED_IMAGE_FEATURE_SERVER + value: docker.io/feastdev/feature-server:0.46.0 + image: feastdev/feast-operator:0.46.0 livenessProbe: httpGet: path: /healthz @@ -239,4 +272,7 @@ spec: provider: name: Feast Community url: https://lf-aidata.atlassian.net/wiki/spaces/FEAST/ - version: 0.41.0 + relatedImages: + - image: docker.io/feastdev/feature-server:0.46.0 + name: feature-server + version: 0.46.0 diff --git a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml index 2142e093eb1..003babbccca 100644 --- a/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml +++ b/infra/feast-operator/bundle/manifests/feast.dev_featurestores.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.15.0 creationTimestamp: null name: featurestores.feast.dev spec: @@ -29,516 +29,849 @@ spec: description: FeatureStore is the Schema for the featurestores API properties: apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + description: APIVersion defines the versioned schema of this representation + of an object. type: string kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + description: Kind is a string value representing the REST resource this + object represents. type: string metadata: type: object spec: description: FeatureStoreSpec defines the desired state of FeatureStore properties: + authz: + description: AuthzConfig defines the authorization settings for the + deployed Feast services. + properties: + kubernetes: + description: |- + KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. + https://kubernetes. + properties: + roles: + description: The Kubernetes RBAC roles to be deployed in the + same namespace of the FeatureStore. + items: + type: string + type: array + type: object + oidc: + description: |- + OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. + https://auth0. + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required between kubernetes or oidc. + rule: '[has(self.kubernetes), has(self.oidc)].exists_one(c, c)' feastProject: - description: FeastProject is the Feast project id. This can be any - alphanumeric string with underscores, but it cannot start with an - underscore. Required. + description: FeastProject is the Feast project id. pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ type: string services: - description: FeatureStoreServices defines the desired feast service - deployments. ephemeral registry is deployed by default. + description: FeatureStoreServices defines the desired feast services. + An ephemeral onlineStore feature server is deployed by default. properties: + deploymentStrategy: + description: DeploymentStrategy describes how to replace existing + pods with new ones. + properties: + rollingUpdate: + description: |- + Rolling update config params. Present only if DeploymentStrategyType = + RollingUpdate. + properties: + maxSurge: + anyOf: + - type: integer + - type: string + description: |- + The maximum number of pods that can be scheduled above the desired number of + pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: The maximum number of pods that can be unavailable + during the update. + x-kubernetes-int-or-string: true + type: object + type: + description: Type of deployment. Can be "Recreate" or "RollingUpdate". + Default is RollingUpdate. + type: string + type: object + disableInitContainers: + description: Disable the 'feast repo initialization' initContainer + type: boolean offlineStore: - description: OfflineStore configures the deployed offline store - service + description: OfflineStore configures the offline store service properties: - env: - items: - description: EnvVar represents an environment variable present - in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". - type: string - valueFrom: - description: Source for the environment variable's value. - Cannot be used if value is not empty. + persistence: + description: OfflineStorePersistence configures the persistence + settings for the offline store service + properties: + file: + description: OfflineStoreFilePersistence configures the + file-based persistence for the offline store service + properties: + pvc: + description: PvcConfig defines the settings for a + persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent volume + access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which this + persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref and + create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and must + not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: + enum: + - file + - dask + - duckdb + type: string + type: object + store: + description: OfflineStoreDBStorePersistence configures + the DB store persistence for the offline store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the secret + key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you want + to use. + enum: + - snowflake.offline + - bigquery + - redshift + - spark + - postgres + - trino + - athena + - mssql + - couchbase.offline + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, c)' + server: + description: Creates a remote offline server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from properties: - key: - description: The key to select. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: - description: Specify whether the ConfigMap or - its key must be defined + description: Specify whether the ConfigMap must + be defined type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in - the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for volumes, - optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of - the exposed resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource type: object x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the pod's - namespace + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: - description: Specify whether the Secret or its - key must be defined + description: Specify whether the Secret must + be defined type: boolean - required: - - key type: object x-kubernetes-map-type: atomic type: object - required: - - name - type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when to - pull a container image - type: string - resources: - description: ResourceRequirements describes the compute resource - requirements. - properties: - claims: + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string name: + description: This must match the Name of a Volume. + type: string + readOnly: description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. type: string required: + - mountPath - name type: object type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object type: object type: object onlineStore: - description: OnlineStore configures the deployed online store - service + description: OnlineStore configures the online store service properties: - env: - items: - description: EnvVar represents an environment variable present - in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". - type: string - valueFrom: - description: Source for the environment variable's value. - Cannot be used if value is not empty. + persistence: + description: OnlineStorePersistence configures the persistence + settings for the online store service + properties: + file: + description: OnlineStoreFilePersistence configures the + file-based persistence for the online store service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for a + persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent volume + access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which this + persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref and + create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and must + not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: object + x-kubernetes-validations: + - message: Ephemeral stores must have absolute paths. + rule: '(!has(self.pvc) && has(self.path)) ? self.path.startsWith(''/'') + : true' + - message: PVC path must be a file name only, with no + slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: Online store does not support S3 or GS buckets. + rule: 'has(self.path) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + store: + description: OnlineStoreDBStorePersistence configures + the DB store persistence for the online store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the secret + key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you want + to use. + enum: + - snowflake.online + - redis + - ikv + - datastore + - dynamodb + - bigtable + - postgres + - cassandra + - mysql + - hazelcast + - singlestore + - hbase + - elasticsearch + - qdrant + - couchbase.online + - milvus + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, c)' + server: + description: Creates a feature server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from properties: - key: - description: The key to select. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: - description: Specify whether the ConfigMap or - its key must be defined + description: Specify whether the ConfigMap must + be defined type: boolean - required: - - key type: object x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in - the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for volumes, - optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of - the exposed resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the pod's - namespace - properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: - description: Specify whether the Secret or its - key must be defined + description: Specify whether the Secret must + be defined type: boolean - required: - - key type: object x-kubernetes-map-type: atomic type: object - required: - - name - type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when to - pull a container image - type: string - resources: - description: ResourceRequirements describes the compute resource - requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - registry: - description: Registry configures the registry service. One selection - is required. Local is the default setting. - properties: - local: - description: LocalRegistryConfig configures the deployed registry - service - properties: - env: - items: - description: EnvVar represents an environment variable - present in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". - type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. - properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for - volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults to - "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the - pod's namespace - properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - required: - - name - type: object type: array image: type: string @@ -546,6 +879,17 @@ spec: description: PullPolicy describes a policy for if/when to pull a container image type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -554,13 +898,6 @@ spec: description: |- Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in PodSpec.ResourceClaims. @@ -568,8 +905,7 @@ spec: name: description: |- Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + the Pod where this field is used. type: string required: - name @@ -587,7 +923,7 @@ spec: x-kubernetes-int-or-string: true description: |- Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + More info: https://kubernetes. type: object requests: additionalProperties: @@ -596,651 +932,5598 @@ spec: - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + description: Requests describes the minimum amount + of compute resources required. type: object type: object - type: object - remote: - description: |- - RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. - Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. - properties: - feastRef: - description: Reference to an existing `FeatureStore` CR - in the same k8s cluster. + tls: + description: TlsConfigs configures server TLS for a feast + service. properties: - name: - description: Name of the FeatureStore - type: string - namespace: - description: Namespace of the FeatureStore - type: string - required: - - name + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic type: object - hostname: - description: Host address of the remote registry service - - :, e.g. `registry..svc.cluster.local:80` - type: string + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, - c)' type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.local), has(self.remote)].exists_one(c, c)' - type: object - required: - - feastProject - type: object - status: - description: FeatureStoreStatus defines the observed state of FeatureStore - properties: - applied: - description: Shows the currently applied feast configuration, including - any pertinent defaults - properties: - feastProject: - description: FeastProject is the Feast project id. This can be - any alphanumeric string with underscores, but it cannot start - with an underscore. Required. - pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ - type: string - services: - description: FeatureStoreServices defines the desired feast service - deployments. ephemeral registry is deployed by default. + registry: + description: Registry configures the registry service. One selection + is required. Local is the default setting. properties: - offlineStore: - description: OfflineStore configures the deployed offline - store service + local: + description: LocalRegistryConfig configures the registry service properties: - env: - items: - description: EnvVar represents an environment variable - present in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". - type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. + persistence: + description: RegistryPersistence configures the persistence + settings for the registry service + properties: + file: + description: RegistryFilePersistence configures the + file-based persistence for the registry service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + s3_additional_kwargs: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-validations: + - message: Registry files must use absolute paths + or be S3 ('s3://') or GS ('gs://') object store + URIs. + rule: '(!has(self.pvc) && has(self.path)) ? (self.path.startsWith(''/'') + || self.path.startsWith(''s3://'') || self.path.startsWith(''gs://'')) + : true' + - message: PVC path must be a file name only, with + no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: PVC persistence does not support S3 or + GS object store URIs. + rule: '(has(self.pvc) && has(self.path)) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: Additional S3 settings are available only + for S3 object store URIs. + rule: '(has(self.s3_additional_kwargs) && has(self.path)) + ? self.path.startsWith(''s3://'') : true' + store: + description: RegistryDBStorePersistence configures + the DB store persistence for the registry service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - sql + - snowflake.registry + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a registry server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from properties: - key: - description: The key to select. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap - or its key must be defined + must be defined type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for - volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults to - "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource type: object x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the - pod's namespace + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret - or its key must be defined + must be defined type: boolean - required: - - key type: object x-kubernetes-map-type: atomic type: object - required: - - name - type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when - to pull a container image - type: string - resources: - description: ResourceRequirements describes the compute - resource requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - onlineStore: - description: OnlineStore configures the deployed online store - service - properties: - env: - items: - description: EnvVar represents an environment variable - present in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". - type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. - properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. properties: - key: - description: The key to select. - type: string name: description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. type: string - optional: - description: Specify whether the ConfigMap - or its key must be defined - type: boolean required: - - key + - name type: object - x-kubernetes-map-type: atomic - fieldRef: + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for - volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults to - "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the - pod's namespace - properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name type: object - required: - - name - type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when - to pull a container image + type: array + type: object + type: object + remote: + description: RemoteRegistryConfig points to a remote feast + registry server. + properties: + feastRef: + description: Reference to an existing `FeatureStore` CR + in the same k8s cluster. + properties: + name: + description: Name of the FeatureStore + type: string + namespace: + description: Namespace of the FeatureStore + type: string + required: + - name + type: object + hostname: + description: Host address of the remote registry service + - :, e.g. `registry..svc.cluster.local:80` type: string - resources: - description: ResourceRequirements describes the compute - resource requirements. + tls: + description: TlsRemoteRegistryConfigs configures client + TLS for a remote feast registry. properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. + certName: + description: defines the configmap key name for the + client TLS cert. + type: string + configMapRef: + description: references the local k8s configmap where + the TLS cert resides + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - certName + - configMapRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, + c)' + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + ui: + description: Creates a UI server container + properties: + env: + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of a set + of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to each + key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must be + defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when to + pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute resource + requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount of + compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. useful + in an openshift cluster, for example, where TLS is configured + by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key names + for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where the + TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes that + should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from which + the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + volumes: + description: Volumes specifies the volumes to mount in the FeatureStore + deployment. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to th + properties: + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes. + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes. + type: string + required: + - volumeID + type: object + azureDisk: + description: azureDisk represents an Azure Data Disk mount + on the host and bind mount to the pod. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk in + the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in the + blob storage + type: string + fsType: + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage accoun' + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: cephFS represents a Ceph FS mount on the host + that shares a pod's lifetime + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s. + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile is + the path to key ring for User, default is /etc/ceph/user.' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is reference + to the authentication secret for User, default is + empty.' + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s. + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + More info: https://examples.k8s. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits used + to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers (Beta fea + properties: + driver: + description: driver is the name of the CSI driver that + handles this volume. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", "ntfs". + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to c + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about the + pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created + files by default.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes. + properties: + medium: + description: medium represents what type of storage + medium should back this directory. + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: sizeLimit is the total amount of local + storage required for this EmptyDir volume. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: ephemeral represents a volume that is handled + by a cluster storage driver. + properties: + volumeClaimTemplate: + description: Will be used to create a stand-alone PVC + to provision the volume. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. + type: object + spec: + description: The specification for the PersistentVolumeClaim. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes. + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s. + properties: + apiGroup: + description: APIGroup is the group for the + resource being referenced. + 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 + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. + properties: + apiGroup: + description: APIGroup is the group for the + resource being referenced. + 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 + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking. + type: string + required: + - kind + - name + type: object + resources: + description: resources represents the minimum + resources the volume should have. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} + pairs. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes. + type: string + volumeAttributesClassName: + description: volumeAttributesClassName may be + used to set the VolumeAttributesClass used + by this claim. + type: string + volumeMode: + description: volumeMode defines what type of + volume is required by the claim. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource that + is attached to a kubelet's host machine and then exposed + to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target worldwide + names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: "wwids Optional: FC volume world wide identifiers + (wwids)\nEither wwids or combination of targetWWNs + and lun must be set, " + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + properties: + driver: + description: driver is the name of the driver to use + for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugi + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: flocker represents a Flocker volume attached + to a kubelet's host machine. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as depreca + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the po + properties: + fsType: + description: fsType is filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes. + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes. + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + DEPRECATED: GitRepo is deprecated. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the specified + revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + endpoints: + description: |- + endpoints is the endpoint name that details Glusterfs topology. + More info: https://examples.k8s. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator + Name. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal List. + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: targetPortal is iSCSI Target Portal. + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes. + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes. + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes. + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + properties: + claimName: + description: claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: photonPersistentDisk represents a PhotonController + persistent disk attached and mounted on kubelets host + machine + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + pdID: + description: pdID is the ID that identifies Photon Controller + persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: portworxVolume represents a portworx volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources secrets, + configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used to set + permissions on created files by default. + format: int32 + type: integer + sources: + description: sources is the list of volume projections + items: + description: Projection that may be projected along + with other supported volume types + properties: + clusterTrustBundle: + description: ClusterTrustBundle allows a pod to + access the `.spec. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} + pairs. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path + within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode + bits used to set permissions on this + file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the + ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the + downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects a field + of the pod: only annotations, labels, + name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file to + be created. Must not be absolute or + contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env + vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path + within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode + bits used to set permissions on this + file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended audience + of the token. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: quobyte represents a Quobyte mount on the host + that shares a pod's lifetime + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple ent + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references an already + created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s. + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s. + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: scaleIO represents a ScaleIO persistent volume + attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + gateway: + description: gateway is the host address of the ScaleIO + API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the ScaleIO + Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL communication + with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage + for a volume should be ThickProvisioned or ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage Pool + associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes. + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits used + to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the Secret + or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes. + type: string + type: object + storageos: + description: storageOS represents a StorageOS volume attached + and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope of + the volume within StorageOS. + type: string + type: object + vsphereVolume: + description: vsphereVolume represents a vSphere volume attached + and mounted on kubelets host machine + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy Based + Management (SPBM) profile ID associated with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array + type: object + required: + - feastProject + type: object + status: + description: FeatureStoreStatus defines the observed state of FeatureStore + properties: + applied: + description: Shows the currently applied feast configuration, including + any pertinent defaults + properties: + authz: + description: AuthzConfig defines the authorization settings for + the deployed Feast services. + properties: + kubernetes: + description: |- + KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. + https://kubernetes. + properties: + roles: + description: The Kubernetes RBAC roles to be deployed + in the same namespace of the FeatureStore. + items: + type: string + type: array + type: object + oidc: + description: |- + OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. + https://auth0. + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required between kubernetes or oidc. + rule: '[has(self.kubernetes), has(self.oidc)].exists_one(c, + c)' + feastProject: + description: FeastProject is the Feast project id. + pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ + type: string + services: + description: FeatureStoreServices defines the desired feast services. + An ephemeral onlineStore feature server is deployed by default. + properties: + deploymentStrategy: + description: DeploymentStrategy describes how to replace existing + pods with new ones. + properties: + rollingUpdate: + description: |- + Rolling update config params. Present only if DeploymentStrategyType = + RollingUpdate. + properties: + maxSurge: + anyOf: + - type: integer + - type: string + description: |- + The maximum number of pods that can be scheduled above the desired number of + pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: The maximum number of pods that can be + unavailable during the update. + x-kubernetes-int-or-string: true + type: object + type: + description: Type of deployment. Can be "Recreate" or + "RollingUpdate". Default is RollingUpdate. + type: string + type: object + disableInitContainers: + description: Disable the 'feast repo initialization' initContainer + type: boolean + offlineStore: + description: OfflineStore configures the offline store service + properties: + persistence: + description: OfflineStorePersistence configures the persistence + settings for the offline store service + properties: + file: + description: OfflineStoreFilePersistence configures + the file-based persistence for the offline store + service + properties: + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: + enum: + - file + - dask + - duckdb + type: string + type: object + store: + description: OfflineStoreDBStorePersistence configures + the DB store persistence for the offline store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - snowflake.offline + - bigquery + - redshift + - spark + - postgres + - trino + - athena + - mssql + - couchbase.offline + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a remote offline server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + onlineStore: + description: OnlineStore configures the online store service + properties: + persistence: + description: OnlineStorePersistence configures the persistence + settings for the online store service + properties: + file: + description: OnlineStoreFilePersistence configures + the file-based persistence for the online store + service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: object + x-kubernetes-validations: + - message: Ephemeral stores must have absolute paths. + rule: '(!has(self.pvc) && has(self.path)) ? self.path.startsWith(''/'') + : true' + - message: PVC path must be a file name only, with + no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: Online store does not support S3 or GS + buckets. + rule: 'has(self.path) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + store: + description: OnlineStoreDBStorePersistence configures + the DB store persistence for the online store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - snowflake.online + - redis + - ikv + - datastore + - dynamodb + - bigtable + - postgres + - cassandra + - mysql + - hazelcast + - singlestore + - hbase + - elasticsearch + - qdrant + - couchbase.online + - milvus + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a feature server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + registry: + description: Registry configures the registry service. One + selection is required. Local is the default setting. + properties: + local: + description: LocalRegistryConfig configures the registry + service + properties: + persistence: + description: RegistryPersistence configures the persistence + settings for the registry service + properties: + file: + description: RegistryFilePersistence configures + the file-based persistence for the registry + service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings + for a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new + PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to + ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the + storage resource requirements for + a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes + the minimum amount of compute + resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the + name of an existing StorageClass + to which this persistent volume + belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing + field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between + ref and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' + and must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + s3_additional_kwargs: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-validations: + - message: Registry files must use absolute paths + or be S3 ('s3://') or GS ('gs://') object + store URIs. + rule: '(!has(self.pvc) && has(self.path)) ? + (self.path.startsWith(''/'') || self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: PVC path must be a file name only, + with no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: PVC persistence does not support S3 + or GS object store URIs. + rule: '(has(self.pvc) && has(self.path)) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: Additional S3 settings are available + only for S3 object store URIs. + rule: '(has(self.s3_additional_kwargs) && has(self.path)) + ? self.path.startsWith(''s3://'') : true' + store: + description: RegistryDBStorePersistence configures + the DB store persistence for the registry service + properties: + secretKeyName: + description: By default, the selected store + "type" is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should + be placed as-is from the "feature_store.yaml" + under the secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type + you want to use. + enum: + - sql + - snowflake.registry + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or + store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a registry server container + properties: + env: + items: + description: EnvVar represents an environment + variable present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment + variable's value. Cannot be used if value + is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the + ConfigMap or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the + pod: supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env + vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret + in the pod's namespace + properties: + key: + description: The key of the secret + to select from. Must be a valid + secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the + Secret or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be + a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for + if/when to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the + compute resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one + entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS + for a feast service. + properties: + disable: + description: will disable TLS for the feast + service. useful in an openshift cluster, + for example, where TLS is configured by + default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret + where the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` + is false.' + rule: '(!has(self.disable) || !self.disable) + ? has(self.secretRef) : true' + volumeMounts: + description: VolumeMounts defines the list of + volumes that should be mounted into the feast + container. + items: + description: VolumeMount describes a mounting + of a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of + a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should + be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + remote: + description: RemoteRegistryConfig points to a remote feast + registry server. + properties: + feastRef: + description: Reference to an existing `FeatureStore` + CR in the same k8s cluster. + properties: + name: + description: Name of the FeatureStore + type: string + namespace: + description: Namespace of the FeatureStore + type: string + required: + - name + type: object + hostname: + description: Host address of the remote registry service + - :, e.g. `registry..svc.cluster.local:80` + type: string + tls: + description: TlsRemoteRegistryConfigs configures client + TLS for a remote feast registry. + properties: + certName: + description: defines the configmap key name for + the client TLS cert. + type: string + configMapRef: + description: references the local k8s configmap + where the TLS cert resides + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - certName + - configMapRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, + c)' + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.local), has(self.remote)].exists_one(c, + c)' + ui: + description: Creates a UI server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + volumes: + description: Volumes specifies the volumes to mount in the + FeatureStore deployment. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to th + properties: + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes. + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes. + type: string + required: + - volumeID + type: object + azureDisk: + description: azureDisk represents an Azure Data Disk + mount on the host and bind mount to the pod. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk + in the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in + the blob storage + type: string + fsType: + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage accoun' + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: cephFS represents a Ceph FS mount on the + host that shares a pod's lifetime + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s. + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default + is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile + is the path to key ring for User, default is /etc/ceph/user.' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is + reference to the authentication secret for User, + default is empty.' + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s. + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + More info: https://examples.k8s. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits + used to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits + used to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers (Beta fea + properties: + driver: + description: driver is the name of the CSI driver + that handles this volume. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", + "ntfs". + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to c + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about + the pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created + files by default.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes. + properties: + medium: + description: medium represents what type of storage + medium should back this directory. + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: sizeLimit is the total amount of local + storage required for this EmptyDir volume. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: ephemeral represents a volume that is handled + by a cluster storage driver. + properties: + volumeClaimTemplate: + description: Will be used to create a stand-alone + PVC to provision the volume. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. + type: object + spec: + description: The specification for the PersistentVolumeClaim. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes. + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s. + properties: + apiGroup: + description: APIGroup is the group for + the resource being referenced. + 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 + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. + properties: + apiGroup: + description: APIGroup is the group for + the resource being referenced. + 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 + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking. + type: string + required: + - kind + - name + type: object + resources: + description: resources represents the minimum + resources the volume should have. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of + {key,value} pairs. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes. + type: string + volumeAttributesClassName: + description: volumeAttributesClassName may + be used to set the VolumeAttributesClass + used by this claim. + type: string + volumeMode: + description: volumeMode defines what type + of volume is required by the claim. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource + that is attached to a kubelet's host machine and then + exposed to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target + worldwide names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: "wwids Optional: FC volume world wide + identifiers (wwids)\nEither wwids or combination + of targetWWNs and lun must be set, " + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + properties: + driver: + description: driver is the name of the driver to + use for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugi + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: flocker represents a Flocker volume attached + to a kubelet's host machine. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as depreca + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the po + properties: + fsType: + description: fsType is filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes. + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes. + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + DEPRECATED: GitRepo is deprecated. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the + specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + endpoints: + description: |- + endpoints is the endpoint name that details Glusterfs topology. + More info: https://examples.k8s. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator + Name. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal + List. + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: targetPortal is iSCSI Target Portal. + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes. + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes. + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes. + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + properties: + claimName: + description: claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: photonPersistentDisk represents a PhotonController + persistent disk attached and mounted on kubelets host + machine + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + pdID: + description: pdID is the ID that identifies Photon + Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: portworxVolume represents a portworx volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources + secrets, configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used + to set permissions on created files by default. + format: int32 + type: integer + sources: + description: sources is the list of volume projections + items: + description: Projection that may be projected + along with other supported volume types + properties: + clusterTrustBundle: + description: ClusterTrustBundle allows a pod + to access the `.spec. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. + properties: + matchExpressions: + description: matchExpressions is a + list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map + of {key,value} pairs. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. + type: string + required: + - path + type: object + configMap: + description: configMap information about the + configMap data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: + mode bits used to set permissions + on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether + the ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about + the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects + a field of the pod: only annotations, + labels, name, namespace and uid + are supported.' + properties: + apiVersion: + description: Version of the + schema the FieldPath is written + in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field + to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file + to be created. Must not be absolute + or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: + required for volumes, optional + for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the + secret data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: + mode bits used to set permissions + on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended + audience of the token. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: quobyte represents a Quobyte mount on the + host that shares a pod's lifetime + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple ent + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references + an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s. + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s. + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: scaleIO represents a ScaleIO persistent + volume attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + gateway: + description: gateway is the host address of the + ScaleIO API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the + ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. properties: name: + default: "" description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - required: - - name type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - registry: - description: Registry configures the registry service. One - selection is required. Local is the default setting. - properties: - local: - description: LocalRegistryConfig configures the deployed - registry service - properties: - env: + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL + communication with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage + for a volume should be ThickProvisioned or ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage + Pool associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes. + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits + used to set permissions on created files by default.' + format: int32 + type: integer items: - description: EnvVar represents an environment variable - present in a Container. + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits + used to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the + Secret or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes. + type: string + type: object + storageos: + description: storageOS represents a StorageOS volume + attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. properties: name: - description: Name of the environment variable. - Must be a C_IDENTIFIER. - type: string - value: + default: "" description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. - properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the - FieldPath is written in terms of, - defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required - for volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults - to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to - select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in - the pod's namespace - properties: - key: - description: The key of the secret to - select from. Must be a valid secret - key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - required: - - name type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when - to pull a container image - type: string - resources: - description: ResourceRequirements describes the compute - resource requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - remote: - description: |- - RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. - Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. - properties: - feastRef: - description: Reference to an existing `FeatureStore` - CR in the same k8s cluster. - properties: - name: - description: Name of the FeatureStore - type: string - namespace: - description: Namespace of the FeatureStore - type: string - required: - - name - type: object - hostname: - description: Host address of the remote registry service - - :, e.g. `registry..svc.cluster.local:80` - type: string - type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, - c)' - type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.local), has(self.remote)].exists_one(c, - c)' + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope + of the volume within StorageOS. + type: string + type: object + vsphereVolume: + description: vsphereVolume represents a vSphere volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy + Based Management (SPBM) profile ID associated + with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array type: object required: - feastProject @@ -1251,21 +6534,12 @@ spec: type: string conditions: items: - description: "Condition contains details for one aspect of the current - state of this API Resource.\n---\nThis struct is intended for - direct use as an array at the field path .status.conditions. For - example,\n\n\n\ttype FooStatus struct{\n\t // Represents the - observations of a foo's current state.\n\t // Known .status.conditions.type - are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // - +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t - \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" - patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t - \ // other fields\n\t}" + description: Condition contains details for one aspect of the current + state of this API Resource. properties: lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + description: lastTransitionTime is the last time the condition + transitioned from one status to another. format: date-time type: string message: @@ -1277,18 +6551,13 @@ spec: observedGeneration: description: |- observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. + For instance, if . format: int64 minimum: 0 type: integer reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ @@ -1304,9 +6573,7 @@ spec: description: |- type of condition in CamelCase or in foo.example.com/CamelCase. --- - Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be - useful (see .node.status.conditions), the ability to deconflict is important. - The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + Many .condition. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string @@ -1319,7 +6586,6 @@ spec: type: object type: array feastVersion: - description: Version of feast that's currently deployed type: string phase: type: string @@ -1333,6 +6599,8 @@ spec: type: string registry: type: string + ui: + type: string type: object type: object type: object diff --git a/infra/feast-operator/bundle/metadata/annotations.yaml b/infra/feast-operator/bundle/metadata/annotations.yaml index bf929b9755b..5e280a43e24 100644 --- a/infra/feast-operator/bundle/metadata/annotations.yaml +++ b/infra/feast-operator/bundle/metadata/annotations.yaml @@ -5,7 +5,7 @@ annotations: operators.operatorframework.io.bundle.metadata.v1: metadata/ operators.operatorframework.io.bundle.package.v1: feast-operator operators.operatorframework.io.bundle.channels.v1: alpha - operators.operatorframework.io.metrics.builder: operator-sdk-v1.37.0 + operators.operatorframework.io.metrics.builder: operator-sdk-v1.38.0 operators.operatorframework.io.metrics.mediatype.v1: metrics+v1 operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v4 diff --git a/infra/feast-operator/cmd/main.go b/infra/feast-operator/cmd/main.go index e132a6a3c9c..82f0fd2eeca 100644 --- a/infra/feast-operator/cmd/main.go +++ b/infra/feast-operator/cmd/main.go @@ -33,12 +33,16 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + routev1 "github.com/openshift/api/route/v1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller" - //+kubebuilder:scaffold:imports + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" + // +kubebuilder:scaffold:imports ) var ( @@ -48,9 +52,9 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - + utilruntime.Must(routev1.AddToScheme(scheme)) utilruntime.Must(feastdevv1alpha1.AddToScheme(scheme)) - //+kubebuilder:scaffold:scheme + // +kubebuilder:scaffold:scheme } func main() { @@ -59,13 +63,15 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool - flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + var tlsOpts []func(*tls.Config) + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ + "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") - flag.BoolVar(&secureMetrics, "metrics-secure", false, - "If set the metrics endpoint is served securely") + flag.BoolVar(&secureMetrics, "metrics-secure", true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") opts := zap.Options{ @@ -87,7 +93,6 @@ func main() { c.NextProtos = []string{"http/1.1"} } - tlsOpts := []func(*tls.Config){} if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } @@ -96,13 +101,33 @@ func main() { TLSOpts: tlsOpts, }) + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.18.4/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + // TODO(user): TLSOpts is used to allow configuring the TLS config used for the server. If certificates are + // not provided, self-signed certificates will be generated by default. This option is not recommended for + // production environments as self-signed certificates do not offer the same level of trust and security + // as certificates issued by a trusted Certificate Authority (CA). The primary risk is potentially allowing + // unauthorized access to sensitive metrics data. Consider replacing with CertDir, CertName, and KeyName + // to provide certificates, ensuring the server communicates using trusted and secure certificates. + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.18.4/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, - Metrics: metricsserver.Options{ - BindAddress: metricsAddr, - SecureServing: secureMetrics, - TLSOpts: tlsOpts, - }, + Scheme: scheme, + Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, @@ -132,6 +157,8 @@ func main() { os.Exit(1) } + services.SetIsOpenShift(mgr.GetConfig()) + if err = (&controller.FeatureStoreReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -139,7 +166,7 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "FeatureStore") os.Exit(1) } - //+kubebuilder:scaffold:builder + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") diff --git a/infra/feast-operator/config/component_metadata.yaml b/infra/feast-operator/config/component_metadata.yaml new file mode 100644 index 00000000000..cbe44e473af --- /dev/null +++ b/infra/feast-operator/config/component_metadata.yaml @@ -0,0 +1,5 @@ +# This file is required to configure Feast release information for ODH/RHOAI Operator +releases: + - name: Feast + version: 0.46.0 + repoUrl: https://github.com/feast-dev/feast diff --git a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml index b4c17b5eb80..4bb7227f856 100644 --- a/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml +++ b/infra/feast-operator/config/crd/bases/feast.dev_featurestores.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.15.0 name: featurestores.feast.dev spec: group: feast.dev @@ -29,39 +29,79 @@ spec: description: FeatureStore is the Schema for the featurestores API properties: apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + description: APIVersion defines the versioned schema of this representation + of an object. type: string kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + description: Kind is a string value representing the REST resource this + object represents. type: string metadata: type: object spec: description: FeatureStoreSpec defines the desired state of FeatureStore properties: + authz: + description: AuthzConfig defines the authorization settings for the + deployed Feast services. + properties: + kubernetes: + description: |- + KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. + https://kubernetes. + properties: + roles: + description: The Kubernetes RBAC roles to be deployed in the + same namespace of the FeatureStore. + items: + type: string + type: array + type: object + oidc: + description: |- + OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. + https://auth0. + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required between kubernetes or oidc. + rule: '[has(self.kubernetes), has(self.oidc)].exists_one(c, c)' feastProject: - description: FeastProject is the Feast project id. This can be any - alphanumeric string with underscores, but it cannot start with an - underscore. Required. + description: FeastProject is the Feast project id. pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ type: string - services: - description: FeatureStoreServices defines the desired feast service - deployments. ephemeral registry is deployed by default. + feastProjectDir: + description: FeastProjectDir defines how to create the feast project + directory. properties: - offlineStore: - description: OfflineStore configures the deployed offline store - service + git: + description: GitCloneOptions describes how a clone should be performed. properties: + configs: + additionalProperties: + type: string + description: |- + Configs passed to git via `-c` + e.g. http.sslVerify: 'false' + OR 'url."https://api:\${TOKEN}@github.com/". + type: object env: items: description: EnvVar represents an environment variable present @@ -75,13 +115,7 @@ spec: description: |- Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + any type: string valueFrom: description: Source for the environment variable's value. @@ -94,10 +128,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap or @@ -108,9 +143,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' properties: apiVersion: description: Version of the schema the FieldPath @@ -127,7 +162,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required for volumes, @@ -157,10 +192,11 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret or its @@ -175,256 +211,268 @@ spec: - name type: object type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when to - pull a container image - type: string - resources: - description: ResourceRequirements describes the compute resource - requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. + envFrom: + items: + description: EnvFromSource represents the source of a set + of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from properties: name: + default: "" description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - required: - - name + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - onlineStore: - description: OnlineStore configures the deployed online store - service - properties: - env: - items: - description: EnvVar represents an environment variable present - in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to each + key in the ConfigMap. Must be a C_IDENTIFIER. type: string - valueFrom: - description: Source for the environment variable's value. - Cannot be used if value is not empty. + secretRef: + description: The Secret to select from properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap or - its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in - the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: + name: + default: "" description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for volumes, - optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of - the exposed resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the pod's - namespace - properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must be + defined + type: boolean type: object - required: - - name + x-kubernetes-map-type: atomic type: object type: array - image: + featureRepoPath: + description: FeatureRepoPath is the relative path to the feature + repo subdirectory. Default is 'feature_repo'. type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when to - pull a container image + ref: + description: Reference to a branch / tag / commit type: string - resources: - description: ResourceRequirements describes the compute resource - requirements. + url: + description: The repository URL to clone from. + type: string + required: + - url + type: object + x-kubernetes-validations: + - message: RepoPath must be a file name only, with no slashes. + rule: 'has(self.featureRepoPath) ? !self.featureRepoPath.startsWith(''/'') + : true' + init: + description: FeastInitOptions defines how to run a `feast init`. + properties: + minimal: + type: boolean + template: + description: Template for the created project + enum: + - local + - gcp + - aws + - snowflake + - spark + - postgres + - hbase + - cassandra + - hazelcast + - ikv + - couchbase + type: string + type: object + type: object + x-kubernetes-validations: + - message: One selection required between init or git. + rule: '[has(self.git), has(self.init)].exists_one(c, c)' + services: + description: FeatureStoreServices defines the desired feast services. + An ephemeral onlineStore feature server is deployed by default. + properties: + deploymentStrategy: + description: DeploymentStrategy describes how to replace existing + pods with new ones. + properties: + rollingUpdate: + description: |- + Rolling update config params. Present only if DeploymentStrategyType = + RollingUpdate. properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true + maxSurge: + anyOf: + - type: integer + - type: string description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object + The maximum number of pods that can be scheduled above the desired number of + pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: The maximum number of pods that can be unavailable + during the update. + x-kubernetes-int-or-string: true type: object + type: + description: Type of deployment. Can be "Recreate" or "RollingUpdate". + Default is RollingUpdate. + type: string type: object - registry: - description: Registry configures the registry service. One selection - is required. Local is the default setting. + disableInitContainers: + description: Disable the 'feast repo initialization' initContainer + type: boolean + offlineStore: + description: OfflineStore configures the offline store service properties: - local: - description: LocalRegistryConfig configures the deployed registry - service + persistence: + description: OfflineStorePersistence configures the persistence + settings for the offline store service + properties: + file: + description: OfflineStoreFilePersistence configures the + file-based persistence for the offline store service + properties: + pvc: + description: PvcConfig defines the settings for a + persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent volume + access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which this + persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref and + create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and must + not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: + enum: + - file + - dask + - duckdb + type: string + type: object + store: + description: OfflineStoreDBStorePersistence configures + the DB store persistence for the offline store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the secret + key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you want + to use. + enum: + - snowflake.offline + - bigquery + - redshift + - spark + - postgres + - trino + - athena + - mssql + - couchbase.offline + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, c)' + server: + description: Creates a remote offline server container properties: env: items: @@ -439,13 +487,7 @@ spec: description: |- Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + any type: string valueFrom: description: Source for the environment variable's @@ -458,10 +500,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap @@ -472,9 +515,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' properties: apiVersion: description: Version of the schema the FieldPath @@ -491,7 +534,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required for @@ -522,10 +565,11 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret @@ -540,12 +584,66 @@ spec: - name type: object type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array image: type: string imagePullPolicy: description: PullPolicy describes a policy for if/when to pull a container image type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -554,13 +652,6 @@ spec: description: |- Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in PodSpec.ResourceClaims. @@ -568,8 +659,7 @@ spec: name: description: |- Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + the Pod where this field is used. type: string required: - name @@ -587,7 +677,7 @@ spec: x-kubernetes-int-or-string: true description: |- Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + More info: https://kubernetes. type: object requests: additionalProperties: @@ -596,69 +686,248 @@ spec: - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array type: object - remote: - description: |- - RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. - Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. + type: object + onlineStore: + description: OnlineStore configures the online store service + properties: + persistence: + description: OnlineStorePersistence configures the persistence + settings for the online store service properties: - feastRef: - description: Reference to an existing `FeatureStore` CR - in the same k8s cluster. + file: + description: OnlineStoreFilePersistence configures the + file-based persistence for the online store service properties: - name: - description: Name of the FeatureStore + path: type: string - namespace: - description: Namespace of the FeatureStore + pvc: + description: PvcConfig defines the settings for a + persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent volume + access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which this + persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref and + create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and must + not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: object + x-kubernetes-validations: + - message: Ephemeral stores must have absolute paths. + rule: '(!has(self.pvc) && has(self.path)) ? self.path.startsWith(''/'') + : true' + - message: PVC path must be a file name only, with no + slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: Online store does not support S3 or GS buckets. + rule: 'has(self.path) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + store: + description: OnlineStoreDBStorePersistence configures + the DB store persistence for the online store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the secret + key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you want + to use. + enum: + - snowflake.online + - redis + - ikv + - datastore + - dynamodb + - bigtable + - postgres + - cassandra + - mysql + - hazelcast + - singlestore + - hbase + - elasticsearch + - qdrant + - couchbase.online + - milvus type: string required: - - name + - secretRef + - type type: object - hostname: - description: Host address of the remote registry service - - :, e.g. `registry..svc.cluster.local:80` - type: string type: object x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, - c)' - type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.local), has(self.remote)].exists_one(c, c)' - type: object - required: - - feastProject - type: object - status: - description: FeatureStoreStatus defines the observed state of FeatureStore - properties: - applied: - description: Shows the currently applied feast configuration, including - any pertinent defaults - properties: - feastProject: - description: FeastProject is the Feast project id. This can be - any alphanumeric string with underscores, but it cannot start - with an underscore. Required. - pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ - type: string - services: - description: FeatureStoreServices defines the desired feast service - deployments. ephemeral registry is deployed by default. - properties: - offlineStore: - description: OfflineStore configures the deployed offline - store service + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, c)' + server: + description: Creates a feature server container properties: env: items: @@ -673,13 +942,7 @@ spec: description: |- Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + any type: string valueFrom: description: Source for the environment variable's @@ -692,10 +955,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap @@ -706,9 +970,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' properties: apiVersion: description: Version of the schema the FieldPath @@ -725,7 +989,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required for @@ -756,10 +1020,11 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret @@ -774,12 +1039,66 @@ spec: - name type: object type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array image: type: string imagePullPolicy: description: PullPolicy describes a policy for if/when to pull a container image type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -788,13 +1107,6 @@ spec: description: |- Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in PodSpec.ResourceClaims. @@ -802,8 +1114,7 @@ spec: name: description: |- Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + the Pod where this field is used. type: string required: - name @@ -821,7 +1132,7 @@ spec: x-kubernetes-int-or-string: true description: |- Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + More info: https://kubernetes. type: object requests: additionalProperties: @@ -830,228 +1141,271 @@ spec: - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + description: Requests describes the minimum amount + of compute resources required. type: object type: object - type: object - onlineStore: - description: OnlineStore configures the deployed online store - service - properties: - env: + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. items: - description: EnvVar represents an environment variable - present in a Container. + description: VolumeMount describes a mounting of a Volume + within a container. properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: This must match the Name of a Volume. type: string - value: + readOnly: description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + registry: + description: Registry configures the registry service. One selection + is required. Local is the default setting. + properties: + local: + description: LocalRegistryConfig configures the registry service + properties: + persistence: + description: RegistryPersistence configures the persistence + settings for the registry service + properties: + file: + description: RegistryFilePersistence configures the + file-based persistence for the registry service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + s3_additional_kwargs: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-validations: + - message: Registry files must use absolute paths + or be S3 ('s3://') or GS ('gs://') object store + URIs. + rule: '(!has(self.pvc) && has(self.path)) ? (self.path.startsWith(''/'') + || self.path.startsWith(''s3://'') || self.path.startsWith(''gs://'')) + : true' + - message: PVC path must be a file name only, with + no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: PVC persistence does not support S3 or + GS object store URIs. + rule: '(has(self.pvc) && has(self.path)) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: Additional S3 settings are available only + for S3 object store URIs. + rule: '(has(self.s3_additional_kwargs) && has(self.path)) + ? self.path.startsWith(''s3://'') : true' + store: + description: RegistryDBStorePersistence configures + the DB store persistence for the registry service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - sql + - snowflake.registry + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a registry server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for - volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults to - "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the - pod's namespace - properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - required: - - name - type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when - to pull a container image - type: string - resources: - description: ResourceRequirements describes the compute - resource requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - registry: - description: Registry configures the registry service. One - selection is required. Local is the default setting. - properties: - local: - description: LocalRegistryConfig configures the deployed - registry service - properties: - env: - items: - description: EnvVar represents an environment variable - present in a Container. - properties: - name: - description: Name of the environment variable. - Must be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". - type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. properties: configMapKeyRef: description: Selects a key of a ConfigMap. @@ -1060,10 +1414,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap @@ -1074,9 +1429,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' properties: apiVersion: description: Version of the schema the @@ -1094,7 +1449,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required @@ -1127,10 +1482,11 @@ spec: key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret @@ -1145,12 +1501,66 @@ spec: - name type: object type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array image: type: string imagePullPolicy: description: PullPolicy describes a policy for if/when to pull a container image type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -1159,13 +1569,6 @@ spec: description: |- Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in PodSpec.ResourceClaims. @@ -1173,8 +1576,7 @@ spec: name: description: |- Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + the Pod where this field is used. type: string required: - name @@ -1192,7 +1594,7 @@ spec: x-kubernetes-int-or-string: true description: |- Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + More info: https://kubernetes. type: object requests: additionalProperties: @@ -1201,97 +1603,5385 @@ spec: - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string type: object + x-kubernetes-map-type: atomic type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array type: object - remote: - description: |- - RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. - Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. + type: object + remote: + description: RemoteRegistryConfig points to a remote feast + registry server. + properties: + feastRef: + description: Reference to an existing `FeatureStore` CR + in the same k8s cluster. properties: - feastRef: - description: Reference to an existing `FeatureStore` - CR in the same k8s cluster. + name: + description: Name of the FeatureStore + type: string + namespace: + description: Namespace of the FeatureStore + type: string + required: + - name + type: object + hostname: + description: Host address of the remote registry service + - :, e.g. `registry..svc.cluster.local:80` + type: string + tls: + description: TlsRemoteRegistryConfigs configures client + TLS for a remote feast registry. + properties: + certName: + description: defines the configmap key name for the + client TLS cert. + type: string + configMapRef: + description: references the local k8s configmap where + the TLS cert resides properties: name: - description: Name of the FeatureStore - type: string - namespace: - description: Namespace of the FeatureStore + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - required: - - name type: object - hostname: - description: Host address of the remote registry service - - :, e.g. `registry..svc.cluster.local:80` - type: string + x-kubernetes-map-type: atomic + required: + - certName + - configMapRef type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, - c)' type: object x-kubernetes-validations: - message: One selection required. - rule: '[has(self.local), has(self.remote)].exists_one(c, + rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, c)' type: object - required: - - feastProject - type: object - clientConfigMap: - description: ConfigMap in this namespace containing a client `feature_store.yaml` - for this feast deployment - type: string - conditions: - items: - description: "Condition contains details for one aspect of the current - state of this API Resource.\n---\nThis struct is intended for - direct use as an array at the field path .status.conditions. For - example,\n\n\n\ttype FooStatus struct{\n\t // Represents the - observations of a foo's current state.\n\t // Known .status.conditions.type - are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // - +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t - \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" - patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t - \ // other fields\n\t}" - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + ui: + description: Creates a UI server container + properties: + env: + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of a set + of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to each + key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must be + defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when to + pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute resource + requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount of + compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. useful + in an openshift cluster, for example, where TLS is configured + by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key names + for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where the + TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes that + should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from which + the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + volumes: + description: Volumes specifies the volumes to mount in the FeatureStore + deployment. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to th + properties: + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes. + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes. + type: string + required: + - volumeID + type: object + azureDisk: + description: azureDisk represents an Azure Data Disk mount + on the host and bind mount to the pod. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk in + the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in the + blob storage + type: string + fsType: + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage accoun' + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: cephFS represents a Ceph FS mount on the host + that shares a pod's lifetime + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s. + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile is + the path to key ring for User, default is /etc/ceph/user.' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is reference + to the authentication secret for User, default is + empty.' + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s. + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + More info: https://examples.k8s. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits used + to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers (Beta fea + properties: + driver: + description: driver is the name of the CSI driver that + handles this volume. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", "ntfs". + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to c + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about the + pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created + files by default.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes. + properties: + medium: + description: medium represents what type of storage + medium should back this directory. + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: sizeLimit is the total amount of local + storage required for this EmptyDir volume. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: ephemeral represents a volume that is handled + by a cluster storage driver. + properties: + volumeClaimTemplate: + description: Will be used to create a stand-alone PVC + to provision the volume. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. + type: object + spec: + description: The specification for the PersistentVolumeClaim. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes. + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s. + properties: + apiGroup: + description: APIGroup is the group for the + resource being referenced. + 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 + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. + properties: + apiGroup: + description: APIGroup is the group for the + resource being referenced. + 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 + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking. + type: string + required: + - kind + - name + type: object + resources: + description: resources represents the minimum + resources the volume should have. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} + pairs. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes. + type: string + volumeAttributesClassName: + description: volumeAttributesClassName may be + used to set the VolumeAttributesClass used + by this claim. + type: string + volumeMode: + description: volumeMode defines what type of + volume is required by the claim. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource that + is attached to a kubelet's host machine and then exposed + to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target worldwide + names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: "wwids Optional: FC volume world wide identifiers + (wwids)\nEither wwids or combination of targetWWNs + and lun must be set, " + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + properties: + driver: + description: driver is the name of the driver to use + for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugi + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: flocker represents a Flocker volume attached + to a kubelet's host machine. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as depreca + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the po + properties: + fsType: + description: fsType is filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes. + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes. + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + DEPRECATED: GitRepo is deprecated. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the specified + revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + endpoints: + description: |- + endpoints is the endpoint name that details Glusterfs topology. + More info: https://examples.k8s. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator + Name. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal List. + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: targetPortal is iSCSI Target Portal. + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes. + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes. + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes. + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + properties: + claimName: + description: claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: photonPersistentDisk represents a PhotonController + persistent disk attached and mounted on kubelets host + machine + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + pdID: + description: pdID is the ID that identifies Photon Controller + persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: portworxVolume represents a portworx volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources secrets, + configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used to set + permissions on created files by default. + format: int32 + type: integer + sources: + description: sources is the list of volume projections + items: + description: Projection that may be projected along + with other supported volume types + properties: + clusterTrustBundle: + description: ClusterTrustBundle allows a pod to + access the `.spec. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} + pairs. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path + within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode + bits used to set permissions on this + file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the + ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the + downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects a field + of the pod: only annotations, labels, + name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file to + be created. Must not be absolute or + contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env + vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path + within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode + bits used to set permissions on this + file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended audience + of the token. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: quobyte represents a Quobyte mount on the host + that shares a pod's lifetime + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple ent + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references an already + created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s. + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s. + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: scaleIO represents a ScaleIO persistent volume + attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + gateway: + description: gateway is the host address of the ScaleIO + API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the ScaleIO + Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL communication + with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage + for a volume should be ThickProvisioned or ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage Pool + associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes. + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits used + to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the Secret + or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes. + type: string + type: object + storageos: + description: storageOS represents a StorageOS volume attached + and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope of + the volume within StorageOS. + type: string + type: object + vsphereVolume: + description: vsphereVolume represents a vSphere volume attached + and mounted on kubelets host machine + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy Based + Management (SPBM) profile ID associated with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array + type: object + required: + - feastProject + type: object + status: + description: FeatureStoreStatus defines the observed state of FeatureStore + properties: + applied: + description: Shows the currently applied feast configuration, including + any pertinent defaults + properties: + authz: + description: AuthzConfig defines the authorization settings for + the deployed Feast services. + properties: + kubernetes: + description: |- + KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. + https://kubernetes. + properties: + roles: + description: The Kubernetes RBAC roles to be deployed + in the same namespace of the FeatureStore. + items: + type: string + type: array + type: object + oidc: + description: |- + OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. + https://auth0. + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required between kubernetes or oidc. + rule: '[has(self.kubernetes), has(self.oidc)].exists_one(c, + c)' + feastProject: + description: FeastProject is the Feast project id. + pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ + type: string + feastProjectDir: + description: FeastProjectDir defines how to create the feast project + directory. + properties: + git: + description: GitCloneOptions describes how a clone should + be performed. + properties: + configs: + additionalProperties: + type: string + description: |- + Configs passed to git via `-c` + e.g. http.sslVerify: 'false' + OR 'url."https://api:\${TOKEN}@github.com/". + type: object + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + featureRepoPath: + description: FeatureRepoPath is the relative path to the + feature repo subdirectory. Default is 'feature_repo'. + type: string + ref: + description: Reference to a branch / tag / commit + type: string + url: + description: The repository URL to clone from. + type: string + required: + - url + type: object + x-kubernetes-validations: + - message: RepoPath must be a file name only, with no slashes. + rule: 'has(self.featureRepoPath) ? !self.featureRepoPath.startsWith(''/'') + : true' + init: + description: FeastInitOptions defines how to run a `feast + init`. + properties: + minimal: + type: boolean + template: + description: Template for the created project + enum: + - local + - gcp + - aws + - snowflake + - spark + - postgres + - hbase + - cassandra + - hazelcast + - ikv + - couchbase + type: string + type: object + type: object + x-kubernetes-validations: + - message: One selection required between init or git. + rule: '[has(self.git), has(self.init)].exists_one(c, c)' + services: + description: FeatureStoreServices defines the desired feast services. + An ephemeral onlineStore feature server is deployed by default. + properties: + deploymentStrategy: + description: DeploymentStrategy describes how to replace existing + pods with new ones. + properties: + rollingUpdate: + description: |- + Rolling update config params. Present only if DeploymentStrategyType = + RollingUpdate. + properties: + maxSurge: + anyOf: + - type: integer + - type: string + description: |- + The maximum number of pods that can be scheduled above the desired number of + pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: The maximum number of pods that can be + unavailable during the update. + x-kubernetes-int-or-string: true + type: object + type: + description: Type of deployment. Can be "Recreate" or + "RollingUpdate". Default is RollingUpdate. + type: string + type: object + disableInitContainers: + description: Disable the 'feast repo initialization' initContainer + type: boolean + offlineStore: + description: OfflineStore configures the offline store service + properties: + persistence: + description: OfflineStorePersistence configures the persistence + settings for the offline store service + properties: + file: + description: OfflineStoreFilePersistence configures + the file-based persistence for the offline store + service + properties: + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: + enum: + - file + - dask + - duckdb + type: string + type: object + store: + description: OfflineStoreDBStorePersistence configures + the DB store persistence for the offline store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - snowflake.offline + - bigquery + - redshift + - spark + - postgres + - trino + - athena + - mssql + - couchbase.offline + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a remote offline server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + onlineStore: + description: OnlineStore configures the online store service + properties: + persistence: + description: OnlineStorePersistence configures the persistence + settings for the online store service + properties: + file: + description: OnlineStoreFilePersistence configures + the file-based persistence for the online store + service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: object + x-kubernetes-validations: + - message: Ephemeral stores must have absolute paths. + rule: '(!has(self.pvc) && has(self.path)) ? self.path.startsWith(''/'') + : true' + - message: PVC path must be a file name only, with + no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: Online store does not support S3 or GS + buckets. + rule: 'has(self.path) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + store: + description: OnlineStoreDBStorePersistence configures + the DB store persistence for the online store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - snowflake.online + - redis + - ikv + - datastore + - dynamodb + - bigtable + - postgres + - cassandra + - mysql + - hazelcast + - singlestore + - hbase + - elasticsearch + - qdrant + - couchbase.online + - milvus + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a feature server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + registry: + description: Registry configures the registry service. One + selection is required. Local is the default setting. + properties: + local: + description: LocalRegistryConfig configures the registry + service + properties: + persistence: + description: RegistryPersistence configures the persistence + settings for the registry service + properties: + file: + description: RegistryFilePersistence configures + the file-based persistence for the registry + service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings + for a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new + PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to + ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the + storage resource requirements for + a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes + the minimum amount of compute + resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the + name of an existing StorageClass + to which this persistent volume + belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing + field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between + ref and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' + and must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + s3_additional_kwargs: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-validations: + - message: Registry files must use absolute paths + or be S3 ('s3://') or GS ('gs://') object + store URIs. + rule: '(!has(self.pvc) && has(self.path)) ? + (self.path.startsWith(''/'') || self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: PVC path must be a file name only, + with no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: PVC persistence does not support S3 + or GS object store URIs. + rule: '(has(self.pvc) && has(self.path)) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: Additional S3 settings are available + only for S3 object store URIs. + rule: '(has(self.s3_additional_kwargs) && has(self.path)) + ? self.path.startsWith(''s3://'') : true' + store: + description: RegistryDBStorePersistence configures + the DB store persistence for the registry service + properties: + secretKeyName: + description: By default, the selected store + "type" is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should + be placed as-is from the "feature_store.yaml" + under the secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type + you want to use. + enum: + - sql + - snowflake.registry + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or + store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a registry server container + properties: + env: + items: + description: EnvVar represents an environment + variable present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment + variable's value. Cannot be used if value + is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the + ConfigMap or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the + pod: supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env + vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret + in the pod's namespace + properties: + key: + description: The key of the secret + to select from. Must be a valid + secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the + Secret or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be + a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for + if/when to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the + compute resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one + entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS + for a feast service. + properties: + disable: + description: will disable TLS for the feast + service. useful in an openshift cluster, + for example, where TLS is configured by + default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret + where the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` + is false.' + rule: '(!has(self.disable) || !self.disable) + ? has(self.secretRef) : true' + volumeMounts: + description: VolumeMounts defines the list of + volumes that should be mounted into the feast + container. + items: + description: VolumeMount describes a mounting + of a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of + a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should + be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + remote: + description: RemoteRegistryConfig points to a remote feast + registry server. + properties: + feastRef: + description: Reference to an existing `FeatureStore` + CR in the same k8s cluster. + properties: + name: + description: Name of the FeatureStore + type: string + namespace: + description: Namespace of the FeatureStore + type: string + required: + - name + type: object + hostname: + description: Host address of the remote registry service + - :, e.g. `registry..svc.cluster.local:80` + type: string + tls: + description: TlsRemoteRegistryConfigs configures client + TLS for a remote feast registry. + properties: + certName: + description: defines the configmap key name for + the client TLS cert. + type: string + configMapRef: + description: references the local k8s configmap + where the TLS cert resides + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - certName + - configMapRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, + c)' + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.local), has(self.remote)].exists_one(c, + c)' + ui: + description: Creates a UI server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + volumes: + description: Volumes specifies the volumes to mount in the + FeatureStore deployment. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to th + properties: + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes. + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes. + type: string + required: + - volumeID + type: object + azureDisk: + description: azureDisk represents an Azure Data Disk + mount on the host and bind mount to the pod. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk + in the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in + the blob storage + type: string + fsType: + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage accoun' + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: cephFS represents a Ceph FS mount on the + host that shares a pod's lifetime + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s. + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default + is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile + is the path to key ring for User, default is /etc/ceph/user.' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is + reference to the authentication secret for User, + default is empty.' + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s. + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + More info: https://examples.k8s. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits + used to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits + used to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers (Beta fea + properties: + driver: + description: driver is the name of the CSI driver + that handles this volume. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", + "ntfs". + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to c + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about + the pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created + files by default.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes. + properties: + medium: + description: medium represents what type of storage + medium should back this directory. + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: sizeLimit is the total amount of local + storage required for this EmptyDir volume. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: ephemeral represents a volume that is handled + by a cluster storage driver. + properties: + volumeClaimTemplate: + description: Will be used to create a stand-alone + PVC to provision the volume. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. + type: object + spec: + description: The specification for the PersistentVolumeClaim. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes. + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s. + properties: + apiGroup: + description: APIGroup is the group for + the resource being referenced. + 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 + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. + properties: + apiGroup: + description: APIGroup is the group for + the resource being referenced. + 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 + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking. + type: string + required: + - kind + - name + type: object + resources: + description: resources represents the minimum + resources the volume should have. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of + {key,value} pairs. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes. + type: string + volumeAttributesClassName: + description: volumeAttributesClassName may + be used to set the VolumeAttributesClass + used by this claim. + type: string + volumeMode: + description: volumeMode defines what type + of volume is required by the claim. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource + that is attached to a kubelet's host machine and then + exposed to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target + worldwide names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: "wwids Optional: FC volume world wide + identifiers (wwids)\nEither wwids or combination + of targetWWNs and lun must be set, " + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + properties: + driver: + description: driver is the name of the driver to + use for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugi + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: flocker represents a Flocker volume attached + to a kubelet's host machine. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as depreca + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the po + properties: + fsType: + description: fsType is filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes. + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes. + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + DEPRECATED: GitRepo is deprecated. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the + specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + endpoints: + description: |- + endpoints is the endpoint name that details Glusterfs topology. + More info: https://examples.k8s. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator + Name. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal + List. + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: targetPortal is iSCSI Target Portal. + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes. + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes. + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes. + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + properties: + claimName: + description: claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: photonPersistentDisk represents a PhotonController + persistent disk attached and mounted on kubelets host + machine + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + pdID: + description: pdID is the ID that identifies Photon + Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: portworxVolume represents a portworx volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources + secrets, configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used + to set permissions on created files by default. + format: int32 + type: integer + sources: + description: sources is the list of volume projections + items: + description: Projection that may be projected + along with other supported volume types + properties: + clusterTrustBundle: + description: ClusterTrustBundle allows a pod + to access the `.spec. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. + properties: + matchExpressions: + description: matchExpressions is a + list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map + of {key,value} pairs. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. + type: string + required: + - path + type: object + configMap: + description: configMap information about the + configMap data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: + mode bits used to set permissions + on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether + the ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about + the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects + a field of the pod: only annotations, + labels, name, namespace and uid + are supported.' + properties: + apiVersion: + description: Version of the + schema the FieldPath is written + in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field + to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file + to be created. Must not be absolute + or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: + required for volumes, optional + for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the + secret data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: + mode bits used to set permissions + on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended + audience of the token. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: quobyte represents a Quobyte mount on the + host that shares a pod's lifetime + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple ent + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references + an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s. + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s. + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: scaleIO represents a ScaleIO persistent + volume attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + gateway: + description: gateway is the host address of the + ScaleIO API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the + ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL + communication with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage + for a volume should be ThickProvisioned or ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage + Pool associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes. + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits + used to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits + used to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the + Secret or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes. + type: string + type: object + storageos: + description: storageOS represents a StorageOS volume + attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope + of the volume within StorageOS. + type: string + type: object + vsphereVolume: + description: vsphereVolume represents a vSphere volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy + Based Management (SPBM) profile ID associated + with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array + type: object + required: + - feastProject + type: object + clientConfigMap: + description: ConfigMap in this namespace containing a client `feature_store.yaml` + for this feast deployment + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if . + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ type: string status: description: status of the condition, one of True, False, Unknown. @@ -1304,9 +6994,7 @@ spec: description: |- type of condition in CamelCase or in foo.example.com/CamelCase. --- - Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be - useful (see .node.status.conditions), the ability to deconflict is important. - The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + Many .condition. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string @@ -1319,7 +7007,6 @@ spec: type: object type: array feastVersion: - description: Version of feast that's currently deployed type: string phase: type: string @@ -1333,6 +7020,8 @@ spec: type: string registry: type: string + ui: + type: string type: object type: object type: object diff --git a/infra/feast-operator/config/default/kustomization.yaml b/infra/feast-operator/config/default/kustomization.yaml index 957965b9b35..ca573154247 100644 --- a/infra/feast-operator/config/default/kustomization.yaml +++ b/infra/feast-operator/config/default/kustomization.yaml @@ -25,12 +25,20 @@ resources: #- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml + patches: -# Protect the /metrics endpoint by putting it behind auth. -# If you want your controller-manager to expose the /metrics -# endpoint w/o any authn/z, please comment the following line. -- path: manager_auth_proxy_patch.yaml +- path: related_image_fs_patch.yaml + target: + kind: Deployment +# Uncomment the patches line if you enable Metrics, and/or are using webhooks and cert-manager +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml + target: + kind: Deployment # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml diff --git a/infra/feast-operator/config/default/manager_auth_proxy_patch.yaml b/infra/feast-operator/config/default/manager_auth_proxy_patch.yaml deleted file mode 100644 index 4c3c27602f5..00000000000 --- a/infra/feast-operator/config/default/manager_auth_proxy_patch.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# This patch inject a sidecar container which is a HTTP proxy for the -# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: controller-manager - namespace: system -spec: - template: - spec: - containers: - - name: kube-rbac-proxy - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - "ALL" - image: gcr.io/kubebuilder/kube-rbac-proxy:v0.16.0 - args: - - "--secure-listen-address=0.0.0.0:8443" - - "--upstream=http://127.0.0.1:8080/" - - "--logtostderr=true" - - "--v=0" - ports: - - containerPort: 8443 - protocol: TCP - name: https - resources: - limits: - cpu: 500m - memory: 128Mi - requests: - cpu: 5m - memory: 64Mi - - name: manager - args: - - "--health-probe-bind-address=:8081" - - "--metrics-bind-address=127.0.0.1:8080" - - "--leader-elect" diff --git a/infra/feast-operator/config/default/manager_metrics_patch.yaml b/infra/feast-operator/config/default/manager_metrics_patch.yaml new file mode 100644 index 00000000000..2aaef6536f4 --- /dev/null +++ b/infra/feast-operator/config/default/manager_metrics_patch.yaml @@ -0,0 +1,4 @@ +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/infra/feast-operator/config/rbac/auth_proxy_service.yaml b/infra/feast-operator/config/default/metrics_service.yaml similarity index 94% rename from infra/feast-operator/config/rbac/auth_proxy_service.yaml rename to infra/feast-operator/config/default/metrics_service.yaml index c2bf4e37939..0207c0469d4 100644 --- a/infra/feast-operator/config/rbac/auth_proxy_service.yaml +++ b/infra/feast-operator/config/default/metrics_service.yaml @@ -12,6 +12,6 @@ spec: - name: https port: 8443 protocol: TCP - targetPort: https + targetPort: 8443 selector: control-plane: controller-manager diff --git a/infra/feast-operator/config/default/related_image_fs_patch.tmpl b/infra/feast-operator/config/default/related_image_fs_patch.tmpl new file mode 100644 index 00000000000..f3508836a86 --- /dev/null +++ b/infra/feast-operator/config/default/related_image_fs_patch.tmpl @@ -0,0 +1,5 @@ +- op: replace + path: "/spec/template/spec/containers/0/env/0" + value: + name: RELATED_IMAGE_FEATURE_SERVER + value: ${FS_IMG} diff --git a/infra/feast-operator/config/default/related_image_fs_patch.yaml b/infra/feast-operator/config/default/related_image_fs_patch.yaml new file mode 100644 index 00000000000..890e4a8f45c --- /dev/null +++ b/infra/feast-operator/config/default/related_image_fs_patch.yaml @@ -0,0 +1,5 @@ +- op: replace + path: "/spec/template/spec/containers/0/env/0" + value: + name: RELATED_IMAGE_FEATURE_SERVER + value: docker.io/feastdev/feature-server:0.46.0 diff --git a/infra/feast-operator/config/manager/kustomization.yaml b/infra/feast-operator/config/manager/kustomization.yaml index 253475b945b..bdf2ea9398c 100644 --- a/infra/feast-operator/config/manager/kustomization.yaml +++ b/infra/feast-operator/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: feastdev/feast-operator - newTag: 0.41.0 + newTag: 0.46.0 diff --git a/infra/feast-operator/config/manager/manager.yaml b/infra/feast-operator/config/manager/manager.yaml index 90ef7b48635..e7550f0db7f 100644 --- a/infra/feast-operator/config/manager/manager.yaml +++ b/infra/feast-operator/config/manager/manager.yaml @@ -62,6 +62,7 @@ spec: - /manager args: - --leader-elect + - --health-probe-bind-address=:8081 image: controller:latest name: manager securityContext: @@ -69,6 +70,9 @@ spec: capabilities: drop: - "ALL" + env: + - name: RELATED_IMAGE_FEATURE_SERVER + value: feast:latest livenessProbe: httpGet: path: /healthz diff --git a/infra/feast-operator/config/overlays/odh/delete-namespace.yaml b/infra/feast-operator/config/overlays/odh/delete-namespace.yaml new file mode 100644 index 00000000000..9a52c0573de --- /dev/null +++ b/infra/feast-operator/config/overlays/odh/delete-namespace.yaml @@ -0,0 +1,5 @@ +$patch: delete +apiVersion: v1 +kind: Namespace +metadata: + name: system diff --git a/infra/feast-operator/config/overlays/odh/kustomization.yaml b/infra/feast-operator/config/overlays/odh/kustomization.yaml new file mode 100644 index 00000000000..508757e76fb --- /dev/null +++ b/infra/feast-operator/config/overlays/odh/kustomization.yaml @@ -0,0 +1,44 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: opendatahub + + +resources: + - ../../default + + +patches: + # patch to remove default `system` namespace in ../../manager/manager.yaml + - path: delete-namespace.yaml + +configMapGenerator: + - name: feast-operator-parameters + envs: + - params.env + +configurations: + - params.yaml + +replacements: + - source: + kind: ConfigMap + name: feast-operator-parameters + version: v1 + fieldPath: data.RELATED_IMAGE_FEAST_OPERATOR + targets: + - select: + kind: Deployment + name: controller-manager + fieldPaths: + - spec.template.spec.containers.[name=manager].image + - source: + kind: ConfigMap + name: feast-operator-parameters + fieldPath: data.RELATED_IMAGE_FEATURE_SERVER + targets: + - select: + kind: Deployment + name: controller-manager + fieldPaths: + - spec.template.spec.containers.[name=manager].env.[name=RELATED_IMAGE_FEATURE_SERVER].value diff --git a/infra/feast-operator/config/overlays/odh/params.env b/infra/feast-operator/config/overlays/odh/params.env new file mode 100644 index 00000000000..3e846e9ccc6 --- /dev/null +++ b/infra/feast-operator/config/overlays/odh/params.env @@ -0,0 +1,2 @@ +RELATED_IMAGE_FEAST_OPERATOR=docker.io/feastdev/feast-operator:0.46.0 +RELATED_IMAGE_FEATURE_SERVER=docker.io/feastdev/feature-server:0.46.0 \ No newline at end of file diff --git a/infra/feast-operator/config/overlays/odh/params.yaml b/infra/feast-operator/config/overlays/odh/params.yaml new file mode 100644 index 00000000000..43509ff293c --- /dev/null +++ b/infra/feast-operator/config/overlays/odh/params.yaml @@ -0,0 +1,3 @@ +varReference: + - path: spec/template/spec/containers[]/image + kind: Deployment diff --git a/infra/feast-operator/config/prometheus/monitor.yaml b/infra/feast-operator/config/prometheus/monitor.yaml index 55484079677..e76479a1305 100644 --- a/infra/feast-operator/config/prometheus/monitor.yaml +++ b/infra/feast-operator/config/prometheus/monitor.yaml @@ -11,10 +11,19 @@ metadata: spec: endpoints: - path: /metrics - port: https + port: https # Ensure this is the name of the port that exposes HTTPS metrics scheme: https bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token tlsConfig: + # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables + # certificate verification. This poses a significant security risk by making the system vulnerable to + # man-in-the-middle attacks, where an attacker could intercept and manipulate the communication between + # Prometheus and the monitored services. This could lead to unauthorized access to sensitive metrics data, + # compromising the integrity and confidentiality of the information. + # Please use the following options for secure configurations: + # caFile: /etc/metrics-certs/ca.crt + # certFile: /etc/metrics-certs/tls.crt + # keyFile: /etc/metrics-certs/tls.key insecureSkipVerify: true selector: matchLabels: diff --git a/infra/feast-operator/config/rbac/auth_proxy_role.yaml b/infra/feast-operator/config/rbac/auth_proxy_role.yaml deleted file mode 100644 index 55f87916462..00000000000 --- a/infra/feast-operator/config/rbac/auth_proxy_role.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: feast-operator - app.kubernetes.io/managed-by: kustomize - name: proxy-role -rules: -- apiGroups: - - authentication.k8s.io - resources: - - tokenreviews - verbs: - - create -- apiGroups: - - authorization.k8s.io - resources: - - subjectaccessreviews - verbs: - - create diff --git a/infra/feast-operator/config/rbac/kustomization.yaml b/infra/feast-operator/config/rbac/kustomization.yaml index 5e4972b5397..d22437a5390 100644 --- a/infra/feast-operator/config/rbac/kustomization.yaml +++ b/infra/feast-operator/config/rbac/kustomization.yaml @@ -9,13 +9,15 @@ resources: - role_binding.yaml - leader_election_role.yaml - leader_election_role_binding.yaml -# Comment the following 4 lines if you want to disable -# the auth proxy (https://github.com/brancz/kube-rbac-proxy) -# which protects your /metrics endpoint. -- auth_proxy_service.yaml -- auth_proxy_role.yaml -- auth_proxy_role_binding.yaml -- auth_proxy_client_clusterrole.yaml +# The following RBAC configurations are used to protect +# the metrics endpoint with authn/authz. These configurations +# ensure that only authorized users and service accounts +# can access the metrics endpoint. Comment the following +# permissions if you want to disable this protection. +# More info: https://book.kubebuilder.io/reference/metrics.html +- metrics_auth_role.yaml +- metrics_auth_role_binding.yaml +- metrics_reader_role.yaml # For each CRD, "Editor" and "Viewer" roles are scaffolded by # default, aiding admins in cluster management. Those roles are # not used by the Project itself. You can comment the following lines diff --git a/infra/feast-operator/config/rbac/metrics_auth_role.yaml b/infra/feast-operator/config/rbac/metrics_auth_role.yaml new file mode 100644 index 00000000000..bee99788cf4 --- /dev/null +++ b/infra/feast-operator/config/rbac/metrics_auth_role.yaml @@ -0,0 +1,20 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: feast-operator + app.kubernetes.io/managed-by: kustomize + name: metrics-auth-role +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/infra/feast-operator/config/rbac/auth_proxy_role_binding.yaml b/infra/feast-operator/config/rbac/metrics_auth_role_binding.yaml similarity index 84% rename from infra/feast-operator/config/rbac/auth_proxy_role_binding.yaml rename to infra/feast-operator/config/rbac/metrics_auth_role_binding.yaml index ffa85c82af6..f84b6c4160c 100644 --- a/infra/feast-operator/config/rbac/auth_proxy_role_binding.yaml +++ b/infra/feast-operator/config/rbac/metrics_auth_role_binding.yaml @@ -4,11 +4,11 @@ metadata: labels: app.kubernetes.io/name: feast-operator app.kubernetes.io/managed-by: kustomize - name: proxy-rolebinding + name: metrics-auth-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: proxy-role + name: metrics-auth-role subjects: - kind: ServiceAccount name: controller-manager diff --git a/infra/feast-operator/config/rbac/auth_proxy_client_clusterrole.yaml b/infra/feast-operator/config/rbac/metrics_reader_role.yaml similarity index 100% rename from infra/feast-operator/config/rbac/auth_proxy_client_clusterrole.yaml rename to infra/feast-operator/config/rbac/metrics_reader_role.yaml diff --git a/infra/feast-operator/config/rbac/role.yaml b/infra/feast-operator/config/rbac/role.yaml index 5ee64d47051..7fba75c23a4 100644 --- a/infra/feast-operator/config/rbac/role.yaml +++ b/infra/feast-operator/config/rbac/role.yaml @@ -19,6 +19,8 @@ rules: - "" resources: - configmaps + - persistentvolumeclaims + - serviceaccounts - services verbs: - create @@ -27,6 +29,13 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list - apiGroups: - feast.dev resources: @@ -53,3 +62,26 @@ rules: - get - patch - update +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - route.openshift.io + resources: + - routes + verbs: + - create + - delete + - get + - list + - update + - watch diff --git a/infra/feast-operator/config/samples/kustomization.yaml b/infra/feast-operator/config/samples/kustomization.yaml index 4869cc7b245..ecb2e09c95b 100644 --- a/infra/feast-operator/config/samples/kustomization.yaml +++ b/infra/feast-operator/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples of your project ## resources: - v1alpha1_featurestore.yaml +- v1alpha1_featurestore_all_servers.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_all_servers.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_all_servers.yaml new file mode 100644 index 00000000000..0d9d744a678 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_all_servers.yaml @@ -0,0 +1,13 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-all-servers +spec: + feastProject: my_project + services: + offlineStore: + server: {} + registry: + local: + server: {} + ui: {} diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_db_persistence.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_db_persistence.yaml new file mode 100644 index 00000000000..e66b7fc3283 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_db_persistence.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + namespace: test + labels: + app: postgres +stringData: + POSTGRES_DB: feast + POSTGRES_USER: feast + POSTGRES_PASSWORD: feast +--- +apiVersion: v1 +kind: Secret +metadata: + name: feast-data-stores + namespace: test +stringData: + sql: | + path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres.test.svc.cluster.local:5432/${POSTGRES_DB} + cache_ttl_seconds: 60 + sqlalchemy_config_kwargs: + echo: false + pool_pre_ping: true + postgres: | + host: postgres.test.svc.cluster.local + port: 5432 + database: ${POSTGRES_DB} + db_schema: public + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} +--- +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-db-persistence + namespace: test +spec: + feastProject: my_project + services: + onlineStore: + persistence: + store: + type: postgres + secretRef: + name: feast-data-stores + server: + envFrom: + - secretRef: + name: postgres-secret + registry: + local: + persistence: + store: + type: sql + secretRef: + name: feast-data-stores diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_git.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_git.yaml new file mode 100644 index 00000000000..7730ef88518 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_git.yaml @@ -0,0 +1,10 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-git +spec: + feastProject: credit_scoring_local + feastProjectDir: + git: + url: https://github.com/feast-dev/feast-credit-score-local-tutorial + ref: 598a270 diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_git_repopath.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_git_repopath.yaml new file mode 100644 index 00000000000..6519e1bf429 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_git_repopath.yaml @@ -0,0 +1,11 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-git-repopath +spec: + feastProject: feast_demo_odfv + feastProjectDir: + git: + url: https://github.com/feast-dev/feast-workshop + ref: e959053 + featureRepoPath: module_2/feature_repo diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_git_token.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_git_token.yaml new file mode 100644 index 00000000000..f16f503c8fb --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_git_token.yaml @@ -0,0 +1,21 @@ +kind: Secret +apiVersion: v1 +metadata: + name: git-token +stringData: + TOKEN: xxxxxxxxxxx +--- +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-git-token +spec: + feastProject: private + feastProjectDir: + git: + configs: + 'url."https://api:${TOKEN}@github.com/".insteadOf': 'https://github.com/' + envFrom: + - secretRef: + name: git-token + url: 'https://github.com/user/private' diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_init.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_init.yaml new file mode 100644 index 00000000000..f2324eeab2d --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_init.yaml @@ -0,0 +1,9 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-init +spec: + feastProject: sample_init + feastProjectDir: + init: + template: spark diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml new file mode 100644 index 00000000000..33225b2edfb --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_kubernetes_auth.yaml @@ -0,0 +1,20 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-kubernetes-auth +spec: + feastProject: feast_rbac + authz: + kubernetes: + roles: + - feast-writer + - feast-reader + services: + offlineStore: + server: {} + onlineStore: + server: {} + registry: + local: + server: {} + ui: {} diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_objectstore_persistence.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_objectstore_persistence.yaml new file mode 100644 index 00000000000..2146dabe85c --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_objectstore_persistence.yaml @@ -0,0 +1,16 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-s3-registry +spec: + feastProject: my_project + services: + registry: + local: + persistence: + file: + path: s3://bucket/registry.db + s3_additional_kwargs: + ServerSideEncryption: AES256 + ACL: bucket-owner-full-control + CacheControl: max-age=3600 diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_oidc_auth.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_oidc_auth.yaml new file mode 100644 index 00000000000..54660a5c232 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_oidc_auth.yaml @@ -0,0 +1,21 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-oidc-auth +spec: + feastProject: my_project + authz: + oidc: + secretRef: + name: oidc-secret +--- +kind: Secret +apiVersion: v1 +metadata: + name: oidc-secret +stringData: + client_id: client_id + auth_discovery_url: auth_discovery_url + client_secret: client_secret + username: username + password: password diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_db_volumes_tls.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_db_volumes_tls.yaml new file mode 100644 index 00000000000..61add153716 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_db_volumes_tls.yaml @@ -0,0 +1,83 @@ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + labels: + app: postgres +stringData: + POSTGRES_DB: feast + POSTGRES_USER: admin + POSTGRES_PASSWORD: password + POSTGRES_HOST: postgresql.feast.svc.cluster.local +--- +apiVersion: v1 +kind: Secret +metadata: + name: feast-data-stores +stringData: + sql: | + path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=verify-full&sslrootcert=/var/lib/postgresql/certs/ca.crt&sslcert=/var/lib/postgresql/certs/tls.crt&sslkey=/var/lib/postgresql/certs/tls.key + cache_ttl_seconds: 60 + sqlalchemy_config_kwargs: + echo: false + pool_pre_ping: true + postgres: | + host: ${POSTGRES_HOST} + port: 5432 + database: ${POSTGRES_DB} + db_schema: public + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + sslmode: verify-full + sslkey_path: /var/lib/postgresql/certs/tls.key + sslcert_path: /var/lib/postgresql/certs/tls.crt + sslrootcert_path: /var/lib/postgresql/certs/ca.crt +--- +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-db-ssl +spec: + feastProject: postgres_tls_sample + services: + volumes: + - name: postgres-certs + secret: + secretName: postgresql-client-certs + items: + - key: ca.crt + path: ca.crt + mode: 0644 # Readable by all, required by PostgreSQL + - key: tls.crt + path: tls.crt + mode: 0644 # Required for the client certificate + - key: tls.key + path: tls.key + mode: 0640 # Required for the private key + offlineStore: + persistence: + store: + type: postgres + secretRef: + name: feast-data-stores + onlineStore: + persistence: + store: + type: postgres + secretRef: + name: feast-data-stores + server: + volumeMounts: + - name: postgres-certs + mountPath: /var/lib/postgresql/certs + readOnly: true + envFrom: + - secretRef: + name: postgres-secret + registry: + local: + persistence: + store: + type: sql + secretRef: + name: feast-data-stores diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml new file mode 100644 index 00000000000..42e1ae4b4a6 --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_postgres_tls_volumes_ca_env.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + labels: + app: postgres +stringData: + POSTGRES_DB: feast + POSTGRES_USER: admin + POSTGRES_PASSWORD: password + POSTGRES_HOST: postgresql.feast.svc.cluster.local + FEAST_CA_CERT_FILE_PATH: /var/lib/postgresql/certs/ca.crt +--- +apiVersion: v1 +kind: Secret +metadata: + name: feast-data-stores +stringData: + sql: | + path: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=verify-full&sslrootcert=system&sslcert=/var/lib/postgresql/certs/tls.crt&sslkey=/var/lib/postgresql/certs/tls.key + cache_ttl_seconds: 60 + sqlalchemy_config_kwargs: + echo: false + pool_pre_ping: true + postgres: | + host: ${POSTGRES_HOST} + port: 5432 + database: ${POSTGRES_DB} + db_schema: public + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + sslmode: verify-full + sslkey_path: /var/lib/postgresql/certs/tls.key + sslcert_path: /var/lib/postgresql/certs/tls.crt + sslrootcert_path: system +--- +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-db-ssl +spec: + feastProject: postgres_tls_sample_env_ca + services: + volumes: + - name: postgres-certs + secret: + secretName: postgresql-client-certs + items: + - key: ca.crt + path: ca.crt + mode: 0644 # Readable by all, required by PostgreSQL + - key: tls.crt + path: tls.crt + mode: 0644 # Required for the client certificate + - key: tls.key + path: tls.key + mode: 0640 # Required for the private key + offlineStore: + persistence: + store: + type: postgres + secretRef: + name: feast-data-stores + onlineStore: + persistence: + store: + type: postgres + secretRef: + name: feast-data-stores + server: + volumeMounts: + - name: postgres-certs + mountPath: /var/lib/postgresql/certs + readOnly: true + envFrom: + - secretRef: + name: postgres-secret + registry: + local: + persistence: + store: + type: sql + secretRef: + name: feast-data-stores diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_pvc_persistence.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_pvc_persistence.yaml new file mode 100644 index 00000000000..15aa46c456c --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_pvc_persistence.yaml @@ -0,0 +1,48 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-pvc-persistence +spec: + feastProject: my_project + services: + # demonstrates using a pre-existing PVC + onlineStore: + persistence: + file: + path: online_store.db + pvc: + ref: + name: online-pvc + mountPath: /data/online + # demonstrates specifying a storageClassName and storage size + offlineStore: + persistence: + file: + type: duckdb + pvc: + create: + storageClassName: standard + resources: + requests: + storage: 5Gi + mountPath: /data/offline + # demonstrates letting the Operator create a PVC w/ defaults set + registry: + local: + persistence: + file: + path: registry.db + pvc: + create: {} + mountPath: /data/registry +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: online-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/infra/feast-operator/config/samples/v1alpha1_featurestore_services_loglevel.yaml b/infra/feast-operator/config/samples/v1alpha1_featurestore_services_loglevel.yaml new file mode 100644 index 00000000000..e738e6352be --- /dev/null +++ b/infra/feast-operator/config/samples/v1alpha1_featurestore_services_loglevel.yaml @@ -0,0 +1,19 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: sample-services-loglevel +spec: + feastProject: my_project + services: + onlineStore: + server: + logLevel: debug + offlineStore: + server: + logLevel: debug + registry: + local: + server: + logLevel: debug + ui: + logLevel: debug diff --git a/infra/feast-operator/dist/install.yaml b/infra/feast-operator/dist/install.yaml index 4d66fbdc734..df7be4ffb0a 100644 --- a/infra/feast-operator/dist/install.yaml +++ b/infra/feast-operator/dist/install.yaml @@ -11,7 +11,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.15.0 name: featurestores.feast.dev spec: group: feast.dev @@ -37,39 +37,79 @@ spec: description: FeatureStore is the Schema for the featurestores API properties: apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + description: APIVersion defines the versioned schema of this representation + of an object. type: string kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + description: Kind is a string value representing the REST resource this + object represents. type: string metadata: type: object spec: description: FeatureStoreSpec defines the desired state of FeatureStore properties: + authz: + description: AuthzConfig defines the authorization settings for the + deployed Feast services. + properties: + kubernetes: + description: |- + KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. + https://kubernetes. + properties: + roles: + description: The Kubernetes RBAC roles to be deployed in the + same namespace of the FeatureStore. + items: + type: string + type: array + type: object + oidc: + description: |- + OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. + https://auth0. + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required between kubernetes or oidc. + rule: '[has(self.kubernetes), has(self.oidc)].exists_one(c, c)' feastProject: - description: FeastProject is the Feast project id. This can be any - alphanumeric string with underscores, but it cannot start with an - underscore. Required. + description: FeastProject is the Feast project id. pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ type: string - services: - description: FeatureStoreServices defines the desired feast service - deployments. ephemeral registry is deployed by default. + feastProjectDir: + description: FeastProjectDir defines how to create the feast project + directory. properties: - offlineStore: - description: OfflineStore configures the deployed offline store - service + git: + description: GitCloneOptions describes how a clone should be performed. properties: + configs: + additionalProperties: + type: string + description: |- + Configs passed to git via `-c` + e.g. http.sslVerify: 'false' + OR 'url."https://api:\${TOKEN}@github.com/". + type: object env: items: description: EnvVar represents an environment variable present @@ -83,13 +123,7 @@ spec: description: |- Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + any type: string valueFrom: description: Source for the environment variable's value. @@ -102,10 +136,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap or @@ -116,9 +151,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' properties: apiVersion: description: Version of the schema the FieldPath @@ -135,7 +170,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required for volumes, @@ -165,10 +200,11 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret or its @@ -183,256 +219,268 @@ spec: - name type: object type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when to - pull a container image - type: string - resources: - description: ResourceRequirements describes the compute resource - requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. + envFrom: + items: + description: EnvFromSource represents the source of a set + of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from properties: name: + default: "" description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - required: - - name + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - onlineStore: - description: OnlineStore configures the deployed online store - service - properties: - env: - items: - description: EnvVar represents an environment variable present - in a Container. - properties: - name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to each + key in the ConfigMap. Must be a C_IDENTIFIER. type: string - valueFrom: - description: Source for the environment variable's value. - Cannot be used if value is not empty. + secretRef: + description: The Secret to select from properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap or - its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in - the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: + name: + default: "" description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for volumes, - optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of - the exposed resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the pod's - namespace - properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must be + defined + type: boolean type: object - required: - - name + x-kubernetes-map-type: atomic type: object type: array - image: + featureRepoPath: + description: FeatureRepoPath is the relative path to the feature + repo subdirectory. Default is 'feature_repo'. type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when to - pull a container image + ref: + description: Reference to a branch / tag / commit type: string - resources: - description: ResourceRequirements describes the compute resource - requirements. + url: + description: The repository URL to clone from. + type: string + required: + - url + type: object + x-kubernetes-validations: + - message: RepoPath must be a file name only, with no slashes. + rule: 'has(self.featureRepoPath) ? !self.featureRepoPath.startsWith(''/'') + : true' + init: + description: FeastInitOptions defines how to run a `feast init`. + properties: + minimal: + type: boolean + template: + description: Template for the created project + enum: + - local + - gcp + - aws + - snowflake + - spark + - postgres + - hbase + - cassandra + - hazelcast + - ikv + - couchbase + type: string + type: object + type: object + x-kubernetes-validations: + - message: One selection required between init or git. + rule: '[has(self.git), has(self.init)].exists_one(c, c)' + services: + description: FeatureStoreServices defines the desired feast services. + An ephemeral onlineStore feature server is deployed by default. + properties: + deploymentStrategy: + description: DeploymentStrategy describes how to replace existing + pods with new ones. + properties: + rollingUpdate: + description: |- + Rolling update config params. Present only if DeploymentStrategyType = + RollingUpdate. properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true + maxSurge: + anyOf: + - type: integer + - type: string description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object + The maximum number of pods that can be scheduled above the desired number of + pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: The maximum number of pods that can be unavailable + during the update. + x-kubernetes-int-or-string: true type: object + type: + description: Type of deployment. Can be "Recreate" or "RollingUpdate". + Default is RollingUpdate. + type: string type: object - registry: - description: Registry configures the registry service. One selection - is required. Local is the default setting. + disableInitContainers: + description: Disable the 'feast repo initialization' initContainer + type: boolean + offlineStore: + description: OfflineStore configures the offline store service properties: - local: - description: LocalRegistryConfig configures the deployed registry - service + persistence: + description: OfflineStorePersistence configures the persistence + settings for the offline store service + properties: + file: + description: OfflineStoreFilePersistence configures the + file-based persistence for the offline store service + properties: + pvc: + description: PvcConfig defines the settings for a + persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent volume + access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which this + persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref and + create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and must + not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: + enum: + - file + - dask + - duckdb + type: string + type: object + store: + description: OfflineStoreDBStorePersistence configures + the DB store persistence for the offline store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the secret + key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you want + to use. + enum: + - snowflake.offline + - bigquery + - redshift + - spark + - postgres + - trino + - athena + - mssql + - couchbase.offline + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, c)' + server: + description: Creates a remote offline server container properties: env: items: @@ -447,13 +495,7 @@ spec: description: |- Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + any type: string valueFrom: description: Source for the environment variable's @@ -466,10 +508,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap @@ -480,9 +523,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' properties: apiVersion: description: Version of the schema the FieldPath @@ -499,7 +542,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required for @@ -530,10 +573,11 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret @@ -548,12 +592,66 @@ spec: - name type: object type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array image: type: string imagePullPolicy: description: PullPolicy describes a policy for if/when to pull a container image type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -562,13 +660,6 @@ spec: description: |- Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in PodSpec.ResourceClaims. @@ -576,8 +667,7 @@ spec: name: description: |- Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + the Pod where this field is used. type: string required: - name @@ -595,7 +685,7 @@ spec: x-kubernetes-int-or-string: true description: |- Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + More info: https://kubernetes. type: object requests: additionalProperties: @@ -604,69 +694,248 @@ spec: - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array type: object - remote: - description: |- - RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. - Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. + type: object + onlineStore: + description: OnlineStore configures the online store service + properties: + persistence: + description: OnlineStorePersistence configures the persistence + settings for the online store service properties: - feastRef: - description: Reference to an existing `FeatureStore` CR - in the same k8s cluster. + file: + description: OnlineStoreFilePersistence configures the + file-based persistence for the online store service properties: - name: - description: Name of the FeatureStore + path: type: string - namespace: - description: Namespace of the FeatureStore + pvc: + description: PvcConfig defines the settings for a + persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent volume + access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which this + persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref and + create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and must + not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: object + x-kubernetes-validations: + - message: Ephemeral stores must have absolute paths. + rule: '(!has(self.pvc) && has(self.path)) ? self.path.startsWith(''/'') + : true' + - message: PVC path must be a file name only, with no + slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: Online store does not support S3 or GS buckets. + rule: 'has(self.path) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + store: + description: OnlineStoreDBStorePersistence configures + the DB store persistence for the online store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the secret + key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you want + to use. + enum: + - snowflake.online + - redis + - ikv + - datastore + - dynamodb + - bigtable + - postgres + - cassandra + - mysql + - hazelcast + - singlestore + - hbase + - elasticsearch + - qdrant + - couchbase.online + - milvus type: string required: - - name + - secretRef + - type type: object - hostname: - description: Host address of the remote registry service - - :, e.g. `registry..svc.cluster.local:80` - type: string type: object x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, - c)' - type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.local), has(self.remote)].exists_one(c, c)' - type: object - required: - - feastProject - type: object - status: - description: FeatureStoreStatus defines the observed state of FeatureStore - properties: - applied: - description: Shows the currently applied feast configuration, including - any pertinent defaults - properties: - feastProject: - description: FeastProject is the Feast project id. This can be - any alphanumeric string with underscores, but it cannot start - with an underscore. Required. - pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ - type: string - services: - description: FeatureStoreServices defines the desired feast service - deployments. ephemeral registry is deployed by default. - properties: - offlineStore: - description: OfflineStore configures the deployed offline - store service + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, c)' + server: + description: Creates a feature server container properties: env: items: @@ -681,13 +950,7 @@ spec: description: |- Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + any type: string valueFrom: description: Source for the environment variable's @@ -700,10 +963,11 @@ spec: description: The key to select. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the ConfigMap @@ -714,9 +978,9 @@ spec: type: object x-kubernetes-map-type: atomic fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' properties: apiVersion: description: Version of the schema the FieldPath @@ -733,7 +997,7 @@ spec: resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + (limits.cpu, limits.memory, limits. properties: containerName: description: 'Container name: required for @@ -764,10 +1028,11 @@ spec: from. Must be a valid secret key. type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret @@ -782,12 +1047,66 @@ spec: - name type: object type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array image: type: string imagePullPolicy: description: PullPolicy describes a policy for if/when to pull a container image type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -796,13 +1115,6 @@ spec: description: |- Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. items: description: ResourceClaim references one entry in PodSpec.ResourceClaims. @@ -810,8 +1122,7 @@ spec: name: description: |- Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + the Pod where this field is used. type: string required: - name @@ -829,7 +1140,7 @@ spec: x-kubernetes-int-or-string: true description: |- Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + More info: https://kubernetes. type: object requests: additionalProperties: @@ -838,417 +1149,5810 @@ spec: - type: string pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + description: Requests describes the minimum amount + of compute resources required. type: object type: object - type: object - onlineStore: - description: OnlineStore configures the deployed online store - service - properties: - env: + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. items: - description: EnvVar represents an environment variable - present in a Container. + description: VolumeMount describes a mounting of a Volume + within a container. properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string name: - description: Name of the environment variable. Must - be a C_IDENTIFIER. + description: This must match the Name of a Volume. type: string - value: + readOnly: description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + registry: + description: Registry configures the registry service. One selection + is required. Local is the default setting. + properties: + local: + description: LocalRegistryConfig configures the registry service + properties: + persistence: + description: RegistryPersistence configures the persistence + settings for the registry service + properties: + file: + description: RegistryFilePersistence configures the + file-based persistence for the registry service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + s3_additional_kwargs: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-validations: + - message: Registry files must use absolute paths + or be S3 ('s3://') or GS ('gs://') object store + URIs. + rule: '(!has(self.pvc) && has(self.path)) ? (self.path.startsWith(''/'') + || self.path.startsWith(''s3://'') || self.path.startsWith(''gs://'')) + : true' + - message: PVC path must be a file name only, with + no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: PVC persistence does not support S3 or + GS object store URIs. + rule: '(has(self.pvc) && has(self.path)) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: Additional S3 settings are available only + for S3 object store URIs. + rule: '(has(self.s3_additional_kwargs) && has(self.path)) + ? self.path.startsWith(''s3://'') : true' + store: + description: RegistryDBStorePersistence configures + the DB store persistence for the registry service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - sql + - snowflake.registry + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a registry server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. properties: - apiVersion: - description: Version of the schema the FieldPath - is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from properties: - containerName: - description: 'Container name: required for - volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults to - "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - required: - - resource + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean type: object x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the - pod's namespace + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from properties: - key: - description: The key of the secret to select - from. Must be a valid secret key. - type: string name: + default: "" description: |- Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string optional: description: Specify whether the Secret - or its key must be defined + must be defined type: boolean - required: - - key type: object x-kubernetes-map-type: atomic type: object - required: - - name - type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when - to pull a container image - type: string - resources: - description: ResourceRequirements describes the compute - resource requirements. - properties: - claims: + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. + description: VolumeMount describes a mounting of + a Volume within a container. properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string name: + description: This must match the Name of a Volume. + type: string + readOnly: description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. type: string required: + - mountPath - name type: object type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object type: object type: object - registry: - description: Registry configures the registry service. One - selection is required. Local is the default setting. + remote: + description: RemoteRegistryConfig points to a remote feast + registry server. properties: - local: - description: LocalRegistryConfig configures the deployed - registry service + feastRef: + description: Reference to an existing `FeatureStore` CR + in the same k8s cluster. properties: - env: - items: - description: EnvVar represents an environment variable - present in a Container. + name: + description: Name of the FeatureStore + type: string + namespace: + description: Namespace of the FeatureStore + type: string + required: + - name + type: object + hostname: + description: Host address of the remote registry service + - :, e.g. `registry..svc.cluster.local:80` + type: string + tls: + description: TlsRemoteRegistryConfigs configures client + TLS for a remote feast registry. + properties: + certName: + description: defines the configmap key name for the + client TLS cert. + type: string + configMapRef: + description: references the local k8s configmap where + the TLS cert resides + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - certName + - configMapRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, + c)' + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.local), has(self.remote)].exists_one(c, c)' + ui: + description: Creates a UI server container + properties: + env: + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of a set + of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to each + key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must be + defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when to + pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute resource + requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount of + compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. useful + in an openshift cluster, for example, where TLS is configured + by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key names + for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where the + TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes that + should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from which + the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + volumes: + description: Volumes specifies the volumes to mount in the FeatureStore + deployment. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to th + properties: + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes. + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes. + type: string + required: + - volumeID + type: object + azureDisk: + description: azureDisk represents an Azure Data Disk mount + on the host and bind mount to the pod. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk in + the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in the + blob storage + type: string + fsType: + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage accoun' + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: cephFS represents a Ceph FS mount on the host + that shares a pod's lifetime + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s. + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile is + the path to key ring for User, default is /etc/ceph/user.' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is reference + to the authentication secret for User, default is + empty.' + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s. + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + More info: https://examples.k8s. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits used + to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers (Beta fea + properties: + driver: + description: driver is the name of the CSI driver that + handles this volume. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", "ntfs". + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to c + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about the + pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created + files by default.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes. + properties: + medium: + description: medium represents what type of storage + medium should back this directory. + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: sizeLimit is the total amount of local + storage required for this EmptyDir volume. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: ephemeral represents a volume that is handled + by a cluster storage driver. + properties: + volumeClaimTemplate: + description: Will be used to create a stand-alone PVC + to provision the volume. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. + type: object + spec: + description: The specification for the PersistentVolumeClaim. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes. + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s. + properties: + apiGroup: + description: APIGroup is the group for the + resource being referenced. + 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 + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. + properties: + apiGroup: + description: APIGroup is the group for the + resource being referenced. + 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 + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking. + type: string + required: + - kind + - name + type: object + resources: + description: resources represents the minimum + resources the volume should have. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} + pairs. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes. + type: string + volumeAttributesClassName: + description: volumeAttributesClassName may be + used to set the VolumeAttributesClass used + by this claim. + type: string + volumeMode: + description: volumeMode defines what type of + volume is required by the claim. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource that + is attached to a kubelet's host machine and then exposed + to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target worldwide + names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: "wwids Optional: FC volume world wide identifiers + (wwids)\nEither wwids or combination of targetWWNs + and lun must be set, " + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + properties: + driver: + description: driver is the name of the driver to use + for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugi + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: flocker represents a Flocker volume attached + to a kubelet's host machine. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as depreca + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the po + properties: + fsType: + description: fsType is filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes. + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes. + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + DEPRECATED: GitRepo is deprecated. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the specified + revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + endpoints: + description: |- + endpoints is the endpoint name that details Glusterfs topology. + More info: https://examples.k8s. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator + Name. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal List. + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: targetPortal is iSCSI Target Portal. + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes. + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes. + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes. + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + properties: + claimName: + description: claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: photonPersistentDisk represents a PhotonController + persistent disk attached and mounted on kubelets host + machine + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + pdID: + description: pdID is the ID that identifies Photon Controller + persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: portworxVolume represents a portworx volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources secrets, + configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used to set + permissions on created files by default. + format: int32 + type: integer + sources: + description: sources is the list of volume projections + items: + description: Projection that may be projected along + with other supported volume types + properties: + clusterTrustBundle: + description: ClusterTrustBundle allows a pod to + access the `.spec. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} + pairs. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path + within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode + bits used to set permissions on this + file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the + ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the + downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects a field + of the pod: only annotations, labels, + name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file to + be created. Must not be absolute or + contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env + vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path + within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode + bits used to set permissions on this + file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended audience + of the token. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: quobyte represents a Quobyte mount on the host + that shares a pod's lifetime + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple ent + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references an already + created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + fsType: + description: fsType is the filesystem type of the volume + that you want to mount. + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s. + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s. + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: scaleIO represents a ScaleIO persistent volume + attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + gateway: + description: gateway is the host address of the ScaleIO + API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the ScaleIO + Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL communication + with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage + for a volume should be ThickProvisioned or ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage Pool + associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes. + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits used + to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits used + to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the Secret + or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes. + type: string + type: object + storageos: + description: storageOS represents a StorageOS volume attached + and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope of + the volume within StorageOS. + type: string + type: object + vsphereVolume: + description: vsphereVolume represents a vSphere volume attached + and mounted on kubelets host machine + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy Based + Management (SPBM) profile ID associated with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array + type: object + required: + - feastProject + type: object + status: + description: FeatureStoreStatus defines the observed state of FeatureStore + properties: + applied: + description: Shows the currently applied feast configuration, including + any pertinent defaults + properties: + authz: + description: AuthzConfig defines the authorization settings for + the deployed Feast services. + properties: + kubernetes: + description: |- + KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. + https://kubernetes. + properties: + roles: + description: The Kubernetes RBAC roles to be deployed + in the same namespace of the FeatureStore. + items: + type: string + type: array + type: object + oidc: + description: |- + OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. + https://auth0. + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required between kubernetes or oidc. + rule: '[has(self.kubernetes), has(self.oidc)].exists_one(c, + c)' + feastProject: + description: FeastProject is the Feast project id. + pattern: ^[A-Za-z0-9][A-Za-z0-9_]*$ + type: string + feastProjectDir: + description: FeastProjectDir defines how to create the feast project + directory. + properties: + git: + description: GitCloneOptions describes how a clone should + be performed. + properties: + configs: + additionalProperties: + type: string + description: |- + Configs passed to git via `-c` + e.g. http.sslVerify: 'false' + OR 'url."https://api:\${TOKEN}@github.com/". + type: object + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + featureRepoPath: + description: FeatureRepoPath is the relative path to the + feature repo subdirectory. Default is 'feature_repo'. + type: string + ref: + description: Reference to a branch / tag / commit + type: string + url: + description: The repository URL to clone from. + type: string + required: + - url + type: object + x-kubernetes-validations: + - message: RepoPath must be a file name only, with no slashes. + rule: 'has(self.featureRepoPath) ? !self.featureRepoPath.startsWith(''/'') + : true' + init: + description: FeastInitOptions defines how to run a `feast + init`. + properties: + minimal: + type: boolean + template: + description: Template for the created project + enum: + - local + - gcp + - aws + - snowflake + - spark + - postgres + - hbase + - cassandra + - hazelcast + - ikv + - couchbase + type: string + type: object + type: object + x-kubernetes-validations: + - message: One selection required between init or git. + rule: '[has(self.git), has(self.init)].exists_one(c, c)' + services: + description: FeatureStoreServices defines the desired feast services. + An ephemeral onlineStore feature server is deployed by default. + properties: + deploymentStrategy: + description: DeploymentStrategy describes how to replace existing + pods with new ones. + properties: + rollingUpdate: + description: |- + Rolling update config params. Present only if DeploymentStrategyType = + RollingUpdate. + properties: + maxSurge: + anyOf: + - type: integer + - type: string + description: |- + The maximum number of pods that can be scheduled above the desired number of + pods. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: The maximum number of pods that can be + unavailable during the update. + x-kubernetes-int-or-string: true + type: object + type: + description: Type of deployment. Can be "Recreate" or + "RollingUpdate". Default is RollingUpdate. + type: string + type: object + disableInitContainers: + description: Disable the 'feast repo initialization' initContainer + type: boolean + offlineStore: + description: OfflineStore configures the offline store service + properties: + persistence: + description: OfflineStorePersistence configures the persistence + settings for the offline store service + properties: + file: + description: OfflineStoreFilePersistence configures + the file-based persistence for the offline store + service + properties: + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: + enum: + - file + - dask + - duckdb + type: string + type: object + store: + description: OfflineStoreDBStorePersistence configures + the DB store persistence for the offline store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - snowflake.offline + - bigquery + - redshift + - spark + - postgres + - trino + - athena + - mssql + - couchbase.offline + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a remote offline server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + onlineStore: + description: OnlineStore configures the online store service + properties: + persistence: + description: OnlineStorePersistence configures the persistence + settings for the online store service + properties: + file: + description: OnlineStoreFilePersistence configures + the file-based persistence for the online store + service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings for + a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the storage + resource requirements for a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + storageClassName: + description: StorageClassName is the name + of an existing StorageClass to which + this persistent volume belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between ref + and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' and + must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + type: object + x-kubernetes-validations: + - message: Ephemeral stores must have absolute paths. + rule: '(!has(self.pvc) && has(self.path)) ? self.path.startsWith(''/'') + : true' + - message: PVC path must be a file name only, with + no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: Online store does not support S3 or GS + buckets. + rule: 'has(self.path) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + store: + description: OnlineStoreDBStorePersistence configures + the DB store persistence for the online store service + properties: + secretKeyName: + description: By default, the selected store "type" + is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should be placed + as-is from the "feature_store.yaml" under the + secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type you + want to use. + enum: + - snowflake.online + - redis + - ikv + - datastore + - dynamodb + - bigtable + - postgres + - cassandra + - mysql + - hazelcast + - singlestore + - hbase + - elasticsearch + - qdrant + - couchbase.online + - milvus + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a feature server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: + supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, + defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults + to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for + a feast service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, + where TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of + a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should be + mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + registry: + description: Registry configures the registry service. One + selection is required. Local is the default setting. + properties: + local: + description: LocalRegistryConfig configures the registry + service + properties: + persistence: + description: RegistryPersistence configures the persistence + settings for the registry service + properties: + file: + description: RegistryFilePersistence configures + the file-based persistence for the registry + service + properties: + path: + type: string + pvc: + description: PvcConfig defines the settings + for a persistent file store based on PVCs. + properties: + create: + description: Settings for creating a new + PVC + properties: + accessModes: + description: AccessModes k8s persistent + volume access modes. Defaults to + ["ReadWriteOnce"]. + items: + type: string + type: array + resources: + description: Resources describes the + storage resource requirements for + a volume. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes + the minimum amount of compute + resources required. + type: object + type: object + storageClassName: + description: StorageClassName is the + name of an existing StorageClass + to which this persistent volume + belongs. + type: string + type: object + x-kubernetes-validations: + - message: PvcCreate is immutable + rule: self == oldSelf + mountPath: + description: |- + MountPath within the container at which the volume should be mounted. + Must start by "/" and cannot contain ':'. + type: string + ref: + description: Reference to an existing + field + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - mountPath + type: object + x-kubernetes-validations: + - message: One selection is required between + ref and create. + rule: '[has(self.ref), has(self.create)].exists_one(c, + c)' + - message: Mount path must start with '/' + and must not contain ':' + rule: self.mountPath.matches('^/[^:]*$') + s3_additional_kwargs: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-validations: + - message: Registry files must use absolute paths + or be S3 ('s3://') or GS ('gs://') object + store URIs. + rule: '(!has(self.pvc) && has(self.path)) ? + (self.path.startsWith(''/'') || self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: PVC path must be a file name only, + with no slashes. + rule: '(has(self.pvc) && has(self.path)) ? !self.path.startsWith(''/'') + : true' + - message: PVC persistence does not support S3 + or GS object store URIs. + rule: '(has(self.pvc) && has(self.path)) ? !(self.path.startsWith(''s3://'') + || self.path.startsWith(''gs://'')) : true' + - message: Additional S3 settings are available + only for S3 object store URIs. + rule: '(has(self.s3_additional_kwargs) && has(self.path)) + ? self.path.startsWith(''s3://'') : true' + store: + description: RegistryDBStorePersistence configures + the DB store persistence for the registry service + properties: + secretKeyName: + description: By default, the selected store + "type" is used as the SecretKeyName + type: string + secretRef: + description: Data store parameters should + be placed as-is from the "feature_store.yaml" + under the secret key. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: + description: Type of the persistence type + you want to use. + enum: + - sql + - snowflake.registry + type: string + required: + - secretRef + - type + type: object + type: object + x-kubernetes-validations: + - message: One selection required between file or + store. + rule: '[has(self.file), has(self.store)].exists_one(c, + c)' + server: + description: Creates a registry server container + properties: + env: + items: + description: EnvVar represents an environment + variable present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment + variable's value. Cannot be used if value + is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the + ConfigMap or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the + pod: supports metadata.name, metadata.namespace, + `metadata.labels['''']`, `metadata.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env + vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret + in the pod's namespace + properties: + key: + description: The key of the secret + to select from. Must be a valid + secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the + Secret or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source + of a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend + to each key in the ConfigMap. Must be + a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for + if/when to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the + compute resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one + entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum + amount of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS + for a feast service. + properties: + disable: + description: will disable TLS for the feast + service. useful in an openshift cluster, + for example, where TLS is configured by + default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret + key names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret + where the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` + is false.' + rule: '(!has(self.disable) || !self.disable) + ? has(self.secretRef) : true' + volumeMounts: + description: VolumeMounts defines the list of + volumes that should be mounted into the feast + container. + items: + description: VolumeMount describes a mounting + of a Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of + a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume + from which the container's volume should + be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + type: object + remote: + description: RemoteRegistryConfig points to a remote feast + registry server. + properties: + feastRef: + description: Reference to an existing `FeatureStore` + CR in the same k8s cluster. + properties: + name: + description: Name of the FeatureStore + type: string + namespace: + description: Namespace of the FeatureStore + type: string + required: + - name + type: object + hostname: + description: Host address of the remote registry service + - :, e.g. `registry..svc.cluster.local:80` + type: string + tls: + description: TlsRemoteRegistryConfigs configures client + TLS for a remote feast registry. + properties: + certName: + description: defines the configmap key name for + the client TLS cert. + type: string + configMapRef: + description: references the local k8s configmap + where the TLS cert resides + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - certName + - configMapRef + type: object + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, + c)' + type: object + x-kubernetes-validations: + - message: One selection required. + rule: '[has(self.local), has(self.remote)].exists_one(c, + c)' + ui: + description: Creates a UI server container + properties: + env: + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: 'Selects a field of the pod: supports + metadata.name, metadata.namespace, `metadata.labels['''']`, + `metadata.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + description: EnvFromSource represents the source of + a set of ConfigMaps + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: An optional identifier to prepend to + each key in the ConfigMap. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + logLevel: + description: |- + LogLevel sets the logging level for the server + Allowed values: "debug", "info", "warning", "error", "critical". + enum: + - debug + - info + - warning + - error + - critical + type: string + resources: + description: ResourceRequirements describes the compute + resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the minimum amount + of compute resources required. + type: object + type: object + tls: + description: TlsConfigs configures server TLS for a feast + service. + properties: + disable: + description: will disable TLS for the feast service. + useful in an openshift cluster, for example, where + TLS is configured by default + type: boolean + secretKeyNames: + description: SecretKeyNames defines the secret key + names for the TLS key and cert. + properties: + tlsCrt: + description: defaults to "tls.crt" + type: string + tlsKey: + description: defaults to "tls.key" + type: string + type: object + secretRef: + description: references the local k8s secret where + the TLS key and cert reside + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + type: object + x-kubernetes-validations: + - message: '`secretRef` required if `disable` is false.' + rule: '(!has(self.disable) || !self.disable) ? has(self.secretRef) + : true' + volumeMounts: + description: VolumeMounts defines the list of volumes + that should be mounted into the feast container. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: Expanded path within the volume from + which the container's volume should be mounted. + type: string + required: + - mountPath + - name + type: object + type: array + type: object + volumes: + description: Volumes specifies the volumes to mount in the + FeatureStore deployment. + items: + description: Volume represents a named volume in a pod that + may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: |- + awsElasticBlockStore represents an AWS Disk resource that is attached to a + kubelet's host machine and then exposed to th + properties: + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + readOnly: + description: |- + readOnly value true will force the readOnly setting in VolumeMounts. + More info: https://kubernetes. + type: boolean + volumeID: + description: |- + volumeID is unique ID of the persistent disk resource in AWS (Amazon EBS volume). + More info: https://kubernetes. + type: string + required: + - volumeID + type: object + azureDisk: + description: azureDisk represents an Azure Data Disk + mount on the host and bind mount to the pod. + properties: + cachingMode: + description: 'cachingMode is the Host Caching mode: + None, Read Only, Read Write.' + type: string + diskName: + description: diskName is the Name of the data disk + in the blob storage + type: string + diskURI: + description: diskURI is the URI of data disk in + the blob storage + type: string + fsType: + description: |- + fsType is Filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + kind: + description: 'kind expected values are Shared: multiple + blob disks per storage account Dedicated: single + blob disk per storage accoun' + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: azureFile represents an Azure File Service + mount on the host and bind mount to the pod. + properties: + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: secretName is the name of secret that + contains Azure Storage Account Name and Key + type: string + shareName: + description: shareName is the azure share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: cephFS represents a Ceph FS mount on the + host that shares a pod's lifetime + properties: + monitors: + description: |- + monitors is Required: Monitors is a collection of Ceph monitors + More info: https://examples.k8s. + items: + type: string + type: array + x-kubernetes-list-type: atomic + path: + description: 'path is Optional: Used as the mounted + root, rather than the full Ceph tree, default + is /' + type: string + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretFile: + description: 'secretFile is Optional: SecretFile + is the path to key ring for User, default is /etc/ceph/user.' + type: string + secretRef: + description: 'secretRef is Optional: SecretRef is + reference to the authentication secret for User, + default is empty.' + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is optional: User is the rados user name, default is admin + More info: https://examples.k8s. + type: string + required: + - monitors + type: object + cinder: + description: |- + cinder represents a cinder volume attached and mounted on kubelets host machine. + More info: https://examples.k8s. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is optional: points to a secret object containing parameters used to connect + to OpenStack. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + volumeID: + description: |- + volumeID used to identify the volume in cinder. + More info: https://examples.k8s.io/mysql-cinder-pd/README.md + type: string + required: + - volumeID + type: object + configMap: + description: configMap represents a configMap that should + populate this volume + properties: + defaultMode: + description: 'defaultMode is optional: mode bits + used to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits + used to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + csi: + description: csi (Container Storage Interface) represents + ephemeral storage that is handled by certain external + CSI drivers (Beta fea + properties: + driver: + description: driver is the name of the CSI driver + that handles this volume. + type: string + fsType: + description: fsType to mount. Ex. "ext4", "xfs", + "ntfs". + type: string + nodePublishSecretRef: + description: |- + nodePublishSecretRef is a reference to the secret object containing + sensitive information to pass to the CSI driver to c + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + readOnly: + description: |- + readOnly specifies a read-only configuration for the volume. + Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: |- + volumeAttributes stores driver-specific properties that are passed to the CSI + driver. + type: object + required: + - driver + type: object + downwardAPI: + description: downwardAPI represents downward API about + the pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created + files by default.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + emptyDir: + description: |- + emptyDir represents a temporary directory that shares a pod's lifetime. + More info: https://kubernetes. + properties: + medium: + description: medium represents what type of storage + medium should back this directory. + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: sizeLimit is the total amount of local + storage required for this EmptyDir volume. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: ephemeral represents a volume that is handled + by a cluster storage driver. + properties: + volumeClaimTemplate: + description: Will be used to create a stand-alone + PVC to provision the volume. + properties: + metadata: + description: |- + May contain labels and annotations that will be copied into the PVC + when creating it. + type: object + spec: + description: The specification for the PersistentVolumeClaim. + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes. + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s. + properties: + apiGroup: + description: APIGroup is the group for + the resource being referenced. + 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 + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. + properties: + apiGroup: + description: APIGroup is the group for + the resource being referenced. + 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 + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking. + type: string + required: + - kind + - name + type: object + resources: + description: resources represents the minimum + resources the volume should have. + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes. + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Requests describes the + minimum amount of compute resources + required. + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of + {key,value} pairs. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes. + type: string + volumeAttributesClassName: + description: volumeAttributesClassName may + be used to set the VolumeAttributesClass + used by this claim. + type: string + volumeMode: + description: volumeMode defines what type + of volume is required by the claim. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: fc represents a Fibre Channel resource + that is attached to a kubelet's host machine and then + exposed to the pod. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + lun: + description: 'lun is Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: |- + readOnly is Optional: Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + targetWWNs: + description: 'targetWWNs is Optional: FC target + worldwide names (WWNs)' + items: + type: string + type: array + x-kubernetes-list-type: atomic + wwids: + description: "wwids Optional: FC volume world wide + identifiers (wwids)\nEither wwids or combination + of targetWWNs and lun must be set, " + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + flexVolume: + description: |- + flexVolume represents a generic volume resource that is + provisioned/attached using an exec based plugin. + properties: + driver: + description: driver is the name of the driver to + use for this volume. + type: string + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + options: + additionalProperties: + type: string + description: 'options is Optional: this field holds + extra command options if any.' + type: object + readOnly: + description: |- + readOnly is Optional: defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef is Optional: secretRef is reference to the secret object containing + sensitive information to pass to the plugi + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - driver + type: object + flocker: + description: flocker represents a Flocker volume attached + to a kubelet's host machine. + properties: + datasetName: + description: |- + datasetName is Name of the dataset stored as metadata -> name on the dataset for Flocker + should be considered as depreca + type: string + datasetUUID: + description: datasetUUID is the UUID of the dataset. + This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: |- + gcePersistentDisk represents a GCE Disk resource that is attached to a + kubelet's host machine and then exposed to the po + properties: + fsType: + description: fsType is filesystem type of the volume + that you want to mount. + type: string + partition: + description: |- + partition is the partition in the volume that you want to mount. + If omitted, the default is to mount by volume name. + format: int32 + type: integer + pdName: + description: |- + pdName is unique name of the PD resource in GCE. Used to identify the disk in GCE. + More info: https://kubernetes. + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://kubernetes. + type: boolean + required: + - pdName + type: object + gitRepo: + description: |- + gitRepo represents a git repository at a particular revision. + DEPRECATED: GitRepo is deprecated. + properties: + directory: + description: |- + directory is the target directory name. + Must not contain or start with '..'. If '. + type: string + repository: + description: repository is the URL + type: string + revision: + description: revision is the commit hash for the + specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: |- + glusterfs represents a Glusterfs mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + endpoints: + description: |- + endpoints is the endpoint name that details Glusterfs topology. + More info: https://examples.k8s. + type: string + path: + description: |- + path is the Glusterfs volume path. + More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod + type: string + readOnly: + description: |- + readOnly here will force the Glusterfs volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: |- + hostPath represents a pre-existing file or directory on the host + machine that is directly exposed to the container. + properties: + path: + description: |- + path of the directory on the host. + If the path is a symlink, it will follow the link to the real path. + type: string + type: + description: |- + type for HostPath Volume + Defaults to "" + More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath + type: string + required: + - path + type: object + iscsi: + description: |- + iscsi represents an ISCSI Disk resource that is attached to a + kubelet's host machine and then exposed to the pod. + properties: + chapAuthDiscovery: + description: chapAuthDiscovery defines whether support + iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: chapAuthSession defines whether support + iSCSI Session CHAP authentication + type: boolean + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + initiatorName: + description: initiatorName is the custom iSCSI Initiator + Name. + type: string + iqn: + description: iqn is the target iSCSI Qualified Name. + type: string + iscsiInterface: + description: |- + iscsiInterface is the interface Name that uses an iSCSI transport. + Defaults to 'default' (tcp). + type: string + lun: + description: lun represents iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: portals is the iSCSI Target Portal + List. + items: + type: string + type: array + x-kubernetes-list-type: atomic + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + type: boolean + secretRef: + description: secretRef is the CHAP Secret for iSCSI + target and initiator authentication + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + targetPortal: + description: targetPortal is iSCSI Target Portal. + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: |- + name of the volume. + Must be a DNS_LABEL and unique within the pod. + More info: https://kubernetes. + type: string + nfs: + description: |- + nfs represents an NFS mount on the host that shares a pod's lifetime + More info: https://kubernetes. + properties: + path: + description: |- + path that is exported by the NFS server. + More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs + type: string + readOnly: + description: |- + readOnly here will force the NFS export to be mounted with read-only permissions. + Defaults to false. + type: boolean + server: + description: |- + server is the hostname or IP address of the NFS server. + More info: https://kubernetes. + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: |- + persistentVolumeClaimVolumeSource represents a reference to a + PersistentVolumeClaim in the same namespace. + properties: + claimName: + description: claimName is the name of a PersistentVolumeClaim + in the same namespace as the pod using this volume. + type: string + readOnly: + description: |- + readOnly Will force the ReadOnly setting in VolumeMounts. + Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: photonPersistentDisk represents a PhotonController + persistent disk attached and mounted on kubelets host + machine + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + pdID: + description: pdID is the ID that identifies Photon + Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: portworxVolume represents a portworx volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fSType represents the filesystem type to mount + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: volumeID uniquely identifies a Portworx + volume + type: string + required: + - volumeID + type: object + projected: + description: projected items for all in one resources + secrets, configmaps, and downward API + properties: + defaultMode: + description: defaultMode are the mode bits used + to set permissions on created files by default. + format: int32 + type: integer + sources: + description: sources is the list of volume projections + items: + description: Projection that may be projected + along with other supported volume types + properties: + clusterTrustBundle: + description: ClusterTrustBundle allows a pod + to access the `.spec. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. + properties: + matchExpressions: + description: matchExpressions is a + list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map + of {key,value} pairs. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. + type: boolean + path: + description: Relative path from the volume + root to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. + type: string + required: + - path + type: object + configMap: + description: configMap information about the + configMap data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volum + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: + mode bits used to set permissions + on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional specify whether + the ConfigMap or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about + the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects + a field of the pod: only annotations, + labels, name, namespace and uid + are supported.' + properties: + apiVersion: + description: Version of the + schema the FieldPath is written + in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field + to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal valu + format: int32 + type: integer + path: + description: 'Required: Path is the + relative path name of the file + to be created. Must not be absolute + or contain the ''..'' path.' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests. + properties: + containerName: + description: 'Container name: + required for volumes, optional + for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource + to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the + secret data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a + path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: + mode bits used to set permissions + on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: audience is the intended + audience of the token. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + x-kubernetes-list-type: atomic + type: object + quobyte: + description: quobyte represents a Quobyte mount on the + host that shares a pod's lifetime + properties: + group: + description: |- + group to map volume access to + Default is no group + type: string + readOnly: + description: |- + readOnly here will force the Quobyte volume to be mounted with read-only permissions. + Defaults to false. + type: boolean + registry: + description: |- + registry represents a single or multiple Quobyte Registry services + specified as a string as host:port pair (multiple ent + type: string + tenant: + description: |- + tenant owning the given Quobyte volume in the Backend + Used with dynamically provisioned Quobyte volumes, value is set by + type: string + user: + description: |- + user to map volume access to + Defaults to serivceaccount user + type: string + volume: + description: volume is a string that references + an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: |- + rbd represents a Rados Block Device mount on the host that shares a pod's lifetime. + More info: https://examples.k8s. + properties: + fsType: + description: fsType is the filesystem type of the + volume that you want to mount. + type: string + image: + description: |- + image is the rados image name. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + keyring: + description: |- + keyring is the path to key ring for RBDUser. + Default is /etc/ceph/keyring. + More info: https://examples.k8s. + type: string + monitors: + description: |- + monitors is a collection of Ceph monitors. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + items: + type: string + type: array + x-kubernetes-list-type: atomic + pool: + description: |- + pool is the rados pool name. + Default is rbd. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + readOnly: + description: |- + readOnly here will force the ReadOnly setting in VolumeMounts. + Defaults to false. + More info: https://examples.k8s. + type: boolean + secretRef: + description: |- + secretRef is name of the authentication secret for RBDUser. If provided + overrides keyring. + Default is nil. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + user: + description: |- + user is the rados user name. + Default is admin. + More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it + type: string + required: + - image + - monitors + type: object + scaleIO: + description: scaleIO represents a ScaleIO persistent + volume attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + gateway: + description: gateway is the host address of the + ScaleIO API Gateway. + type: string + protectionDomain: + description: protectionDomain is the name of the + ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: |- + readOnly Defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef references to the secret for ScaleIO user and other + sensitive information. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. + type: string + type: object + x-kubernetes-map-type: atomic + sslEnabled: + description: sslEnabled Flag enable/disable SSL + communication with Gateway, default false + type: boolean + storageMode: + description: storageMode indicates whether the storage + for a volume should be ThickProvisioned or ThinProvisioned. + type: string + storagePool: + description: storagePool is the ScaleIO Storage + Pool associated with the protection domain. + type: string + system: + description: system is the name of the storage system + as configured in ScaleIO. + type: string + volumeName: + description: |- + volumeName is the name of a volume already created in the ScaleIO system + that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: |- + secret represents a secret that should populate this volume. + More info: https://kubernetes. + properties: + defaultMode: + description: 'defaultMode is Optional: mode bits + used to set permissions on created files by default.' + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume a + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: 'mode is Optional: mode bits + used to set permissions on this file.' + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the + Secret or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes. + type: string + type: object + storageos: + description: storageOS represents a StorageOS volume + attached and mounted on Kubernetes nodes. + properties: + fsType: + description: |- + fsType is the filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + readOnly: + description: |- + readOnly defaults to false (read/write). ReadOnly here will force + the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: |- + secretRef specifies the secret to use for obtaining the StorageOS API + credentials. properties: name: - description: Name of the environment variable. - Must be a C_IDENTIFIER. - type: string - value: + default: "" description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. type: string - valueFrom: - description: Source for the environment variable's - value. Cannot be used if value is not empty. - properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. - properties: - key: - description: The key to select. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the ConfigMap - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the - FieldPath is written in terms of, - defaults to "v1". - type: string - fieldPath: - description: Path of the field to select - in the specified API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - resourceFieldRef: - description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required - for volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format - of the exposed resources, defaults - to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to - select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in - the pod's namespace - properties: - key: - description: The key of the secret to - select from. Must be a valid secret - key. - type: string - name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - TODO: Add other useful fields. apiVersion, kind, uid? - type: string - optional: - description: Specify whether the Secret - or its key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - type: object - required: - - name type: object - type: array - image: - type: string - imagePullPolicy: - description: PullPolicy describes a policy for if/when - to pull a container image - type: string - resources: - description: ResourceRequirements describes the compute - resource requirements. - properties: - claims: - description: |- - Claims lists the names of resources, defined in spec.resourceClaims, - that are used by this container. - - - This is an alpha field and requires enabling the - DynamicResourceAllocation feature gate. - - - This field is immutable. It can only be set for containers. - items: - description: ResourceClaim references one entry - in PodSpec.ResourceClaims. - properties: - name: - description: |- - Name must match the name of one entry in pod.spec.resourceClaims of - the Pod where this field is used. It makes that resource available - inside a container. - type: string - required: - - name - type: object - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - type: object - remote: - description: |- - RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. - Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. - properties: - feastRef: - description: Reference to an existing `FeatureStore` - CR in the same k8s cluster. - properties: - name: - description: Name of the FeatureStore - type: string - namespace: - description: Namespace of the FeatureStore - type: string - required: - - name - type: object - hostname: - description: Host address of the remote registry service - - :, e.g. `registry..svc.cluster.local:80` - type: string - type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.hostname), has(self.feastRef)].exists_one(c, - c)' - type: object - x-kubernetes-validations: - - message: One selection required. - rule: '[has(self.local), has(self.remote)].exists_one(c, - c)' + x-kubernetes-map-type: atomic + volumeName: + description: |- + volumeName is the human-readable name of the StorageOS volume. Volume + names are only unique within a namespace. + type: string + volumeNamespace: + description: volumeNamespace specifies the scope + of the volume within StorageOS. + type: string + type: object + vsphereVolume: + description: vsphereVolume represents a vSphere volume + attached and mounted on kubelets host machine + properties: + fsType: + description: |- + fsType is filesystem type to mount. + Must be a filesystem type supported by the host operating system. + Ex. + type: string + storagePolicyID: + description: storagePolicyID is the storage Policy + Based Management (SPBM) profile ID associated + with the StoragePolicyName. + type: string + storagePolicyName: + description: storagePolicyName is the storage Policy + Based Management (SPBM) profile name. + type: string + volumePath: + description: volumePath is the path that identifies + vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: array type: object required: - feastProject @@ -1259,21 +6963,12 @@ spec: type: string conditions: items: - description: "Condition contains details for one aspect of the current - state of this API Resource.\n---\nThis struct is intended for - direct use as an array at the field path .status.conditions. For - example,\n\n\n\ttype FooStatus struct{\n\t // Represents the - observations of a foo's current state.\n\t // Known .status.conditions.type - are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // - +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t - \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" - patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t - \ // other fields\n\t}" + description: Condition contains details for one aspect of the current + state of this API Resource. properties: lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + description: lastTransitionTime is the last time the condition + transitioned from one status to another. format: date-time type: string message: @@ -1285,18 +6980,13 @@ spec: observedGeneration: description: |- observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. + For instance, if . format: int64 minimum: 0 type: integer reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ @@ -1312,9 +7002,7 @@ spec: description: |- type of condition in CamelCase or in foo.example.com/CamelCase. --- - Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be - useful (see .node.status.conditions), the ability to deconflict is important. - The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + Many .condition. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string @@ -1327,7 +7015,6 @@ spec: type: object type: array feastVersion: - description: Version of feast that's currently deployed type: string phase: type: string @@ -1341,6 +7028,8 @@ spec: type: string registry: type: string + ui: + type: string type: object type: object type: object @@ -1469,6 +7158,8 @@ rules: - "" resources: - configmaps + - persistentvolumeclaims + - serviceaccounts - services verbs: - create @@ -1477,6 +7168,13 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list - apiGroups: - feast.dev resources: @@ -1503,19 +7201,29 @@ rules: - get - patch - update ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/managed-by: kustomize - app.kubernetes.io/name: feast-operator - name: feast-operator-metrics-reader -rules: -- nonResourceURLs: - - /metrics +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - route.openshift.io + resources: + - routes verbs: + - create + - delete - get + - list + - update + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -1523,7 +7231,7 @@ metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: feast-operator - name: feast-operator-proxy-role + name: feast-operator-metrics-auth-role rules: - apiGroups: - authentication.k8s.io @@ -1539,6 +7247,19 @@ rules: - create --- apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: feast-operator + name: feast-operator-metrics-reader +rules: +- nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: labels: @@ -1577,11 +7298,11 @@ metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: feast-operator - name: feast-operator-proxy-rolebinding + name: feast-operator-metrics-auth-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: feast-operator-proxy-role + name: feast-operator-metrics-auth-role subjects: - kind: ServiceAccount name: feast-operator-controller-manager @@ -1601,7 +7322,7 @@ spec: - name: https port: 8443 protocol: TCP - targetPort: https + targetPort: 8443 selector: control-plane: controller-manager --- @@ -1628,35 +7349,15 @@ spec: spec: containers: - args: - - --secure-listen-address=0.0.0.0:8443 - - --upstream=http://127.0.0.1:8080/ - - --logtostderr=true - - --v=0 - image: gcr.io/kubebuilder/kube-rbac-proxy:v0.16.0 - name: kube-rbac-proxy - ports: - - containerPort: 8443 - name: https - protocol: TCP - resources: - limits: - cpu: 500m - memory: 128Mi - requests: - cpu: 5m - memory: 64Mi - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - - args: - - --health-probe-bind-address=:8081 - - --metrics-bind-address=127.0.0.1:8080 + - --metrics-bind-address=:8443 - --leader-elect + - --health-probe-bind-address=:8081 command: - /manager - image: feastdev/feast-operator:0.41.0 + env: + - name: RELATED_IMAGE_FEATURE_SERVER + value: docker.io/feastdev/feature-server:0.46.0 + image: feastdev/feast-operator:0.46.0 livenessProbe: httpGet: path: /healthz diff --git a/infra/feast-operator/docs/api/markdown/ref.md b/infra/feast-operator/docs/api/markdown/ref.md new file mode 100644 index 00000000000..aefb5abca76 --- /dev/null +++ b/infra/feast-operator/docs/api/markdown/ref.md @@ -0,0 +1,605 @@ +# API Reference + +## Packages +- [feast.dev/v1alpha1](#feastdevv1alpha1) + + +## feast.dev/v1alpha1 + +Package v1alpha1 contains API Schema definitions for the v1alpha1 API group + +### Resource Types +- [FeatureStore](#featurestore) + + + +#### AuthzConfig + + + +AuthzConfig defines the authorization settings for the deployed Feast services. + +_Appears in:_ +- [FeatureStoreSpec](#featurestorespec) + +| Field | Description | +| --- | --- | +| `kubernetes` _[KubernetesAuthz](#kubernetesauthz)_ | | +| `oidc` _[OidcAuthz](#oidcauthz)_ | | + + +#### ContainerConfigs + + + +ContainerConfigs k8s container settings for the server + +_Appears in:_ +- [ServerConfigs](#serverconfigs) + +| Field | Description | +| --- | --- | +| `image` _string_ | | +| `env` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envvar-v1-core)_ | | +| `envFrom` _[EnvFromSource](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envfromsource-v1-core)_ | | +| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#pullpolicy-v1-core)_ | | +| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#resourcerequirements-v1-core)_ | | + + +#### DefaultCtrConfigs + + + +DefaultCtrConfigs k8s container settings that are applied by default + +_Appears in:_ +- [ContainerConfigs](#containerconfigs) +- [ServerConfigs](#serverconfigs) + +| Field | Description | +| --- | --- | +| `image` _string_ | | + + +#### FeastInitOptions + + + +FeastInitOptions defines how to run a `feast init`. + +_Appears in:_ +- [FeastProjectDir](#feastprojectdir) + +| Field | Description | +| --- | --- | +| `minimal` _boolean_ | | +| `template` _string_ | Template for the created project | + + +#### FeastProjectDir + + + +FeastProjectDir defines how to create the feast project directory. + +_Appears in:_ +- [FeatureStoreSpec](#featurestorespec) + +| Field | Description | +| --- | --- | +| `git` _[GitCloneOptions](#gitcloneoptions)_ | | +| `init` _[FeastInitOptions](#feastinitoptions)_ | | + + +#### FeatureStore + + + +FeatureStore is the Schema for the featurestores API + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `feast.dev/v1alpha1` +| `kind` _string_ | `FeatureStore` +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | +| `spec` _[FeatureStoreSpec](#featurestorespec)_ | | +| `status` _[FeatureStoreStatus](#featurestorestatus)_ | | + + +#### FeatureStoreRef + + + +FeatureStoreRef defines which existing FeatureStore's registry should be used + +_Appears in:_ +- [RemoteRegistryConfig](#remoteregistryconfig) + +| Field | Description | +| --- | --- | +| `name` _string_ | Name of the FeatureStore | +| `namespace` _string_ | Namespace of the FeatureStore | + + +#### FeatureStoreServices + + + +FeatureStoreServices defines the desired feast services. An ephemeral onlineStore feature server is deployed by default. + +_Appears in:_ +- [FeatureStoreSpec](#featurestorespec) + +| Field | Description | +| --- | --- | +| `offlineStore` _[OfflineStore](#offlinestore)_ | | +| `onlineStore` _[OnlineStore](#onlinestore)_ | | +| `registry` _[Registry](#registry)_ | | +| `ui` _[ServerConfigs](#serverconfigs)_ | Creates a UI server container | +| `deploymentStrategy` _[DeploymentStrategy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#deploymentstrategy-v1-apps)_ | | +| `disableInitContainers` _boolean_ | Disable the 'feast repo initialization' initContainer | +| `volumes` _[Volume](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volume-v1-core) array_ | Volumes specifies the volumes to mount in the FeatureStore deployment. A corresponding `VolumeMount` should be added to whichever feast service(s) require access to said volume(s). | + + +#### FeatureStoreSpec + + + +FeatureStoreSpec defines the desired state of FeatureStore + +_Appears in:_ +- [FeatureStore](#featurestore) +- [FeatureStoreStatus](#featurestorestatus) + +| Field | Description | +| --- | --- | +| `feastProject` _string_ | FeastProject is the Feast project id. This can be any alphanumeric string with underscores, but it cannot start with an underscore. Required. | +| `feastProjectDir` _[FeastProjectDir](#feastprojectdir)_ | | +| `services` _[FeatureStoreServices](#featurestoreservices)_ | | +| `authz` _[AuthzConfig](#authzconfig)_ | | + + +#### FeatureStoreStatus + + + +FeatureStoreStatus defines the observed state of FeatureStore + +_Appears in:_ +- [FeatureStore](#featurestore) + +| Field | Description | +| --- | --- | +| `applied` _[FeatureStoreSpec](#featurestorespec)_ | Shows the currently applied feast configuration, including any pertinent defaults | +| `clientConfigMap` _string_ | ConfigMap in this namespace containing a client `feature_store.yaml` for this feast deployment | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#condition-v1-meta) array_ | | +| `feastVersion` _string_ | | +| `phase` _string_ | | +| `serviceHostnames` _[ServiceHostnames](#servicehostnames)_ | | + + +#### GitCloneOptions + + + +GitCloneOptions describes how a clone should be performed. + +_Appears in:_ +- [FeastProjectDir](#feastprojectdir) + +| Field | Description | +| --- | --- | +| `url` _string_ | The repository URL to clone from. | +| `ref` _string_ | Reference to a branch / tag / commit | +| `configs` _object (keys:string, values:string)_ | Configs passed to git via `-c` +e.g. http.sslVerify: 'false' +OR 'url."https://api:\${TOKEN}@github.com/".insteadOf': 'https://github.com/' | +| `featureRepoPath` _string_ | FeatureRepoPath is the relative path to the feature repo subdirectory. Default is 'feature_repo'. | +| `env` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envvar-v1-core)_ | | +| `envFrom` _[EnvFromSource](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envfromsource-v1-core)_ | | + + +#### KubernetesAuthz + + + +KubernetesAuthz provides a way to define the authorization settings using Kubernetes RBAC resources. +https://kubernetes.io/docs/reference/access-authn-authz/rbac/ + +_Appears in:_ +- [AuthzConfig](#authzconfig) + +| Field | Description | +| --- | --- | +| `roles` _string array_ | The Kubernetes RBAC roles to be deployed in the same namespace of the FeatureStore. +Roles are managed by the operator and created with an empty list of rules. +See the Feast permission model at https://docs.feast.dev/getting-started/concepts/permission +The feature store admin is not obligated to manage roles using the Feast operator, roles can be managed independently. +This configuration option is only providing a way to automate this procedure. +Important note: the operator cannot ensure that these roles will match the ones used in the configured Feast permissions. | + + +#### LocalRegistryConfig + + + +LocalRegistryConfig configures the registry service + +_Appears in:_ +- [Registry](#registry) + +| Field | Description | +| --- | --- | +| `server` _[ServerConfigs](#serverconfigs)_ | Creates a registry server container | +| `persistence` _[RegistryPersistence](#registrypersistence)_ | | + + +#### OfflineStore + + + +OfflineStore configures the offline store service + +_Appears in:_ +- [FeatureStoreServices](#featurestoreservices) + +| Field | Description | +| --- | --- | +| `server` _[ServerConfigs](#serverconfigs)_ | Creates a remote offline server container | +| `persistence` _[OfflineStorePersistence](#offlinestorepersistence)_ | | + + +#### OfflineStoreDBStorePersistence + + + +OfflineStoreDBStorePersistence configures the DB store persistence for the offline store service + +_Appears in:_ +- [OfflineStorePersistence](#offlinestorepersistence) + +| Field | Description | +| --- | --- | +| `type` _string_ | Type of the persistence type you want to use. | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Data store parameters should be placed as-is from the "feature_store.yaml" under the secret key. "registry_type" & "type" fields should be removed. | +| `secretKeyName` _string_ | By default, the selected store "type" is used as the SecretKeyName | + + +#### OfflineStoreFilePersistence + + + +OfflineStoreFilePersistence configures the file-based persistence for the offline store service + +_Appears in:_ +- [OfflineStorePersistence](#offlinestorepersistence) + +| Field | Description | +| --- | --- | +| `type` _string_ | | +| `pvc` _[PvcConfig](#pvcconfig)_ | | + + +#### OfflineStorePersistence + + + +OfflineStorePersistence configures the persistence settings for the offline store service + +_Appears in:_ +- [OfflineStore](#offlinestore) + +| Field | Description | +| --- | --- | +| `file` _[OfflineStoreFilePersistence](#offlinestorefilepersistence)_ | | +| `store` _[OfflineStoreDBStorePersistence](#offlinestoredbstorepersistence)_ | | + + +#### OidcAuthz + + + +OidcAuthz defines the authorization settings for deployments using an Open ID Connect identity provider. +https://auth0.com/docs/authenticate/protocols/openid-connect-protocol + +_Appears in:_ +- [AuthzConfig](#authzconfig) + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | | + + +#### OnlineStore + + + +OnlineStore configures the online store service + +_Appears in:_ +- [FeatureStoreServices](#featurestoreservices) + +| Field | Description | +| --- | --- | +| `server` _[ServerConfigs](#serverconfigs)_ | Creates a feature server container | +| `persistence` _[OnlineStorePersistence](#onlinestorepersistence)_ | | + + +#### OnlineStoreDBStorePersistence + + + +OnlineStoreDBStorePersistence configures the DB store persistence for the online store service + +_Appears in:_ +- [OnlineStorePersistence](#onlinestorepersistence) + +| Field | Description | +| --- | --- | +| `type` _string_ | Type of the persistence type you want to use. | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Data store parameters should be placed as-is from the "feature_store.yaml" under the secret key. "registry_type" & "type" fields should be removed. | +| `secretKeyName` _string_ | By default, the selected store "type" is used as the SecretKeyName | + + +#### OnlineStoreFilePersistence + + + +OnlineStoreFilePersistence configures the file-based persistence for the online store service + +_Appears in:_ +- [OnlineStorePersistence](#onlinestorepersistence) + +| Field | Description | +| --- | --- | +| `path` _string_ | | +| `pvc` _[PvcConfig](#pvcconfig)_ | | + + +#### OnlineStorePersistence + + + +OnlineStorePersistence configures the persistence settings for the online store service + +_Appears in:_ +- [OnlineStore](#onlinestore) + +| Field | Description | +| --- | --- | +| `file` _[OnlineStoreFilePersistence](#onlinestorefilepersistence)_ | | +| `store` _[OnlineStoreDBStorePersistence](#onlinestoredbstorepersistence)_ | | + + +#### OptionalCtrConfigs + + + +OptionalCtrConfigs k8s container settings that are optional + +_Appears in:_ +- [ContainerConfigs](#containerconfigs) +- [ServerConfigs](#serverconfigs) + +| Field | Description | +| --- | --- | +| `env` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envvar-v1-core)_ | | +| `envFrom` _[EnvFromSource](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envfromsource-v1-core)_ | | +| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#pullpolicy-v1-core)_ | | +| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#resourcerequirements-v1-core)_ | | + + +#### PvcConfig + + + +PvcConfig defines the settings for a persistent file store based on PVCs. +We can refer to an existing PVC using the `Ref` field, or create a new one using the `Create` field. + +_Appears in:_ +- [OfflineStoreFilePersistence](#offlinestorefilepersistence) +- [OnlineStoreFilePersistence](#onlinestorefilepersistence) +- [RegistryFilePersistence](#registryfilepersistence) + +| Field | Description | +| --- | --- | +| `ref` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Reference to an existing field | +| `create` _[PvcCreate](#pvccreate)_ | Settings for creating a new PVC | +| `mountPath` _string_ | MountPath within the container at which the volume should be mounted. +Must start by "/" and cannot contain ':'. | + + +#### PvcCreate + + + +PvcCreate defines the immutable settings to create a new PVC mounted at the given path. +The PVC name is the same as the associated deployment & feast service name. + +_Appears in:_ +- [PvcConfig](#pvcconfig) + +| Field | Description | +| --- | --- | +| `accessModes` _[PersistentVolumeAccessMode](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#persistentvolumeaccessmode-v1-core) array_ | AccessModes k8s persistent volume access modes. Defaults to ["ReadWriteOnce"]. | +| `storageClassName` _string_ | StorageClassName is the name of an existing StorageClass to which this persistent volume belongs. Empty value +means that this volume does not belong to any StorageClass and the cluster default will be used. | +| `resources` _[VolumeResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volumeresourcerequirements-v1-core)_ | Resources describes the storage resource requirements for a volume. +Default requested storage size depends on the associated service: +- 10Gi for offline store +- 5Gi for online store +- 5Gi for registry | + + +#### Registry + + + +Registry configures the registry service. One selection is required. Local is the default setting. + +_Appears in:_ +- [FeatureStoreServices](#featurestoreservices) + +| Field | Description | +| --- | --- | +| `local` _[LocalRegistryConfig](#localregistryconfig)_ | | +| `remote` _[RemoteRegistryConfig](#remoteregistryconfig)_ | | + + +#### RegistryDBStorePersistence + + + +RegistryDBStorePersistence configures the DB store persistence for the registry service + +_Appears in:_ +- [RegistryPersistence](#registrypersistence) + +| Field | Description | +| --- | --- | +| `type` _string_ | Type of the persistence type you want to use. | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | Data store parameters should be placed as-is from the "feature_store.yaml" under the secret key. "registry_type" & "type" fields should be removed. | +| `secretKeyName` _string_ | By default, the selected store "type" is used as the SecretKeyName | + + +#### RegistryFilePersistence + + + +RegistryFilePersistence configures the file-based persistence for the registry service + +_Appears in:_ +- [RegistryPersistence](#registrypersistence) + +| Field | Description | +| --- | --- | +| `path` _string_ | | +| `pvc` _[PvcConfig](#pvcconfig)_ | | +| `s3_additional_kwargs` _map[string]string_ | | + + +#### RegistryPersistence + + + +RegistryPersistence configures the persistence settings for the registry service + +_Appears in:_ +- [LocalRegistryConfig](#localregistryconfig) + +| Field | Description | +| --- | --- | +| `file` _[RegistryFilePersistence](#registryfilepersistence)_ | | +| `store` _[RegistryDBStorePersistence](#registrydbstorepersistence)_ | | + + +#### RemoteRegistryConfig + + + +RemoteRegistryConfig points to a remote feast registry server. When set, the operator will not deploy a registry for this FeatureStore CR. +Instead, this FeatureStore CR's online/offline services will use a remote registry. One selection is required. + +_Appears in:_ +- [Registry](#registry) + +| Field | Description | +| --- | --- | +| `hostname` _string_ | Host address of the remote registry service - :, e.g. `registry..svc.cluster.local:80` | +| `feastRef` _[FeatureStoreRef](#featurestoreref)_ | Reference to an existing `FeatureStore` CR in the same k8s cluster. | +| `tls` _[TlsRemoteRegistryConfigs](#tlsremoteregistryconfigs)_ | | + + +#### SecretKeyNames + + + +SecretKeyNames defines the secret key names for the TLS key and cert. + +_Appears in:_ +- [TlsConfigs](#tlsconfigs) + +| Field | Description | +| --- | --- | +| `tlsCrt` _string_ | defaults to "tls.crt" | +| `tlsKey` _string_ | defaults to "tls.key" | + + +#### ServerConfigs + + + +ServerConfigs creates a server for the feast service, with specified container configurations. + +_Appears in:_ +- [FeatureStoreServices](#featurestoreservices) +- [LocalRegistryConfig](#localregistryconfig) +- [OfflineStore](#offlinestore) +- [OnlineStore](#onlinestore) + +| Field | Description | +| --- | --- | +| `image` _string_ | | +| `env` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envvar-v1-core)_ | | +| `envFrom` _[EnvFromSource](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#envfromsource-v1-core)_ | | +| `imagePullPolicy` _[PullPolicy](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#pullpolicy-v1-core)_ | | +| `resources` _[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#resourcerequirements-v1-core)_ | | +| `tls` _[TlsConfigs](#tlsconfigs)_ | | +| `logLevel` _string_ | LogLevel sets the logging level for the server +Allowed values: "debug", "info", "warning", "error", "critical". | +| `volumeMounts` _[VolumeMount](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#volumemount-v1-core) array_ | VolumeMounts defines the list of volumes that should be mounted into the feast container. +This allows attaching persistent storage, config files, secrets, or other resources +required by the Feast components. Ensure that each volume mount has a corresponding +volume definition in the Volumes field. | + + +#### ServiceHostnames + + + +ServiceHostnames defines the service hostnames in the format of :, e.g. example.svc.cluster.local:80 + +_Appears in:_ +- [FeatureStoreStatus](#featurestorestatus) + +| Field | Description | +| --- | --- | +| `offlineStore` _string_ | | +| `onlineStore` _string_ | | +| `registry` _string_ | | +| `ui` _string_ | | + + +#### TlsConfigs + + + +TlsConfigs configures server TLS for a feast service. in an openshift cluster, this is configured by default using service serving certificates. + +_Appears in:_ +- [ServerConfigs](#serverconfigs) + +| Field | Description | +| --- | --- | +| `secretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | references the local k8s secret where the TLS key and cert reside | +| `secretKeyNames` _[SecretKeyNames](#secretkeynames)_ | | +| `disable` _boolean_ | will disable TLS for the feast service. useful in an openshift cluster, for example, where TLS is configured by default | + + +#### TlsRemoteRegistryConfigs + + + +TlsRemoteRegistryConfigs configures client TLS for a remote feast registry. in an openshift cluster, this is configured by default when the remote feast registry is using service serving certificates. + +_Appears in:_ +- [RemoteRegistryConfig](#remoteregistryconfig) + +| Field | Description | +| --- | --- | +| `configMapRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_ | references the local k8s configmap where the TLS cert resides | +| `certName` _string_ | defines the configmap key name for the client TLS cert. | + + diff --git a/infra/feast-operator/docs/crd-ref-templates/config.yaml b/infra/feast-operator/docs/crd-ref-templates/config.yaml new file mode 100644 index 00000000000..42d10e08b03 --- /dev/null +++ b/infra/feast-operator/docs/crd-ref-templates/config.yaml @@ -0,0 +1,8 @@ +processor: + ignoreTypes: + - "(FeatureStore)List$" + ignoreFields: + - "TypeMeta$" + +render: + kubernetesVersion: "1.30" \ No newline at end of file diff --git a/infra/feast-operator/docs/crd-ref-templates/markdown/gv_details.tpl b/infra/feast-operator/docs/crd-ref-templates/markdown/gv_details.tpl new file mode 100644 index 00000000000..30ad0d75184 --- /dev/null +++ b/infra/feast-operator/docs/crd-ref-templates/markdown/gv_details.tpl @@ -0,0 +1,19 @@ +{{- define "gvDetails" -}} +{{- $gv := . -}} + +## {{ $gv.GroupVersionString }} + +{{ $gv.Doc }} + +{{- if $gv.Kinds }} +### Resource Types +{{- range $gv.SortedKinds }} +- {{ $gv.TypeForKind . | markdownRenderTypeLink }} +{{- end }} +{{ end }} + +{{ range $gv.SortedTypes }} +{{ template "type" . }} +{{ end }} + +{{- end -}} diff --git a/infra/feast-operator/docs/crd-ref-templates/markdown/gv_list.tpl b/infra/feast-operator/docs/crd-ref-templates/markdown/gv_list.tpl new file mode 100644 index 00000000000..a4d3dadf18c --- /dev/null +++ b/infra/feast-operator/docs/crd-ref-templates/markdown/gv_list.tpl @@ -0,0 +1,15 @@ +{{- define "gvList" -}} +{{- $groupVersions := . -}} + +# API Reference + +## Packages +{{- range $groupVersions }} +- {{ markdownRenderGVLink . }} +{{- end }} + +{{ range $groupVersions }} +{{ template "gvDetails" . }} +{{ end }} + +{{- end -}} diff --git a/infra/feast-operator/docs/crd-ref-templates/markdown/type.tpl b/infra/feast-operator/docs/crd-ref-templates/markdown/type.tpl new file mode 100644 index 00000000000..c0ac2e03539 --- /dev/null +++ b/infra/feast-operator/docs/crd-ref-templates/markdown/type.tpl @@ -0,0 +1,33 @@ +{{- define "type" -}} +{{- $type := . -}} +{{- if markdownShouldRenderType $type -}} + +#### {{ $type.Name }} + +{{ if $type.IsAlias }}_Underlying type:_ `{{ markdownRenderTypeLink $type.UnderlyingType }}`{{ end }} + +{{ $type.Doc }} + +{{ if $type.References -}} +_Appears in:_ +{{- range $type.SortedReferences }} +- {{ markdownRenderTypeLink . }} +{{- end }} +{{- end }} + +{{ if $type.Members -}} +| Field | Description | +| --- | --- | +{{ if $type.GVK -}} +| `apiVersion` _string_ | `{{ $type.GVK.Group }}/{{ $type.GVK.Version }}` +| `kind` _string_ | `{{ $type.GVK.Kind }}` +{{ end -}} + +{{ range $type.Members -}} +| `{{ .Name }}` _{{ markdownRenderType .Type }}_ | {{ template "type_members" . }} | +{{ end -}} + +{{ end -}} + +{{- end -}} +{{- end -}} diff --git a/infra/feast-operator/docs/crd-ref-templates/markdown/type_members.tpl b/infra/feast-operator/docs/crd-ref-templates/markdown/type_members.tpl new file mode 100644 index 00000000000..182fa182166 --- /dev/null +++ b/infra/feast-operator/docs/crd-ref-templates/markdown/type_members.tpl @@ -0,0 +1,8 @@ +{{- define "type_members" -}} +{{- $field := . -}} +{{- if eq $field.Name "metadata" -}} +Refer to Kubernetes API documentation for fields of `metadata`. +{{- else -}} +{{ $field.Doc }} +{{- end -}} +{{- end -}} diff --git a/infra/feast-operator/go.mod b/infra/feast-operator/go.mod index 65d2aaac502..c8608cb242f 100644 --- a/infra/feast-operator/go.mod +++ b/infra/feast-operator/go.mod @@ -1,23 +1,31 @@ module github.com/feast-dev/feast/infra/feast-operator -go 1.21 +go 1.22.9 require ( - github.com/onsi/ginkgo/v2 v2.14.0 - github.com/onsi/gomega v1.30.0 - k8s.io/apimachinery v0.29.2 - k8s.io/client-go v0.29.2 - sigs.k8s.io/controller-runtime v0.17.3 + github.com/onsi/ginkgo/v2 v2.17.1 + github.com/onsi/gomega v1.32.0 + github.com/openshift/api v0.0.0-20240912201240-0a8800162826 // release-4.17 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.30.1 + k8s.io/apimachinery v0.30.1 + k8s.io/client-go v0.30.1 + sigs.k8s.io/controller-runtime v0.18.4 ) require ( + github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch/v5 v5.8.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -25,12 +33,14 @@ require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/cel-go v0.17.8 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -45,28 +55,41 @@ require ( github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 // indirect + go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.19.0 // indirect + go.opentelemetry.io/otel/sdk v1.19.0 // indirect + go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect - golang.org/x/net v0.23.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.12.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/term v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.16.1 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.31.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.29.2 // indirect - k8s.io/apiextensions-apiserver v0.29.2 // indirect - k8s.io/component-base v0.29.2 // indirect - k8s.io/klog/v2 v2.110.1 // indirect - k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/apiextensions-apiserver v0.30.1 // indirect + k8s.io/apiserver v0.30.1 // indirect + k8s.io/component-base v0.30.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.29.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/infra/feast-operator/go.sum b/infra/feast-operator/go.sum index be475e11018..ef5d6204916 100644 --- a/infra/feast-operator/go.sum +++ b/infra/feast-operator/go.sum @@ -1,5 +1,11 @@ +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -13,13 +19,17 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1u0KQro= -github.com/evanphx/json-patch/v5 v5.8.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= @@ -32,15 +42,17 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.17.8 h1:j9m730pMZt1Fc4oKhCLUHfjj6527LuhYcYw0Rl8gqto= +github.com/google/cel-go v0.17.8/go.mod h1:HXZKzB0LXqer5lHHgfWAnlYwJaQBDKMjxjulNQzhwhY= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -51,6 +63,8 @@ github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJY github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= @@ -78,10 +92,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY= -github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= -github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= -github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/openshift/api v0.0.0-20240912201240-0a8800162826 h1:A8D9SN/hJUwAbdO0rPCVTqmuBOctdgurr53gK701SYo= +github.com/openshift/api v0.0.0-20240912201240-0a8800162826/go.mod h1:OOh6Qopf21pSzqNVCB5gomomBXb8o5sGKZxG2KNpaXM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -98,10 +114,13 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -110,6 +129,22 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= +go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= +go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= +go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= +go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= +go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -128,34 +163,36 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= -golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -164,39 +201,50 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 h1:L6iMMGrtzgHsWofoFcihmDEMYeDR9KN/ThbPWGrh++g= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e h1:z3vDksarJxsAKM5dmEGv0GHwE2hKJ096wZra71Vs4sw= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= -k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= -k8s.io/apiextensions-apiserver v0.29.2 h1:UK3xB5lOWSnhaCk0RFZ0LUacPZz9RY4wi/yt2Iu+btg= -k8s.io/apiextensions-apiserver v0.29.2/go.mod h1:aLfYjpA5p3OwtqNXQFkhJ56TB+spV8Gc4wfMhUA3/b8= -k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= -k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= -k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= -k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= -k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8= -k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM= -k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= -k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= -k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= -k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY= +k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM= +k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws= +k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= +k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= +k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/apiserver v0.30.1 h1:BEWEe8bzS12nMtDKXzCF5Q5ovp6LjjYkSp8qOPk8LZ8= +k8s.io/apiserver v0.30.1/go.mod h1:i87ZnQ+/PGAmSbD/iEKM68bm1D5reX8fO4Ito4B01mo= +k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= +k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= +k8s.io/component-base v0.30.1 h1:bvAtlPh1UrdaZL20D9+sWxsJljMi0QZ3Lmw+kmZAaxQ= +k8s.io/component-base v0.30.1/go.mod h1:e/X9kDiOebwlI41AvBHuWdqFriSRrX50CdwA9TFaHLI= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.17.3 h1:65QmN7r3FWgTxDMz9fvGnO1kbf2nu+acg9p2R9oYYYk= -sigs.k8s.io/controller-runtime v0.17.3/go.mod h1:N0jpP5Lo7lMTF9aL56Z/B2oWBJjey6StQM0jRbKQXtY= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.29.0 h1:/U5vjBbQn3RChhv7P11uhYvCSm5G2GaIi5AIGBS6r4c= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.29.0/go.mod h1:z7+wmGM2dfIiLRfrC6jb5kV2Mq/sK1ZP303cxzkV5Y4= +sigs.k8s.io/controller-runtime v0.18.4 h1:87+guW1zhvuPLh1PHybKdYFLU0YJp4FhJRmiHvm5BZw= +sigs.k8s.io/controller-runtime v0.18.4/go.mod h1:TVoGrfdpbA9VRFaRnKgk9P5/atA0pMwq+f+msb9M8Sg= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= diff --git a/infra/feast-operator/internal/controller/authz/authz.go b/infra/feast-operator/internal/controller/authz/authz.go new file mode 100644 index 00000000000..122e9f69457 --- /dev/null +++ b/infra/feast-operator/internal/controller/authz/authz.go @@ -0,0 +1,206 @@ +package authz + +import ( + "context" + "slices" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" + rbacv1 "k8s.io/api/rbac/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// Deploy the feast authorization +func (authz *FeastAuthorization) Deploy() error { + if authz.isKubernetesAuth() { + return authz.deployKubernetesAuth() + } + + authz.removeOrphanedRoles() + _ = authz.Handler.DeleteOwnedFeastObj(authz.initFeastRole()) + _ = authz.Handler.DeleteOwnedFeastObj(authz.initFeastRoleBinding()) + apimeta.RemoveStatusCondition(&authz.Handler.FeatureStore.Status.Conditions, feastKubernetesAuthConditions[metav1.ConditionTrue].Type) + return nil +} + +func (authz *FeastAuthorization) isKubernetesAuth() bool { + authzConfig := authz.Handler.FeatureStore.Status.Applied.AuthzConfig + return authzConfig != nil && authzConfig.KubernetesAuthz != nil +} + +func (authz *FeastAuthorization) deployKubernetesAuth() error { + if authz.isKubernetesAuth() { + authz.removeOrphanedRoles() + + if err := authz.createFeastRole(); err != nil { + return authz.setFeastKubernetesAuthCondition(err) + } + if err := authz.createFeastRoleBinding(); err != nil { + return authz.setFeastKubernetesAuthCondition(err) + } + + for _, roleName := range authz.Handler.FeatureStore.Status.Applied.AuthzConfig.KubernetesAuthz.Roles { + if err := authz.createAuthRole(roleName); err != nil { + return authz.setFeastKubernetesAuthCondition(err) + } + } + } + return authz.setFeastKubernetesAuthCondition(nil) +} + +func (authz *FeastAuthorization) removeOrphanedRoles() { + roleList := &rbacv1.RoleList{} + err := authz.Handler.Client.List(context.TODO(), roleList, &client.ListOptions{ + Namespace: authz.Handler.FeatureStore.Namespace, + LabelSelector: labels.SelectorFromSet(authz.getLabels()), + }) + if err != nil { + return + } + + desiredRoles := []string{} + if authz.isKubernetesAuth() { + desiredRoles = authz.Handler.FeatureStore.Status.Applied.AuthzConfig.KubernetesAuthz.Roles + } + for _, role := range roleList.Items { + roleName := role.Name + if roleName != authz.getFeastRoleName() && !slices.Contains(desiredRoles, roleName) { + _ = authz.Handler.DeleteOwnedFeastObj(authz.initAuthRole(roleName)) + } + } +} + +func (authz *FeastAuthorization) createFeastRole() error { + logger := log.FromContext(authz.Handler.Context) + role := authz.initFeastRole() + if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, role, controllerutil.MutateFn(func() error { + return authz.setFeastRole(role) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "Role", role.Name, "operation", op) + } + + return nil +} + +func (authz *FeastAuthorization) initFeastRole() *rbacv1.Role { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastRoleName(), Namespace: authz.Handler.FeatureStore.Namespace}, + } + role.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("Role")) + return role +} + +func (authz *FeastAuthorization) setFeastRole(role *rbacv1.Role) error { + role.Labels = authz.getLabels() + role.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{rbacv1.GroupName}, + Resources: []string{"roles", "rolebindings"}, + Verbs: []string{"get", "list", "watch"}, + }, + } + + return controllerutil.SetControllerReference(authz.Handler.FeatureStore, role, authz.Handler.Scheme) +} + +func (authz *FeastAuthorization) createFeastRoleBinding() error { + logger := log.FromContext(authz.Handler.Context) + roleBinding := authz.initFeastRoleBinding() + if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, roleBinding, controllerutil.MutateFn(func() error { + return authz.setFeastRoleBinding(roleBinding) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "RoleBinding", roleBinding.Name, "operation", op) + } + + return nil +} + +func (authz *FeastAuthorization) initFeastRoleBinding() *rbacv1.RoleBinding { + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastRoleName(), Namespace: authz.Handler.FeatureStore.Namespace}, + } + roleBinding.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("RoleBinding")) + return roleBinding +} + +func (authz *FeastAuthorization) setFeastRoleBinding(roleBinding *rbacv1.RoleBinding) error { + roleBinding.Labels = authz.getLabels() + roleBinding.Subjects = []rbacv1.Subject{{ + Kind: rbacv1.ServiceAccountKind, + Name: services.GetFeastName(authz.Handler.FeatureStore), + Namespace: authz.Handler.FeatureStore.Namespace, + }} + roleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: authz.getFeastRoleName(), + } + + return controllerutil.SetControllerReference(authz.Handler.FeatureStore, roleBinding, authz.Handler.Scheme) +} + +func (authz *FeastAuthorization) createAuthRole(roleName string) error { + logger := log.FromContext(authz.Handler.Context) + role := authz.initAuthRole(roleName) + if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, role, controllerutil.MutateFn(func() error { + return authz.setAuthRole(role) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "Role", role.Name, "operation", op) + } + + return nil +} + +func (authz *FeastAuthorization) initAuthRole(roleName string) *rbacv1.Role { + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: roleName, Namespace: authz.Handler.FeatureStore.Namespace}, + } + role.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("Role")) + return role +} + +func (authz *FeastAuthorization) setAuthRole(role *rbacv1.Role) error { + role.Labels = authz.getLabels() + role.Rules = []rbacv1.PolicyRule{} + + return controllerutil.SetControllerReference(authz.Handler.FeatureStore, role, authz.Handler.Scheme) +} + +func (authz *FeastAuthorization) getLabels() map[string]string { + return map[string]string{ + services.NameLabelKey: authz.Handler.FeatureStore.Name, + } +} + +func (authz *FeastAuthorization) setFeastKubernetesAuthCondition(err error) error { + if err != nil { + logger := log.FromContext(authz.Handler.Context) + cond := feastKubernetesAuthConditions[metav1.ConditionFalse] + cond.Message = "Error: " + err.Error() + apimeta.SetStatusCondition(&authz.Handler.FeatureStore.Status.Conditions, cond) + logger.Error(err, "Error deploying the Kubernetes authorization") + return err + } else { + apimeta.SetStatusCondition(&authz.Handler.FeatureStore.Status.Conditions, feastKubernetesAuthConditions[metav1.ConditionTrue]) + } + return nil +} + +func (authz *FeastAuthorization) getFeastRoleName() string { + return GetFeastRoleName(authz.Handler.FeatureStore) +} + +func GetFeastRoleName(featureStore *feastdevv1alpha1.FeatureStore) string { + return services.GetFeastName(featureStore) +} diff --git a/infra/feast-operator/internal/controller/authz/authz_types.go b/infra/feast-operator/internal/controller/authz/authz_types.go new file mode 100644 index 00000000000..f955f5b40f1 --- /dev/null +++ b/infra/feast-operator/internal/controller/authz/authz_types.go @@ -0,0 +1,28 @@ +package authz + +import ( + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// FeastAuthorization is an interface for configuring feast authorization +type FeastAuthorization struct { + Handler handler.FeastHandler +} + +var ( + feastKubernetesAuthConditions = map[metav1.ConditionStatus]metav1.Condition{ + metav1.ConditionTrue: { + Type: feastdevv1alpha1.AuthorizationReadyType, + Status: metav1.ConditionTrue, + Reason: feastdevv1alpha1.ReadyReason, + Message: feastdevv1alpha1.KubernetesAuthzReadyMessage, + }, + metav1.ConditionFalse: { + Type: feastdevv1alpha1.AuthorizationReadyType, + Status: metav1.ConditionFalse, + Reason: feastdevv1alpha1.KubernetesAuthzFailedReason, + }, + } +) diff --git a/infra/feast-operator/internal/controller/featurestore_controller.go b/infra/feast-operator/internal/controller/featurestore_controller.go index 244bbcaae80..c3353c859f8 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller.go +++ b/infra/feast-operator/internal/controller/featurestore_controller.go @@ -23,6 +23,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,13 +31,15 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/handler" + handler "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/authz" + feasthandler "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" + routev1 "github.com/openshift/api/route/v1" ) // Constants for requeue @@ -50,11 +53,14 @@ type FeatureStoreReconciler struct { Scheme *runtime.Scheme } -//+kubebuilder:rbac:groups=feast.dev,resources=featurestores,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=feast.dev,resources=featurestores/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=feast.dev,resources=featurestores/finalizers,verbs=update -//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;create;update;watch;delete -//+kubebuilder:rbac:groups=core,resources=services;configmaps,verbs=get;list;create;update;watch;delete +// +kubebuilder:rbac:groups=feast.dev,resources=featurestores,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=feast.dev,resources=featurestores/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=feast.dev,resources=featurestores/finalizers,verbs=update +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;create;update;watch;delete +// +kubebuilder:rbac:groups=core,resources=services;configmaps;persistentvolumeclaims;serviceaccounts,verbs=get;list;create;update;watch;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;create;update;watch;delete +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list +// +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;create;update;watch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -77,11 +83,9 @@ func (r *FeatureStoreReconciler) Reconcile(ctx context.Context, req ctrl.Request } currentStatus := cr.Status.DeepCopy() - // initial status defaults must occur before feast deployment - applyDefaultsToStatus(cr) result, recErr = r.deployFeast(ctx, cr) if cr.DeletionTimestamp == nil && !reflect.DeepEqual(currentStatus, cr.Status) { - if err := r.Client.Status().Update(ctx, cr); err != nil { + if err = r.Client.Status().Update(ctx, cr); err != nil { if apierrors.IsConflict(err) { logger.Info("FeatureStore object modified, retry syncing status") // Re-queue and preserve existing recErr @@ -106,21 +110,58 @@ func (r *FeatureStoreReconciler) deployFeast(ctx context.Context, cr *feastdevv1 Reason: feastdevv1alpha1.ReadyReason, Message: feastdevv1alpha1.ReadyMessage, } - feast := services.FeastServices{ - Client: r.Client, - Context: ctx, - FeatureStore: cr, - Scheme: r.Scheme, + Handler: feasthandler.FeastHandler{ + Client: r.Client, + Context: ctx, + FeatureStore: cr, + Scheme: r.Scheme, + }, + } + authz := authz.FeastAuthorization{ + Handler: feast.Handler, + } + + // status defaults must be applied before deployments + errResult := ctrl.Result{Requeue: true, RequeueAfter: RequeueDelayError} + if err = feast.ApplyDefaults(); err != nil { + result = errResult + } else if err = authz.Deploy(); err != nil { + result = errResult + } else if err = feast.Deploy(); err != nil { + result = errResult } - if err = feast.Deploy(); err != nil { + if err != nil { condition = metav1.Condition{ Type: feastdevv1alpha1.ReadyType, Status: metav1.ConditionFalse, Reason: feastdevv1alpha1.FailedReason, Message: "Error: " + err.Error(), } - result = ctrl.Result{Requeue: true, RequeueAfter: RequeueDelayError} + } else { + deployment, deploymentErr := feast.GetDeployment() + if deploymentErr != nil { + condition = metav1.Condition{ + Type: feastdevv1alpha1.ReadyType, + Status: metav1.ConditionUnknown, + Reason: feastdevv1alpha1.DeploymentNotAvailableReason, + Message: feastdevv1alpha1.DeploymentNotAvailableMessage, + } + + result = errResult + } else { + isDeployAvailable := services.IsDeploymentAvailable(deployment.Status.Conditions) + if !isDeployAvailable { + condition = metav1.Condition{ + Type: feastdevv1alpha1.ReadyType, + Status: metav1.ConditionUnknown, + Reason: feastdevv1alpha1.DeploymentNotAvailableReason, + Message: feastdevv1alpha1.DeploymentNotAvailableMessage, + } + + result = errResult + } + } } logger.Info(condition.Message) @@ -138,13 +179,22 @@ func (r *FeatureStoreReconciler) deployFeast(ctx context.Context, cr *feastdevv1 // SetupWithManager sets up the controller with the Manager. func (r *FeatureStoreReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). + bldr := ctrl.NewControllerManagedBy(mgr). For(&feastdevv1alpha1.FeatureStore{}). Owns(&corev1.ConfigMap{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). - Watches(&feastdevv1alpha1.FeatureStore{}, handler.EnqueueRequestsFromMapFunc(r.mapFeastRefsToFeastRequests)). - Complete(r) + Owns(&corev1.PersistentVolumeClaim{}). + Owns(&corev1.ServiceAccount{}). + Owns(&rbacv1.RoleBinding{}). + Owns(&rbacv1.Role{}). + Watches(&feastdevv1alpha1.FeatureStore{}, handler.EnqueueRequestsFromMapFunc(r.mapFeastRefsToFeastRequests)) + if services.IsOpenShift() { + bldr = bldr.Owns(&routev1.Route{}) + } + + return bldr.Complete(r) + } // if a remotely referenced FeatureStore is changed, reconcile any FeatureStores that reference it. @@ -167,11 +217,12 @@ func (r *FeatureStoreReconciler) mapFeastRefsToFeastRequests(ctx context.Context // this if statement is extra protection against any potential infinite reconcile loops if feastRefNsName != objNsName { feast := services.FeastServices{ - Client: r.Client, - Context: ctx, - FeatureStore: &obj, - Scheme: r.Scheme, - } + Handler: feasthandler.FeastHandler{ + Client: r.Client, + Context: ctx, + FeatureStore: &obj, + Scheme: r.Scheme, + }} if feast.IsRemoteRefRegistry() { remoteRef := obj.Status.Applied.Services.Registry.Remote.FeastRef remoteRefNsName := types.NamespacedName{Name: remoteRef.Name, Namespace: remoteRef.Namespace} @@ -184,39 +235,3 @@ func (r *FeatureStoreReconciler) mapFeastRefsToFeastRequests(ctx context.Context return requests } - -func applyDefaultsToStatus(cr *feastdevv1alpha1.FeatureStore) { - cr.Status.FeastVersion = feastversion.FeastVersion - applied := cr.Spec.DeepCopy() - if applied.Services == nil { - applied.Services = &feastdevv1alpha1.FeatureStoreServices{} - } - - // default to registry service deployment - if applied.Services.Registry == nil { - applied.Services.Registry = &feastdevv1alpha1.Registry{} - } - // if remote registry not set, proceed w/ local registry defaults - if applied.Services.Registry.Remote == nil { - // if local registry not set, apply an empty pointer struct - if applied.Services.Registry.Local == nil { - applied.Services.Registry.Local = &feastdevv1alpha1.LocalRegistryConfig{} - } - setServiceDefaultConfigs(&applied.Services.Registry.Local.ServiceConfigs.DefaultConfigs) - } - if applied.Services.OfflineStore != nil { - setServiceDefaultConfigs(&applied.Services.OfflineStore.ServiceConfigs.DefaultConfigs) - } - if applied.Services.OnlineStore != nil { - setServiceDefaultConfigs(&applied.Services.OnlineStore.ServiceConfigs.DefaultConfigs) - } - - // overwrite status.applied with every reconcile - applied.DeepCopyInto(&cr.Status.Applied) -} - -func setServiceDefaultConfigs(defaultConfigs *feastdevv1alpha1.DefaultConfigs) { - if defaultConfigs.Image == nil { - defaultConfigs.Image = &services.DefaultImage - } -} diff --git a/infra/feast-operator/internal/controller/featurestore_controller_db_store_test.go b/infra/feast-operator/internal/controller/featurestore_controller_db_store_test.go new file mode 100644 index 00000000000..390c225f613 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_db_store_test.go @@ -0,0 +1,725 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var cassandraYamlString = ` +hosts: + - 192.168.1.1 + - 192.168.1.2 + - 192.168.1.3 +keyspace: KeyspaceName +port: 9042 +username: user +password: secret +protocol_version: 5 +load_balancing: + local_dc: datacenter1 + load_balancing_policy: TokenAwarePolicy(DCAwareRoundRobinPolicy) +read_concurrency: 100 +write_concurrency: 100 +` + +var snowflakeYamlString = ` +account: snowflake_deployment.us-east-1 +user: user_login +password: user_password +role: SYSADMIN +warehouse: COMPUTE_WH +database: FEAST +schema: PUBLIC +` + +var sqlTypeYamlString = ` +path: postgresql://postgres:mysecretpassword@127.0.0.1:55001/feast +cache_ttl_seconds: 60 +sqlalchemy_config_kwargs: + echo: false + pool_pre_ping: true +` + +var secretContainingValidTypeYamlString = ` +type: cassandra +hosts: + - 192.168.1.1 + - 192.168.1.2 + - 192.168.1.3 +keyspace: KeyspaceName +port: 9042 +username: user +password: secret +protocol_version: 5 +load_balancing: + local_dc: datacenter1 + load_balancing_policy: TokenAwarePolicy(DCAwareRoundRobinPolicy) +read_concurrency: 100 +write_concurrency: 100 +` + +var invalidSecretTypeYamlString = ` +type: wrong +hosts: + - 192.168.1.1 + - 192.168.1.2 + - 192.168.1.3 +keyspace: KeyspaceName +port: 9042 +username: user +password: secret +protocol_version: 5 +load_balancing: + local_dc: datacenter1 + load_balancing_policy: TokenAwarePolicy(DCAwareRoundRobinPolicy) +read_concurrency: 100 +write_concurrency: 100 +` + +var invalidSecretRegistryTypeYamlString = ` +registry_type: sql +path: postgresql://postgres:mysecretpassword@127.0.0.1:55001/feast +cache_ttl_seconds: 60 +sqlalchemy_config_kwargs: + echo: false + pool_pre_ping: true +` + +var _ = Describe("FeatureStore Controller - db storage services", func() { + Context("When deploying a resource with all db storage services", func() { + const resourceName = "cr-name" + var pullPolicy = corev1.PullAlways + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + offlineSecretNamespacedName := types.NamespacedName{ + Name: "offline-store-secret", + Namespace: "default", + } + + onlineSecretNamespacedName := types.NamespacedName{ + Name: "online-store-secret", + Namespace: "default", + } + + registrySecretNamespacedName := types.NamespacedName{ + Name: "registry-store-secret", + Namespace: "default", + } + + featurestore := &feastdevv1alpha1.FeatureStore{} + offlineType := services.OfflineDBPersistenceSnowflakeConfigType + onlineType := services.OnlineDBPersistenceCassandraConfigType + registryType := services.RegistryDBPersistenceSQLConfigType + + BeforeEach(func() { + By("creating secrets for db stores for custom resource of Kind FeatureStore") + secret := &corev1.Secret{} + + secretData := map[string][]byte{ + string(offlineType): []byte(snowflakeYamlString), + } + err := k8sClient.Get(ctx, offlineSecretNamespacedName, secret) + if err != nil && errors.IsNotFound(err) { + secret.ObjectMeta = metav1.ObjectMeta{ + Name: offlineSecretNamespacedName.Name, + Namespace: offlineSecretNamespacedName.Namespace, + } + secret.Data = secretData + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + } + + secret = &corev1.Secret{} + + secretData = map[string][]byte{ + string(onlineType): []byte(cassandraYamlString), + } + err = k8sClient.Get(ctx, onlineSecretNamespacedName, secret) + if err != nil && errors.IsNotFound(err) { + secret.ObjectMeta = metav1.ObjectMeta{ + Name: onlineSecretNamespacedName.Name, + Namespace: onlineSecretNamespacedName.Namespace, + } + secret.Data = secretData + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + } + + secret = &corev1.Secret{} + + secretData = map[string][]byte{ + "sql_custom_registry_key": []byte(sqlTypeYamlString), + } + err = k8sClient.Get(ctx, registrySecretNamespacedName, secret) + if err != nil && errors.IsNotFound(err) { + secret.ObjectMeta = metav1.ObjectMeta{ + Name: registrySecretNamespacedName.Name, + Namespace: registrySecretNamespacedName.Namespace, + } + secret.Data = secretData + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + } + + createEnvFromSecretAndConfigMap() + + By("creating the custom resource for the Kind FeatureStore") + err = k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{}, withEnvFrom()) + resource.Spec.Services.OfflineStore.Persistence = &feastdevv1alpha1.OfflineStorePersistence{ + DBPersistence: &feastdevv1alpha1.OfflineStoreDBStorePersistence{ + Type: string(offlineType), + SecretRef: corev1.LocalObjectReference{ + Name: "offline-store-secret", + }, + }, + } + resource.Spec.Services.OnlineStore.Persistence = &feastdevv1alpha1.OnlineStorePersistence{ + DBPersistence: &feastdevv1alpha1.OnlineStoreDBStorePersistence{ + Type: string(onlineType), + SecretRef: corev1.LocalObjectReference{ + Name: "online-store-secret", + }, + }, + } + resource.Spec.Services.Registry.Local.Persistence = &feastdevv1alpha1.RegistryPersistence{ + DBPersistence: &feastdevv1alpha1.RegistryDBStorePersistence{ + Type: string(registryType), + SecretRef: corev1.LocalObjectReference{ + Name: "registry-store-secret", + }, + SecretKeyName: "sql_custom_registry_key", + }, + } + + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + onlineSecret := &corev1.Secret{} + err := k8sClient.Get(ctx, onlineSecretNamespacedName, onlineSecret) + Expect(err).NotTo(HaveOccurred()) + + offlineSecret := &corev1.Secret{} + err = k8sClient.Get(ctx, offlineSecretNamespacedName, offlineSecret) + Expect(err).NotTo(HaveOccurred()) + + registrySecret := &corev1.Secret{} + err = k8sClient.Get(ctx, registrySecretNamespacedName, registrySecret) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + deleteEnvFromSecretAndConfigMap() + + By("Cleanup the secrets") + Expect(k8sClient.Delete(ctx, onlineSecret)).To(Succeed()) + Expect(k8sClient.Delete(ctx, offlineSecret)).To(Succeed()) + Expect(k8sClient.Delete(ctx, registrySecret)).To(Succeed()) + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + It("should fail reconciling the resource", func() { + By("Referring to a secret that doesn't exist") + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretRef = corev1.LocalObjectReference{Name: "invalid_secret"} + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + + Expect(err.Error()).To(Equal("secrets \"invalid_secret\" not found")) + + By("Referring to a secret with a key that doesn't exist") + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretRef = corev1.LocalObjectReference{Name: "online-store-secret"} + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretKeyName = "invalid.secret.key" + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + + Expect(err.Error()).To(Equal("secret key invalid.secret.key doesn't exist in secret online-store-secret")) + + By("Referring to a secret that contains parameter named type with invalid value") + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + secret := &corev1.Secret{} + err = k8sClient.Get(ctx, onlineSecretNamespacedName, secret) + Expect(err).NotTo(HaveOccurred()) + secret.Data[string(services.OnlineDBPersistenceCassandraConfigType)] = []byte(invalidSecretTypeYamlString) + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretRef = corev1.LocalObjectReference{Name: "online-store-secret"} + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretKeyName = "" + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + + Expect(err.Error()).To(Equal("secret key cassandra in secret online-store-secret contains tag named type with value wrong")) + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.DBPersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.DBPersistence.Type).To(Equal(string(offlineType))) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.DBPersistence.SecretRef).To(Equal(corev1.LocalObjectReference{Name: "offline-store-secret"})) + Expect(resource.Status.Applied.Services.OfflineStore.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.DBPersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.DBPersistence.Type).To(Equal(string(onlineType))) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.DBPersistence.SecretRef).To(Equal(corev1.LocalObjectReference{Name: "online-store-secret"})) + Expect(resource.Status.Applied.Services.OnlineStore.Server.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(&image)) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.DBPersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.DBPersistence.Type).To(Equal(string(registryType))) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.DBPersistence.SecretRef).To(Equal(corev1.LocalObjectReference{Name: "registry-store-secret"})) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.DBPersistence.SecretKeyName).To(Equal("sql_custom_registry_key")) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + svc) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(svc)).To(BeTrue()) + Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.RegistryFeastType].TargetHttpPort)))) + + By("Referring to a secret that contains parameter named type") + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + secret := &corev1.Secret{} + err = k8sClient.Get(ctx, onlineSecretNamespacedName, secret) + Expect(err).NotTo(HaveOccurred()) + secret.Data[string(services.OnlineDBPersistenceCassandraConfigType)] = []byte(secretContainingValidTypeYamlString) + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretRef = corev1.LocalObjectReference{Name: "online-store-secret"} + resource.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretKeyName = "" + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).To(Not(HaveOccurred())) + + By("Referring to a secret that contains parameter named registry_type") + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + secret = &corev1.Secret{} + err = k8sClient.Get(ctx, onlineSecretNamespacedName, secret) + Expect(err).NotTo(HaveOccurred()) + secret.Data[string(services.OnlineDBPersistenceCassandraConfigType)] = []byte(cassandraYamlString) + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + + secret = &corev1.Secret{} + err = k8sClient.Get(ctx, registrySecretNamespacedName, secret) + Expect(err).NotTo(HaveOccurred()) + secret.Data["sql_custom_registry_key"] = nil + secret.Data[string(services.RegistryDBPersistenceSQLConfigType)] = []byte(invalidSecretRegistryTypeYamlString) + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + + resource.Spec.Services.Registry.Local.Persistence.DBPersistence.SecretRef = corev1.LocalObjectReference{Name: "registry-store-secret"} + resource.Spec.Services.Registry.Local.Persistence.DBPersistence.SecretKeyName = "" + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(Not(HaveOccurred())) + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(4)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.Env).To(HaveLen(1)) + env := getFeatureStoreYamlEnvVar(registryContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + dbParametersMap := unmarshallYamlString(sqlTypeYamlString) + copyMap := services.CopyMap(dbParametersMap) + delete(dbParametersMap, "path") + testConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: services.OfflineStoreConfig{ + Type: services.OfflineDBPersistenceSnowflakeConfigType, + DBParameters: unmarshallYamlString(snowflakeYamlString), + }, + Registry: services.RegistryConfig{ + Path: copyMap["path"].(string), + RegistryType: services.RegistryDBPersistenceSQLConfigType, + DBParameters: dbParametersMap, + }, + OnlineStore: services.OnlineStoreConfig{ + Type: onlineType, + DBParameters: unmarshallYamlString(cassandraYamlString), + }, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfig).To(Equal(testConfig)) + + offlineContainer := services.GetOfflineContainer(*deploy) + Expect(offlineContainer.Env).To(HaveLen(1)) + assertEnvFrom(*offlineContainer) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOffline).To(Equal(testConfig)) + + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.VolumeMounts).To(HaveLen(1)) + Expect(onlineContainer.Env).To(HaveLen(1)) + assertEnvFrom(*onlineContainer) + Expect(onlineContainer.ImagePullPolicy).To(Equal(corev1.PullAlways)) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOnline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOnline).To(Equal(testConfig)) + onlineContainer = services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(1)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + offlineRemote := services.OfflineStoreConfig{ + Host: fmt.Sprintf("feast-%s-offline.default.svc.cluster.local", resourceName), + Type: services.OfflineRemoteConfigType, + Port: services.HttpPort, + } + regRemote := services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:80", resourceName), + } + clientConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: offlineRemote, + OnlineStore: services.OnlineStoreConfig{ + Path: fmt.Sprintf("http://feast-%s-online.default.svc.cluster.local:80", resourceName), + Type: services.OnlineRemoteConfigType, + }, + Registry: regRemote, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfigClient).To(Equal(clientConfig)) + + // change paths and reconcile + resourceNew := resource.DeepCopy() + newOnlineSecretName := "offline-store-secret" + newOnlineDBPersistenceType := services.OnlineDBPersistenceSnowflakeConfigType + resourceNew.Spec.Services.OnlineStore.Persistence.DBPersistence.Type = string(newOnlineDBPersistenceType) + resourceNew.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretRef = corev1.LocalObjectReference{Name: newOnlineSecretName} + resourceNew.Spec.Services.OnlineStore.Persistence.DBPersistence.SecretKeyName = string(services.OfflineDBPersistenceSnowflakeConfigType) + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check online config + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + onlineContainer = services.GetOnlineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + + repoConfigOnline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + testConfig.OnlineStore.Type = services.OnlineDBPersistenceSnowflakeConfigType + testConfig.OnlineStore.DBParameters = unmarshallYamlString(snowflakeYamlString) + Expect(repoConfigOnline).To(Equal(testConfig)) + }) + }) +}) + +func unmarshallYamlString(yamlString string) map[string]interface{} { + var parameters map[string]interface{} + + err := yaml.Unmarshal([]byte(yamlString), ¶meters) + if err != nil { + fmt.Println(err) + } + return parameters +} diff --git a/infra/feast-operator/internal/controller/featurestore_controller_ephemeral_test.go b/infra/feast-operator/internal/controller/featurestore_controller_ephemeral_test.go new file mode 100644 index 00000000000..a0c01c11449 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_ephemeral_test.go @@ -0,0 +1,458 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller-Ephemeral services", func() { + Context("When deploying a resource with all ephemeral services", func() { + const resourceName = "services-ephemeral" + const offlineType = "duckdb" + var pullPolicy = corev1.PullAlways + var testEnvVarName = "testEnvVarName" + var testEnvVarValue = "testEnvVarValue" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + onlineStorePath := "/data/online.db" + registryPath := "/data/registry.db" + + BeforeEach(func() { + createEnvFromSecretAndConfigMap() + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, + {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}, withEnvFrom()) + resource.Spec.Services.OfflineStore.Persistence = &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + Type: offlineType, + }, + } + resource.Spec.Services.OnlineStore.Persistence = &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: onlineStorePath, + }, + } + resource.Spec.Services.Registry.Local.Persistence = &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: registryPath, + }, + } + + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + deleteEnvFromSecretAndConfigMap() + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.AuthzConfig).To(BeNil()) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.Type).To(Equal(offlineType)) + Expect(resource.Status.Applied.Services.OfflineStore.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.Path).To(Equal(onlineStorePath)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Env).To(Equal(&[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})) + Expect(resource.Status.Applied.Services.OnlineStore.Server.EnvFrom).To(Equal(withEnvFrom())) + Expect(resource.Status.Applied.Services.OnlineStore.Server.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(&image)) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.Path).To(Equal(registryPath)) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + svc) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(svc)).To(BeTrue()) + Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.RegistryFeastType].TargetHttpPort)))) + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(4)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.Env).To(HaveLen(1)) + env := getFeatureStoreYamlEnvVar(registryContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: services.OfflineStoreConfig{ + Type: services.OfflineFilePersistenceDuckDbConfigType, + }, + Registry: services.RegistryConfig{ + RegistryType: services.RegistryFileConfigType, + Path: registryPath, + }, + OnlineStore: services.OnlineStoreConfig{ + Path: onlineStorePath, + Type: services.OnlineSqliteConfigType, + }, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfig).To(Equal(testConfig)) + + offlineContainer := services.GetOfflineContainer(*deploy) + Expect(offlineContainer.Env).To(HaveLen(1)) + assertEnvFrom(*offlineContainer) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + // check envFrom for offlineContainer + assertEnvFrom(*offlineContainer) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOffline).To(Equal(testConfig)) + + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(3)) + Expect(onlineContainer.ImagePullPolicy).To(Equal(corev1.PullAlways)) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOnline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOnline).To(Equal(testConfig)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + clientConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: services.OfflineStoreConfig{ + Host: fmt.Sprintf("feast-%s-offline.default.svc.cluster.local", resourceName), + Type: services.OfflineRemoteConfigType, + Port: services.HttpPort, + }, + OnlineStore: services.OnlineStoreConfig{ + Path: fmt.Sprintf("http://feast-%s-online.default.svc.cluster.local:80", resourceName), + Type: services.OnlineRemoteConfigType, + }, + Registry: services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:80", resourceName), + }, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfigClient).To(Equal(clientConfig)) + + // change paths and reconcile + resourceNew := resource.DeepCopy() + newOnlineStorePath := "/data/new_online.db" + newRegistryPath := "/data/new_registry.db" + resourceNew.Spec.Services.OnlineStore.Persistence.FilePersistence.Path = newOnlineStorePath + resourceNew.Spec.Services.Registry.Local.Persistence.FilePersistence.Path = newRegistryPath + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check registry + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + registryContainer = services.GetRegistryContainer(*deploy) + env = getFeatureStoreYamlEnvVar(registryContainer.Env) + Expect(env).NotTo(BeNil()) + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig.OnlineStore.Path = newOnlineStorePath + testConfig.Registry.Path = newRegistryPath + Expect(repoConfig).To(Equal(testConfig)) + + // check offline config + offlineContainer = services.GetRegistryContainer(*deploy) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOffline).To(Equal(testConfig)) + + // check online config + onlineContainer = services.GetOnlineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + // check envFrom + // Validate `envFrom` for ConfigMap and Secret + assertEnvFrom(*onlineContainer) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + + repoConfigOnline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + testConfig.OnlineStore.Path = newOnlineStorePath + Expect(repoConfigOnline).To(Equal(testConfig)) + }) + }) +}) diff --git a/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go b/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go new file mode 100644 index 00000000000..dd799a8c8e8 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_kubernetes_auth_test.go @@ -0,0 +1,499 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/authz" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller-Kubernetes authorization", func() { + Context("When deploying a resource with all ephemeral services and Kubernetes authorization", func() { + const resourceName = "kubernetes-authorization" + var pullPolicy = corev1.PullAlways + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + roles := []string{"reader", "writer"} + + BeforeEach(func() { + createEnvFromSecretAndConfigMap() + + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{}, withEnvFrom()) + resource.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{KubernetesAuthz: &feastdevv1alpha1.KubernetesAuthz{ + Roles: roles, + }} + + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + deleteEnvFromSecretAndConfigMap() + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + expectedAuthzConfig := &feastdevv1alpha1.AuthzConfig{ + KubernetesAuthz: &feastdevv1alpha1.KubernetesAuthz{ + Roles: roles, + }, + } + Expect(resource.Status.Applied.AuthzConfig).To(Equal(expectedAuthzConfig)) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.Type).To(Equal(string(services.OfflineFilePersistenceDaskConfigType))) + Expect(resource.Status.Applied.Services.OfflineStore.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.Path).To(Equal(services.EphemeralPath + "/" + services.DefaultOnlineStorePath)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Env).To(Equal(&[]corev1.EnvVar{})) + Expect(resource.Status.Applied.Services.OnlineStore.Server.EnvFrom).To(Equal(withEnvFrom())) + Expect(resource.Status.Applied.Services.OnlineStore.Server.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(&image)) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.Path).To(Equal(services.EphemeralPath + "/" + services.DefaultRegistryPath)) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.AuthorizationReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.KubernetesAuthzReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers[0].VolumeMounts).To(HaveLen(1)) + + // check configured Roles + for _, roleName := range roles { + role := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: roleName, + Namespace: resource.Namespace, + }, + role) + Expect(err).NotTo(HaveOccurred()) + Expect(role.Rules).To(BeEmpty()) + } + + // check Feast Role + feastRole := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: authz.GetFeastRoleName(resource), + Namespace: resource.Namespace, + }, + feastRole) + Expect(err).NotTo(HaveOccurred()) + Expect(feastRole.Rules).ToNot(BeEmpty()) + Expect(feastRole.Rules).To(HaveLen(1)) + Expect(feastRole.Rules[0].APIGroups).To(HaveLen(1)) + Expect(feastRole.Rules[0].APIGroups[0]).To(Equal(rbacv1.GroupName)) + Expect(feastRole.Rules[0].Resources).To(HaveLen(2)) + Expect(feastRole.Rules[0].Resources).To(ContainElement("roles")) + Expect(feastRole.Rules[0].Resources).To(ContainElement("rolebindings")) + Expect(feastRole.Rules[0].Verbs).To(HaveLen(3)) + Expect(feastRole.Rules[0].Verbs).To(ContainElement("get")) + Expect(feastRole.Rules[0].Verbs).To(ContainElement("list")) + Expect(feastRole.Rules[0].Verbs).To(ContainElement("watch")) + + // check RoleBinding + roleBinding := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: authz.GetFeastRoleName(resource), + Namespace: resource.Namespace, + }, + roleBinding) + Expect(err).NotTo(HaveOccurred()) + + // check ServiceAccounts + expectedRoleRef := rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: feastRole.Name, + } + sa := &corev1.ServiceAccount{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: services.GetFeastName(feast.Handler.FeatureStore), + Namespace: resource.Namespace, + }, + sa) + Expect(err).NotTo(HaveOccurred()) + + expectedSubject := rbacv1.Subject{ + Kind: rbacv1.ServiceAccountKind, + Name: sa.Name, + Namespace: sa.Namespace, + } + Expect(roleBinding.Subjects).To(ContainElement(expectedSubject)) + Expect(roleBinding.RoleRef).To(Equal(expectedRoleRef)) + + By("Updating the user roled and reconciling") + resourceNew := resource.DeepCopy() + rolesNew := roles[1:] + resourceNew.Spec.AuthzConfig.KubernetesAuthz.Roles = rolesNew + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check new Roles + for _, roleName := range rolesNew { + role := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: roleName, + Namespace: resource.Namespace, + }, + role) + Expect(err).NotTo(HaveOccurred()) + Expect(role.Rules).To(BeEmpty()) + } + + // check deleted Role + role := &rbacv1.Role{} + deletedRole := roles[0] + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: deletedRole, + Namespace: resource.Namespace, + }, + role) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + By("Clearing the kubernetes authorization and reconciling") + resourceNew = resource.DeepCopy() + resourceNew.Spec.AuthzConfig = nil + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check no Roles + for _, roleName := range roles { + role := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: roleName, + Namespace: resource.Namespace, + }, + role) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + } + // check no RoleBinding + roleBinding = &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: authz.GetFeastRoleName(resource), + Namespace: resource.Namespace, + }, + roleBinding) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(4)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check registry + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + env := getFeatureStoreYamlEnvVar(services.GetRegistryContainer(*deploy).Env) + Expect(env).NotTo(BeNil()) + + // check registry config + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := feast.GetDefaultRepoConfig() + testConfig.OfflineStore = services.OfflineStoreConfig{ + Type: services.OfflineFilePersistenceDaskConfigType, + } + testConfig.Registry.RegistryType = services.RegistryFileConfigType + testConfig.AuthzConfig = services.AuthzConfig{ + Type: services.KubernetesAuthType, + } + Expect(repoConfig).To(Equal(&testConfig)) + + // check offline + offlineContainer := services.GetOfflineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + assertEnvFrom(*offlineContainer) + + // check offline config + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig).To(Equal(&testConfig)) + + // check online + onlineContainer := services.GetOnlineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + assertEnvFrom(*onlineContainer) + + // check online config + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig).To(Equal(&testConfig)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + regRemote := services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:80", resourceName), + } + offlineRemote := services.OfflineStoreConfig{ + Host: fmt.Sprintf("feast-%s-offline.default.svc.cluster.local", resourceName), + Type: services.OfflineRemoteConfigType, + Port: services.HttpPort, + } + clientConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: offlineRemote, + OnlineStore: services.OnlineStoreConfig{ + Path: fmt.Sprintf("http://feast-%s-online.default.svc.cluster.local:80", resourceName), + Type: services.OnlineRemoteConfigType, + }, + Registry: regRemote, + AuthzConfig: services.AuthzConfig{ + Type: services.KubernetesAuthType, + }, + } + Expect(repoConfigClient).To(Equal(clientConfig)) + }) + }) +}) diff --git a/infra/feast-operator/internal/controller/featurestore_controller_loglevel_test.go b/infra/feast-operator/internal/controller/featurestore_controller_loglevel_test.go new file mode 100644 index 00000000000..90b80c907d5 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_loglevel_test.go @@ -0,0 +1,250 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller - Feast service LogLevel", func() { + Context("When reconciling a FeatureStore resource", func() { + const resourceName = "test-loglevel" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + + BeforeEach(func() { + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: feastProject, + Services: &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{ + LogLevel: strPtr("error"), + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + LogLevel: strPtr("debug"), + }, + }, + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + LogLevel: strPtr("info"), + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + LogLevel: strPtr("info"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + It("should successfully reconcile the resource with logLevel", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + command := services.GetRegistryContainer(*deploy).Command + Expect(command).To(ContainElement("--log-level")) + Expect(command).To(ContainElement("ERROR")) + + command = services.GetOfflineContainer(*deploy).Command + Expect(command).To(ContainElement("--log-level")) + Expect(command).To(ContainElement("INFO")) + + command = services.GetOnlineContainer(*deploy).Command + Expect(command).To(ContainElement("--log-level")) + Expect(command).To(ContainElement("DEBUG")) + }) + + It("should not include --log-level parameter when logLevel is not specified for any service", func() { + By("Updating the FeatureStore resource without specifying logLevel for any service") + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + resource.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, + }, + OfflineStore: &feastdevv1alpha1.OfflineStore{}, + UI: &feastdevv1alpha1.ServerConfigs{}, + } + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(3)) + command := services.GetRegistryContainer(*deploy).Command + Expect(command).NotTo(ContainElement("--log-level")) + + command = services.GetOnlineContainer(*deploy).Command + Expect(command).NotTo(ContainElement("--log-level")) + + command = services.GetUIContainer(*deploy).Command + Expect(command).NotTo(ContainElement("--log-level")) + }) + + }) +}) + +func strPtr(str string) *string { + return &str +} diff --git a/infra/feast-operator/internal/controller/featurestore_controller_objectstore_test.go b/infra/feast-operator/internal/controller/featurestore_controller_objectstore_test.go new file mode 100644 index 00000000000..81dc15d8545 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_objectstore_test.go @@ -0,0 +1,371 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller-Ephemeral services", func() { + Context("When deploying a resource with all ephemeral services", func() { + const resourceName = "services-object-store" + var pullPolicy = corev1.PullAlways + var testEnvVarName = "testEnvVarName" + var testEnvVarValue = "testEnvVarValue" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + registryPath := "s3://bucket/registry.db" + + s3AdditionalKwargs := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + BeforeEach(func() { + createEnvFromSecretAndConfigMap() + + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, + {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}, withEnvFrom()) + resource.Spec.Services.UI = nil + resource.Spec.Services.OfflineStore = nil + resource.Spec.Services.Registry.Local.Persistence = &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: registryPath, + S3AdditionalKwargs: &s3AdditionalKwargs, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + deleteEnvFromSecretAndConfigMap() + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.AuthzConfig).To(BeNil()) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).To(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.UI).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.Path).To(Equal(registryPath)) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs).To(Equal(&s3AdditionalKwargs)) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(BeEmpty()) + Expect(resource.Status.ServiceHostnames.OnlineStore).NotTo(BeEmpty()) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).To(BeNil()) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).NotTo(BeNil()) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(2)) + Expect(services.GetRegistryContainer(*deploy)).NotTo(BeNil()) + Expect(services.GetOnlineContainer(*deploy)).NotTo(BeNil()) + Expect(services.GetOfflineContainer(*deploy)).To(BeNil()) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers[0].VolumeMounts).To(HaveLen(1)) + + // update S3 additional args and reconcile + resourceNew := resource.DeepCopy() + newS3AdditionalKwargs := make(map[string]string) + for k, v := range s3AdditionalKwargs { + newS3AdditionalKwargs[k] = v + } + newS3AdditionalKwargs["key3"] = "value3" + resourceNew.Spec.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs = &newS3AdditionalKwargs + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs).NotTo(Equal(&s3AdditionalKwargs)) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs).To(Equal(&newS3AdditionalKwargs)) + + // check registry deployment + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(1)) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.VolumeMounts).To(HaveLen(1)) + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(2)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(2)) + Expect(services.GetRegistryContainer(*deploy)).NotTo(BeNil()) + Expect(services.GetOnlineContainer(*deploy)).NotTo(BeNil()) + Expect(services.GetOfflineContainer(*deploy)).To(BeNil()) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers[0].VolumeMounts).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) + env := getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) + Expect(env).NotTo(BeNil()) + + // check registry config + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := feast.GetDefaultRepoConfig() + testConfig.Registry = services.RegistryConfig{ + RegistryType: services.RegistryFileConfigType, + Path: registryPath, + S3AdditionalKwargs: &s3AdditionalKwargs, + } + Expect(repoConfig).To(Equal(&testConfig)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + clientConfig := feast.GetInitRepoConfig() + clientConfig.OnlineStore = services.OnlineStoreConfig{ + Type: services.OnlineRemoteConfigType, + Path: fmt.Sprintf("http://feast-%s-online.default.svc.cluster.local:80", resourceName), + } + clientConfig.Registry = services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:80", resourceName), + } + Expect(repoConfigClient).To(Equal(&clientConfig)) + + // remove S3 additional keywords and reconcile + resourceNew := resource.DeepCopy() + resourceNew.Spec.Services.Registry.Local.Persistence.FilePersistence.S3AdditionalKwargs = nil + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check registry config + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + env = getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) + Expect(env).NotTo(BeNil()) + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig.Registry.S3AdditionalKwargs = nil + Expect(repoConfig).To(Equal(&testConfig)) + }) + }) +}) diff --git a/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go b/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go new file mode 100644 index 00000000000..f192d07cd08 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_oidc_auth_test.go @@ -0,0 +1,537 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/authz" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller-OIDC authorization", func() { + Context("When deploying a resource with all ephemeral services and OIDC authorization", func() { + const resourceName = "oidc-authorization" + const oidcSecretName = "oidc-secret" + var pullPolicy = corev1.PullAlways + + ctx := context.Background() + + typeNamespacedSecretName := types.NamespacedName{ + Name: oidcSecretName, + Namespace: "default", + } + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + + BeforeEach(func() { + By("creating the OIDC secret") + oidcSecret := createValidOidcSecret(oidcSecretName) + err := k8sClient.Get(ctx, typeNamespacedSecretName, oidcSecret) + if err != nil && errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, oidcSecret)).To(Succeed()) + } + + createEnvFromSecretAndConfigMap() + + By("creating the custom resource for the Kind FeatureStore") + err = k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{}, withEnvFrom()) + resource.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{OidcAuthz: &feastdevv1alpha1.OidcAuthz{ + SecretRef: corev1.LocalObjectReference{ + Name: oidcSecretName, + }, + }} + + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + oidcSecret := createValidOidcSecret(oidcSecretName) + err = k8sClient.Get(ctx, typeNamespacedSecretName, oidcSecret) + if err != nil && errors.IsNotFound(err) { + By("Cleanup the OIDC secret") + Expect(k8sClient.Delete(ctx, oidcSecret)).To(Succeed()) + } + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + deleteEnvFromSecretAndConfigMap() + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + expectedAuthzConfig := &feastdevv1alpha1.AuthzConfig{ + OidcAuthz: &feastdevv1alpha1.OidcAuthz{ + SecretRef: corev1.LocalObjectReference{ + Name: oidcSecretName, + }, + }, + } + Expect(resource.Status.Applied.AuthzConfig).To(Equal(expectedAuthzConfig)) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.Type).To(Equal(string(services.OfflineFilePersistenceDaskConfigType))) + Expect(resource.Status.Applied.Services.OfflineStore.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.Path).To(Equal(services.EphemeralPath + "/" + services.DefaultOnlineStorePath)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Env).To(Equal(&[]corev1.EnvVar{})) + Expect(resource.Status.Applied.Services.OnlineStore.Server.EnvFrom).To(Equal(withEnvFrom())) + Expect(resource.Status.Applied.Services.OnlineStore.Server.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(&image)) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.Path).To(Equal(services.EphemeralPath + "/" + services.DefaultRegistryPath)) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(services.GetOfflineContainer(*deploy).VolumeMounts).To(HaveLen(1)) + Expect(services.GetOnlineContainer(*deploy).VolumeMounts).To(HaveLen(1)) + Expect(services.GetRegistryContainer(*deploy).VolumeMounts).To(HaveLen(1)) + + assertEnvFrom(*services.GetOnlineContainer(*deploy)) + assertEnvFrom(*services.GetOfflineContainer(*deploy)) + + // check Feast Role + feastRole := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: authz.GetFeastRoleName(resource), + Namespace: resource.Namespace, + }, + feastRole) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + // check RoleBinding + roleBinding := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: authz.GetFeastRoleName(resource), + Namespace: resource.Namespace, + }, + roleBinding) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + // check ServiceAccount + sa := &corev1.ServiceAccount{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: services.GetFeastName(feast.Handler.FeatureStore), + Namespace: resource.Namespace, + }, + sa) + Expect(err).NotTo(HaveOccurred()) + + By("Clearing the OIDC authorization and reconciling") + resourceNew := resource.DeepCopy() + resourceNew.Spec.AuthzConfig = nil + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check no RoleBinding + roleBinding = &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: authz.GetFeastRoleName(resource), + Namespace: resource.Namespace, + }, + roleBinding) + Expect(err).To(HaveOccurred()) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(4)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + env := getFeatureStoreYamlEnvVar(services.GetRegistryContainer(*deploy).Env) + Expect(env).NotTo(BeNil()) + + // check registry config + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: services.OfflineStoreConfig{ + Type: services.OfflineFilePersistenceDaskConfigType, + }, + Registry: services.RegistryConfig{ + RegistryType: services.RegistryFileConfigType, + Path: services.EphemeralPath + "/" + services.DefaultRegistryPath, + }, + OnlineStore: services.OnlineStoreConfig{ + Path: services.EphemeralPath + "/" + services.DefaultOnlineStorePath, + Type: services.OnlineSqliteConfigType, + }, + AuthzConfig: expectedServerOidcAuthorizConfig(), + } + Expect(repoConfig).To(Equal(testConfig)) + + // check offline + env = getFeatureStoreYamlEnvVar(services.GetOfflineContainer(*deploy).Env) + Expect(env).NotTo(BeNil()) + + // check offline config + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig).To(Equal(testConfig)) + + // check online + env = getFeatureStoreYamlEnvVar(services.GetOnlineContainer(*deploy).Env) + Expect(env).NotTo(BeNil()) + + // check online config + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig).To(Equal(testConfig)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + offlineRemote := services.OfflineStoreConfig{ + Host: fmt.Sprintf("feast-%s-offline.default.svc.cluster.local", resourceName), + Type: services.OfflineRemoteConfigType, + Port: services.HttpPort, + } + clientConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: offlineRemote, + OnlineStore: services.OnlineStoreConfig{ + Path: fmt.Sprintf("http://feast-%s-online.default.svc.cluster.local:80", resourceName), + Type: services.OnlineRemoteConfigType, + }, + Registry: services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:80", resourceName), + }, + AuthzConfig: expectedClientOidcAuthorizConfig(), + } + Expect(repoConfigClient).To(Equal(clientConfig)) + }) + + It("should fail to reconcile the resource", func() { + By("Reconciling an invalid OIDC set of properties") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + newOidcSecretName := "invalid-secret" + newTypeNamespaceSecretdName := types.NamespacedName{ + Name: newOidcSecretName, + Namespace: "default", + } + newOidcSecret := createInvalidOidcSecret(newOidcSecretName) + err := k8sClient.Get(ctx, newTypeNamespaceSecretdName, newOidcSecret) + if err != nil && errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, newOidcSecret)).To(Succeed()) + } + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + resource.Spec.AuthzConfig.OidcAuthz.SecretRef.Name = newOidcSecretName + err = k8sClient.Update(ctx, resource) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.FailedReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(ContainSubstring("missing OIDC")) + }) + }) +}) + +func expectedServerOidcAuthorizConfig() services.AuthzConfig { + return services.AuthzConfig{ + Type: services.OidcAuthType, + OidcParameters: map[string]interface{}{ + string(services.OidcAuthDiscoveryUrl): "auth-discovery-url", + string(services.OidcClientId): "client-id", + }, + } +} +func expectedClientOidcAuthorizConfig() services.AuthzConfig { + return services.AuthzConfig{ + Type: services.OidcAuthType, + OidcParameters: map[string]interface{}{ + string(services.OidcClientSecret): "client-secret", + string(services.OidcUsername): "username", + string(services.OidcPassword): "password"}, + } +} + +func validOidcSecretMap() map[string]string { + return map[string]string{ + string(services.OidcClientId): "client-id", + string(services.OidcAuthDiscoveryUrl): "auth-discovery-url", + string(services.OidcClientSecret): "client-secret", + string(services.OidcUsername): "username", + string(services.OidcPassword): "password", + } +} + +func createValidOidcSecret(secretName string) *corev1.Secret { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "default", + }, + StringData: validOidcSecretMap(), + } + + return secret +} + +func createInvalidOidcSecret(secretName string) *corev1.Secret { + oidcProperties := validOidcSecretMap() + delete(oidcProperties, string(services.OidcClientId)) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "default", + }, + StringData: oidcProperties, + } + + return secret +} diff --git a/infra/feast-operator/internal/controller/featurestore_controller_pvc_test.go b/infra/feast-operator/internal/controller/featurestore_controller_pvc_test.go new file mode 100644 index 00000000000..ec40527ceae --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_pvc_test.go @@ -0,0 +1,653 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + "path" + + apiresource "k8s.io/apimachinery/pkg/api/resource" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller-Ephemeral services", func() { + Context("When deploying a resource with all ephemeral services", func() { + const resourceName = "services-pvc" + var pullPolicy = corev1.PullAlways + var testEnvVarName = "testEnvVarName" + var testEnvVarValue = "testEnvVarValue" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + onlineStorePath := "online.db" + registryPath := "registry.db" + offlineType := "duckdb" + + offlineStoreMountPath := "/offline" + onlineStoreMountPath := "/online" + registryMountPath := "/registry" + + accessModes := []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce, corev1.ReadWriteMany} + storageClassName := "test" + + onlineStoreMountedPath := path.Join(onlineStoreMountPath, onlineStorePath) + registryMountedPath := path.Join(registryMountPath, registryPath) + + BeforeEach(func() { + createEnvFromSecretAndConfigMap() + + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, + {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}, withEnvFrom()) + resource.Spec.Services.UI = nil + resource.Spec.Services.OfflineStore.Persistence = &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + Type: offlineType, + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{ + AccessModes: accessModes, + StorageClassName: &storageClassName, + }, + MountPath: offlineStoreMountPath, + }, + }, + } + resource.Spec.Services.OnlineStore.Persistence = &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: onlineStorePath, + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: onlineStoreMountPath, + }, + }, + } + resource.Spec.Services.Registry.Local.Persistence = &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: registryPath, + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: registryMountPath, + }, + }, + } + + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + deleteEnvFromSecretAndConfigMap() + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.AuthzConfig).To(BeNil()) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.Type).To(Equal(offlineType)) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.AccessModes).To(Equal(accessModes)) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.StorageClassName).To(Equal(&storageClassName)) + expectedResources := corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: apiresource.MustParse("20Gi"), + }, + } + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.Resources).To(Equal(expectedResources)) + Expect(resource.Status.Applied.Services.OfflineStore.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.Path).To(Equal(onlineStorePath)) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.AccessModes).To(Equal(services.DefaultPVCAccessModes)) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.StorageClassName).To(BeNil()) + expectedResources = corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: apiresource.MustParse("5Gi"), + }, + } + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.Resources).To(Equal(expectedResources)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Env).To(Equal(&[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})) + Expect(resource.Status.Applied.Services.OnlineStore.Server.EnvFrom).To(Equal(withEnvFrom())) + Expect(resource.Status.Applied.Services.OnlineStore.Server.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(&image)) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.Path).To(Equal(registryPath)) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.AccessModes).To(Equal(services.DefaultPVCAccessModes)) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.StorageClassName).To(BeNil()) + expectedResources = corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: apiresource.MustParse("5Gi"), + }, + } + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.Resources).To(Equal(expectedResources)) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + ephemeralName := "feast-data" + ephemeralVolume := corev1.Volume{ + Name: ephemeralName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + } + ephemeralVolMount := corev1.VolumeMount{ + Name: ephemeralName, + MountPath: "/" + ephemeralName, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(3)) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(3)) + Expect(deploy.Spec.Template.Spec.Volumes).NotTo(ContainElement(ephemeralVolume)) + name := feast.GetFeastServiceName(services.RegistryFeastType) + regVol := services.GetRegistryVolume(feast.Handler.FeatureStore, deploy.Spec.Template.Spec.Volumes) + Expect(regVol.Name).To(Equal(name)) + Expect(regVol.PersistentVolumeClaim.ClaimName).To(Equal(name)) + + offlineContainer := services.GetOfflineContainer(*deploy) + Expect(offlineContainer.VolumeMounts).To(HaveLen(3)) + Expect(offlineContainer.VolumeMounts).NotTo(ContainElement(ephemeralVolMount)) + offlineVolMount := services.GetOfflineVolumeMount(feast.Handler.FeatureStore, offlineContainer.VolumeMounts) + Expect(offlineVolMount.MountPath).To(Equal(offlineStoreMountPath)) + offlinePvcName := feast.GetFeastServiceName(services.OfflineFeastType) + Expect(offlineVolMount.Name).To(Equal(offlinePvcName)) + + assertEnvFrom(*offlineContainer) + + // check offline pvc + pvc := &corev1.PersistentVolumeClaim{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: offlinePvcName, + Namespace: resource.Namespace, + }, + pvc) + Expect(err).NotTo(HaveOccurred()) + Expect(pvc.Spec.StorageClassName).To(Equal(&storageClassName)) + Expect(pvc.Spec.AccessModes).To(Equal(accessModes)) + Expect(pvc.Spec.Resources.Requests.Storage().String()).To(Equal(services.DefaultOfflineStorageRequest)) + Expect(pvc.DeletionTimestamp).To(BeNil()) + + // check online + onlinePvcName := feast.GetFeastServiceName(services.OnlineFeastType) + onlineVol := services.GetOnlineVolume(feast.Handler.FeatureStore, deploy.Spec.Template.Spec.Volumes) + Expect(onlineVol.Name).To(Equal(onlinePvcName)) + Expect(onlineVol.PersistentVolumeClaim.ClaimName).To(Equal(onlinePvcName)) + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.VolumeMounts).To(HaveLen(3)) + Expect(onlineContainer.VolumeMounts).NotTo(ContainElement(ephemeralVolMount)) + onlineVolMount := services.GetOnlineVolumeMount(feast.Handler.FeatureStore, onlineContainer.VolumeMounts) + Expect(onlineVolMount.MountPath).To(Equal(onlineStoreMountPath)) + Expect(onlineVolMount.Name).To(Equal(onlinePvcName)) + + assertEnvFrom(*onlineContainer) + + // check online pvc + pvc = &corev1.PersistentVolumeClaim{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: onlinePvcName, + Namespace: resource.Namespace, + }, + pvc) + Expect(err).NotTo(HaveOccurred()) + Expect(pvc.Name).To(Equal(onlinePvcName)) + Expect(pvc.Spec.AccessModes).To(Equal(services.DefaultPVCAccessModes)) + Expect(pvc.Spec.Resources.Requests.Storage().String()).To(Equal(services.DefaultOnlineStorageRequest)) + Expect(pvc.DeletionTimestamp).To(BeNil()) + + // check registry + registryPvcName := feast.GetFeastServiceName(services.RegistryFeastType) + registryVol := services.GetRegistryVolume(feast.Handler.FeatureStore, deploy.Spec.Template.Spec.Volumes) + Expect(registryVol.Name).To(Equal(registryPvcName)) + Expect(registryVol.PersistentVolumeClaim.ClaimName).To(Equal(registryPvcName)) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.VolumeMounts).To(HaveLen(3)) + Expect(registryContainer.VolumeMounts).NotTo(ContainElement(ephemeralVolMount)) + registryVolMount := services.GetRegistryVolumeMount(feast.Handler.FeatureStore, registryContainer.VolumeMounts) + Expect(registryVolMount.MountPath).To(Equal(registryMountPath)) + Expect(registryVolMount.Name).To(Equal(registryPvcName)) + + // check registry pvc + pvc = &corev1.PersistentVolumeClaim{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: registryPvcName, + Namespace: resource.Namespace, + }, + pvc) + Expect(err).NotTo(HaveOccurred()) + Expect(pvc.Name).To(Equal(registryPvcName)) + Expect(pvc.Spec.AccessModes).To(Equal(services.DefaultPVCAccessModes)) + Expect(pvc.Spec.Resources.Requests.Storage().String()).To(Equal(services.DefaultRegistryStorageRequest)) + Expect(pvc.DeletionTimestamp).To(BeNil()) + + // remove online PVC and reconcile + resourceNew := resource.DeepCopy() + newOnlineStorePath := "/tmp/new_online.db" + resourceNew.Spec.Services.OnlineStore.Persistence.FilePersistence.Path = newOnlineStorePath + resourceNew.Spec.Services.OnlineStore.Persistence.FilePersistence.PvcConfig = nil + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig).To(BeNil()) + + // check online deployment/container + deploy = &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Volumes).To(HaveLen(3)) + Expect(deploy.Spec.Template.Spec.Volumes).To(ContainElement(ephemeralVolume)) + Expect(services.GetOnlineContainer(*deploy).VolumeMounts).To(HaveLen(3)) + Expect(services.GetOnlineContainer(*deploy).VolumeMounts).To(ContainElement(ephemeralVolMount)) + Expect(services.GetRegistryContainer(*deploy).VolumeMounts).To(ContainElement(ephemeralVolMount)) + Expect(services.GetOfflineContainer(*deploy).VolumeMounts).To(ContainElement(ephemeralVolMount)) + + // check online pvc is deleted + log.FromContext(feast.Handler.Context).Info("Checking deletion of", "PersistentVolumeClaim", deploy.Name) + pvc = &corev1.PersistentVolumeClaim{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: onlinePvcName, + Namespace: resource.Namespace, + }, + pvc) + if err != nil { + Expect(errors.IsNotFound(err)).To(BeTrue()) + } else { + Expect(pvc.DeletionTimestamp).NotTo(BeNil()) + } + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(3)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(3)) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.Env).To(HaveLen(1)) + env := getFeatureStoreYamlEnvVar(registryContainer.Env) + Expect(env).NotTo(BeNil()) + + // check registry config + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + Registry: services.RegistryConfig{ + RegistryType: services.RegistryFileConfigType, + Path: registryMountedPath, + }, + OfflineStore: services.OfflineStoreConfig{ + Type: services.OfflineFilePersistenceDuckDbConfigType, + }, + OnlineStore: services.OnlineStoreConfig{ + Path: onlineStoreMountedPath, + Type: services.OnlineSqliteConfigType, + }, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfig).To(Equal(testConfig)) + + offlineContainer := services.GetOfflineContainer(*deploy) + Expect(offlineContainer.Env).To(HaveLen(1)) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + // check offline config + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOffline).To(Equal(testConfig)) + + // check online config + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(3)) + Expect(onlineContainer.ImagePullPolicy).To(Equal(corev1.PullAlways)) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOnline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOnline).To(Equal(testConfig)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + offlineRemote := services.OfflineStoreConfig{ + Host: fmt.Sprintf("feast-%s-offline.default.svc.cluster.local", resourceName), + Type: services.OfflineRemoteConfigType, + Port: services.HttpPort, + } + regRemote := services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:80", resourceName), + } + clientConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: offlineRemote, + OnlineStore: services.OnlineStoreConfig{ + Path: fmt.Sprintf("http://feast-%s-online.default.svc.cluster.local:80", resourceName), + Type: services.OnlineRemoteConfigType, + }, + Registry: regRemote, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfigClient).To(Equal(clientConfig)) + + // change paths and reconcile + resourceNew := resource.DeepCopy() + newOnlineStorePath := "new_online.db" + newRegistryPath := "new_registry.db" + + newOnlineStoreMountedPath := path.Join(onlineStoreMountPath, newOnlineStorePath) + newRegistryMountedPath := path.Join(registryMountPath, newRegistryPath) + + resourceNew.Spec.Services.OnlineStore.Persistence.FilePersistence.Path = newOnlineStorePath + resourceNew.Spec.Services.Registry.Local.Persistence.FilePersistence.Path = newRegistryPath + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check registry config + deploy = &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + registryContainer = services.GetRegistryContainer(*deploy) + env = getFeatureStoreYamlEnvVar(registryContainer.Env) + Expect(env).NotTo(BeNil()) + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig.OnlineStore.Path = newOnlineStoreMountedPath + testConfig.Registry.Path = newRegistryMountedPath + Expect(repoConfig).To(Equal(testConfig)) + + // check offline config + offlineContainer = services.GetOfflineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOffline).To(Equal(testConfig)) + + // check online config + onlineContainer = services.GetOfflineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + + repoConfigOnline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + testConfig.OnlineStore.Path = newOnlineStoreMountedPath + Expect(repoConfigOnline).To(Equal(testConfig)) + }) + }) +}) diff --git a/infra/feast-operator/internal/controller/featurestore_controller_test.go b/infra/feast-operator/internal/controller/featurestore_controller_test.go index 10b5f64c567..c73fbe1bff3 100644 --- a/infra/feast-operator/internal/controller/featurestore_controller_test.go +++ b/infra/feast-operator/internal/controller/featurestore_controller_test.go @@ -39,10 +39,15 @@ import ( "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" ) const feastProject = "test_project" +const domain = ".svc.cluster.local:80" +const domainTls = ".svc.cluster.local:443" + +var image = "test:latest" var _ = Describe("FeatureStore Controller", func() { Context("When reconciling a resource", func() { @@ -115,27 +120,53 @@ var _ = Describe("FeatureStore Controller", func() { Expect(cmList.Items).To(HaveLen(1)) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + deployment, _ := feast.GetDeployment() + deployment.Status = appsv1.DeploymentStatus{ + Conditions: []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentAvailable, + Status: "True", // Mark as available + Reason: "MinimumReplicasAvailable", + }, + }, } + + // Update the deployment's status + err = controllerReconciler.Status().Update(context.Background(), &deployment) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + Expect(resource.Status).NotTo(BeNil()) Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) Expect(resource.Status.ServiceHostnames.OfflineStore).To(BeEmpty()) - Expect(resource.Status.ServiceHostnames.OnlineStore).To(BeEmpty()) - Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + ".svc.cluster.local:80")) + Expect(resource.Status.ServiceHostnames.Registry).To(BeEmpty()) + Expect(resource.Status.ServiceHostnames.UI).To(BeEmpty()) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + ".svc.cluster.local:80")) Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.AuthzConfig).To(BeNil()) Expect(resource.Status.Applied.Services).NotTo(BeNil()) Expect(resource.Status.Applied.Services.OfflineStore).To(BeNil()) - Expect(resource.Status.Applied.Services.OnlineStore).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) - Expect(resource.Status.Applied.Services.OnlineStore).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Remote).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Local.ImagePullPolicy).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Local.Resources).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Local.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.Registry).To(BeNil()) + Expect(resource.Status.Applied.Services.UI).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry).To(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) Expect(resource.Status.Conditions).NotTo(BeEmpty()) cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) @@ -144,13 +175,15 @@ var _ = Describe("FeatureStore Controller", func() { Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) Expect(cond.Message).To(Equal(feastdevv1alpha1.ReadyMessage)) + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) - cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) Expect(cond).ToNot(BeNil()) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) - Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) - Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) Expect(cond).ToNot(BeNil()) @@ -162,25 +195,96 @@ var _ = Describe("FeatureStore Controller", func() { Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.ReadyPhase)) deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.ServiceAccountName).To(Equal(deploy.Name)) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Args[0]).To(ContainSubstring("feast init")) Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) svc := &corev1.Service{} err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), + Name: feast.GetFeastServiceName(services.OnlineFeastType), Namespace: resource.Namespace, }, svc) Expect(err).NotTo(HaveOccurred()) Expect(controllerutil.HasControllerReference(svc)).To(BeTrue()) - Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.RegistryFeastType].TargetPort)))) + Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.OnlineFeastType].TargetHttpPort)))) + + // change projectDir to use a git repo + featureRepoPath := "test/dir/feature_repo2" + ref := "xxxxx" + envVars := []corev1.EnvVar{ + { + Name: "test", + Value: "value", + }, + } + resource.Spec.FeastProjectDir = &feastdevv1alpha1.FeastProjectDir{ + Git: &feastdevv1alpha1.GitCloneOptions{ + URL: "test", + Ref: ref, + FeatureRepoPath: featureRepoPath, + Configs: map[string]string{ + "http.sslVerify": "false", + }, + Env: &envVars, + }, + } + err = k8sClient.Update(ctx, resource) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Args[0]).To(ContainSubstring("git -c http.sslVerify=false clone")) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Args[0]).To(ContainSubstring("git checkout " + ref)) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Args[0]).To(ContainSubstring(featureRepoPath)) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Env).To(ContainElements(envVars)) + + online := services.GetOnlineContainer(*deploy) + Expect(online.WorkingDir).To(Equal(services.EphemeralPath + "/" + resource.Spec.FeastProject + "/" + featureRepoPath)) + + // change projectDir to use an init template + resource.Spec.FeastProjectDir = &feastdevv1alpha1.FeastProjectDir{ + Init: &feastdevv1alpha1.FeastInitOptions{ + Template: "spark", + }, + } + err = k8sClient.Update(ctx, resource) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(deploy.Spec.Template.Spec.InitContainers[0].Args[0]).To(ContainSubstring("feast init -t spark")) }) It("should properly encode a feature_store.yaml config", func() { @@ -200,25 +304,29 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.ServiceAccountName).To(Equal(deploy.Name)) + Expect(deploy.Spec.Strategy.Type).To(Equal(appsv1.RecreateDeploymentStrategyType)) Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) env := getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) Expect(env).NotTo(BeNil()) - fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64(services.RegistryFeastType) + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) Expect(fsYamlStr).To(Equal(env.Value)) @@ -227,16 +335,8 @@ var _ = Describe("FeatureStore Controller", func() { repoConfig := &services.RepoConfig{} err = yaml.Unmarshal(envByte, repoConfig) Expect(err).NotTo(HaveOccurred()) - testConfig := &services.RepoConfig{ - Project: feastProject, - Provider: services.LocalProviderType, - EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, - Registry: services.RegistryConfig{ - RegistryType: services.RegistryFileConfigType, - Path: services.LocalRegistryPath, - }, - } - Expect(repoConfig).To(Equal(testConfig)) + testConfig := feast.GetDefaultRepoConfig() + Expect(repoConfig).To(Equal(&testConfig)) // check client config cm := &corev1.ConfigMap{} @@ -250,20 +350,21 @@ var _ = Describe("FeatureStore Controller", func() { repoConfigClient := &services.RepoConfig{} err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) Expect(err).NotTo(HaveOccurred()) - clientConfig := &services.RepoConfig{ - Project: feastProject, - Provider: services.LocalProviderType, - EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, - Registry: services.RegistryConfig{ - RegistryType: services.RegistryRemoteConfigType, - Path: "feast-test-resource-registry.default.svc.cluster.local:80", - }, + clientConfig := feast.GetInitRepoConfig() + clientConfig.OnlineStore = services.OnlineStoreConfig{ + Type: services.OnlineRemoteConfigType, + Path: "http://feast-test-resource-online.default.svc.cluster.local:80", } - Expect(repoConfigClient).To(Equal(clientConfig)) + Expect(repoConfigClient).To(Equal(&clientConfig)) // change feast project and reconcile resourceNew := resource.DeepCopy() resourceNew.Spec.FeastProject = "changed" + resourceNew.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + DeploymentStrategy: &appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + }, + } err = k8sClient.Update(ctx, resourceNew) Expect(err).NotTo(HaveOccurred()) _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ @@ -275,18 +376,19 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) Expect(resource.Spec.FeastProject).To(Equal(resourceNew.Spec.FeastProject)) err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, + Name: objMeta.Name, + Namespace: objMeta.Namespace, }, deploy) Expect(err).NotTo(HaveOccurred()) testConfig.Project = resourceNew.Spec.FeastProject + Expect(deploy.Spec.Strategy.Type).To(Equal(appsv1.RollingUpdateDeploymentStrategyType)) Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) env = getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) Expect(env).NotTo(BeNil()) - fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64(services.RegistryFeastType) + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) Expect(fsYamlStr).To(Equal(env.Value)) @@ -294,7 +396,7 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) err = yaml.Unmarshal(envByte, repoConfig) Expect(err).NotTo(HaveOccurred()) - Expect(repoConfig).To(Equal(testConfig)) + Expect(repoConfig).To(Equal(&testConfig)) }) It("should error on reconcile", func() { @@ -314,18 +416,20 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) err = controllerutil.RemoveControllerReference(resource, deploy, controllerReconciler.Scheme) @@ -333,7 +437,7 @@ var _ = Describe("FeatureStore Controller", func() { Expect(controllerutil.HasControllerReference(deploy)).To(BeFalse()) svc := &corev1.Service{} - name := feast.GetFeastServiceName(services.RegistryFeastType) + name := feast.GetFeastServiceName(services.OnlineFeastType) err = k8sClient.Get(ctx, types.NamespacedName{ Name: name, Namespace: resource.Namespace, @@ -360,14 +464,16 @@ var _ = Describe("FeatureStore Controller", func() { Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) Expect(cond.Status).To(Equal(metav1.ConditionFalse)) Expect(cond.Reason).To(Equal(feastdevv1alpha1.FailedReason)) - Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + name + " is already owned by another Service controller " + name)) + Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + deploy.Name + " is already owned by another Service controller " + name)) - cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) Expect(cond).ToNot(BeNil()) - Expect(cond.Status).To(Equal(metav1.ConditionFalse)) - Expect(cond.Reason).To(Equal(feastdevv1alpha1.RegistryFailedReason)) - Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) - Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + name + " is already owned by another Service controller " + name)) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) Expect(cond).ToNot(BeNil()) @@ -382,7 +488,6 @@ var _ = Describe("FeatureStore Controller", func() { Context("When reconciling a resource with all services enabled", func() { const resourceName = "services" - image := "test:latest" var pullPolicy = corev1.PullAlways var testEnvVarName = "testEnvVarName" var testEnvVarValue = "testEnvVarValue" @@ -396,11 +501,13 @@ var _ = Describe("FeatureStore Controller", func() { featurestore := &feastdevv1alpha1.FeatureStore{} BeforeEach(func() { + createEnvFromSecretAndConfigMap() + By("creating the custom resource for the Kind FeatureStore") err := k8sClient.Get(ctx, typeNamespacedName, featurestore) if err != nil && errors.IsNotFound(err) { resource := createFeatureStoreResource(resourceName, image, pullPolicy, &[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, - {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}) + {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}}, withEnvFrom()) Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } }) @@ -411,6 +518,9 @@ var _ = Describe("FeatureStore Controller", func() { By("Cleanup the specific resource instance FeatureStore") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + // Delete ConfigMap + deleteEnvFromSecretAndConfigMap() }) It("should successfully reconcile the resource", func() { @@ -430,43 +540,64 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } Expect(resource.Status).NotTo(BeNil()) Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.AuthzConfig).To(BeNil()) Expect(resource.Status.Applied.Services).NotTo(BeNil()) Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) - Expect(resource.Status.Applied.Services.OfflineStore.ImagePullPolicy).To(BeNil()) - Expect(resource.Status.Applied.Services.OfflineStore.Resources).To(BeNil()) - Expect(resource.Status.Applied.Services.OfflineStore.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.Type).To(Equal("dask")) + Expect(resource.Status.Applied.Services.OfflineStore.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore.Server.Image).To(Equal(&services.DefaultImage)) Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) - Expect(resource.Status.Applied.Services.OnlineStore.Env).To(Equal(&[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})) - Expect(resource.Status.Applied.Services.OnlineStore.ImagePullPolicy).To(Equal(&pullPolicy)) - Expect(resource.Status.Applied.Services.OnlineStore.Resources).NotTo(BeNil()) - Expect(resource.Status.Applied.Services.OnlineStore.Image).To(Equal(&image)) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.Path).To(Equal(services.EphemeralPath + "/" + services.DefaultOnlineStorePath)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Env).To(Equal(&[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})) + Expect(resource.Status.Applied.Services.OnlineStore.Server.EnvFrom).To(Equal(withEnvFrom())) + Expect(resource.Status.Applied.Services.OnlineStore.Server.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore.Server.Image).To(Equal(&image)) Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) Expect(resource.Status.Applied.Services.Registry.Local).NotTo(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Local.ImagePullPolicy).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Local.Resources).To(BeNil()) - Expect(resource.Status.Applied.Services.Registry.Local.Image).To(Equal(&services.DefaultImage)) - - domain := ".svc.cluster.local:80" + Expect(resource.Status.Applied.Services.Registry.Local.Persistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.Path).To(Equal(services.EphemeralPath + "/" + services.DefaultRegistryPath)) + Expect(resource.Status.Applied.Services.Registry.Local.Server.ImagePullPolicy).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Resources).To(BeNil()) + Expect(resource.Status.Applied.Services.Registry.Local.Server.Image).To(Equal(&services.DefaultImage)) + Expect(resource.Status.Applied.Services.UI).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.UI.Env).To(Equal(&[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})) + Expect(resource.Status.Applied.Services.UI.EnvFrom).To(Equal(withEnvFrom())) + Expect(resource.Status.Applied.Services.UI.ImagePullPolicy).To(Equal(&pullPolicy)) + Expect(resource.Status.Applied.Services.UI.Resources).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.UI.Image).To(Equal(&image)) Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domain)) Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domain)) Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domain)) + Expect(resource.Status.ServiceHostnames.UI).To(Equal(feast.GetFeastServiceName(services.UIFeastType) + "." + resource.Namespace + domain)) Expect(resource.Status.Conditions).NotTo(BeEmpty()) cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) Expect(cond).ToNot(BeNil()) - Expect(cond.Status).To(Equal(metav1.ConditionTrue)) - Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) - Expect(cond.Message).To(Equal(feastdevv1alpha1.ReadyMessage)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) Expect(cond).ToNot(BeNil()) @@ -496,19 +627,26 @@ var _ = Describe("FeatureStore Controller", func() { Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) - Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.ReadyPhase)) + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.UIReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.UIReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.UIReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - + Expect(deploy.Spec.Template.Spec.ServiceAccountName).To(Equal(deploy.Name)) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) svc := &corev1.Service{} err = k8sClient.Get(ctx, types.NamespacedName{ Name: feast.GetFeastServiceName(services.RegistryFeastType), @@ -517,7 +655,7 @@ var _ = Describe("FeatureStore Controller", func() { svc) Expect(err).NotTo(HaveOccurred()) Expect(controllerutil.HasControllerReference(svc)).To(BeTrue()) - Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.RegistryFeastType].TargetPort)))) + Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.RegistryFeastType].TargetHttpPort)))) }) It("should properly encode a feature_store.yaml config", func() { @@ -543,12 +681,17 @@ var _ = Describe("FeatureStore Controller", func() { deployList := appsv1.DeploymentList{} err = k8sClient.List(ctx, &deployList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(deployList.Items).To(HaveLen(3)) + Expect(deployList.Items).To(HaveLen(1)) + + saList := corev1.ServiceAccountList{} + err = k8sClient.List(ctx, &saList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(saList.Items).To(HaveLen(1)) svcList := corev1.ServiceList{} err = k8sClient.List(ctx, &svcList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(svcList.Items).To(HaveLen(3)) + Expect(svcList.Items).To(HaveLen(4)) cmList := corev1.ConfigMapList{} err = k8sClient.List(ctx, &cmList, listOpts) @@ -556,26 +699,30 @@ var _ = Describe("FeatureStore Controller", func() { Expect(cmList.Items).To(HaveLen(1)) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } // check registry config deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, - }, - deploy) - Expect(err).NotTo(HaveOccurred()) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) - env := getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.ServiceAccountName).To(Equal(deploy.Name)) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.Env).To(HaveLen(1)) + env := getFeatureStoreYamlEnvVar(registryContainer.Env) Expect(env).NotTo(BeNil()) - fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64(services.RegistryFeastType) + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) Expect(fsYamlStr).To(Equal(env.Value)) @@ -584,31 +731,22 @@ var _ = Describe("FeatureStore Controller", func() { repoConfig := &services.RepoConfig{} err = yaml.Unmarshal(envByte, repoConfig) Expect(err).NotTo(HaveOccurred()) - testConfig := &services.RepoConfig{ - Project: feastProject, - Provider: services.LocalProviderType, - EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, - Registry: services.RegistryConfig{ - RegistryType: services.RegistryFileConfigType, - Path: services.LocalRegistryPath, - }, + testConfig := feast.GetDefaultRepoConfig() + testConfig.OfflineStore = services.OfflineStoreConfig{ + Type: services.OfflineFilePersistenceDaskConfigType, } - Expect(repoConfig).To(Equal(testConfig)) + Expect(repoConfig).To(Equal(&testConfig)) // check offline config - deploy = &appsv1.Deployment{} - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.OfflineFeastType), - Namespace: resource.Namespace, - }, - deploy) - Expect(err).NotTo(HaveOccurred()) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) - env = getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) + Expect(deploy.Spec.Template.Spec.ServiceAccountName).To(Equal(deploy.Name)) + offlineContainer := services.GetOfflineContainer(*deploy) + Expect(offlineContainer.Env).To(HaveLen(1)) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) Expect(env).NotTo(BeNil()) - fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64(services.OfflineFeastType) + assertEnvFrom(*offlineContainer) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) Expect(fsYamlStr).To(Equal(env.Value)) @@ -617,36 +755,18 @@ var _ = Describe("FeatureStore Controller", func() { repoConfigOffline := &services.RepoConfig{} err = yaml.Unmarshal(envByte, repoConfigOffline) Expect(err).NotTo(HaveOccurred()) - regRemote := services.RegistryConfig{ - RegistryType: services.RegistryRemoteConfigType, - Path: "feast-services-registry.default.svc.cluster.local:80", - } - offlineConfig := &services.RepoConfig{ - Project: feastProject, - Provider: services.LocalProviderType, - EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, - OfflineStore: services.OfflineStoreConfig{ - Type: services.OfflineDaskConfigType, - }, - Registry: regRemote, - } - Expect(repoConfigOffline).To(Equal(offlineConfig)) + Expect(repoConfigOffline).To(Equal(&testConfig)) // check online config - deploy = &appsv1.Deployment{} - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.OnlineFeastType), - Namespace: resource.Namespace, - }, - deploy) - Expect(err).NotTo(HaveOccurred()) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(3)) - Expect(deploy.Spec.Template.Spec.Containers[0].ImagePullPolicy).To(Equal(corev1.PullAlways)) - env = getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(3)) + Expect(onlineContainer.ImagePullPolicy).To(Equal(corev1.PullAlways)) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) Expect(env).NotTo(BeNil()) - fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64(services.OnlineFeastType) + assertEnvFrom(*onlineContainer) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) Expect(fsYamlStr).To(Equal(env.Value)) @@ -655,24 +775,7 @@ var _ = Describe("FeatureStore Controller", func() { repoConfigOnline := &services.RepoConfig{} err = yaml.Unmarshal(envByte, repoConfigOnline) Expect(err).NotTo(HaveOccurred()) - offlineRemote := services.OfflineStoreConfig{ - Host: "feast-services-offline.default.svc.cluster.local", - Type: services.OfflineRemoteConfigType, - Port: services.HttpPort, - } - onlineConfig := &services.RepoConfig{ - Project: feastProject, - Provider: services.LocalProviderType, - EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, - OfflineStore: offlineRemote, - OnlineStore: services.OnlineStoreConfig{ - Path: services.LocalOnlinePath, - Type: services.OnlineSqliteConfigType, - }, - Registry: regRemote, - } - Expect(repoConfigOnline).To(Equal(onlineConfig)) - Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(3)) + Expect(repoConfigOnline).To(Equal(&testConfig)) // check client config cm := &corev1.ConfigMap{} @@ -686,6 +789,15 @@ var _ = Describe("FeatureStore Controller", func() { repoConfigClient := &services.RepoConfig{} err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) Expect(err).NotTo(HaveOccurred()) + offlineRemote := services.OfflineStoreConfig{ + Host: "feast-services-offline.default.svc.cluster.local", + Type: services.OfflineRemoteConfigType, + Port: services.HttpPort, + } + regRemote := services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: "feast-services-registry.default.svc.cluster.local:80", + } clientConfig := &services.RepoConfig{ Project: feastProject, Provider: services.LocalProviderType, @@ -695,7 +807,8 @@ var _ = Describe("FeatureStore Controller", func() { Path: "http://feast-services-online.default.svc.cluster.local:80", Type: services.OnlineRemoteConfigType, }, - Registry: regRemote, + Registry: regRemote, + AuthzConfig: noAuthzConfig(), } Expect(repoConfigClient).To(Equal(clientConfig)) @@ -713,8 +826,8 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) Expect(resource.Spec.FeastProject).To(Equal(resourceNew.Spec.FeastProject)) err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.RegistryFeastType), - Namespace: resource.Namespace, + Name: objMeta.Name, + Namespace: objMeta.Namespace, }, deploy) Expect(err).NotTo(HaveOccurred()) @@ -724,7 +837,7 @@ var _ = Describe("FeatureStore Controller", func() { env = getFeatureStoreYamlEnvVar(deploy.Spec.Template.Spec.Containers[0].Env) Expect(env).NotTo(BeNil()) - fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64(services.RegistryFeastType) + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) Expect(fsYamlStr).To(Equal(env.Value)) @@ -732,7 +845,7 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) err = yaml.Unmarshal(envByte, repoConfig) Expect(err).NotTo(HaveOccurred()) - Expect(repoConfig).To(Equal(testConfig)) + Expect(repoConfig).To(Equal(&testConfig)) }) It("should properly set container env variables", func() { @@ -758,12 +871,12 @@ var _ = Describe("FeatureStore Controller", func() { deployList := appsv1.DeploymentList{} err = k8sClient.List(ctx, &deployList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(deployList.Items).To(HaveLen(3)) + Expect(deployList.Items).To(HaveLen(1)) svcList := corev1.ServiceList{} err = k8sClient.List(ctx, &svcList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(svcList.Items).To(HaveLen(3)) + Expect(svcList.Items).To(HaveLen(4)) cmList := corev1.ConfigMapList{} err = k8sClient.List(ctx, &cmList, listOpts) @@ -771,32 +884,36 @@ var _ = Describe("FeatureStore Controller", func() { Expect(cmList.Items).To(HaveLen(1)) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } fsYamlStr := "" - fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64(services.OnlineFeastType) + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() Expect(err).NotTo(HaveOccurred()) // check online config deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.OnlineFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) - Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) - Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(3)) - Expect(areEnvVarArraysEqual(deploy.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: services.FeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})).To(BeTrue()) - Expect(deploy.Spec.Template.Spec.Containers[0].ImagePullPolicy).To(Equal(corev1.PullAlways)) + Expect(deploy.Spec.Template.Spec.ServiceAccountName).To(Equal(deploy.Name)) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(3)) + Expect(areEnvVarArraysEqual(onlineContainer.Env, []corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue}, {Name: services.TmpFeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.namespace"}}}})).To(BeTrue()) + Expect(onlineContainer.ImagePullPolicy).To(Equal(corev1.PullAlways)) // change feast project and reconcile resourceNew := resource.DeepCopy() - resourceNew.Spec.Services.OnlineStore.Env = &[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue + "1"}, {Name: services.FeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}} + resourceNew.Spec.Services.OnlineStore.Server.Env = &[]corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue + "1"}, {Name: services.TmpFeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}} err = k8sClient.Update(ctx, resourceNew) Expect(err).NotTo(HaveOccurred()) _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ @@ -806,16 +923,16 @@ var _ = Describe("FeatureStore Controller", func() { err = k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) - Expect(areEnvVarArraysEqual(*resource.Status.Applied.Services.OnlineStore.Env, []corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue + "1"}, {Name: services.FeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}})).To(BeTrue()) + Expect(areEnvVarArraysEqual(*resource.Status.Applied.Services.OnlineStore.Server.Env, []corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue + "1"}, {Name: services.TmpFeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}})).To(BeTrue()) err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.OnlineFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) - Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(3)) - Expect(areEnvVarArraysEqual(deploy.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue + "1"}, {Name: services.FeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.name"}}}})).To(BeTrue()) + onlineContainer = services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(3)) + Expect(areEnvVarArraysEqual(onlineContainer.Env, []corev1.EnvVar{{Name: testEnvVarName, Value: testEnvVarValue + "1"}, {Name: services.TmpFeatureStoreYamlEnvVar, Value: fsYamlStr}, {Name: "fieldRefName", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "v1", FieldPath: "metadata.name"}}}})).To(BeTrue()) }) It("Should delete k8s objects owned by the FeatureStore CR", func() { @@ -841,15 +958,15 @@ var _ = Describe("FeatureStore Controller", func() { deployList := appsv1.DeploymentList{} err = k8sClient.List(ctx, &deployList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(deployList.Items).To(HaveLen(3)) + Expect(deployList.Items).To(HaveLen(1)) svcList := corev1.ServiceList{} err = k8sClient.List(ctx, &svcList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(svcList.Items).To(HaveLen(3)) + Expect(svcList.Items).To(HaveLen(4)) - // disable the Online Store service - resource.Spec.Services.OnlineStore = nil + // disable the UI Store service + resource.Spec.Services.UI = nil err = k8sClient.Update(ctx, resource) Expect(err).NotTo(HaveOccurred()) @@ -860,11 +977,11 @@ var _ = Describe("FeatureStore Controller", func() { err = k8sClient.List(ctx, &deployList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(deployList.Items).To(HaveLen(2)) + Expect(deployList.Items).To(HaveLen(1)) err = k8sClient.List(ctx, &svcList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(svcList.Items).To(HaveLen(2)) + Expect(svcList.Items).To(HaveLen(3)) err = k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) @@ -885,7 +1002,7 @@ var _ = Describe("FeatureStore Controller", func() { err = k8sClient.List(ctx, &svcList, listOpts) Expect(err).NotTo(HaveOccurred()) - Expect(svcList.Items).To(HaveLen(1)) + Expect(svcList.Items).To(HaveLen(2)) }) It("should handle remote registry references", func() { @@ -913,7 +1030,9 @@ var _ = Describe("FeatureStore Controller", func() { Spec: feastdevv1alpha1.FeatureStoreSpec{ FeastProject: referencedRegistry.Spec.FeastProject, Services: &feastdevv1alpha1.FeatureStoreServices{ - OnlineStore: &feastdevv1alpha1.OnlineStore{}, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, OfflineStore: &feastdevv1alpha1.OfflineStore{}, Registry: &feastdevv1alpha1.Registry{ Remote: &feastdevv1alpha1.RemoteRegistryConfig{ @@ -935,6 +1054,8 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).To(HaveOccurred()) err = k8sClient.Get(ctx, nsName, resource) Expect(err).NotTo(HaveOccurred()) + Expect(resource.Status.Applied.Services.Registry.Remote.FeastRef.Namespace).NotTo(BeEmpty()) + Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType)).To(BeNil()) Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType)).To(BeNil()) Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.ReadyType)).To(BeFalse()) cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) @@ -950,6 +1071,7 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).To(HaveOccurred()) err = k8sClient.Get(ctx, nsName, resource) Expect(err).NotTo(HaveOccurred()) + Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType)).To(BeNil()) Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType)).To(BeNil()) Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.ReadyType)).To(BeFalse()) cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) @@ -967,20 +1089,40 @@ var _ = Describe("FeatureStore Controller", func() { err = k8sClient.Get(ctx, nsName, resource) Expect(err).NotTo(HaveOccurred()) + Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType)).To(BeNil()) Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType)).To(BeNil()) - Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.ReadyType)).To(BeTrue()) Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType)).To(BeTrue()) Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType)).To(BeTrue()) - Expect(resource.Status.Applied.Services.Registry.Remote.FeastRef.Namespace).To(Equal(resource.Namespace)) Expect(resource.Status.ServiceHostnames.Registry).ToNot(BeEmpty()) Expect(resource.Status.ServiceHostnames.Registry).To(Equal(referencedRegistry.Status.ServiceHostnames.Registry)) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(1)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + // check client config cm := &corev1.ConfigMap{} err = k8sClient.Get(ctx, types.NamespacedName{ @@ -995,11 +1137,6 @@ var _ = Describe("FeatureStore Controller", func() { Project: feastProject, Provider: services.LocalProviderType, EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, - OfflineStore: services.OfflineStoreConfig{ - Host: "feast-" + resource.Name + "-offline.default.svc.cluster.local", - Type: services.OfflineRemoteConfigType, - Port: services.HttpPort, - }, OnlineStore: services.OnlineStoreConfig{ Path: "http://feast-" + resource.Name + "-online.default.svc.cluster.local:80", Type: services.OnlineRemoteConfigType, @@ -1008,9 +1145,30 @@ var _ = Describe("FeatureStore Controller", func() { RegistryType: services.RegistryRemoteConfigType, Path: "feast-" + referencedRegistry.Name + "-registry.default.svc.cluster.local:80", }, + AuthzConfig: noAuthzConfig(), } Expect(repoConfigClient).To(Equal(clientConfig)) + // disable init containers + resource.Spec.Services.DisableInitContainers = true + err = k8sClient.Update(ctx, resource) + Expect(err).NotTo(HaveOccurred()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: nsName, + }) + Expect(err).NotTo(HaveOccurred()) + + deploy = &appsv1.Deployment{} + objMeta = feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.InitContainers).To(BeEmpty()) + + // break remote reference hostname := "test:80" referencedRegistry.Spec.Services.Registry = &feastdevv1alpha1.Registry{ Remote: &feastdevv1alpha1.RemoteRegistryConfig{ @@ -1032,6 +1190,7 @@ var _ = Describe("FeatureStore Controller", func() { err = k8sClient.Get(ctx, nsName, resource) Expect(err).NotTo(HaveOccurred()) Expect(resource.Status.ServiceHostnames.Registry).To(BeEmpty()) + Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType)).To(BeNil()) Expect(apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType)).To(BeNil()) Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.ReadyType)).To(BeFalse()) Expect(apimeta.IsStatusConditionTrue(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType)).To(BeTrue()) @@ -1059,18 +1218,21 @@ var _ = Describe("FeatureStore Controller", func() { Expect(err).NotTo(HaveOccurred()) feast := services.FeastServices{ - Client: controllerReconciler.Client, - Context: ctx, - Scheme: controllerReconciler.Scheme, - FeatureStore: resource, + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, } + // check deployment deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() err = k8sClient.Get(ctx, types.NamespacedName{ - Name: feast.GetFeastServiceName(services.OfflineFeastType), - Namespace: resource.Namespace, - }, - deploy) + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) Expect(err).NotTo(HaveOccurred()) err = controllerutil.RemoveControllerReference(resource, deploy, controllerReconciler.Scheme) @@ -1098,14 +1260,17 @@ var _ = Describe("FeatureStore Controller", func() { err = k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) - Expect(resource.Status.Conditions).To(HaveLen(5)) + Expect(resource.Status.Conditions).To(HaveLen(6)) cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) Expect(cond).ToNot(BeNil()) Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) Expect(cond.Status).To(Equal(metav1.ConditionFalse)) Expect(cond.Reason).To(Equal(feastdevv1alpha1.FailedReason)) - Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + name + " is already owned by another Service controller " + name)) + Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + deploy.Name + " is already owned by another Service controller " + name)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.AuthorizationReadyType) + Expect(cond).To(BeNil()) cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) Expect(cond).ToNot(BeNil()) @@ -1123,10 +1288,9 @@ var _ = Describe("FeatureStore Controller", func() { cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) Expect(cond).ToNot(BeNil()) - Expect(cond.Status).To(Equal(metav1.ConditionFalse)) - Expect(cond.Reason).To(Equal(feastdevv1alpha1.OfflineStoreFailedReason)) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) - Expect(cond.Message).To(Equal("Error: Object " + resource.Namespace + "/" + name + " is already owned by another Service controller " + name)) cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) Expect(cond).ToNot(BeNil()) @@ -1145,8 +1309,6 @@ var _ = Describe("FeatureStore Controller", func() { err := k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) - Expect(resource.Spec.Services.Registry).To(BeNil()) - resource.Spec.Services.Registry = &feastdevv1alpha1.Registry{} err = k8sClient.Update(ctx, resource) Expect(err).To(HaveOccurred()) @@ -1189,42 +1351,21 @@ var _ = Describe("FeatureStore Controller", func() { }) }) -func createFeatureStoreResource(resourceName string, image string, pullPolicy corev1.PullPolicy, envVars *[]corev1.EnvVar) *feastdevv1alpha1.FeatureStore { - return &feastdevv1alpha1.FeatureStore{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: feastdevv1alpha1.FeatureStoreSpec{ - FeastProject: feastProject, - Services: &feastdevv1alpha1.FeatureStoreServices{ - OfflineStore: &feastdevv1alpha1.OfflineStore{}, - OnlineStore: &feastdevv1alpha1.OnlineStore{ - ServiceConfigs: feastdevv1alpha1.ServiceConfigs{ - DefaultConfigs: feastdevv1alpha1.DefaultConfigs{ - Image: &image, - }, - OptionalConfigs: feastdevv1alpha1.OptionalConfigs{ - Env: envVars, - ImagePullPolicy: &pullPolicy, - Resources: &corev1.ResourceRequirements{}, - }, - }, - }, - }, - }, - } -} - func getFeatureStoreYamlEnvVar(envs []corev1.EnvVar) *corev1.EnvVar { for _, e := range envs { - if e.Name == services.FeatureStoreYamlEnvVar { + if e.Name == services.TmpFeatureStoreYamlEnvVar { return &e } } return nil } +func noAuthzConfig() services.AuthzConfig { + return services.AuthzConfig{ + Type: services.NoAuthAuthType, + } +} + func areEnvVarArraysEqual(arr1 []corev1.EnvVar, arr2 []corev1.EnvVar) bool { if len(arr1) != len(arr2) { return false diff --git a/infra/feast-operator/internal/controller/featurestore_controller_test_utils_test.go b/infra/feast-operator/internal/controller/featurestore_controller_test_utils_test.go new file mode 100644 index 00000000000..dcf684c7733 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_test_utils_test.go @@ -0,0 +1,169 @@ +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +func assertEnvFrom(container corev1.Container) { + envFrom := container.EnvFrom + Expect(envFrom).NotTo(BeNil()) + checkEnvFromCounter := 0 + + for _, source := range envFrom { + if source.ConfigMapRef != nil && source.ConfigMapRef.Name == "example-configmap" { + checkEnvFromCounter += 1 + // Simulate retrieval of ConfigMap data and validate + configMap := &corev1.ConfigMap{} + err := k8sClient.Get(context.TODO(), types.NamespacedName{ + Name: source.ConfigMapRef.Name, + Namespace: "default", + }, configMap) + Expect(err).NotTo(HaveOccurred()) + // Validate a specific key-value pair from the ConfigMap + Expect(configMap.Data["example-key"]).To(Equal("example-value")) + } + + if source.SecretRef != nil && source.SecretRef.Name == "example-secret" { + checkEnvFromCounter += 1 + // Simulate retrieval of Secret data and validate + secret := &corev1.Secret{} + err := k8sClient.Get(context.TODO(), types.NamespacedName{ + Name: source.SecretRef.Name, + Namespace: "default", + }, secret) + Expect(err).NotTo(HaveOccurred()) + // Validate a specific key-value pair from the Secret + Expect(string(secret.Data["secret-key"])).To(Equal("secret-value")) + } + } + Expect(checkEnvFromCounter).To(Equal(2)) +} + +func createEnvFromSecretAndConfigMap() { + By("creating the config map and secret for envFrom") + envFromConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-configmap", + Namespace: "default", + }, + Data: map[string]string{"example-key": "example-value"}, + } + err := k8sClient.Create(context.TODO(), envFromConfigMap) + Expect(err).ToNot(HaveOccurred()) + + envFromSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-secret", + Namespace: "default", + }, + StringData: map[string]string{"secret-key": "secret-value"}, + } + err = k8sClient.Create(context.TODO(), envFromSecret) + Expect(err).ToNot(HaveOccurred()) +} + +func deleteEnvFromSecretAndConfigMap() { + // Delete ConfigMap + By("Deleting the configmap and secret for envFrom") + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-configmap", + Namespace: "default", + }, + } + err := k8sClient.Delete(context.TODO(), configMap) + Expect(err).ToNot(HaveOccurred()) + + // Delete Secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-secret", + Namespace: "default", + }, + } + err = k8sClient.Delete(context.TODO(), secret) + Expect(err).ToNot(HaveOccurred()) +} + +func createFeatureStoreResource(resourceName string, image string, pullPolicy corev1.PullPolicy, envVars *[]corev1.EnvVar, envFromVar *[]corev1.EnvFromSource) *feastdevv1alpha1.FeatureStore { + return &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: feastProject, + Services: &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + ContainerConfigs: feastdevv1alpha1.ContainerConfigs{ + OptionalCtrConfigs: feastdevv1alpha1.OptionalCtrConfigs{ + EnvFrom: envFromVar, + }, + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + ContainerConfigs: feastdevv1alpha1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1alpha1.DefaultCtrConfigs{ + Image: &image, + }, + OptionalCtrConfigs: feastdevv1alpha1.OptionalCtrConfigs{ + Env: envVars, + EnvFrom: envFromVar, + ImagePullPolicy: &pullPolicy, + Resources: &corev1.ResourceRequirements{}, + }, + }, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + ContainerConfigs: feastdevv1alpha1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1alpha1.DefaultCtrConfigs{ + Image: &image, + }, + OptionalCtrConfigs: feastdevv1alpha1.OptionalCtrConfigs{ + Env: envVars, + EnvFrom: envFromVar, + ImagePullPolicy: &pullPolicy, + Resources: &corev1.ResourceRequirements{}, + }, + }, + }, + }, + }, + } +} + +func withEnvFrom() *[]corev1.EnvFromSource { + + return &[]corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "example-configmap"}, + }, + }, + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "example-secret"}, + }, + }, + } + +} diff --git a/infra/feast-operator/internal/controller/featurestore_controller_tls_test.go b/infra/feast-operator/internal/controller/featurestore_controller_tls_test.go new file mode 100644 index 00000000000..883cfe940aa --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_tls_test.go @@ -0,0 +1,443 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "encoding/base64" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +var _ = Describe("FeatureStore Controller - Feast service TLS", func() { + Context("When reconciling a FeatureStore resource", func() { + const resourceName = "test-tls" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + localRef := corev1.LocalObjectReference{Name: "test"} + tlsConfigs := &feastdevv1alpha1.TlsConfigs{ + SecretRef: &localRef, + } + BeforeEach(func() { + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: feastProject, + Services: &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: tlsConfigs, + }, + }, + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: tlsConfigs, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: tlsConfigs, + }, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + TLS: tlsConfigs, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + Expect(resource.Status).NotTo(BeNil()) + Expect(resource.Status.FeastVersion).To(Equal(feastversion.FeastVersion)) + Expect(resource.Status.ClientConfigMap).To(Equal(feast.GetFeastServiceName(services.ClientFeastType))) + Expect(resource.Status.Applied.FeastProject).To(Equal(resource.Spec.FeastProject)) + Expect(resource.Status.Applied.Services).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OfflineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.OnlineStore).NotTo(BeNil()) + Expect(resource.Status.Applied.Services.Registry).NotTo(BeNil()) + + Expect(resource.Status.ServiceHostnames.OfflineStore).To(Equal(feast.GetFeastServiceName(services.OfflineFeastType) + "." + resource.Namespace + domainTls)) + Expect(resource.Status.ServiceHostnames.OnlineStore).To(Equal(feast.GetFeastServiceName(services.OnlineFeastType) + "." + resource.Namespace + domainTls)) + Expect(resource.Status.ServiceHostnames.Registry).To(Equal(feast.GetFeastServiceName(services.RegistryFeastType) + "." + resource.Namespace + domainTls)) + + Expect(resource.Status.Conditions).NotTo(BeEmpty()) + cond := apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionUnknown)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.DeploymentNotAvailableReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.DeploymentNotAvailableMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.RegistryReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.RegistryReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.RegistryReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.ClientReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.ClientReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.ClientReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OfflineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OfflineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OfflineStoreReadyMessage)) + + cond = apimeta.FindStatusCondition(resource.Status.Conditions, feastdevv1alpha1.OnlineStoreReadyType) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal(feastdevv1alpha1.ReadyReason)) + Expect(cond.Type).To(Equal(feastdevv1alpha1.OnlineStoreReadyType)) + Expect(cond.Message).To(Equal(feastdevv1alpha1.OnlineStoreReadyMessage)) + + Expect(resource.Status.Phase).To(Equal(feastdevv1alpha1.PendingPhase)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(Equal(&services.DefaultReplicas)) + Expect(controllerutil.HasControllerReference(deploy)).To(BeTrue()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: feast.GetFeastServiceName(services.RegistryFeastType), + Namespace: resource.Namespace, + }, + svc) + Expect(err).NotTo(HaveOccurred()) + Expect(controllerutil.HasControllerReference(svc)).To(BeTrue()) + Expect(svc.Spec.Ports[0].TargetPort).To(Equal(intstr.FromInt(int(services.FeastServiceConstants[services.RegistryFeastType].TargetHttpsPort)))) + }) + + It("should properly encode a feature_store.yaml config", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + req, err := labels.NewRequirement(services.NameLabelKey, selection.Equals, []string{resource.Name}) + Expect(err).NotTo(HaveOccurred()) + labelSelector := labels.NewSelector().Add(*req) + listOpts := &client.ListOptions{Namespace: resource.Namespace, LabelSelector: labelSelector} + deployList := appsv1.DeploymentList{} + err = k8sClient.List(ctx, &deployList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(deployList.Items).To(HaveLen(1)) + + svcList := corev1.ServiceList{} + err = k8sClient.List(ctx, &svcList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(svcList.Items).To(HaveLen(4)) + + cmList := corev1.ConfigMapList{} + err = k8sClient.List(ctx, &cmList, listOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(cmList.Items).To(HaveLen(1)) + + // check deployment + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + registryContainer := services.GetRegistryContainer(*deploy) + Expect(registryContainer.Env).To(HaveLen(1)) + env := getFeatureStoreYamlEnvVar(registryContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err := feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err := base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfig := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfig) + Expect(err).NotTo(HaveOccurred()) + testConfig := feast.GetDefaultRepoConfig() + testConfig.OfflineStore = services.OfflineStoreConfig{ + Type: services.OfflineFilePersistenceDaskConfigType, + } + Expect(repoConfig).To(Equal(&testConfig)) + + // check offline config + offlineContainer := services.GetOfflineContainer(*deploy) + Expect(offlineContainer.Env).To(HaveLen(1)) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOffline).To(Equal(&testConfig)) + + // check online config + onlineContainer := services.GetOnlineContainer(*deploy) + Expect(onlineContainer.Env).To(HaveLen(1)) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOnline := &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfigOnline).To(Equal(&testConfig)) + Expect(deploy.Spec.Template.Spec.Containers[0].Env).To(HaveLen(1)) + + // check client config + cm := &corev1.ConfigMap{} + name := feast.GetFeastServiceName(services.ClientFeastType) + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: resource.Namespace, + }, + cm) + Expect(err).NotTo(HaveOccurred()) + repoConfigClient := &services.RepoConfig{} + err = yaml.Unmarshal([]byte(cm.Data[services.FeatureStoreYamlCmKey]), repoConfigClient) + Expect(err).NotTo(HaveOccurred()) + offlineRemote := services.OfflineStoreConfig{ + Host: fmt.Sprintf("feast-%s-offline.default.svc.cluster.local", resourceName), + Type: services.OfflineRemoteConfigType, + Port: services.HttpsPort, + Scheme: services.HttpsScheme, + Cert: services.GetTlsPath(services.OfflineFeastType) + "tls.crt", + } + regRemote := services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: fmt.Sprintf("feast-%s-registry.default.svc.cluster.local:443", resourceName), + Cert: services.GetTlsPath(services.RegistryFeastType) + "tls.crt", + } + clientConfig := &services.RepoConfig{ + Project: feastProject, + Provider: services.LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + OfflineStore: offlineRemote, + OnlineStore: services.OnlineStoreConfig{ + Path: fmt.Sprintf("https://feast-%s-online.default.svc.cluster.local:443", resourceName), + Type: services.OnlineRemoteConfigType, + Cert: services.GetTlsPath(services.OnlineFeastType) + "tls.crt", + }, + Registry: regRemote, + AuthzConfig: noAuthzConfig(), + } + Expect(repoConfigClient).To(Equal(clientConfig)) + + // change tls and reconcile + resourceNew := resource.DeepCopy() + disable := true + remoteRegHost := "test.other-ns:443" + resourceNew.Spec = feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: feastProject, + Services: &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + Disable: &disable, + }, + }, + }, + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: tlsConfigs, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Remote: &feastdevv1alpha1.RemoteRegistryConfig{ + Hostname: &remoteRegHost, + TLS: &feastdevv1alpha1.TlsRemoteRegistryConfigs{ + ConfigMapRef: localRef, + CertName: "remote.crt", + }, + }, + }, + }, + } + err = k8sClient.Update(ctx, resourceNew) + Expect(err).NotTo(HaveOccurred()) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource = &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + feast.Handler.FeatureStore = resource + + // check registry + deploy = &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(2)) + + // check offline config + offlineContainer = services.GetOfflineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(offlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + repoConfigOffline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOffline) + Expect(err).NotTo(HaveOccurred()) + regRemote = services.RegistryConfig{ + RegistryType: services.RegistryRemoteConfigType, + Path: remoteRegHost, + Cert: services.GetTlsPath(services.RegistryFeastType) + "remote.crt", + } + testConfig.Registry = regRemote + Expect(repoConfigOffline).To(Equal(&testConfig)) + + // check online config + onlineContainer = services.GetOnlineContainer(*deploy) + env = getFeatureStoreYamlEnvVar(onlineContainer.Env) + Expect(env).NotTo(BeNil()) + + fsYamlStr, err = feast.GetServiceFeatureStoreYamlBase64() + Expect(err).NotTo(HaveOccurred()) + Expect(fsYamlStr).To(Equal(env.Value)) + + envByte, err = base64.StdEncoding.DecodeString(env.Value) + Expect(err).NotTo(HaveOccurred()) + + repoConfigOnline = &services.RepoConfig{} + err = yaml.Unmarshal(envByte, repoConfigOnline) + Expect(err).NotTo(HaveOccurred()) + testConfig.Registry = regRemote + Expect(repoConfigOnline).To(Equal(&testConfig)) + }) + }) +}) diff --git a/infra/feast-operator/internal/controller/featurestore_controller_volume_volumemount_test.go b/infra/feast-operator/internal/controller/featurestore_controller_volume_volumemount_test.go new file mode 100644 index 00000000000..521f18cdc36 --- /dev/null +++ b/infra/feast-operator/internal/controller/featurestore_controller_volume_volumemount_test.go @@ -0,0 +1,215 @@ +/* +Copyright 2024 Feast Community. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("FeatureStore Controller - Deployment Volumes and VolumeMounts", func() { + Context("When deploying featureStore Spec we should have an option do Volumes and VolumeMounts", func() { + const resourceName = "services-ephemeral" + const offlineType = "duckdb" + var pullPolicy = corev1.PullAlways + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + featurestore := &feastdevv1alpha1.FeatureStore{} + onlineStorePath := "/data/online.db" + registryPath := "/data/registry.db" + + BeforeEach(func() { + By("creating the custom resource for the Kind FeatureStore") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + if err != nil && errors.IsNotFound(err) { + resource := createFeatureStoreVolumeResource(resourceName, image, pullPolicy) + resource.Spec.Services.OfflineStore.Persistence = &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + Type: offlineType, + }, + } + resource.Spec.Services.OnlineStore.Persistence = &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: onlineStorePath, + }, + } + resource.Spec.Services.Registry = &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: registryPath, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + AfterEach(func() { + resource := &feastdevv1alpha1.FeatureStore{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance FeatureStore") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + }) + + It("should successfully reconcile the resource and volumes and volumeMounts should be available", func() { + By("Reconciling the created resource") + controllerReconciler := &FeatureStoreReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + resource := &feastdevv1alpha1.FeatureStore{} + err = k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + feast := services.FeastServices{ + Handler: handler.FeastHandler{ + Client: controllerReconciler.Client, + Context: ctx, + Scheme: controllerReconciler.Scheme, + FeatureStore: resource, + }, + } + + deploy := &appsv1.Deployment{} + objMeta := feast.GetObjectMeta() + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: objMeta.Name, + Namespace: objMeta.Namespace, + }, deploy) + + Expect(err).NotTo(HaveOccurred()) + + // Extract the PodSpec from DeploymentSpec + podSpec := deploy.Spec.Template.Spec + + // Validate Volumes + // Validate Volumes - Check if our test volume exists among multiple + Expect(podSpec.Volumes).To(ContainElement(WithTransform(func(v corev1.Volume) string { + return v.Name + }, Equal("test-volume"))), "Expected volume 'test-volume' to be present") + + // Ensure 'online' container has the test volume mount + var onlineContainer *corev1.Container + for i, container := range podSpec.Containers { + if container.Name == "online" { + onlineContainer = &podSpec.Containers[i] + break + } + } + Expect(onlineContainer).ToNot(BeNil(), "Expected to find container 'online'") + + // Validate that 'online' container has the test-volume mount + Expect(onlineContainer.VolumeMounts).To(ContainElement(WithTransform(func(vm corev1.VolumeMount) string { + return vm.Name + }, Equal("test-volume"))), "Expected 'online' container to have volume mount 'test-volume'") + + // Ensure all other containers do NOT have the test volume mount + for _, container := range podSpec.Containers { + if container.Name != "online" { + Expect(container.VolumeMounts).ToNot(ContainElement(WithTransform(func(vm corev1.VolumeMount) string { + return vm.Name + }, Equal("test-volume"))), "Unexpected volume mount 'test-volume' found in container "+container.Name) + } + } + + }) + }) +}) + +func createFeatureStoreVolumeResource(resourceName string, image string, pullPolicy corev1.PullPolicy) *feastdevv1alpha1.FeatureStore { + volume := corev1.Volume{ + Name: "test-volume", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + } + volumeMount := corev1.VolumeMount{ + Name: "test-volume", + MountPath: "/data", + } + + return &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: feastProject, + Services: &feastdevv1alpha1.FeatureStoreServices{ + Volumes: []corev1.Volume{volume}, + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + ContainerConfigs: feastdevv1alpha1.ContainerConfigs{}, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + VolumeMounts: []corev1.VolumeMount{volumeMount}, + ContainerConfigs: feastdevv1alpha1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1alpha1.DefaultCtrConfigs{ + Image: &image, + }, + OptionalCtrConfigs: feastdevv1alpha1.OptionalCtrConfigs{ + ImagePullPolicy: &pullPolicy, + Resources: &corev1.ResourceRequirements{}, + }, + }, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + ContainerConfigs: feastdevv1alpha1.ContainerConfigs{ + DefaultCtrConfigs: feastdevv1alpha1.DefaultCtrConfigs{ + Image: &image, + }, + OptionalCtrConfigs: feastdevv1alpha1.OptionalCtrConfigs{ + ImagePullPolicy: &pullPolicy, + Resources: &corev1.ResourceRequirements{}, + }, + }, + }, + }, + }, + } +} diff --git a/infra/feast-operator/internal/controller/handler/handler.go b/infra/feast-operator/internal/controller/handler/handler.go new file mode 100644 index 00000000000..73bacffea47 --- /dev/null +++ b/infra/feast-operator/internal/controller/handler/handler.go @@ -0,0 +1,28 @@ +package handler + +import ( + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// delete an object if the FeatureStore is set as the object's controller/owner +func (handler *FeastHandler) DeleteOwnedFeastObj(obj client.Object) error { + name := obj.GetName() + kind := obj.GetObjectKind().GroupVersionKind().Kind + if err := handler.Client.Get(handler.Context, client.ObjectKeyFromObject(obj), obj); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + for _, ref := range obj.GetOwnerReferences() { + if *ref.Controller && ref.UID == handler.FeatureStore.UID { + if err := handler.Client.Delete(handler.Context, obj); err != nil { + return err + } + log.FromContext(handler.Context).Info("Successfully deleted", kind, name) + } + } + return nil +} diff --git a/infra/feast-operator/internal/controller/handler/handler_types.go b/infra/feast-operator/internal/controller/handler/handler_types.go new file mode 100644 index 00000000000..5a26776f569 --- /dev/null +++ b/infra/feast-operator/internal/controller/handler/handler_types.go @@ -0,0 +1,20 @@ +package handler + +import ( + "context" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + FeastPrefix = "feast-" +) + +type FeastHandler struct { + client.Client + Context context.Context + Scheme *runtime.Scheme + FeatureStore *feastdevv1alpha1.FeatureStore +} diff --git a/infra/feast-operator/internal/controller/services/client.go b/infra/feast-operator/internal/controller/services/client.go index 1befd2df194..89e22f7be6d 100644 --- a/infra/feast-operator/internal/controller/services/client.go +++ b/infra/feast-operator/internal/controller/services/client.go @@ -30,12 +30,12 @@ func (feast *FeastServices) deployClient() error { } func (feast *FeastServices) createClientConfigMap() error { - logger := log.FromContext(feast.Context) + logger := log.FromContext(feast.Handler.Context) cm := &corev1.ConfigMap{ - ObjectMeta: feast.GetObjectMeta(ClientFeastType), + ObjectMeta: feast.GetObjectMetaType(ClientFeastType), } cm.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) - if op, err := controllerutil.CreateOrUpdate(feast.Context, feast.Client, cm, controllerutil.MutateFn(func() error { + if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, cm, controllerutil.MutateFn(func() error { return feast.setClientConfigMap(cm) })); err != nil { return err @@ -46,12 +46,43 @@ func (feast *FeastServices) createClientConfigMap() error { } func (feast *FeastServices) setClientConfigMap(cm *corev1.ConfigMap) error { - cm.Labels = feast.getLabels(ClientFeastType) - clientYaml, err := feast.getClientFeatureStoreYaml() + cm.Labels = feast.getFeastTypeLabels(ClientFeastType) + clientYaml, err := feast.getClientFeatureStoreYaml(feast.extractConfigFromSecret) if err != nil { return err } cm.Data = map[string]string{FeatureStoreYamlCmKey: string(clientYaml)} - feast.FeatureStore.Status.ClientConfigMap = cm.Name - return controllerutil.SetControllerReference(feast.FeatureStore, cm, feast.Scheme) + feast.Handler.FeatureStore.Status.ClientConfigMap = cm.Name + return controllerutil.SetControllerReference(feast.Handler.FeatureStore, cm, feast.Handler.Scheme) +} + +func (feast *FeastServices) createCaConfigMap() error { + logger := log.FromContext(feast.Handler.Context) + cm := feast.initCaConfigMap() + if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, cm, controllerutil.MutateFn(func() error { + return feast.setCaConfigMap(cm) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "ConfigMap", cm.Name, "operation", op) + } + return nil +} + +func (feast *FeastServices) setCaConfigMap(cm *corev1.ConfigMap) error { + cm.Labels = map[string]string{ + NameLabelKey: feast.Handler.FeatureStore.Name, + } + cm.Annotations = map[string]string{ + "service.beta.openshift.io/inject-cabundle": "true", + } + return controllerutil.SetControllerReference(feast.Handler.FeatureStore, cm, feast.Handler.Scheme) +} + +func (feast *FeastServices) initCaConfigMap() *corev1.ConfigMap { + cm := &corev1.ConfigMap{ + ObjectMeta: feast.GetObjectMetaType(ClientCaFeastType), + } + cm.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + return cm } diff --git a/infra/feast-operator/internal/controller/services/repo_config.go b/infra/feast-operator/internal/controller/services/repo_config.go index 3137417f3ac..50ad3b92858 100644 --- a/infra/feast-operator/internal/controller/services/repo_config.go +++ b/infra/feast-operator/internal/controller/services/repo_config.go @@ -18,69 +18,220 @@ package services import ( "encoding/base64" + "fmt" + "path" "strings" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" "gopkg.in/yaml.v3" - corev1 "k8s.io/api/core/v1" ) // GetServiceFeatureStoreYamlBase64 returns a base64 encoded feature_store.yaml config for the feast service -func (feast *FeastServices) GetServiceFeatureStoreYamlBase64(feastType FeastServiceType) (string, error) { - fsYaml, err := feast.getServiceFeatureStoreYaml(feastType) +func (feast *FeastServices) GetServiceFeatureStoreYamlBase64() (string, error) { + fsYaml, err := feast.getServiceFeatureStoreYaml() if err != nil { return "", err } return base64.StdEncoding.EncodeToString(fsYaml), nil } -func (feast *FeastServices) getServiceFeatureStoreYaml(feastType FeastServiceType) ([]byte, error) { - return yaml.Marshal(feast.getServiceRepoConfig(feastType)) +func (feast *FeastServices) getServiceFeatureStoreYaml() ([]byte, error) { + repoConfig, err := feast.getServiceRepoConfig() + if err != nil { + return nil, err + } + return yaml.Marshal(repoConfig) +} + +func (feast *FeastServices) getServiceRepoConfig() (RepoConfig, error) { + return getServiceRepoConfig(feast.Handler.FeatureStore, feast.extractConfigFromSecret) } -func (feast *FeastServices) getServiceRepoConfig(feastType FeastServiceType) RepoConfig { - appliedSpec := feast.FeatureStore.Status.Applied +func getServiceRepoConfig( + featureStore *feastdevv1alpha1.FeatureStore, + secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { + repoConfig, err := getBaseServiceRepoConfig(featureStore, secretExtractionFunc) + if err != nil { + return repoConfig, err + } - repoConfig := feast.getClientRepoConfig() + appliedSpec := featureStore.Status.Applied if appliedSpec.Services != nil { - // Offline server has an `offline_store` section and a remote `registry` - if feastType == OfflineFeastType && appliedSpec.Services.OfflineStore != nil { - repoConfig.OfflineStore = OfflineStoreConfig{ - Type: OfflineDaskConfigType, + services := appliedSpec.Services + if services.OfflineStore != nil { + err := setRepoConfigOffline(services, secretExtractionFunc, &repoConfig) + if err != nil { + return repoConfig, err } - repoConfig.OnlineStore = OnlineStoreConfig{} } - // Online server has an `online_store` section, a remote `registry` and a remote `offline_store` - if feastType == OnlineFeastType && appliedSpec.Services.OnlineStore != nil { - repoConfig.OnlineStore = OnlineStoreConfig{ - Type: OnlineSqliteConfigType, - Path: LocalOnlinePath, + if services.OnlineStore != nil { + err := setRepoConfigOnline(services, secretExtractionFunc, &repoConfig) + if err != nil { + return repoConfig, err } } - // Registry server only has a `registry` section - if feastType == RegistryFeastType && feast.isLocalRegistry() { - repoConfig.Registry = RegistryConfig{ - RegistryType: RegistryFileConfigType, - Path: LocalRegistryPath, + if IsLocalRegistry(featureStore) { + err := setRepoConfigRegistry(services, secretExtractionFunc, &repoConfig) + if err != nil { + return repoConfig, err } - repoConfig.OfflineStore = OfflineStoreConfig{} - repoConfig.OnlineStore = OnlineStoreConfig{} } } - return repoConfig + return repoConfig, nil } -func (feast *FeastServices) getClientFeatureStoreYaml() ([]byte, error) { - return yaml.Marshal(feast.getClientRepoConfig()) +func getBaseServiceRepoConfig( + featureStore *feastdevv1alpha1.FeatureStore, + secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { + + repoConfig := defaultRepoConfig(featureStore) + clientRepoConfig, err := getClientRepoConfig(featureStore, secretExtractionFunc) + if err != nil { + return repoConfig, err + } + if isRemoteRegistry(featureStore) { + repoConfig.Registry = clientRepoConfig.Registry + } + repoConfig.AuthzConfig = clientRepoConfig.AuthzConfig + + appliedSpec := featureStore.Status.Applied + if appliedSpec.AuthzConfig != nil && appliedSpec.AuthzConfig.OidcAuthz != nil { + propertiesMap, authSecretErr := secretExtractionFunc("", appliedSpec.AuthzConfig.OidcAuthz.SecretRef.Name, "") + if authSecretErr != nil { + return repoConfig, authSecretErr + } + + oidcServerProperties := map[string]interface{}{} + for _, oidcServerProperty := range OidcServerProperties { + if val, exists := propertiesMap[string(oidcServerProperty)]; exists { + oidcServerProperties[string(oidcServerProperty)] = val + } else { + return repoConfig, missingOidcSecretProperty(oidcServerProperty) + } + } + repoConfig.AuthzConfig.OidcParameters = oidcServerProperties + } + + return repoConfig, nil } -func (feast *FeastServices) getClientRepoConfig() RepoConfig { - status := feast.FeatureStore.Status - clientRepoConfig := RepoConfig{ - Project: status.Applied.FeastProject, - Provider: LocalProviderType, - EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, +func setRepoConfigRegistry(services *feastdevv1alpha1.FeatureStoreServices, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), repoConfig *RepoConfig) error { + registryPersistence := services.Registry.Local.Persistence + + if registryPersistence != nil { + filePersistence := registryPersistence.FilePersistence + dbPersistence := registryPersistence.DBPersistence + + if filePersistence != nil { + repoConfig.Registry.RegistryType = RegistryFileConfigType + repoConfig.Registry.Path = getActualPath(filePersistence.Path, filePersistence.PvcConfig) + repoConfig.Registry.S3AdditionalKwargs = filePersistence.S3AdditionalKwargs + } else if dbPersistence != nil && len(dbPersistence.Type) > 0 { + repoConfig.Registry.Path = "" + repoConfig.Registry.RegistryType = RegistryConfigType(dbPersistence.Type) + secretKeyName := dbPersistence.SecretKeyName + if len(secretKeyName) == 0 { + secretKeyName = string(repoConfig.Registry.RegistryType) + } + parametersMap, err := secretExtractionFunc(dbPersistence.Type, dbPersistence.SecretRef.Name, secretKeyName) + if err != nil { + return err + } + + err = mergeStructWithDBParametersMap(¶metersMap, &repoConfig.Registry) + if err != nil { + return err + } + + repoConfig.Registry.DBParameters = parametersMap + } + } + return nil +} + +func setRepoConfigOnline(services *feastdevv1alpha1.FeatureStoreServices, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), repoConfig *RepoConfig) error { + onlineStorePersistence := services.OnlineStore.Persistence + + if onlineStorePersistence != nil { + filePersistence := onlineStorePersistence.FilePersistence + dbPersistence := onlineStorePersistence.DBPersistence + + if filePersistence != nil { + repoConfig.OnlineStore.Path = getActualPath(filePersistence.Path, filePersistence.PvcConfig) + } else if dbPersistence != nil && len(dbPersistence.Type) > 0 { + repoConfig.OnlineStore.Path = "" + repoConfig.OnlineStore.Type = OnlineConfigType(dbPersistence.Type) + secretKeyName := dbPersistence.SecretKeyName + if len(secretKeyName) == 0 { + secretKeyName = string(repoConfig.OnlineStore.Type) + } + + parametersMap, err := secretExtractionFunc(dbPersistence.Type, dbPersistence.SecretRef.Name, secretKeyName) + if err != nil { + return err + } + + err = mergeStructWithDBParametersMap(¶metersMap, &repoConfig.OnlineStore) + if err != nil { + return err + } + + repoConfig.OnlineStore.DBParameters = parametersMap + } + } + return nil +} + +func setRepoConfigOffline(services *feastdevv1alpha1.FeatureStoreServices, secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error), repoConfig *RepoConfig) error { + repoConfig.OfflineStore = defaultOfflineStoreConfig + offlineStorePersistence := services.OfflineStore.Persistence + + if offlineStorePersistence != nil { + dbPersistence := offlineStorePersistence.DBPersistence + filePersistence := offlineStorePersistence.FilePersistence + + if filePersistence != nil && len(filePersistence.Type) > 0 { + repoConfig.OfflineStore.Type = OfflineConfigType(filePersistence.Type) + } else if offlineStorePersistence.DBPersistence != nil && len(dbPersistence.Type) > 0 { + repoConfig.OfflineStore.Type = OfflineConfigType(dbPersistence.Type) + secretKeyName := dbPersistence.SecretKeyName + if len(secretKeyName) == 0 { + secretKeyName = string(repoConfig.OfflineStore.Type) + } + + parametersMap, err := secretExtractionFunc(dbPersistence.Type, dbPersistence.SecretRef.Name, secretKeyName) + if err != nil { + return err + } + + err = mergeStructWithDBParametersMap(¶metersMap, &repoConfig.OfflineStore) + if err != nil { + return err + } + + repoConfig.OfflineStore.DBParameters = parametersMap + } + } + return nil +} + +func (feast *FeastServices) getClientFeatureStoreYaml(secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) ([]byte, error) { + clientRepo, err := getClientRepoConfig(feast.Handler.FeatureStore, secretExtractionFunc) + if err != nil { + return []byte{}, err + } + return yaml.Marshal(clientRepo) +} + +func getClientRepoConfig( + featureStore *feastdevv1alpha1.FeatureStore, + secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { + status := featureStore.Status + appliedServices := status.Applied.Services + clientRepoConfig, err := getRepoConfig(featureStore, secretExtractionFunc) + if err != nil { + return clientRepoConfig, err } if len(status.ServiceHostnames.OfflineStore) > 0 { clientRepoConfig.OfflineStore = OfflineStoreConfig{ @@ -88,11 +239,23 @@ func (feast *FeastServices) getClientRepoConfig() RepoConfig { Host: strings.Split(status.ServiceHostnames.OfflineStore, ":")[0], Port: HttpPort, } + if appliedServices.OfflineStore != nil && + appliedServices.OfflineStore.Server != nil && appliedServices.OfflineStore.Server.TLS.IsTLS() { + clientRepoConfig.OfflineStore.Cert = GetTlsPath(OfflineFeastType) + appliedServices.OfflineStore.Server.TLS.SecretKeyNames.TlsCrt + clientRepoConfig.OfflineStore.Port = HttpsPort + clientRepoConfig.OfflineStore.Scheme = HttpsScheme + } } if len(status.ServiceHostnames.OnlineStore) > 0 { + onlinePath := "://" + status.ServiceHostnames.OnlineStore clientRepoConfig.OnlineStore = OnlineStoreConfig{ Type: OnlineRemoteConfigType, - Path: strings.ToLower(string(corev1.URISchemeHTTP)) + "://" + status.ServiceHostnames.OnlineStore, + Path: HttpScheme + onlinePath, + } + if appliedServices.OnlineStore != nil && + appliedServices.OnlineStore.Server != nil && appliedServices.OnlineStore.Server.TLS.IsTLS() { + clientRepoConfig.OnlineStore.Cert = GetTlsPath(OnlineFeastType) + appliedServices.OnlineStore.Server.TLS.SecretKeyNames.TlsCrt + clientRepoConfig.OnlineStore.Path = HttpsScheme + onlinePath } } if len(status.ServiceHostnames.Registry) > 0 { @@ -100,6 +263,155 @@ func (feast *FeastServices) getClientRepoConfig() RepoConfig { RegistryType: RegistryRemoteConfigType, Path: status.ServiceHostnames.Registry, } + if localRegistryTls(featureStore) { + clientRepoConfig.Registry.Cert = GetTlsPath(RegistryFeastType) + appliedServices.Registry.Local.Server.TLS.SecretKeyNames.TlsCrt + } else if remoteRegistryTls(featureStore) { + clientRepoConfig.Registry.Cert = GetTlsPath(RegistryFeastType) + appliedServices.Registry.Remote.TLS.CertName + } + } + + return clientRepoConfig, nil +} + +func getRepoConfig( + featureStore *feastdevv1alpha1.FeatureStore, + secretExtractionFunc func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error)) (RepoConfig, error) { + status := featureStore.Status + repoConfig := initRepoConfig(status.Applied.FeastProject) + if status.Applied.AuthzConfig != nil { + if status.Applied.AuthzConfig.KubernetesAuthz != nil { + repoConfig.AuthzConfig = AuthzConfig{ + Type: KubernetesAuthType, + } + } else if status.Applied.AuthzConfig.OidcAuthz != nil { + repoConfig.AuthzConfig = AuthzConfig{ + Type: OidcAuthType, + } + + propertiesMap, err := secretExtractionFunc("", status.Applied.AuthzConfig.OidcAuthz.SecretRef.Name, "") + if err != nil { + return repoConfig, err + } + + oidcClientProperties := map[string]interface{}{} + for _, oidcClientProperty := range OidcClientProperties { + if val, exists := propertiesMap[string(oidcClientProperty)]; exists { + oidcClientProperties[string(oidcClientProperty)] = val + } else { + return repoConfig, missingOidcSecretProperty(oidcClientProperty) + } + } + repoConfig.AuthzConfig.OidcParameters = oidcClientProperties + } + } + return repoConfig, nil +} + +func getActualPath(filePath string, pvcConfig *feastdevv1alpha1.PvcConfig) string { + if pvcConfig == nil { + return filePath + } + return path.Join(pvcConfig.MountPath, filePath) +} + +func (feast *FeastServices) extractConfigFromSecret(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error) { + secret, err := feast.getSecret(secretRef) + if err != nil { + return nil, err + } + parameters := map[string]interface{}{} + + if secretKeyName != "" { + val, exists := secret.Data[secretKeyName] + if !exists { + return nil, fmt.Errorf("secret key %s doesn't exist in secret %s", secretKeyName, secretRef) + } + + err = yaml.Unmarshal(val, ¶meters) + if err != nil { + return nil, fmt.Errorf("secret %s contains invalid value", secretKeyName) + } + + typeVal, typeExists := parameters["type"] + if typeExists && storeType != typeVal { + return nil, fmt.Errorf("secret key %s in secret %s contains tag named type with value %s", secretKeyName, secretRef, typeVal) + } + + typeVal, typeExists = parameters["registry_type"] + if typeExists && storeType != typeVal { + return nil, fmt.Errorf("secret key %s in secret %s contains tag named registry_type with value %s", secretKeyName, secretRef, typeVal) + } + } else { + for k, v := range secret.Data { + var val interface{} + err := yaml.Unmarshal(v, &val) + if err != nil { + return nil, fmt.Errorf("secret %s contains invalid value %v", k, v) + } + parameters[k] = val + } } - return clientRepoConfig + + return parameters, nil +} + +func mergeStructWithDBParametersMap(parametersMap *map[string]interface{}, s interface{}) error { + for key, val := range *parametersMap { + hasAttribute, err := hasAttrib(s, key, val) + if err != nil { + return err + } + + if hasAttribute { + delete(*parametersMap, key) + } + } + + return nil +} + +func (feast *FeastServices) GetDefaultRepoConfig() RepoConfig { + return defaultRepoConfig(feast.Handler.FeatureStore) +} + +func defaultRepoConfig(featureStore *feastdevv1alpha1.FeatureStore) RepoConfig { + repoConfig := initRepoConfig(featureStore.Status.Applied.FeastProject) + repoConfig.OnlineStore = defaultOnlineStoreConfig(featureStore) + repoConfig.Registry = defaultRegistryConfig(featureStore) + return repoConfig +} + +func (feast *FeastServices) GetInitRepoConfig() RepoConfig { + return initRepoConfig(feast.Handler.FeatureStore.Status.Applied.FeastProject) +} + +func initRepoConfig(feastProject string) RepoConfig { + return RepoConfig{ + Project: feastProject, + Provider: LocalProviderType, + EntityKeySerializationVersion: feastdevv1alpha1.SerializationVersion, + AuthzConfig: defaultAuthzConfig, + } +} + +func defaultOnlineStoreConfig(featureStore *feastdevv1alpha1.FeatureStore) OnlineStoreConfig { + return OnlineStoreConfig{ + Type: OnlineSqliteConfigType, + Path: defaultOnlineStorePath(featureStore), + } +} + +func defaultRegistryConfig(featureStore *feastdevv1alpha1.FeatureStore) RegistryConfig { + return RegistryConfig{ + RegistryType: RegistryFileConfigType, + Path: defaultRegistryPath(featureStore), + } +} + +var defaultOfflineStoreConfig = OfflineStoreConfig{ + Type: OfflineFilePersistenceDaskConfigType, +} + +var defaultAuthzConfig = AuthzConfig{ + Type: NoAuthAuthType, } diff --git a/infra/feast-operator/internal/controller/services/repo_config_test.go b/infra/feast-operator/internal/controller/services/repo_config_test.go new file mode 100644 index 00000000000..a346ac72e80 --- /dev/null +++ b/infra/feast-operator/internal/controller/services/repo_config_test.go @@ -0,0 +1,424 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" +) + +var projectName = "test-project" + +var _ = Describe("Repo Config", func() { + Context("When creating the RepoConfig of a FeatureStore", func() { + It("should successfully create the repo configs", func() { + By("Having the minimal created resource") + featureStore := minimalFeatureStore() + ApplyDefaultsToStatus(featureStore) + + expectedRegistryConfig := RegistryConfig{ + RegistryType: "file", + Path: EphemeralPath + "/" + DefaultRegistryPath, + } + expectedOnlineConfig := OnlineStoreConfig{ + Type: "sqlite", + Path: EphemeralPath + "/" + DefaultOnlineStorePath, + } + + repoConfig, err := getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) + Expect(repoConfig.OfflineStore).To(Equal(emptyOfflineStoreConfig)) + Expect(repoConfig.OnlineStore).To(Equal(expectedOnlineConfig)) + Expect(repoConfig.Registry).To(Equal(expectedRegistryConfig)) + + By("Having the local registry resource") + featureStore = minimalFeatureStore() + testPath := "/test/file.db" + featureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: testPath, + }, + }, + }, + }, + } + ApplyDefaultsToStatus(featureStore) + + expectedRegistryConfig = RegistryConfig{ + RegistryType: "file", + Path: testPath, + } + + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) + Expect(repoConfig.OfflineStore).To(Equal(emptyOfflineStoreConfig)) + Expect(repoConfig.OnlineStore).To(Equal(expectedOnlineConfig)) + Expect(repoConfig.Registry).To(Equal(expectedRegistryConfig)) + + By("Adding an offlineStore with PVC") + featureStore.Spec.Services.OfflineStore = &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + PvcConfig: &feastdevv1alpha1.PvcConfig{ + MountPath: "/testing", + }, + }, + }, + } + ApplyDefaultsToStatus(featureStore) + appliedServices := featureStore.Status.Applied.Services + Expect(appliedServices.OnlineStore).NotTo(BeNil()) + Expect(appliedServices.Registry.Local).NotTo(BeNil()) + + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.OfflineStore).To(Equal(defaultOfflineStoreConfig)) + Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) + Expect(repoConfig.Registry).To(Equal(expectedRegistryConfig)) + Expect(repoConfig.OnlineStore).To(Equal(expectedOnlineConfig)) + + By("Having the remote registry resource") + featureStore = minimalFeatureStore() + featureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Remote: &feastdevv1alpha1.RemoteRegistryConfig{ + FeastRef: &feastdevv1alpha1.FeatureStoreRef{ + Name: "registry", + }, + }, + }, + } + ApplyDefaultsToStatus(featureStore) + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) + Expect(repoConfig.OfflineStore).To(Equal(emptyOfflineStoreConfig)) + Expect(repoConfig.OnlineStore).To(Equal(expectedOnlineConfig)) + Expect(repoConfig.Registry).To(Equal(emptyRegistryConfig)) + + By("Having the all the file services") + featureStore = minimalFeatureStore() + featureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + Type: "duckdb", + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: "/data/online.db", + }, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: "/data/registry.db", + }, + }, + }, + }, + } + ApplyDefaultsToStatus(featureStore) + + expectedOfflineConfig := OfflineStoreConfig{ + Type: "duckdb", + } + expectedRegistryConfig = RegistryConfig{ + RegistryType: "file", + Path: "/data/registry.db", + } + expectedOnlineConfig = OnlineStoreConfig{ + Type: "sqlite", + Path: "/data/online.db", + } + + repoConfig, err = getServiceRepoConfig(featureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(NoAuthAuthType)) + Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) + Expect(repoConfig.OnlineStore).To(Equal(expectedOnlineConfig)) + Expect(repoConfig.Registry).To(Equal(expectedRegistryConfig)) + + By("Having kubernetes authorization") + featureStore = minimalFeatureStore() + featureStore.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{ + KubernetesAuthz: &feastdevv1alpha1.KubernetesAuthz{}, + } + featureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{}, + OnlineStore: &feastdevv1alpha1.OnlineStore{}, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{}, + }, + } + ApplyDefaultsToStatus(featureStore) + + expectedOfflineConfig = OfflineStoreConfig{ + Type: "dask", + } + + repoConfig, err = getServiceRepoConfig(featureStore, mockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(KubernetesAuthType)) + Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) + Expect(repoConfig.OnlineStore).To(Equal(defaultOnlineStoreConfig(featureStore))) + Expect(repoConfig.Registry).To(Equal(defaultRegistryConfig(featureStore))) + + By("Having oidc authorization") + featureStore.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{ + OidcAuthz: &feastdevv1alpha1.OidcAuthz{ + SecretRef: corev1.LocalObjectReference{ + Name: "oidc-secret", + }, + }, + } + ApplyDefaultsToStatus(featureStore) + + secretExtractionFunc := mockOidcConfigFromSecret(map[string]interface{}{ + string(OidcAuthDiscoveryUrl): "discovery-url", + string(OidcClientId): "client-id", + string(OidcClientSecret): "client-secret", + string(OidcUsername): "username", + string(OidcPassword): "password"}) + repoConfig, err = getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(2)) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcClientId))) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcAuthDiscoveryUrl))) + Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) + Expect(repoConfig.OnlineStore).To(Equal(defaultOnlineStoreConfig(featureStore))) + Expect(repoConfig.Registry).To(Equal(defaultRegistryConfig(featureStore))) + + repoConfig, err = getClientRepoConfig(featureStore, secretExtractionFunc) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.AuthzConfig.Type).To(Equal(OidcAuthType)) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveLen(3)) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcClientSecret))) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcUsername))) + Expect(repoConfig.AuthzConfig.OidcParameters).To(HaveKey(string(OidcPassword))) + + By("Having the all the db services") + featureStore = minimalFeatureStore() + featureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + DBPersistence: &feastdevv1alpha1.OfflineStoreDBStorePersistence{ + Type: string(OfflineDBPersistenceSnowflakeConfigType), + SecretRef: corev1.LocalObjectReference{ + Name: "offline-test-secret", + }, + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + DBPersistence: &feastdevv1alpha1.OnlineStoreDBStorePersistence{ + Type: string(OnlineDBPersistenceSnowflakeConfigType), + SecretRef: corev1.LocalObjectReference{ + Name: "online-test-secret", + }, + }, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + DBPersistence: &feastdevv1alpha1.RegistryDBStorePersistence{ + Type: string(RegistryDBPersistenceSnowflakeConfigType), + SecretRef: corev1.LocalObjectReference{ + Name: "registry-test-secret", + }, + }, + }, + }, + }, + } + parameterMap := createParameterMap() + ApplyDefaultsToStatus(featureStore) + featureStore.Spec.Services.OfflineStore.Persistence.FilePersistence = nil + featureStore.Spec.Services.OnlineStore.Persistence.FilePersistence = nil + featureStore.Spec.Services.Registry.Local.Persistence.FilePersistence = nil + repoConfig, err = getServiceRepoConfig(featureStore, mockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + newMap := CopyMap(parameterMap) + port := parameterMap["port"].(int) + delete(newMap, "port") + expectedOfflineConfig = OfflineStoreConfig{ + Type: OfflineDBPersistenceSnowflakeConfigType, + Port: port, + DBParameters: newMap, + } + expectedOnlineConfig = OnlineStoreConfig{ + Type: OnlineDBPersistenceSnowflakeConfigType, + DBParameters: CopyMap(parameterMap), + } + expectedRegistryConfig = RegistryConfig{ + RegistryType: RegistryDBPersistenceSnowflakeConfigType, + DBParameters: parameterMap, + } + Expect(repoConfig.OfflineStore).To(Equal(expectedOfflineConfig)) + Expect(repoConfig.OnlineStore).To(Equal(expectedOnlineConfig)) + Expect(repoConfig.Registry).To(Equal(expectedRegistryConfig)) + }) + }) + It("should fail to create the repo configs", func() { + featureStore := minimalFeatureStore() + + By("Having invalid server oidc authorization") + featureStore.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{ + OidcAuthz: &feastdevv1alpha1.OidcAuthz{ + SecretRef: corev1.LocalObjectReference{ + Name: "oidc-secret", + }, + }, + } + ApplyDefaultsToStatus(featureStore) + + secretExtractionFunc := mockOidcConfigFromSecret(map[string]interface{}{ + string(OidcClientId): "client-id", + string(OidcClientSecret): "client-secret", + string(OidcUsername): "username", + string(OidcPassword): "password"}) + _, err := getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + _, err = getClientRepoConfig(featureStore, secretExtractionFunc) + Expect(err).ToNot(HaveOccurred()) + + By("Having invalid client oidc authorization") + featureStore.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{ + OidcAuthz: &feastdevv1alpha1.OidcAuthz{ + SecretRef: corev1.LocalObjectReference{ + Name: "oidc-secret", + }, + }, + } + ApplyDefaultsToStatus(featureStore) + + secretExtractionFunc = mockOidcConfigFromSecret(map[string]interface{}{ + string(OidcAuthDiscoveryUrl): "discovery-url", + string(OidcClientId): "client-id", + string(OidcUsername): "username", + string(OidcPassword): "password"}) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + _, err = getServiceRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + _, err = getClientRepoConfig(featureStore, secretExtractionFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing OIDC secret")) + }) +}) + +var emptyOfflineStoreConfig = OfflineStoreConfig{} +var emptyRegistryConfig = RegistryConfig{} + +func minimalFeatureStore() *feastdevv1alpha1.FeatureStore { + return &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: projectName, + }, + } +} + +func minimalFeatureStoreWithAllServers() *feastdevv1alpha1.FeatureStore { + feast := minimalFeatureStore() + // onlineStore configured by default + feast.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{}, + } + return feast +} + +func emptyMockExtractConfigFromSecret(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error) { + return map[string]interface{}{}, nil +} + +func mockExtractConfigFromSecret(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error) { + return createParameterMap(), nil +} + +func mockOidcConfigFromSecret( + oidcProperties map[string]interface{}) func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error) { + return func(storeType string, secretRef string, secretKeyName string) (map[string]interface{}, error) { + return oidcProperties, nil + } +} + +func createParameterMap() map[string]interface{} { + yamlString := ` +hosts: + - 192.168.1.1 + - 192.168.1.2 + - 192.168.1.3 +keyspace: KeyspaceName +port: 9042 +username: user +password: secret +protocol_version: 5 +load_balancing: + local_dc: datacenter1 + load_balancing_policy: TokenAwarePolicy(DCAwareRoundRobinPolicy) +read_concurrency: 100 +write_concurrency: 100 +` + var parameters map[string]interface{} + + err := yaml.Unmarshal([]byte(yamlString), ¶meters) + if err != nil { + fmt.Println(err) + } + return parameters +} diff --git a/infra/feast-operator/internal/controller/services/services.go b/infra/feast-operator/internal/controller/services/services.go index 5e1778322f3..d6d943568d7 100644 --- a/infra/feast-operator/internal/controller/services/services.go +++ b/infra/feast-operator/internal/controller/services/services.go @@ -22,6 +22,9 @@ import ( "strings" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + routev1 "github.com/openshift/api/route/v1" + + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -34,77 +37,222 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ) +// Apply defaults and set service hostnames in FeatureStore status +func (feast *FeastServices) ApplyDefaults() error { + ApplyDefaultsToStatus(feast.Handler.FeatureStore) + if err := feast.setTlsDefaults(); err != nil { + return err + } + if err := feast.setServiceHostnames(); err != nil { + return err + } + return nil +} + // Deploy the feast services func (feast *FeastServices) Deploy() error { - if err := feast.setServiceHostnames(); err != nil { + if feast.noLocalCoreServerConfigured() { + return errors.New("at least one local server must be configured. e.g. registry / online / offline") + } + openshiftTls, err := feast.checkOpenshiftTls() + if err != nil { + return err + } + if openshiftTls { + if err := feast.createCaConfigMap(); err != nil { + return err + } + } else { + _ = feast.Handler.DeleteOwnedFeastObj(feast.initCaConfigMap()) + } + + services := feast.Handler.FeatureStore.Status.Applied.Services + if feast.isOfflineStore() { + err := feast.validateOfflineStorePersistence(services.OfflineStore.Persistence) + if err != nil { + return err + } + + if err = feast.deployFeastServiceByType(OfflineFeastType); err != nil { + return err + } + } else { + if err := feast.removeFeastServiceByType(OfflineFeastType); err != nil { + return err + } + } + + if feast.isOnlineStore() { + err := feast.validateOnlineStorePersistence(services.OnlineStore.Persistence) + if err != nil { + return err + } + + if err = feast.deployFeastServiceByType(OnlineFeastType); err != nil { + return err + } + } else { + if err := feast.removeFeastServiceByType(OnlineFeastType); err != nil { + return err + } + } + + if feast.isLocalRegistry() { + err := feast.validateRegistryPersistence(services.Registry.Local.Persistence) + if err != nil { + return err + } + + if err = feast.deployFeastServiceByType(RegistryFeastType); err != nil { + return err + } + } else { + if err := feast.removeFeastServiceByType(RegistryFeastType); err != nil { + return err + } + } + if feast.isUiServer() { + if err = feast.deployFeastServiceByType(UIFeastType); err != nil { + return err + } + if err = feast.createRoute(UIFeastType); err != nil { + return err + } + } else { + if err := feast.removeFeastServiceByType(UIFeastType); err != nil { + return err + } + if err := feast.removeRoute(UIFeastType); err != nil { + return err + } + } + + if err := feast.createServiceAccount(); err != nil { + return err + } + if err := feast.createDeployment(); err != nil { + return err + } + if err := feast.deployClient(); err != nil { return err } - services := feast.FeatureStore.Status.Applied.Services - if services != nil { - if services.OfflineStore != nil { - if err := feast.deployFeastServiceByType(OfflineFeastType); err != nil { + return nil +} + +func (feast *FeastServices) validateRegistryPersistence(registryPersistence *feastdevv1alpha1.RegistryPersistence) error { + if registryPersistence != nil { + dbPersistence := registryPersistence.DBPersistence + + if dbPersistence != nil && len(dbPersistence.Type) > 0 { + if err := checkRegistryDBStorePersistenceType(dbPersistence.Type); err != nil { return err } - } else { - if err := feast.removeFeastServiceByType(OfflineFeastType); err != nil { - return err + + if len(dbPersistence.SecretRef.Name) > 0 { + secretRef := dbPersistence.SecretRef.Name + if _, err := feast.getSecret(secretRef); err != nil { + return err + } } } + } - if services.OnlineStore != nil { - if err := feast.deployFeastServiceByType(OnlineFeastType); err != nil { + return nil +} + +func (feast *FeastServices) validateOnlineStorePersistence(onlinePersistence *feastdevv1alpha1.OnlineStorePersistence) error { + if onlinePersistence != nil { + dbPersistence := onlinePersistence.DBPersistence + + if dbPersistence != nil && len(dbPersistence.Type) > 0 { + if err := checkOnlineStoreDBStorePersistenceType(dbPersistence.Type); err != nil { return err } - } else { - if err := feast.removeFeastServiceByType(OnlineFeastType); err != nil { - return err + + if len(dbPersistence.SecretRef.Name) > 0 { + secretRef := dbPersistence.SecretRef.Name + if _, err := feast.getSecret(secretRef); err != nil { + return err + } } } + } - if feast.isLocalRegistry() { - if err := feast.deployFeastServiceByType(RegistryFeastType); err != nil { + return nil +} + +func (feast *FeastServices) validateOfflineStorePersistence(offlinePersistence *feastdevv1alpha1.OfflineStorePersistence) error { + if offlinePersistence != nil { + filePersistence := offlinePersistence.FilePersistence + dbPersistence := offlinePersistence.DBPersistence + + if filePersistence != nil && len(filePersistence.Type) > 0 { + if err := checkOfflineStoreFilePersistenceType(filePersistence.Type); err != nil { return err } - } else { - if err := feast.removeFeastServiceByType(RegistryFeastType); err != nil { + } else if dbPersistence != nil && + len(dbPersistence.Type) > 0 { + if err := checkOfflineStoreDBStorePersistenceType(dbPersistence.Type); err != nil { return err } - } - } - if err := feast.deployClient(); err != nil { - return err + if len(dbPersistence.SecretRef.Name) > 0 { + secretRef := dbPersistence.SecretRef.Name + if _, err := feast.getSecret(secretRef); err != nil { + return err + } + } + } } return nil } func (feast *FeastServices) deployFeastServiceByType(feastType FeastServiceType) error { - if err := feast.createService(feastType); err != nil { - return feast.setFeastServiceCondition(err, feastType) + if pvcCreate, shouldCreate := shouldCreatePvc(feast.Handler.FeatureStore, feastType); shouldCreate { + if err := feast.createPVC(pvcCreate, feastType); err != nil { + return feast.setFeastServiceCondition(err, feastType) + } + } else { + _ = feast.Handler.DeleteOwnedFeastObj(feast.initPVC(feastType)) } - if err := feast.createDeployment(feastType); err != nil { - return feast.setFeastServiceCondition(err, feastType) + if serviceConfig := feast.getServerConfigs(feastType); serviceConfig != nil { + if err := feast.createService(feastType); err != nil { + return feast.setFeastServiceCondition(err, feastType) + } + } else { + _ = feast.Handler.DeleteOwnedFeastObj(feast.initFeastSvc(feastType)) } return feast.setFeastServiceCondition(nil, feastType) } func (feast *FeastServices) removeFeastServiceByType(feastType FeastServiceType) error { - if err := feast.deleteOwnedFeastObj(feast.initFeastDeploy(feastType)); err != nil { + if err := feast.Handler.DeleteOwnedFeastObj(feast.initFeastSvc(feastType)); err != nil { return err } - if err := feast.deleteOwnedFeastObj(feast.initFeastSvc(feastType)); err != nil { + if err := feast.Handler.DeleteOwnedFeastObj(feast.initPVC(feastType)); err != nil { + return err + } + apimeta.RemoveStatusCondition(&feast.Handler.FeatureStore.Status.Conditions, FeastServiceConditions[feastType][metav1.ConditionTrue].Type) + return nil +} + +func (feast *FeastServices) removeRoute(feastType FeastServiceType) error { + if !isOpenShift { + return nil + } + route := feast.initRoute(feastType) + if err := feast.Handler.DeleteOwnedFeastObj(route); err != nil { return err } - apimeta.RemoveStatusCondition(&feast.FeatureStore.Status.Conditions, FeastServiceConditions[feastType][metav1.ConditionTrue].Type) return nil } func (feast *FeastServices) createService(feastType FeastServiceType) error { - logger := log.FromContext(feast.Context) + logger := log.FromContext(feast.Handler.Context) svc := feast.initFeastSvc(feastType) - if op, err := controllerutil.CreateOrUpdate(feast.Context, feast.Client, svc, controllerutil.MutateFn(func() error { + if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, svc, controllerutil.MutateFn(func() error { return feast.setService(svc, feastType) })); err != nil { return err @@ -114,11 +262,24 @@ func (feast *FeastServices) createService(feastType FeastServiceType) error { return nil } -func (feast *FeastServices) createDeployment(feastType FeastServiceType) error { - logger := log.FromContext(feast.Context) - deploy := feast.initFeastDeploy(feastType) - if op, err := controllerutil.CreateOrUpdate(feast.Context, feast.Client, deploy, controllerutil.MutateFn(func() error { - return feast.setDeployment(deploy, feastType) +func (feast *FeastServices) createServiceAccount() error { + logger := log.FromContext(feast.Handler.Context) + sa := feast.initFeastSA() + if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, sa, controllerutil.MutateFn(func() error { + return feast.setServiceAccount(sa) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "ServiceAccount", sa.Name, "operation", op) + } + return nil +} + +func (feast *FeastServices) createDeployment() error { + logger := log.FromContext(feast.Handler.Context) + deploy := feast.initFeastDeploy() + if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, deploy, controllerutil.MutateFn(func() error { + return feast.setDeployment(deploy) })); err != nil { return err } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { @@ -128,147 +289,426 @@ func (feast *FeastServices) createDeployment(feastType FeastServiceType) error { return nil } -func (feast *FeastServices) setDeployment(deploy *appsv1.Deployment, feastType FeastServiceType) error { - fsYamlB64, err := feast.GetServiceFeatureStoreYamlBase64(feastType) +func (feast *FeastServices) createRoute(feastType FeastServiceType) error { + logger := log.FromContext(feast.Handler.Context) + if !isOpenShift { + return nil + } + logger.Info("Reconciling route for Feast service", "ServiceType", feastType) + route := feast.initRoute(feastType) + if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, route, controllerutil.MutateFn(func() error { + return feast.setRoute(route, feastType) + })); err != nil { + return err + } else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated { + logger.Info("Successfully reconciled", "Route", route.Name, "operation", op) + } + + return nil +} + +func (feast *FeastServices) createPVC(pvcCreate *feastdevv1alpha1.PvcCreate, feastType FeastServiceType) error { + logger := log.FromContext(feast.Handler.Context) + pvc, err := feast.createNewPVC(pvcCreate, feastType) if err != nil { return err } - deploy.Labels = feast.getLabels(feastType) - deploySettings := FeastServiceConstants[feastType] - serviceConfigs := feast.getServiceConfigs(feastType) - defaultServiceConfigs := serviceConfigs.DefaultConfigs - // standard configs are applied here - probeHandler := corev1.ProbeHandler{ - TCPSocket: &corev1.TCPSocketAction{ - Port: intstr.FromInt(int(deploySettings.TargetPort)), - }, + // PVCs are immutable, so we only create... we don't update an existing one. + err = feast.Handler.Client.Get(feast.Handler.Context, client.ObjectKeyFromObject(pvc), pvc) + if err != nil && apierrors.IsNotFound(err) { + err = feast.Handler.Client.Create(feast.Handler.Context, pvc) + if err != nil { + return err + } + logger.Info("Successfully created", "PersistentVolumeClaim", pvc.Name) } + + return nil +} + +func (feast *FeastServices) setDeployment(deploy *appsv1.Deployment) error { + deploy.Labels = feast.getLabels() deploy.Spec = appsv1.DeploymentSpec{ Replicas: &DefaultReplicas, Selector: metav1.SetAsLabelSelector(deploy.GetLabels()), + Strategy: feast.getDeploymentStrategy(), Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: deploy.GetLabels(), }, Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: string(feastType), - Image: *defaultServiceConfigs.Image, - Command: deploySettings.Command, - Ports: []corev1.ContainerPort{ - { - Name: string(feastType), - ContainerPort: deploySettings.TargetPort, - Protocol: corev1.ProtocolTCP, - }, - }, - Env: []corev1.EnvVar{ - { - Name: FeatureStoreYamlEnvVar, - Value: fsYamlB64, - }, - }, - LivenessProbe: &corev1.Probe{ - ProbeHandler: probeHandler, - InitialDelaySeconds: 30, - PeriodSeconds: 30, - }, - ReadinessProbe: &corev1.Probe{ - ProbeHandler: probeHandler, - InitialDelaySeconds: 20, - PeriodSeconds: 10, - }, - }, + ServiceAccountName: feast.initFeastSA().Name, + }, + }, + } + if err := feast.setPod(&deploy.Spec.Template.Spec); err != nil { + return err + } + return controllerutil.SetControllerReference(feast.Handler.FeatureStore, deploy, feast.Handler.Scheme) +} + +func (feast *FeastServices) setPod(podSpec *corev1.PodSpec) error { + if err := feast.setContainers(podSpec); err != nil { + return err + } + feast.mountTlsConfigs(podSpec) + feast.mountPvcConfigs(podSpec) + feast.mountEmptyDirVolumes(podSpec) + feast.mountUserDefinedVolumes(podSpec) + + return nil +} + +func (feast *FeastServices) setContainers(podSpec *corev1.PodSpec) error { + fsYamlB64, err := feast.GetServiceFeatureStoreYamlBase64() + if err != nil { + return err + } + + feast.setInitContainer(podSpec, fsYamlB64) + if feast.isRegistryServer() { + feast.setContainer(&podSpec.Containers, RegistryFeastType, fsYamlB64) + } + if feast.isOnlineServer() { + feast.setContainer(&podSpec.Containers, OnlineFeastType, fsYamlB64) + } + if feast.isOfflineServer() { + feast.setContainer(&podSpec.Containers, OfflineFeastType, fsYamlB64) + } + if feast.isUiServer() { + feast.setContainer(&podSpec.Containers, UIFeastType, fsYamlB64) + } + return nil +} + +func (feast *FeastServices) setContainer(containers *[]corev1.Container, feastType FeastServiceType, fsYamlB64 string) { + if serverConfigs := feast.getServerConfigs(feastType); serverConfigs != nil { + defaultCtrConfigs := serverConfigs.ContainerConfigs.DefaultCtrConfigs + tls := feast.getTlsConfigs(feastType) + probeHandler := getProbeHandler(feastType, tls) + container := &corev1.Container{ + Name: string(feastType), + Image: *defaultCtrConfigs.Image, + WorkingDir: feast.getFeatureRepoDir(), + Command: feast.getContainerCommand(feastType), + Ports: []corev1.ContainerPort{ + { + Name: string(feastType), + ContainerPort: getTargetPort(feastType, tls), + Protocol: corev1.ProtocolTCP, }, }, + Env: []corev1.EnvVar{ + { + Name: TmpFeatureStoreYamlEnvVar, + Value: fsYamlB64, + }, + }, + StartupProbe: &corev1.Probe{ + ProbeHandler: probeHandler, + PeriodSeconds: 3, + FailureThreshold: 40, + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: probeHandler, + PeriodSeconds: 20, + FailureThreshold: 6, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: probeHandler, + PeriodSeconds: 10, + }, + } + applyOptionalCtrConfigs(container, serverConfigs.ContainerConfigs.OptionalCtrConfigs) + volumeMounts := feast.getVolumeMounts(feastType) + if len(volumeMounts) > 0 { + container.VolumeMounts = append(container.VolumeMounts, volumeMounts...) + } + *containers = append(*containers, *container) + } +} + +func (feast *FeastServices) mountUserDefinedVolumes(podSpec *corev1.PodSpec) { + var volumes []corev1.Volume + if feast.Handler.FeatureStore.Status.Applied.Services != nil { + volumes = feast.Handler.FeatureStore.Status.Applied.Services.Volumes + } + if len(volumes) > 0 { + podSpec.Volumes = append(podSpec.Volumes, volumes...) + } +} + +func (feast *FeastServices) getVolumeMounts(feastType FeastServiceType) (volumeMounts []corev1.VolumeMount) { + if serviceConfigs := feast.getServerConfigs(feastType); serviceConfigs != nil { + return serviceConfigs.VolumeMounts + } + return []corev1.VolumeMount{} // Default empty slice +} + +func (feast *FeastServices) setRoute(route *routev1.Route, feastType FeastServiceType) error { + + svcName := feast.GetFeastServiceName(feastType) + route.Labels = feast.getFeastTypeLabels(feastType) + + tls := feast.getTlsConfigs(feastType) + route.Spec = routev1.RouteSpec{ + To: routev1.RouteTargetReference{ + Kind: "Service", + Name: svcName, }, + Port: &routev1.RoutePort{ + TargetPort: intstr.FromInt(int(getTargetPort(feastType, tls))), + }, + } + if tls.IsTLS() { + route.Spec.TLS = &routev1.TLSConfig{ + Termination: routev1.TLSTerminationPassthrough, + InsecureEdgeTerminationPolicy: routev1.InsecureEdgeTerminationPolicyRedirect, + } } - // configs are applied here - container := &deploy.Spec.Template.Spec.Containers[0] - applyOptionalContainerConfigs(container, serviceConfigs.OptionalConfigs) + return controllerutil.SetControllerReference(feast.Handler.FeatureStore, route, feast.Handler.Scheme) +} - return controllerutil.SetControllerReference(feast.FeatureStore, deploy, feast.Scheme) +func (feast *FeastServices) getContainerCommand(feastType FeastServiceType) []string { + baseCommand := "feast" + options := []string{} + logLevel := feast.getLogLevelForType(feastType) + if logLevel != nil { + options = append(options, "--log-level", strings.ToUpper(*logLevel)) + } + + deploySettings := FeastServiceConstants[feastType] + targetPort := deploySettings.TargetHttpPort + tls := feast.getTlsConfigs(feastType) + if tls.IsTLS() { + targetPort = deploySettings.TargetHttpsPort + feastTlsPath := GetTlsPath(feastType) + deploySettings.Args = append(deploySettings.Args, []string{"--key", feastTlsPath + tls.SecretKeyNames.TlsKey, + "--cert", feastTlsPath + tls.SecretKeyNames.TlsCrt}...) + } + deploySettings.Args = append(deploySettings.Args, []string{"-p", strconv.Itoa(int(targetPort))}...) + + // Combine base command, options, and arguments + feastCommand := append([]string{baseCommand}, options...) + feastCommand = append(feastCommand, deploySettings.Args...) + + return feastCommand +} + +func (feast *FeastServices) getDeploymentStrategy() appsv1.DeploymentStrategy { + if feast.Handler.FeatureStore.Status.Applied.Services.DeploymentStrategy != nil { + return *feast.Handler.FeatureStore.Status.Applied.Services.DeploymentStrategy + } + return appsv1.DeploymentStrategy{ + Type: appsv1.RecreateDeploymentStrategyType, + } +} + +func (feast *FeastServices) setInitContainer(podSpec *corev1.PodSpec, fsYamlB64 string) { + applied := feast.Handler.FeatureStore.Status.Applied + if applied.FeastProjectDir != nil && !applied.Services.DisableInitContainers { + feastProjectDir := applied.FeastProjectDir + workingDir := getOfflineMountPath(feast.Handler.FeatureStore) + projectPath := workingDir + "/" + applied.FeastProject + container := corev1.Container{ + Name: "feast-init", + Image: getFeatureServerImage(), + Env: []corev1.EnvVar{ + { + Name: TmpFeatureStoreYamlEnvVar, + Value: fsYamlB64, + }, + }, + Command: []string{"bash", "-c"}, + WorkingDir: workingDir, + } + + var createCommand string + if feastProjectDir.Init != nil { + initSlice := []string{"feast", "init"} + if feastProjectDir.Init.Minimal { + initSlice = append(initSlice, "-m") + } + if len(feastProjectDir.Init.Template) > 0 { + initSlice = append(initSlice, "-t", feastProjectDir.Init.Template) + } + initSlice = append(initSlice, applied.FeastProject) + createCommand = strings.Join(initSlice, " ") + } else if feastProjectDir.Git != nil { + gitSlice := []string{"git"} + for key, value := range feastProjectDir.Git.Configs { + gitSlice = append(gitSlice, "-c", key+"="+value) + } + gitSlice = append(gitSlice, "clone", feastProjectDir.Git.URL, projectPath) + + if len(feastProjectDir.Git.Ref) > 0 { + gitSlice = append(gitSlice, "&&", "cd "+projectPath, "&&", "git checkout "+feastProjectDir.Git.Ref) + } + createCommand = strings.Join(gitSlice, " ") + + if feastProjectDir.Git.Env != nil { + container.Env = envOverride(container.Env, *feastProjectDir.Git.Env) + } + if feastProjectDir.Git.EnvFrom != nil { + container.EnvFrom = *feastProjectDir.Git.EnvFrom + } + } + + featureRepoDir := feast.getFeatureRepoDir() + container.Args = []string{ + "echo \"Creating feast repository...\"\necho '" + createCommand + "'\n" + + "if [[ ! -d " + featureRepoDir + " ]]; then " + createCommand + "; fi;\n" + + "echo $" + TmpFeatureStoreYamlEnvVar + " | base64 -d \u003e " + featureRepoDir + "/feature_store.yaml;\necho \"Feast repo creation complete\";\n", + } + podSpec.InitContainers = append(podSpec.InitContainers, container) + } } func (feast *FeastServices) setService(svc *corev1.Service, feastType FeastServiceType) error { - svc.Labels = feast.getLabels(feastType) - deploySettings := FeastServiceConstants[feastType] + svc.Labels = feast.getFeastTypeLabels(feastType) + if feast.isOpenShiftTls(feastType) { + svc.Annotations = map[string]string{ + "service.beta.openshift.io/serving-cert-secret-name": svc.Name + tlsNameSuffix, + } + } + var port int32 = HttpPort + scheme := HttpScheme + tls := feast.getTlsConfigs(feastType) + if tls.IsTLS() { + port = HttpsPort + scheme = HttpsScheme + } svc.Spec = corev1.ServiceSpec{ - Selector: svc.GetLabels(), + Selector: feast.getLabels(), Type: corev1.ServiceTypeClusterIP, Ports: []corev1.ServicePort{ { - Name: strings.ToLower(string(corev1.URISchemeHTTP)), - Port: HttpPort, + Name: scheme, + Port: port, Protocol: corev1.ProtocolTCP, - TargetPort: intstr.FromInt(int(deploySettings.TargetPort)), + TargetPort: intstr.FromInt(int(getTargetPort(feastType, tls))), }, }, } - return controllerutil.SetControllerReference(feast.FeatureStore, svc, feast.Scheme) + return controllerutil.SetControllerReference(feast.Handler.FeatureStore, svc, feast.Handler.Scheme) } -func (feast *FeastServices) getServiceConfigs(feastType FeastServiceType) feastdevv1alpha1.ServiceConfigs { - appliedSpec := feast.FeatureStore.Status.Applied - if feastType == OfflineFeastType && appliedSpec.Services.OfflineStore != nil { - return appliedSpec.Services.OfflineStore.ServiceConfigs +func (feast *FeastServices) setServiceAccount(sa *corev1.ServiceAccount) error { + sa.Labels = feast.getLabels() + return controllerutil.SetControllerReference(feast.Handler.FeatureStore, sa, feast.Handler.Scheme) +} + +func (feast *FeastServices) createNewPVC(pvcCreate *feastdevv1alpha1.PvcCreate, feastType FeastServiceType) (*corev1.PersistentVolumeClaim, error) { + pvc := feast.initPVC(feastType) + + pvc.Spec = corev1.PersistentVolumeClaimSpec{ + AccessModes: pvcCreate.AccessModes, + Resources: pvcCreate.Resources, } - if feastType == OnlineFeastType && appliedSpec.Services.OnlineStore != nil { - return appliedSpec.Services.OnlineStore.ServiceConfigs + if pvcCreate.StorageClassName != nil { + pvc.Spec.StorageClassName = pvcCreate.StorageClassName } - if feastType == RegistryFeastType && appliedSpec.Services.Registry != nil { - if appliedSpec.Services.Registry.Local != nil { - return appliedSpec.Services.Registry.Local.ServiceConfigs + return pvc, controllerutil.SetControllerReference(feast.Handler.FeatureStore, pvc, feast.Handler.Scheme) +} + +func (feast *FeastServices) getServerConfigs(feastType FeastServiceType) *feastdevv1alpha1.ServerConfigs { + appliedServices := feast.Handler.FeatureStore.Status.Applied.Services + switch feastType { + case OfflineFeastType: + if feast.isOfflineStore() { + return appliedServices.OfflineStore.Server + } + case OnlineFeastType: + if feast.isOnlineStore() { + return appliedServices.OnlineStore.Server + } + case RegistryFeastType: + if feast.isLocalRegistry() { + return appliedServices.Registry.Local.Server } + case UIFeastType: + return appliedServices.UI } - return feastdevv1alpha1.ServiceConfigs{} + return nil } -// GetObjectMeta returns the feast k8s object metadata -func (feast *FeastServices) GetObjectMeta(feastType FeastServiceType) metav1.ObjectMeta { - return metav1.ObjectMeta{Name: feast.GetFeastServiceName(feastType), Namespace: feast.FeatureStore.Namespace} +func (feast *FeastServices) getLogLevelForType(feastType FeastServiceType) *string { + if serviceConfigs := feast.getServerConfigs(feastType); serviceConfigs != nil { + return serviceConfigs.LogLevel + } + return nil +} + +// GetObjectMeta returns the feast k8s object metadata with type +func (feast *FeastServices) GetObjectMeta() metav1.ObjectMeta { + return metav1.ObjectMeta{Name: GetFeastName(feast.Handler.FeatureStore), Namespace: feast.Handler.FeatureStore.Namespace} +} + +// GetObjectMeta returns the feast k8s object metadata with type +func (feast *FeastServices) GetObjectMetaType(feastType FeastServiceType) metav1.ObjectMeta { + return metav1.ObjectMeta{Name: feast.GetFeastServiceName(feastType), Namespace: feast.Handler.FeatureStore.Namespace} } -// GetFeastServiceName returns the feast service object name based on service type func (feast *FeastServices) GetFeastServiceName(feastType FeastServiceType) string { - return feast.getFeastName() + "-" + string(feastType) + return GetFeastServiceName(feast.Handler.FeatureStore, feastType) } -func (feast *FeastServices) getFeastName() string { - return FeastPrefix + feast.FeatureStore.Name +func (feast *FeastServices) GetDeployment() (appsv1.Deployment, error) { + deployment := appsv1.Deployment{} + obj := feast.GetObjectMeta() + err := feast.Handler.Get(feast.Handler.Context, client.ObjectKey{Namespace: obj.GetNamespace(), Name: obj.GetName()}, &deployment) + return deployment, err } -func (feast *FeastServices) getLabels(feastType FeastServiceType) map[string]string { +// GetFeastServiceName returns the feast service object name based on service type +func GetFeastServiceName(featureStore *feastdevv1alpha1.FeatureStore, feastType FeastServiceType) string { + return GetFeastName(featureStore) + "-" + string(feastType) +} + +func GetFeastName(featureStore *feastdevv1alpha1.FeatureStore) string { + return handler.FeastPrefix + featureStore.Name +} + +func (feast *FeastServices) getFeastTypeLabels(feastType FeastServiceType) map[string]string { + labels := feast.getLabels() + labels[ServiceTypeLabelKey] = string(feastType) + return labels +} + +func (feast *FeastServices) getLabels() map[string]string { return map[string]string{ - NameLabelKey: feast.FeatureStore.Name, - ServiceTypeLabelKey: string(feastType), + NameLabelKey: feast.Handler.FeatureStore.Name, } } func (feast *FeastServices) setServiceHostnames() error { - feast.FeatureStore.Status.ServiceHostnames = feastdevv1alpha1.ServiceHostnames{} - services := feast.FeatureStore.Status.Applied.Services - if services != nil { - domain := svcDomain + ":" + strconv.Itoa(HttpPort) - if services.OfflineStore != nil { - objMeta := feast.GetObjectMeta(OfflineFeastType) - feast.FeatureStore.Status.ServiceHostnames.OfflineStore = objMeta.Name + "." + objMeta.Namespace + domain - } - if services.OnlineStore != nil { - objMeta := feast.GetObjectMeta(OnlineFeastType) - feast.FeatureStore.Status.ServiceHostnames.OnlineStore = objMeta.Name + "." + objMeta.Namespace + domain - } - if feast.isLocalRegistry() { - objMeta := feast.GetObjectMeta(RegistryFeastType) - feast.FeatureStore.Status.ServiceHostnames.Registry = objMeta.Name + "." + objMeta.Namespace + domain - } else if feast.isRemoteRegistry() { - return feast.setRemoteRegistryURL() - } + feast.Handler.FeatureStore.Status.ServiceHostnames = feastdevv1alpha1.ServiceHostnames{} + domain := svcDomain + ":" + if feast.isOfflineServer() { + objMeta := feast.initFeastSvc(OfflineFeastType) + feast.Handler.FeatureStore.Status.ServiceHostnames.OfflineStore = objMeta.Name + "." + objMeta.Namespace + domain + + getPortStr(feast.Handler.FeatureStore.Status.Applied.Services.OfflineStore.Server.TLS) + } + if feast.isOnlineServer() { + objMeta := feast.initFeastSvc(OnlineFeastType) + feast.Handler.FeatureStore.Status.ServiceHostnames.OnlineStore = objMeta.Name + "." + objMeta.Namespace + domain + + getPortStr(feast.Handler.FeatureStore.Status.Applied.Services.OnlineStore.Server.TLS) + } + if feast.isRegistryServer() { + objMeta := feast.initFeastSvc(RegistryFeastType) + feast.Handler.FeatureStore.Status.ServiceHostnames.Registry = objMeta.Name + "." + objMeta.Namespace + domain + + getPortStr(feast.Handler.FeatureStore.Status.Applied.Services.Registry.Local.Server.TLS) + } else if feast.isRemoteRegistry() { + return feast.setRemoteRegistryURL() + } + if feast.isUiServer() { + objMeta := feast.initFeastSvc(UIFeastType) + feast.Handler.FeatureStore.Status.ServiceHostnames.UI = objMeta.Name + "." + objMeta.Namespace + domain + + getPortStr(feast.Handler.FeatureStore.Status.Applied.Services.UI.TLS) } return nil } @@ -276,87 +716,123 @@ func (feast *FeastServices) setServiceHostnames() error { func (feast *FeastServices) setFeastServiceCondition(err error, feastType FeastServiceType) error { conditionMap := FeastServiceConditions[feastType] if err != nil { - logger := log.FromContext(feast.Context) + logger := log.FromContext(feast.Handler.Context) cond := conditionMap[metav1.ConditionFalse] cond.Message = "Error: " + err.Error() - apimeta.SetStatusCondition(&feast.FeatureStore.Status.Conditions, cond) + apimeta.SetStatusCondition(&feast.Handler.FeatureStore.Status.Conditions, cond) logger.Error(err, "Error deploying the FeatureStore "+string(ClientFeastType)+" service") return err } else { - apimeta.SetStatusCondition(&feast.FeatureStore.Status.Conditions, conditionMap[metav1.ConditionTrue]) + apimeta.SetStatusCondition(&feast.Handler.FeatureStore.Status.Conditions, conditionMap[metav1.ConditionTrue]) } return nil } func (feast *FeastServices) setRemoteRegistryURL() error { if feast.isRemoteHostnameRegistry() { - feast.FeatureStore.Status.ServiceHostnames.Registry = *feast.FeatureStore.Status.Applied.Services.Registry.Remote.Hostname + feast.Handler.FeatureStore.Status.ServiceHostnames.Registry = *feast.Handler.FeatureStore.Status.Applied.Services.Registry.Remote.Hostname } else if feast.IsRemoteRefRegistry() { - feastRemoteRef := feast.FeatureStore.Status.Applied.Services.Registry.Remote.FeastRef - // default to FeatureStore namespace if not set - if len(feastRemoteRef.Namespace) == 0 { - feastRemoteRef.Namespace = feast.FeatureStore.Namespace + remoteFeast, err := feast.getRemoteRegistryFeastHandler() + if err != nil { + return err + } + // referenced/remote registry must use the local registry server option and be in a 'Ready' state. + if remoteFeast != nil && + remoteFeast.isRegistryServer() && + apimeta.IsStatusConditionTrue(remoteFeast.Handler.FeatureStore.Status.Conditions, feastdevv1alpha1.RegistryReadyType) && + len(remoteFeast.Handler.FeatureStore.Status.ServiceHostnames.Registry) > 0 { + feast.Handler.FeatureStore.Status.ServiceHostnames.Registry = remoteFeast.Handler.FeatureStore.Status.ServiceHostnames.Registry + } else { + return errors.New("Remote feast registry of referenced FeatureStore '" + remoteFeast.Handler.FeatureStore.Name + "' is not ready") } + } + return nil +} +func (feast *FeastServices) getRemoteRegistryFeastHandler() (*FeastServices, error) { + if feast.IsRemoteRefRegistry() { + feastRemoteRef := feast.Handler.FeatureStore.Status.Applied.Services.Registry.Remote.FeastRef nsName := types.NamespacedName{Name: feastRemoteRef.Name, Namespace: feastRemoteRef.Namespace} - crNsName := client.ObjectKeyFromObject(feast.FeatureStore) + crNsName := client.ObjectKeyFromObject(feast.Handler.FeatureStore) if nsName == crNsName { - return errors.New("FeatureStore '" + crNsName.Name + "' can't reference itself in `spec.services.registry.remote.feastRef`") + return nil, errors.New("FeatureStore '" + crNsName.Name + "' can't reference itself in `spec.services.registry.remote.feastRef`") } - remoteFeastObj := &feastdevv1alpha1.FeatureStore{} - if err := feast.Client.Get(feast.Context, nsName, remoteFeastObj); err != nil { + if err := feast.Handler.Client.Get(feast.Handler.Context, nsName, remoteFeastObj); err != nil { if apierrors.IsNotFound(err) { - return errors.New("Referenced FeatureStore '" + feastRemoteRef.Name + "' was not found") + return nil, errors.New("Referenced FeatureStore '" + feastRemoteRef.Name + "' was not found") } - return err - } - - remoteFeast := FeastServices{ - Client: feast.Client, - Context: feast.Context, - FeatureStore: remoteFeastObj, - Scheme: feast.Scheme, + return nil, err } - // referenced/remote registry must use the local install option and be in a 'Ready' state. - if remoteFeast.isLocalRegistry() && apimeta.IsStatusConditionTrue(remoteFeastObj.Status.Conditions, feastdevv1alpha1.RegistryReadyType) { - feast.FeatureStore.Status.ServiceHostnames.Registry = remoteFeastObj.Status.ServiceHostnames.Registry - } else { - return errors.New("Remote feast registry of referenced FeatureStore '" + feastRemoteRef.Name + "' is not ready") + if feast.Handler.FeatureStore.Status.Applied.FeastProject != remoteFeastObj.Status.Applied.FeastProject { + return nil, errors.New("FeatureStore '" + remoteFeastObj.Name + "' is using a different feast project than '" + feast.Handler.FeatureStore.Status.Applied.FeastProject + "'. Project names must match.") } + return &FeastServices{ + Handler: handler.FeastHandler{ + Client: feast.Handler.Client, + Context: feast.Handler.Context, + FeatureStore: remoteFeastObj, + Scheme: feast.Handler.Scheme, + }, + }, nil } - return nil + return nil, nil } func (feast *FeastServices) isLocalRegistry() bool { - appliedServices := feast.FeatureStore.Status.Applied.Services - return appliedServices != nil && appliedServices.Registry != nil && appliedServices.Registry.Local != nil + return IsLocalRegistry(feast.Handler.FeatureStore) +} + +func (feast *FeastServices) isRegistryServer() bool { + return IsRegistryServer(feast.Handler.FeatureStore) } func (feast *FeastServices) isRemoteRegistry() bool { - appliedServices := feast.FeatureStore.Status.Applied.Services - return appliedServices != nil && appliedServices.Registry != nil && appliedServices.Registry.Remote != nil + return isRemoteRegistry(feast.Handler.FeatureStore) } func (feast *FeastServices) IsRemoteRefRegistry() bool { - if feast.isRemoteRegistry() { - remote := feast.FeatureStore.Status.Applied.Services.Registry.Remote - return remote != nil && remote.FeastRef != nil - } - return false + return feast.isRemoteRegistry() && + feast.Handler.FeatureStore.Status.Applied.Services.Registry.Remote.FeastRef != nil } func (feast *FeastServices) isRemoteHostnameRegistry() bool { - if feast.isRemoteRegistry() { - remote := feast.FeatureStore.Status.Applied.Services.Registry.Remote - return remote != nil && remote.Hostname != nil - } - return false + return feast.isRemoteRegistry() && + feast.Handler.FeatureStore.Status.Applied.Services.Registry.Remote.Hostname != nil } -func (feast *FeastServices) initFeastDeploy(feastType FeastServiceType) *appsv1.Deployment { +func (feast *FeastServices) isOfflineServer() bool { + return feast.isOfflineStore() && + feast.Handler.FeatureStore.Status.Applied.Services.OfflineStore.Server != nil +} + +func (feast *FeastServices) isOfflineStore() bool { + appliedServices := feast.Handler.FeatureStore.Status.Applied.Services + return appliedServices != nil && appliedServices.OfflineStore != nil +} + +func (feast *FeastServices) isOnlineServer() bool { + return feast.isOnlineStore() && + feast.Handler.FeatureStore.Status.Applied.Services.OnlineStore.Server != nil +} + +func (feast *FeastServices) isOnlineStore() bool { + appliedServices := feast.Handler.FeatureStore.Status.Applied.Services + return appliedServices != nil && appliedServices.OnlineStore != nil +} + +func (feast *FeastServices) noLocalCoreServerConfigured() bool { + return !(feast.isRegistryServer() || feast.isOnlineServer() || feast.isOfflineServer()) +} + +func (feast *FeastServices) isUiServer() bool { + appliedServices := feast.Handler.FeatureStore.Status.Applied.Services + return appliedServices != nil && appliedServices.UI != nil +} + +func (feast *FeastServices) initFeastDeploy() *appsv1.Deployment { deploy := &appsv1.Deployment{ - ObjectMeta: feast.GetObjectMeta(feastType), + ObjectMeta: feast.GetObjectMeta(), } deploy.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("Deployment")) return deploy @@ -364,33 +840,42 @@ func (feast *FeastServices) initFeastDeploy(feastType FeastServiceType) *appsv1. func (feast *FeastServices) initFeastSvc(feastType FeastServiceType) *corev1.Service { svc := &corev1.Service{ - ObjectMeta: feast.GetObjectMeta(feastType), + ObjectMeta: feast.GetObjectMetaType(feastType), } svc.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Service")) return svc } -// delete an object if the FeatureStore is set as the object's controller/owner -func (feast *FeastServices) deleteOwnedFeastObj(obj client.Object) error { - if err := feast.Client.Get(feast.Context, client.ObjectKeyFromObject(obj), obj); err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return err +func (feast *FeastServices) initFeastSA() *corev1.ServiceAccount { + sa := &corev1.ServiceAccount{ + ObjectMeta: feast.GetObjectMeta(), } - for _, ref := range obj.GetOwnerReferences() { - if *ref.Controller && ref.UID == feast.FeatureStore.UID { - if err := feast.Client.Delete(feast.Context, obj); err != nil { - return err - } - } + sa.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ServiceAccount")) + return sa +} + +func (feast *FeastServices) initPVC(feastType FeastServiceType) *corev1.PersistentVolumeClaim { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: feast.GetObjectMetaType(feastType), } - return nil + pvc.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim")) + return pvc } -func applyOptionalContainerConfigs(container *corev1.Container, optionalConfigs feastdevv1alpha1.OptionalConfigs) { +func (feast *FeastServices) initRoute(feastType FeastServiceType) *routev1.Route { + route := &routev1.Route{ + ObjectMeta: feast.GetObjectMetaType(feastType), + } + route.SetGroupVersionKind(routev1.SchemeGroupVersion.WithKind("Route")) + return route +} + +func applyOptionalCtrConfigs(container *corev1.Container, optionalConfigs feastdevv1alpha1.OptionalCtrConfigs) { if optionalConfigs.Env != nil { - container.Env = mergeEnvVarsArrays(container.Env, optionalConfigs.Env) + container.Env = envOverride(container.Env, *optionalConfigs.Env) + } + if optionalConfigs.EnvFrom != nil { + container.EnvFrom = *optionalConfigs.EnvFrom } if optionalConfigs.ImagePullPolicy != nil { container.ImagePullPolicy = *optionalConfigs.ImagePullPolicy @@ -400,24 +885,119 @@ func applyOptionalContainerConfigs(container *corev1.Container, optionalConfigs } } -func mergeEnvVarsArrays(envVars1 []corev1.EnvVar, envVars2 *[]corev1.EnvVar) []corev1.EnvVar { - merged := make(map[string]corev1.EnvVar) +func (feast *FeastServices) mountPvcConfigs(podSpec *corev1.PodSpec) { + for _, feastType := range feastServerTypes { + if pvcConfig, hasPvcConfig := hasPvcConfig(feast.Handler.FeatureStore, feastType); hasPvcConfig { + feast.mountPvcConfig(podSpec, pvcConfig, feastType) + } + } +} + +func (feast *FeastServices) mountPvcConfig(podSpec *corev1.PodSpec, pvcConfig *feastdevv1alpha1.PvcConfig, feastType FeastServiceType) { + if podSpec != nil && pvcConfig != nil { + volName := feast.initPVC(feastType).Name + pvcName := volName + if pvcConfig.Ref != nil { + pvcName = pvcConfig.Ref.Name + } + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: volName, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: pvcName, + }, + }, + }) + if feastType == OfflineFeastType { + for i := range podSpec.InitContainers { + podSpec.InitContainers[i].VolumeMounts = append(podSpec.InitContainers[i].VolumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: pvcConfig.MountPath, + }) + } + } + for i := range podSpec.Containers { + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: pvcConfig.MountPath, + }) + } + } +} + +func (feast *FeastServices) mountEmptyDirVolumes(podSpec *corev1.PodSpec) { + if shouldMountEmptyDir(feast.Handler.FeatureStore) { + mountEmptyDirVolume(podSpec) + } +} + +func (feast *FeastServices) getFeatureRepoDir() string { + applied := feast.Handler.FeatureStore.Status.Applied + feastProjectDir := getOfflineMountPath(feast.Handler.FeatureStore) + "/" + applied.FeastProject + if applied.FeastProjectDir != nil && applied.FeastProjectDir.Git != nil && len(applied.FeastProjectDir.Git.FeatureRepoPath) > 0 { + return feastProjectDir + "/" + applied.FeastProjectDir.Git.FeatureRepoPath + } + return feastProjectDir + "/" + FeatureRepoDir +} - // Add all env vars from the first array - for _, envVar := range envVars1 { - merged[envVar.Name] = envVar +func mountEmptyDirVolume(podSpec *corev1.PodSpec) { + if podSpec != nil { + volName := strings.TrimPrefix(EphemeralPath, "/") + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: volName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }) + for i := range podSpec.InitContainers { + podSpec.InitContainers[i].VolumeMounts = append(podSpec.InitContainers[i].VolumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: EphemeralPath, + }) + } + for i := range podSpec.Containers { + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: EphemeralPath, + }) + } } +} - // Add all env vars from the second array, overriding duplicates - for _, envVar := range *envVars2 { - merged[envVar.Name] = envVar +func getTargetPort(feastType FeastServiceType, tls *feastdevv1alpha1.TlsConfigs) int32 { + if tls.IsTLS() { + return FeastServiceConstants[feastType].TargetHttpsPort } + return FeastServiceConstants[feastType].TargetHttpPort +} - // Convert the map back to an array - result := make([]corev1.EnvVar, 0, len(merged)) - for _, envVar := range merged { - result = append(result, envVar) +func getProbeHandler(feastType FeastServiceType, tls *feastdevv1alpha1.TlsConfigs) corev1.ProbeHandler { + targetPort := getTargetPort(feastType, tls) + if feastType == OnlineFeastType { + probeHandler := corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/health", + Port: intstr.FromInt(int(targetPort)), + }, + } + if tls.IsTLS() { + probeHandler.HTTPGet.Scheme = corev1.URISchemeHTTPS + } + return probeHandler } + return corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromInt(int(targetPort)), + }, + } +} - return result +func IsDeploymentAvailable(conditions []appsv1.DeploymentCondition) bool { + for _, condition := range conditions { + if condition.Type == appsv1.DeploymentAvailable { + return condition.Status == corev1.ConditionTrue + } + } + + return false } diff --git a/infra/feast-operator/internal/controller/services/services_types.go b/infra/feast-operator/internal/controller/services/services_types.go index c2348666179..ac75caad19a 100644 --- a/infra/feast-operator/internal/controller/services/services_types.go +++ b/infra/feast-operator/internal/controller/services/services_types.go @@ -17,59 +17,98 @@ limitations under the License. package services import ( - "context" - "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + handler "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) const ( - FeastPrefix = "feast-" - FeatureStoreYamlEnvVar = "FEATURE_STORE_YAML_BASE64" - FeatureStoreYamlCmKey = "feature_store.yaml" - LocalRegistryPath = "/tmp/registry.db" - LocalOnlinePath = "/tmp/online_store.db" - svcDomain = ".svc.cluster.local" - HttpPort = 80 + TmpFeatureStoreYamlEnvVar = "TMP_FEATURE_STORE_YAML_BASE64" + feastServerImageVar = "RELATED_IMAGE_FEATURE_SERVER" + FeatureStoreYamlCmKey = "feature_store.yaml" + EphemeralPath = "/feast-data" + FeatureRepoDir = "feature_repo" + DefaultRegistryPath = "registry.db" + DefaultOnlineStorePath = "online_store.db" + svcDomain = ".svc.cluster.local" + + HttpPort = 80 + HttpsPort = 443 + HttpScheme = "http" + HttpsScheme = "https" + tlsPath = "/tls/" + tlsNameSuffix = "-tls" + + DefaultOfflineStorageRequest = "20Gi" + DefaultOnlineStorageRequest = "5Gi" + DefaultRegistryStorageRequest = "5Gi" OfflineFeastType FeastServiceType = "offline" OnlineFeastType FeastServiceType = "online" RegistryFeastType FeastServiceType = "registry" + UIFeastType FeastServiceType = "ui" ClientFeastType FeastServiceType = "client" + ClientCaFeastType FeastServiceType = "client-ca" - OfflineRemoteConfigType OfflineConfigType = "remote" - OfflineDaskConfigType OfflineConfigType = "dask" + OfflineRemoteConfigType OfflineConfigType = "remote" + OfflineFilePersistenceDaskConfigType OfflineConfigType = "dask" + OfflineFilePersistenceDuckDbConfigType OfflineConfigType = "duckdb" + OfflineDBPersistenceSnowflakeConfigType OfflineConfigType = "snowflake.offline" - OnlineRemoteConfigType OnlineConfigType = "remote" - OnlineSqliteConfigType OnlineConfigType = "sqlite" + OnlineRemoteConfigType OnlineConfigType = "remote" + OnlineSqliteConfigType OnlineConfigType = "sqlite" + OnlineDBPersistenceSnowflakeConfigType OnlineConfigType = "snowflake.online" + OnlineDBPersistenceCassandraConfigType OnlineConfigType = "cassandra" - RegistryRemoteConfigType RegistryConfigType = "remote" - RegistryFileConfigType RegistryConfigType = "file" + RegistryRemoteConfigType RegistryConfigType = "remote" + RegistryFileConfigType RegistryConfigType = "file" + RegistryDBPersistenceSnowflakeConfigType RegistryConfigType = "snowflake.registry" + RegistryDBPersistenceSQLConfigType RegistryConfigType = "sql" LocalProviderType FeastProviderType = "local" + + NoAuthAuthType AuthzType = "no_auth" + KubernetesAuthType AuthzType = "kubernetes" + OidcAuthType AuthzType = "oidc" + + OidcClientId OidcPropertyType = "client_id" + OidcAuthDiscoveryUrl OidcPropertyType = "auth_discovery_url" + OidcClientSecret OidcPropertyType = "client_secret" + OidcUsername OidcPropertyType = "username" + OidcPassword OidcPropertyType = "password" + + OidcMissingSecretError string = "missing OIDC secret: %s" ) var ( - DefaultImage = "feastdev/feature-server:" + feastversion.FeastVersion - DefaultReplicas = int32(1) - NameLabelKey = feastdevv1alpha1.GroupVersion.Group + "/name" - ServiceTypeLabelKey = feastdevv1alpha1.GroupVersion.Group + "/service-type" + DefaultImage = "feastdev/feature-server:" + feastversion.FeastVersion + DefaultReplicas = int32(1) + DefaultPVCAccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} + NameLabelKey = feastdevv1alpha1.GroupVersion.Group + "/name" + ServiceTypeLabelKey = feastdevv1alpha1.GroupVersion.Group + "/service-type" FeastServiceConstants = map[FeastServiceType]deploymentSettings{ OfflineFeastType: { - Command: []string{"feast", "serve_offline", "-h", "0.0.0.0"}, - TargetPort: 8815, + Args: []string{"serve_offline", "-h", "0.0.0.0"}, + TargetHttpPort: 8815, + TargetHttpsPort: 8816, }, OnlineFeastType: { - Command: []string{"feast", "serve", "-h", "0.0.0.0"}, - TargetPort: 6566, + Args: []string{"serve", "-h", "0.0.0.0"}, + TargetHttpPort: 6566, + TargetHttpsPort: 6567, }, RegistryFeastType: { - Command: []string{"feast", "serve_registry"}, - TargetPort: 6570, + Args: []string{"serve_registry"}, + TargetHttpPort: 6570, + TargetHttpsPort: 6571, + }, + UIFeastType: { + Args: []string{"ui", "-h", "0.0.0.0"}, + TargetHttpPort: 8888, + TargetHttpsPort: 8443, }, } @@ -113,6 +152,20 @@ var ( Reason: feastdevv1alpha1.RegistryFailedReason, }, }, + UIFeastType: { + metav1.ConditionTrue: { + Type: feastdevv1alpha1.UIReadyType, + Status: metav1.ConditionTrue, + Reason: feastdevv1alpha1.ReadyReason, + Message: feastdevv1alpha1.UIReadyMessage, + }, + metav1.ConditionFalse: { + Type: feastdevv1alpha1.UIReadyType, + Status: metav1.ConditionFalse, + Reason: feastdevv1alpha1.UIFailedReason, + }, + }, + ClientFeastType: { metav1.ConditionTrue: { Type: feastdevv1alpha1.ClientReadyType, @@ -127,8 +180,24 @@ var ( }, }, } + + OidcServerProperties = []OidcPropertyType{OidcClientId, OidcAuthDiscoveryUrl} + OidcClientProperties = []OidcPropertyType{OidcClientSecret, OidcUsername, OidcPassword} ) +// Feast server types: Reserved only for server types like Online, Offline, and Registry servers. Should not be used for client types like the UI, etc. +var feastServerTypes = []FeastServiceType{ + RegistryFeastType, + OfflineFeastType, + OnlineFeastType, +} + +// AuthzType defines the authorization type +type AuthzType string + +// OidcPropertyType defines the OIDC property type +type OidcPropertyType string + // FeastServiceType is the type of feast service type FeastServiceType string @@ -146,10 +215,7 @@ type FeastProviderType string // FeastServices is an interface for configuring and deploying feast services type FeastServices struct { - client.Client - Context context.Context - Scheme *runtime.Scheme - FeatureStore *feastdevv1alpha1.FeatureStore + Handler handler.FeastHandler } // RepoConfig is the Repo config. Typically loaded from feature_store.yaml. @@ -160,29 +226,45 @@ type RepoConfig struct { OfflineStore OfflineStoreConfig `yaml:"offline_store,omitempty"` OnlineStore OnlineStoreConfig `yaml:"online_store,omitempty"` Registry RegistryConfig `yaml:"registry,omitempty"` + AuthzConfig AuthzConfig `yaml:"auth,omitempty"` EntityKeySerializationVersion int `yaml:"entity_key_serialization_version,omitempty"` } // OfflineStoreConfig is the configuration that relates to reading from and writing to the Feast offline store. type OfflineStoreConfig struct { - Host string `yaml:"host,omitempty"` - Type OfflineConfigType `yaml:"type,omitempty"` - Port int `yaml:"port,omitempty"` + Host string `yaml:"host,omitempty"` + Type OfflineConfigType `yaml:"type,omitempty"` + Port int `yaml:"port,omitempty"` + Scheme string `yaml:"scheme,omitempty"` + Cert string `yaml:"cert,omitempty"` + DBParameters map[string]interface{} `yaml:",inline,omitempty"` } // OnlineStoreConfig is the configuration that relates to reading from and writing to the Feast online store. type OnlineStoreConfig struct { - Path string `yaml:"path,omitempty"` - Type OnlineConfigType `yaml:"type,omitempty"` + Path string `yaml:"path,omitempty"` + Type OnlineConfigType `yaml:"type,omitempty"` + Cert string `yaml:"cert,omitempty"` + DBParameters map[string]interface{} `yaml:",inline,omitempty"` } // RegistryConfig is the configuration that relates to reading from and writing to the Feast registry. type RegistryConfig struct { - Path string `yaml:"path,omitempty"` - RegistryType RegistryConfigType `yaml:"registry_type,omitempty"` + Path string `yaml:"path,omitempty"` + RegistryType RegistryConfigType `yaml:"registry_type,omitempty"` + Cert string `yaml:"cert,omitempty"` + S3AdditionalKwargs *map[string]string `yaml:"s3_additional_kwargs,omitempty"` + DBParameters map[string]interface{} `yaml:",inline,omitempty"` +} + +// AuthzConfig is the RBAC authorization configuration. +type AuthzConfig struct { + Type AuthzType `yaml:"type,omitempty"` + OidcParameters map[string]interface{} `yaml:",inline,omitempty"` } type deploymentSettings struct { - Command []string - TargetPort int32 + Args []string + TargetHttpPort int32 + TargetHttpsPort int32 } diff --git a/infra/feast-operator/internal/controller/services/suite_test.go b/infra/feast-operator/internal/controller/services/suite_test.go new file mode 100644 index 00000000000..5e922bc7e4a --- /dev/null +++ b/infra/feast-operator/internal/controller/services/suite_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + "fmt" + "path/filepath" + "runtime" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var k8sClient client.Client +var testEnv *envtest.Environment + +func TestServices(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Services Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "..", "..", "bin", "k8s", + fmt.Sprintf("1.29.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + cfg, err := testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = feastdevv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +func testSetIsOpenShift() { + isOpenShift = true +} diff --git a/infra/feast-operator/internal/controller/services/tls.go b/infra/feast-operator/internal/controller/services/tls.go new file mode 100644 index 00000000000..03a26a9031d --- /dev/null +++ b/infra/feast-operator/internal/controller/services/tls.go @@ -0,0 +1,260 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + "strconv" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" +) + +func (feast *FeastServices) setTlsDefaults() error { + if err := feast.setOpenshiftTls(); err != nil { + return err + } + appliedServices := feast.Handler.FeatureStore.Status.Applied.Services + if feast.isOfflineServer() { + tlsDefaults(appliedServices.OfflineStore.Server.TLS) + } + if feast.isOnlineServer() { + tlsDefaults(appliedServices.OnlineStore.Server.TLS) + } + if feast.isRegistryServer() { + tlsDefaults(appliedServices.Registry.Local.Server.TLS) + } + if feast.isUiServer() { + tlsDefaults(appliedServices.UI.TLS) + } + return nil +} + +func (feast *FeastServices) setOpenshiftTls() error { + appliedServices := feast.Handler.FeatureStore.Status.Applied.Services + if feast.offlineOpenshiftTls() { + appliedServices.OfflineStore.Server.TLS = &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{ + Name: feast.initFeastSvc(OfflineFeastType).Name + tlsNameSuffix, + }, + } + } + if feast.onlineOpenshiftTls() { + appliedServices.OnlineStore.Server.TLS = &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{ + Name: feast.initFeastSvc(OnlineFeastType).Name + tlsNameSuffix, + }, + } + } + if feast.uiOpenshiftTls() { + appliedServices.UI.TLS = &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{ + Name: feast.initFeastSvc(UIFeastType).Name + tlsNameSuffix, + }, + } + } + if feast.localRegistryOpenshiftTls() { + appliedServices.Registry.Local.Server.TLS = &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{ + Name: feast.initFeastSvc(RegistryFeastType).Name + tlsNameSuffix, + }, + } + } else if remote, err := feast.remoteRegistryOpenshiftTls(); remote { + // if the remote registry reference is using openshift's service serving certificates, we can use the injected service CA bundle configMap + if appliedServices.Registry.Remote.TLS == nil { + appliedServices.Registry.Remote.TLS = &feastdevv1alpha1.TlsRemoteRegistryConfigs{ + ConfigMapRef: corev1.LocalObjectReference{ + Name: feast.initCaConfigMap().Name, + }, + CertName: "service-ca.crt", + } + } + } else if err != nil { + return err + } + return nil +} + +func (feast *FeastServices) checkOpenshiftTls() (bool, error) { + if feast.offlineOpenshiftTls() || feast.onlineOpenshiftTls() || feast.localRegistryOpenshiftTls() || feast.uiOpenshiftTls() { + return true, nil + } + return feast.remoteRegistryOpenshiftTls() +} + +func (feast *FeastServices) isOpenShiftTls(feastType FeastServiceType) (isOpenShift bool) { + switch feastType { + case OfflineFeastType: + isOpenShift = feast.offlineOpenshiftTls() + case OnlineFeastType: + isOpenShift = feast.onlineOpenshiftTls() + case RegistryFeastType: + isOpenShift = feast.localRegistryOpenshiftTls() + case UIFeastType: + isOpenShift = feast.uiOpenshiftTls() + } + + return +} + +func (feast *FeastServices) getTlsConfigs(feastType FeastServiceType) *feastdevv1alpha1.TlsConfigs { + if serviceConfigs := feast.getServerConfigs(feastType); serviceConfigs != nil { + return serviceConfigs.TLS + } + return nil +} + +// True if running in an openshift cluster and Tls not configured in the service Spec +func (feast *FeastServices) offlineOpenshiftTls() bool { + return isOpenShift && + feast.isOfflineServer() && feast.Handler.FeatureStore.Spec.Services.OfflineStore.Server.TLS == nil +} + +// True if running in an openshift cluster and Tls not configured in the service Spec +func (feast *FeastServices) onlineOpenshiftTls() bool { + return isOpenShift && + feast.isOnlineServer() && + (feast.Handler.FeatureStore.Spec.Services == nil || + feast.Handler.FeatureStore.Spec.Services.OnlineStore == nil || + feast.Handler.FeatureStore.Spec.Services.OnlineStore.Server == nil || + feast.Handler.FeatureStore.Spec.Services.OnlineStore.Server.TLS == nil) +} + +// True if running in an openshift cluster and Tls not configured in the service Spec +func (feast *FeastServices) uiOpenshiftTls() bool { + return isOpenShift && + feast.isUiServer() && feast.Handler.FeatureStore.Spec.Services.UI.TLS == nil +} + +// True if running in an openshift cluster and Tls not configured in the service Spec +func (feast *FeastServices) localRegistryOpenshiftTls() bool { + return isOpenShift && + feast.isRegistryServer() && feast.Handler.FeatureStore.Spec.Services.Registry.Local.Server.TLS == nil +} + +// True if running in an openshift cluster, and using a remote registry in the same cluster, with no remote Tls set in the service Spec +func (feast *FeastServices) remoteRegistryOpenshiftTls() (bool, error) { + if isOpenShift && feast.isRemoteRegistry() { + remoteFeast, err := feast.getRemoteRegistryFeastHandler() + if err != nil { + return false, err + } + return (remoteFeast != nil && remoteFeast.localRegistryOpenshiftTls() && + feast.Handler.FeatureStore.Spec.Services.Registry.Remote.TLS == nil), + nil + } + return false, nil +} + +func (feast *FeastServices) localRegistryTls() bool { + return localRegistryTls(feast.Handler.FeatureStore) +} + +func (feast *FeastServices) remoteRegistryTls() bool { + return remoteRegistryTls(feast.Handler.FeatureStore) +} + +func (feast *FeastServices) mountRegistryClientTls(podSpec *corev1.PodSpec) { + if podSpec != nil { + if feast.localRegistryTls() { + feast.mountTlsConfig(RegistryFeastType, podSpec) + } else if feast.remoteRegistryTls() { + mountTlsRemoteRegistryConfig(podSpec, + feast.Handler.FeatureStore.Status.Applied.Services.Registry.Remote.TLS) + } + } +} + +func (feast *FeastServices) mountTlsConfigs(podSpec *corev1.PodSpec) { + // how deal w/ client deployment tls mounts when the time comes? new function? + feast.mountRegistryClientTls(podSpec) + feast.mountTlsConfig(OfflineFeastType, podSpec) + feast.mountTlsConfig(OnlineFeastType, podSpec) + feast.mountTlsConfig(UIFeastType, podSpec) +} + +func (feast *FeastServices) mountTlsConfig(feastType FeastServiceType, podSpec *corev1.PodSpec) { + tls := feast.getTlsConfigs(feastType) + if tls.IsTLS() && podSpec != nil { + volName := string(feastType) + tlsNameSuffix + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: volName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: tls.SecretRef.Name, + }, + }, + }) + if i, container := getContainerByType(feastType, *podSpec); container != nil { + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: GetTlsPath(feastType), + ReadOnly: true, + }) + } + } +} + +func mountTlsRemoteRegistryConfig(podSpec *corev1.PodSpec, tls *feastdevv1alpha1.TlsRemoteRegistryConfigs) { + if tls != nil { + volName := string(RegistryFeastType) + tlsNameSuffix + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: volName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: tls.ConfigMapRef, + }, + }, + }) + for i := range podSpec.Containers { + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: GetTlsPath(RegistryFeastType), + ReadOnly: true, + }) + } + } +} + +func getPortStr(tls *feastdevv1alpha1.TlsConfigs) string { + if tls.IsTLS() { + return strconv.Itoa(HttpsPort) + } + return strconv.Itoa(HttpPort) +} + +func tlsDefaults(tls *feastdevv1alpha1.TlsConfigs) { + if tls.IsTLS() { + if len(tls.SecretKeyNames.TlsCrt) == 0 { + tls.SecretKeyNames.TlsCrt = "tls.crt" + } + if len(tls.SecretKeyNames.TlsKey) == 0 { + tls.SecretKeyNames.TlsKey = "tls.key" + } + } +} + +func localRegistryTls(featureStore *feastdevv1alpha1.FeatureStore) bool { + return IsRegistryServer(featureStore) && featureStore.Status.Applied.Services.Registry.Local.Server.TLS.IsTLS() +} + +func remoteRegistryTls(featureStore *feastdevv1alpha1.FeatureStore) bool { + return isRemoteRegistry(featureStore) && featureStore.Status.Applied.Services.Registry.Remote.TLS != nil +} + +func GetTlsPath(feastType FeastServiceType) string { + return tlsPath + string(feastType) + "/" +} diff --git a/infra/feast-operator/internal/controller/services/tls_test.go b/infra/feast-operator/internal/controller/services/tls_test.go new file mode 100644 index 00000000000..04925ff02ee --- /dev/null +++ b/infra/feast-operator/internal/controller/services/tls_test.go @@ -0,0 +1,330 @@ +/* +Copyright 2024 Feast Community. + +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 services + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +// test tls functions directly +var _ = Describe("TLS Config", func() { + Context("When reconciling a FeatureStore", func() { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(feastdevv1alpha1.AddToScheme(scheme)) + + secretKeyNames := feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "tls.crt", + TlsKey: "tls.key", + } + + It("should set default TLS configs", func() { + By("Having the created resource") + + // registry server w/o tls + feast := FeastServices{ + Handler: handler.FeastHandler{ + FeatureStore: minimalFeatureStore(), + Scheme: scheme, + }, + } + feast.Handler.FeatureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, + }, + } + err := feast.ApplyDefaults() + Expect(err).ToNot(HaveOccurred()) + + tls := feast.getTlsConfigs(RegistryFeastType) + Expect(tls).To(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + Expect(getPortStr(tls)).To(Equal("80")) + + Expect(feast.remoteRegistryTls()).To(BeFalse()) + Expect(feast.localRegistryTls()).To(BeFalse()) + Expect(feast.isOpenShiftTls(OfflineFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(OnlineFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(RegistryFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(UIFeastType)).To(BeFalse()) + + openshiftTls, err := feast.checkOpenshiftTls() + Expect(err).ToNot(HaveOccurred()) + Expect(openshiftTls).To(BeFalse()) + + // registry service w/ openshift tls + testSetIsOpenShift() + feast.Handler.FeatureStore = minimalFeatureStore() + feast.Handler.FeatureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{}, + }, + }, + } + err = feast.ApplyDefaults() + Expect(err).ToNot(HaveOccurred()) + + tls = feast.getTlsConfigs(OfflineFeastType) + Expect(tls).To(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + tls = feast.getTlsConfigs(OnlineFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeTrue()) + tls = feast.getTlsConfigs(UIFeastType) + Expect(tls).To(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + tls = feast.getTlsConfigs(RegistryFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeTrue()) + Expect(tls.SecretKeyNames).To(Equal(secretKeyNames)) + Expect(getPortStr(tls)).To(Equal("443")) + Expect(GetTlsPath(RegistryFeastType)).To(Equal("/tls/registry/")) + + Expect(feast.remoteRegistryTls()).To(BeFalse()) + Expect(feast.localRegistryTls()).To(BeTrue()) + Expect(feast.isOpenShiftTls(OfflineFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(OnlineFeastType)).To(BeTrue()) + Expect(feast.isOpenShiftTls(UIFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(RegistryFeastType)).To(BeTrue()) + + openshiftTls, err = feast.checkOpenshiftTls() + Expect(err).ToNot(HaveOccurred()) + Expect(openshiftTls).To(BeTrue()) + + // all services w/ openshift tls + feast.Handler.FeatureStore = minimalFeatureStoreWithAllServers() + err = feast.ApplyDefaults() + Expect(err).ToNot(HaveOccurred()) + + repoConfig, err := getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) + Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) + Expect(repoConfig.OfflineStore.Cert).To(ContainSubstring(string(OfflineFeastType))) + Expect(repoConfig.OnlineStore.Cert).To(ContainSubstring(string(OnlineFeastType))) + Expect(repoConfig.Registry.Cert).To(ContainSubstring(string(RegistryFeastType))) + + tls = feast.getTlsConfigs(OfflineFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeTrue()) + Expect(tls.SecretRef).NotTo(BeNil()) + Expect(tls.SecretRef.Name).To(Equal("feast-test-offline-tls")) + tls = feast.getTlsConfigs(OnlineFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeTrue()) + Expect(tls.SecretRef).NotTo(BeNil()) + Expect(tls.SecretRef.Name).To(Equal("feast-test-online-tls")) + tls = feast.getTlsConfigs(RegistryFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.SecretRef).NotTo(BeNil()) + Expect(tls.SecretRef.Name).To(Equal("feast-test-registry-tls")) + Expect(tls.SecretKeyNames).To(Equal(secretKeyNames)) + Expect(tls.IsTLS()).To(BeTrue()) + tls = feast.getTlsConfigs(UIFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.SecretRef).NotTo(BeNil()) + Expect(tls.SecretRef.Name).To(Equal("feast-test-ui-tls")) + Expect(tls.SecretKeyNames).To(Equal(secretKeyNames)) + Expect(tls.IsTLS()).To(BeTrue()) + + Expect(feast.remoteRegistryTls()).To(BeFalse()) + Expect(feast.localRegistryTls()).To(BeTrue()) + Expect(feast.isOpenShiftTls(OfflineFeastType)).To(BeTrue()) + Expect(feast.isOpenShiftTls(OnlineFeastType)).To(BeTrue()) + Expect(feast.isOpenShiftTls(RegistryFeastType)).To(BeTrue()) + Expect(feast.isOpenShiftTls(UIFeastType)).To(BeTrue()) + openshiftTls, err = feast.checkOpenshiftTls() + Expect(err).ToNot(HaveOccurred()) + Expect(openshiftTls).To(BeTrue()) + + // check k8s deployment objects + feastDeploy := feast.initFeastDeploy() + err = feast.setDeployment(feastDeploy) + Expect(err).ToNot(HaveOccurred()) + Expect(feastDeploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + Expect(feastDeploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + Expect(feastDeploy.Spec.Template.Spec.Containers[0].Command).To(ContainElements(ContainSubstring("--key"))) + Expect(feastDeploy.Spec.Template.Spec.Containers[1].Command).To(ContainElements(ContainSubstring("--key"))) + Expect(feastDeploy.Spec.Template.Spec.Containers[2].Command).To(ContainElements(ContainSubstring("--key"))) + Expect(feastDeploy.Spec.Template.Spec.Containers[3].Command).To(ContainElements(ContainSubstring("--key"))) + Expect(feastDeploy.Spec.Template.Spec.Volumes).To(HaveLen(5)) + + // registry service w/ tls and in an openshift cluster + feast.Handler.FeatureStore = minimalFeatureStore() + feast.Handler.FeatureStore.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{}, + }, + }, + UI: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{}, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + SecretRef: &corev1.LocalObjectReference{}, + SecretKeyNames: feastdevv1alpha1.SecretKeyNames{ + TlsCrt: "test.crt", + }, + }, + }, + }, + }, + } + err = feast.ApplyDefaults() + Expect(err).ToNot(HaveOccurred()) + + tls = feast.getTlsConfigs(OfflineFeastType) + Expect(tls).To(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + tls = feast.getTlsConfigs(OnlineFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + tls = feast.getTlsConfigs(UIFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + tls = feast.getTlsConfigs(RegistryFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeTrue()) + Expect(tls.SecretKeyNames).NotTo(Equal(secretKeyNames)) + Expect(getPortStr(tls)).To(Equal("443")) + Expect(GetTlsPath(RegistryFeastType)).To(Equal("/tls/registry/")) + Expect(feast.remoteRegistryTls()).To(BeFalse()) + Expect(feast.localRegistryTls()).To(BeTrue()) + Expect(feast.isOpenShiftTls(OfflineFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(OnlineFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(UIFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(RegistryFeastType)).To(BeFalse()) + openshiftTls, err = feast.checkOpenshiftTls() + Expect(err).ToNot(HaveOccurred()) + Expect(openshiftTls).To(BeFalse()) + + // all services w/ tls and in an openshift cluster + feast.Handler.FeatureStore = minimalFeatureStoreWithAllServers() + disable := true + feast.Handler.FeatureStore.Spec.Services.OnlineStore = &feastdevv1alpha1.OnlineStore{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + Disable: &disable, + }, + }, + } + feast.Handler.FeatureStore.Spec.Services.UI.TLS = &feastdevv1alpha1.TlsConfigs{ + Disable: &disable, + } + feast.Handler.FeatureStore.Spec.Services.Registry = &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Server: &feastdevv1alpha1.ServerConfigs{ + TLS: &feastdevv1alpha1.TlsConfigs{ + Disable: &disable, + }, + }, + }, + } + err = feast.ApplyDefaults() + Expect(err).ToNot(HaveOccurred()) + + repoConfig, err = getClientRepoConfig(feast.Handler.FeatureStore, emptyMockExtractConfigFromSecret) + Expect(err).NotTo(HaveOccurred()) + Expect(repoConfig.OfflineStore.Port).To(Equal(HttpsPort)) + Expect(repoConfig.OfflineStore.Scheme).To(Equal(HttpsScheme)) + Expect(repoConfig.OfflineStore.Cert).To(ContainSubstring(string(OfflineFeastType))) + Expect(repoConfig.OnlineStore.Cert).NotTo(ContainSubstring(string(OnlineFeastType))) + Expect(repoConfig.Registry.Cert).NotTo(ContainSubstring(string(RegistryFeastType))) + + tls = feast.getTlsConfigs(OfflineFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeTrue()) + Expect(tls.SecretKeyNames).To(Equal(secretKeyNames)) + tls = feast.getTlsConfigs(OnlineFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + Expect(tls.SecretKeyNames).NotTo(Equal(secretKeyNames)) + tls = feast.getTlsConfigs(UIFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + Expect(tls.SecretKeyNames).NotTo(Equal(secretKeyNames)) + tls = feast.getTlsConfigs(RegistryFeastType) + Expect(tls).NotTo(BeNil()) + Expect(tls.IsTLS()).To(BeFalse()) + Expect(tls.SecretKeyNames).NotTo(Equal(secretKeyNames)) + Expect(getPortStr(tls)).To(Equal("80")) + Expect(GetTlsPath(RegistryFeastType)).To(Equal("/tls/registry/")) + + Expect(feast.remoteRegistryTls()).To(BeFalse()) + Expect(feast.localRegistryTls()).To(BeFalse()) + Expect(feast.isOpenShiftTls(OfflineFeastType)).To(BeTrue()) + Expect(feast.isOpenShiftTls(OnlineFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(UIFeastType)).To(BeFalse()) + Expect(feast.isOpenShiftTls(RegistryFeastType)).To(BeFalse()) + openshiftTls, err = feast.checkOpenshiftTls() + Expect(err).ToNot(HaveOccurred()) + Expect(openshiftTls).To(BeTrue()) + + // check k8s service objects + offlineSvc := feast.initFeastSvc(OfflineFeastType) + Expect(offlineSvc.Annotations).To(BeEmpty()) + err = feast.setService(offlineSvc, OfflineFeastType) + Expect(err).ToNot(HaveOccurred()) + Expect(offlineSvc.Annotations).NotTo(BeEmpty()) + Expect(offlineSvc.Spec.Ports[0].Name).To(Equal(HttpsScheme)) + + onlineSvc := feast.initFeastSvc(OnlineFeastType) + err = feast.setService(onlineSvc, OnlineFeastType) + Expect(err).ToNot(HaveOccurred()) + Expect(onlineSvc.Annotations).To(BeEmpty()) + Expect(onlineSvc.Spec.Ports[0].Name).To(Equal(HttpScheme)) + + uiSvc := feast.initFeastSvc(UIFeastType) + err = feast.setService(uiSvc, UIFeastType) + Expect(err).ToNot(HaveOccurred()) + Expect(uiSvc.Annotations).To(BeEmpty()) + Expect(uiSvc.Spec.Ports[0].Name).To(Equal(HttpScheme)) + + // check k8s deployment objects + feastDeploy = feast.initFeastDeploy() + err = feast.setDeployment(feastDeploy) + Expect(err).ToNot(HaveOccurred()) + Expect(feastDeploy.Spec.Template.Spec.Containers).To(HaveLen(4)) + Expect(GetOfflineContainer(*feastDeploy)).NotTo(BeNil()) + Expect(feastDeploy.Spec.Template.Spec.Volumes).To(HaveLen(2)) + + Expect(GetRegistryContainer(*feastDeploy).Command).NotTo(ContainElements(ContainSubstring("--key"))) + Expect(GetRegistryContainer(*feastDeploy).VolumeMounts).To(HaveLen(1)) + Expect(GetOfflineContainer(*feastDeploy).Command).To(ContainElements(ContainSubstring("--key"))) + Expect(GetOfflineContainer(*feastDeploy).VolumeMounts).To(HaveLen(2)) + Expect(GetOnlineContainer(*feastDeploy).Command).NotTo(ContainElements(ContainSubstring("--key"))) + Expect(GetOnlineContainer(*feastDeploy).VolumeMounts).To(HaveLen(1)) + Expect(GetUIContainer(*feastDeploy).Command).NotTo(ContainElements(ContainSubstring("--key"))) + Expect(GetUIContainer(*feastDeploy).VolumeMounts).To(HaveLen(1)) + + }) + }) +}) diff --git a/infra/feast-operator/internal/controller/services/util.go b/infra/feast-operator/internal/controller/services/util.go new file mode 100644 index 00000000000..41f3e837157 --- /dev/null +++ b/infra/feast-operator/internal/controller/services/util.go @@ -0,0 +1,470 @@ +package services + +import ( + "fmt" + "os" + "reflect" + "slices" + "strings" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var isOpenShift = false + +func IsRegistryServer(featureStore *feastdevv1alpha1.FeatureStore) bool { + return IsLocalRegistry(featureStore) && featureStore.Status.Applied.Services.Registry.Local.Server != nil +} + +func IsLocalRegistry(featureStore *feastdevv1alpha1.FeatureStore) bool { + appliedServices := featureStore.Status.Applied.Services + return appliedServices != nil && appliedServices.Registry != nil && appliedServices.Registry.Local != nil +} + +func isRemoteRegistry(featureStore *feastdevv1alpha1.FeatureStore) bool { + appliedServices := featureStore.Status.Applied.Services + return appliedServices != nil && appliedServices.Registry != nil && appliedServices.Registry.Remote != nil +} + +func hasPvcConfig(featureStore *feastdevv1alpha1.FeatureStore, feastType FeastServiceType) (*feastdevv1alpha1.PvcConfig, bool) { + var pvcConfig *feastdevv1alpha1.PvcConfig + services := featureStore.Status.Applied.Services + if services != nil { + switch feastType { + case OnlineFeastType: + if services.OnlineStore != nil && services.OnlineStore.Persistence != nil && + services.OnlineStore.Persistence.FilePersistence != nil { + pvcConfig = services.OnlineStore.Persistence.FilePersistence.PvcConfig + } + case OfflineFeastType: + if services.OfflineStore != nil && services.OfflineStore.Persistence != nil && + services.OfflineStore.Persistence.FilePersistence != nil { + pvcConfig = services.OfflineStore.Persistence.FilePersistence.PvcConfig + } + case RegistryFeastType: + if IsLocalRegistry(featureStore) && services.Registry.Local.Persistence != nil && + services.Registry.Local.Persistence.FilePersistence != nil { + pvcConfig = services.Registry.Local.Persistence.FilePersistence.PvcConfig + } + } + } + return pvcConfig, pvcConfig != nil +} + +func shouldCreatePvc(featureStore *feastdevv1alpha1.FeatureStore, feastType FeastServiceType) (*feastdevv1alpha1.PvcCreate, bool) { + if pvcConfig, ok := hasPvcConfig(featureStore, feastType); ok { + return pvcConfig.Create, pvcConfig.Create != nil + } + return nil, false +} + +func shouldMountEmptyDir(featureStore *feastdevv1alpha1.FeatureStore) bool { + for _, feastType := range feastServerTypes { + if _, ok := hasPvcConfig(featureStore, feastType); !ok { + return true + } + } + return false +} + +func getOfflineMountPath(featureStore *feastdevv1alpha1.FeatureStore) string { + if pvcConfig, ok := hasPvcConfig(featureStore, OfflineFeastType); ok { + return pvcConfig.MountPath + } + return EphemeralPath +} + +func ApplyDefaultsToStatus(cr *feastdevv1alpha1.FeatureStore) { + // overwrite status.applied with every reconcile + cr.Spec.DeepCopyInto(&cr.Status.Applied) + cr.Status.FeastVersion = feastversion.FeastVersion + + applied := &cr.Status.Applied + if applied.FeastProjectDir == nil { + applied.FeastProjectDir = &feastdevv1alpha1.FeastProjectDir{ + Init: &feastdevv1alpha1.FeastInitOptions{}, + } + } + if applied.Services == nil { + applied.Services = &feastdevv1alpha1.FeatureStoreServices{} + } + services := applied.Services + + if services.Registry != nil { + // if remote registry not set, proceed w/ local registry defaults + if services.Registry.Remote == nil { + // if local registry not set, apply an empty pointer struct + if services.Registry.Local == nil { + services.Registry.Local = &feastdevv1alpha1.LocalRegistryConfig{} + } + if services.Registry.Local.Persistence == nil { + services.Registry.Local.Persistence = &feastdevv1alpha1.RegistryPersistence{} + } + + if services.Registry.Local.Persistence.DBPersistence == nil { + if services.Registry.Local.Persistence.FilePersistence == nil { + services.Registry.Local.Persistence.FilePersistence = &feastdevv1alpha1.RegistryFilePersistence{} + } + + if len(services.Registry.Local.Persistence.FilePersistence.Path) == 0 { + services.Registry.Local.Persistence.FilePersistence.Path = defaultRegistryPath(cr) + } + + ensurePVCDefaults(services.Registry.Local.Persistence.FilePersistence.PvcConfig, RegistryFeastType) + } + + if services.Registry.Local.Server != nil { + setDefaultCtrConfigs(&services.Registry.Local.Server.ContainerConfigs.DefaultCtrConfigs) + } + } else if services.Registry.Remote.FeastRef != nil && len(services.Registry.Remote.FeastRef.Namespace) == 0 { + services.Registry.Remote.FeastRef.Namespace = cr.Namespace + } + } + + if services.OfflineStore != nil { + if services.OfflineStore.Persistence == nil { + services.OfflineStore.Persistence = &feastdevv1alpha1.OfflineStorePersistence{} + } + + if services.OfflineStore.Persistence.DBPersistence == nil { + if services.OfflineStore.Persistence.FilePersistence == nil { + services.OfflineStore.Persistence.FilePersistence = &feastdevv1alpha1.OfflineStoreFilePersistence{} + } + + if len(services.OfflineStore.Persistence.FilePersistence.Type) == 0 { + services.OfflineStore.Persistence.FilePersistence.Type = string(OfflineFilePersistenceDaskConfigType) + } + + ensurePVCDefaults(services.OfflineStore.Persistence.FilePersistence.PvcConfig, OfflineFeastType) + } + + if services.OfflineStore.Server != nil { + setDefaultCtrConfigs(&services.OfflineStore.Server.ContainerConfigs.DefaultCtrConfigs) + } + } + + // default to onlineStore service deployment + if services.OnlineStore == nil { + services.OnlineStore = &feastdevv1alpha1.OnlineStore{} + } + if services.OnlineStore.Persistence == nil { + services.OnlineStore.Persistence = &feastdevv1alpha1.OnlineStorePersistence{} + } + + if services.OnlineStore.Persistence.DBPersistence == nil { + if services.OnlineStore.Persistence.FilePersistence == nil { + services.OnlineStore.Persistence.FilePersistence = &feastdevv1alpha1.OnlineStoreFilePersistence{} + } + + if len(services.OnlineStore.Persistence.FilePersistence.Path) == 0 { + services.OnlineStore.Persistence.FilePersistence.Path = defaultOnlineStorePath(cr) + } + + ensurePVCDefaults(services.OnlineStore.Persistence.FilePersistence.PvcConfig, OnlineFeastType) + } + + if services.OnlineStore.Server == nil { + services.OnlineStore.Server = &feastdevv1alpha1.ServerConfigs{} + } + setDefaultCtrConfigs(&services.OnlineStore.Server.ContainerConfigs.DefaultCtrConfigs) + + if services.UI != nil { + setDefaultCtrConfigs(&services.UI.ContainerConfigs.DefaultCtrConfigs) + } +} + +func setDefaultCtrConfigs(defaultConfigs *feastdevv1alpha1.DefaultCtrConfigs) { + if defaultConfigs.Image == nil { + img := getFeatureServerImage() + defaultConfigs.Image = &img + } +} + +func getFeatureServerImage() string { + if img, exists := os.LookupEnv(feastServerImageVar); exists { + return img + } + return DefaultImage +} + +func checkOfflineStoreFilePersistenceType(value string) error { + if slices.Contains(feastdevv1alpha1.ValidOfflineStoreFilePersistenceTypes, value) { + return nil + } + return fmt.Errorf("invalid file type %s for offline store", value) +} + +func ensureRequestedStorage(resources *corev1.VolumeResourceRequirements, requestedStorage string) { + if resources.Requests == nil { + resources.Requests = corev1.ResourceList{} + } + if _, ok := resources.Requests[corev1.ResourceStorage]; !ok { + resources.Requests[corev1.ResourceStorage] = resource.MustParse(requestedStorage) + } +} + +func ensurePVCDefaults(pvc *feastdevv1alpha1.PvcConfig, feastType FeastServiceType) { + if pvc != nil { + var storageRequest string + switch feastType { + case OnlineFeastType: + storageRequest = DefaultOnlineStorageRequest + case OfflineFeastType: + storageRequest = DefaultOfflineStorageRequest + case RegistryFeastType: + storageRequest = DefaultRegistryStorageRequest + } + if pvc.Create != nil { + ensureRequestedStorage(&pvc.Create.Resources, storageRequest) + if pvc.Create.AccessModes == nil { + pvc.Create.AccessModes = DefaultPVCAccessModes + } + } + } +} + +func defaultOnlineStorePath(featureStore *feastdevv1alpha1.FeatureStore) string { + if _, ok := hasPvcConfig(featureStore, OnlineFeastType); ok { + return DefaultOnlineStorePath + } + // if pvc not set, use the ephemeral mount path. + return EphemeralPath + "/" + DefaultOnlineStorePath +} + +func defaultRegistryPath(featureStore *feastdevv1alpha1.FeatureStore) string { + if _, ok := hasPvcConfig(featureStore, RegistryFeastType); ok { + return DefaultRegistryPath + } + // if pvc not set, use the ephemeral mount path. + return EphemeralPath + "/" + DefaultRegistryPath +} + +func checkOfflineStoreDBStorePersistenceType(value string) error { + if slices.Contains(feastdevv1alpha1.ValidOfflineStoreDBStorePersistenceTypes, value) { + return nil + } + return fmt.Errorf("invalid DB store type %s for offline store", value) +} + +func checkOnlineStoreDBStorePersistenceType(value string) error { + if slices.Contains(feastdevv1alpha1.ValidOnlineStoreDBStorePersistenceTypes, value) { + return nil + } + return fmt.Errorf("invalid DB store type %s for online store", value) +} + +func checkRegistryDBStorePersistenceType(value string) error { + if slices.Contains(feastdevv1alpha1.ValidRegistryDBStorePersistenceTypes, value) { + return nil + } + return fmt.Errorf("invalid DB store type %s for registry", value) +} + +func (feast *FeastServices) getSecret(secretRef string) (*corev1.Secret, error) { + logger := log.FromContext(feast.Handler.Context) + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretRef, Namespace: feast.Handler.FeatureStore.Namespace}} + objectKey := client.ObjectKeyFromObject(secret) + if err := feast.Handler.Client.Get(feast.Handler.Context, objectKey, secret); err != nil { + if apierrors.IsNotFound(err) { + logger.Error(err, "invalid secret "+secretRef+" for offline store") + } + return nil, err + } + + return secret, nil +} + +// Function to check if a struct has a specific field or field tag and sets the value in the field if empty +func hasAttrib(s interface{}, fieldName string, value interface{}) (bool, error) { + val := reflect.ValueOf(s) + + // Check that the object is a pointer so we can modify it + if val.Kind() != reflect.Ptr || val.IsNil() { + return false, fmt.Errorf("expected a pointer to struct, got %v", val.Kind()) + } + + val = val.Elem() + + // Loop through the fields and check the tag + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := val.Type().Field(i) + + tagVal := fieldType.Tag.Get("yaml") + + // Remove other metadata if exists + commaIndex := strings.Index(tagVal, ",") + + if commaIndex != -1 { + tagVal = tagVal[:commaIndex] + } + + // Check if the field name or the tag value matches the one we're looking for + if strings.EqualFold(fieldType.Name, fieldName) || strings.EqualFold(tagVal, fieldName) { + + // Ensure the field is settable + if !field.CanSet() { + return false, fmt.Errorf("cannot set field %s", fieldName) + } + + // Check if the field is empty (zero value) + if field.IsZero() { + // Set the field value only if it's empty + field.Set(reflect.ValueOf(value)) + } + + return true, nil + } + } + + return false, nil +} + +func CopyMap(original map[string]interface{}) map[string]interface{} { + // Create a new map to store the copy + newCopy := make(map[string]interface{}) + + // Loop through the original map and copy each key-value pair + for key, value := range original { + newCopy[key] = value + } + + return newCopy +} + +// IsOpenShift is a global flag that can be safely called across reconciliation cycles, defined at the controller manager start. +func IsOpenShift() bool { + return isOpenShift +} + +// SetIsOpenShift sets the global flag isOpenShift by the controller manager. +// We don't need to keep fetching the API every reconciliation cycle that we need to know about the platform. +func SetIsOpenShift(cfg *rest.Config) { + if cfg == nil { + panic("Rest Config struct is nil, impossible to get cluster information") + } + // adapted from https://github.com/RHsyseng/operator-utils/blob/a226fabb2226a313dd3a16591c5579ebcd8a74b0/internal/platform/platform_versioner.go#L95 + client, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + panic(fmt.Sprintf("Impossible to get new client for config when fetching cluster information: %s", err)) + } + apiList, err := client.ServerGroups() + if err != nil { + panic(fmt.Sprintf("issue occurred while fetching ServerGroups: %s", err)) + } + + for _, v := range apiList.Groups { + if v.Name == "route.openshift.io" { + isOpenShift = true + break + } + } +} + +func missingOidcSecretProperty(property OidcPropertyType) error { + return fmt.Errorf(OidcMissingSecretError, property) +} + +// getEnvVar returns the position of the EnvVar found by name +func getEnvVar(envName string, env []corev1.EnvVar) int { + for pos, v := range env { + if v.Name == envName { + return pos + } + } + return -1 +} + +// envOverride replaces or appends the provided EnvVar to the collection +func envOverride(dst, src []corev1.EnvVar) []corev1.EnvVar { + for _, cre := range src { + pos := getEnvVar(cre.Name, dst) + if pos != -1 { + dst[pos] = cre + } else { + dst = append(dst, cre) + } + } + return dst +} + +func GetRegistryContainer(deployment appsv1.Deployment) *corev1.Container { + _, container := getContainerByType(RegistryFeastType, deployment.Spec.Template.Spec) + return container +} + +func GetOfflineContainer(deployment appsv1.Deployment) *corev1.Container { + _, container := getContainerByType(OfflineFeastType, deployment.Spec.Template.Spec) + return container +} + +func GetUIContainer(deployment appsv1.Deployment) *corev1.Container { + _, container := getContainerByType(UIFeastType, deployment.Spec.Template.Spec) + return container +} + +func GetOnlineContainer(deployment appsv1.Deployment) *corev1.Container { + _, container := getContainerByType(OnlineFeastType, deployment.Spec.Template.Spec) + return container +} + +func getContainerByType(feastType FeastServiceType, podSpec corev1.PodSpec) (int, *corev1.Container) { + for i, c := range podSpec.Containers { + if c.Name == string(feastType) { + return i, &c + } + } + return -1, nil +} + +func GetRegistryVolume(featureStore *feastdevv1alpha1.FeatureStore, volumes []corev1.Volume) *corev1.Volume { + return getVolumeByType(RegistryFeastType, featureStore, volumes) +} + +func GetOnlineVolume(featureStore *feastdevv1alpha1.FeatureStore, volumes []corev1.Volume) *corev1.Volume { + return getVolumeByType(OnlineFeastType, featureStore, volumes) +} + +func GetOfflineVolume(featureStore *feastdevv1alpha1.FeatureStore, volumes []corev1.Volume) *corev1.Volume { + return getVolumeByType(OfflineFeastType, featureStore, volumes) +} + +func getVolumeByType(feastType FeastServiceType, featureStore *feastdevv1alpha1.FeatureStore, volumes []corev1.Volume) *corev1.Volume { + for _, v := range volumes { + if v.Name == GetFeastServiceName(featureStore, feastType) { + return &v + } + } + return nil +} + +func GetRegistryVolumeMount(featureStore *feastdevv1alpha1.FeatureStore, volumeMounts []corev1.VolumeMount) *corev1.VolumeMount { + return getVolumeMountByType(RegistryFeastType, featureStore, volumeMounts) +} + +func GetOnlineVolumeMount(featureStore *feastdevv1alpha1.FeatureStore, volumeMounts []corev1.VolumeMount) *corev1.VolumeMount { + return getVolumeMountByType(OnlineFeastType, featureStore, volumeMounts) +} + +func GetOfflineVolumeMount(featureStore *feastdevv1alpha1.FeatureStore, volumeMounts []corev1.VolumeMount) *corev1.VolumeMount { + return getVolumeMountByType(OfflineFeastType, featureStore, volumeMounts) +} + +func getVolumeMountByType(feastType FeastServiceType, featureStore *feastdevv1alpha1.FeatureStore, volumeMounts []corev1.VolumeMount) *corev1.VolumeMount { + for _, vm := range volumeMounts { + if vm.Name == GetFeastServiceName(featureStore, feastType) { + return &vm + } + } + return nil +} diff --git a/infra/feast-operator/internal/controller/suite_test.go b/infra/feast-operator/internal/controller/suite_test.go index 57091df5c00..51208d6dbb0 100644 --- a/infra/feast-operator/internal/controller/suite_test.go +++ b/infra/feast-operator/internal/controller/suite_test.go @@ -26,20 +26,18 @@ import ( . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" - //+kubebuilder:scaffold:imports + // +kubebuilder:scaffold:imports ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -var cfg *rest.Config var k8sClient client.Client var testEnv *envtest.Environment @@ -66,16 +64,14 @@ var _ = BeforeSuite(func() { fmt.Sprintf("1.29.0-%s-%s", runtime.GOOS, runtime.GOARCH)), } - var err error - // cfg is defined in this file globally. - cfg, err = testEnv.Start() + cfg, err := testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil()) err = feastdevv1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) - //+kubebuilder:scaffold:scheme + // +kubebuilder:scaffold:scheme k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) diff --git a/infra/feast-operator/test/api/featurestore_types_test.go b/infra/feast-operator/test/api/featurestore_types_test.go new file mode 100644 index 00000000000..12a7406e80d --- /dev/null +++ b/infra/feast-operator/test/api/featurestore_types_test.go @@ -0,0 +1,479 @@ +package api + +import ( + "context" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func createFeatureStore() *feastdevv1alpha1.FeatureStore { + return &feastdevv1alpha1.FeatureStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: namespaceName, + }, + Spec: feastdevv1alpha1.FeatureStoreSpec{ + FeastProject: "test_project", + }, + } +} + +func attemptInvalidCreationAndAsserts(ctx context.Context, featurestore *feastdevv1alpha1.FeatureStore, matcher string) { + By("Creating the resource") + logger := log.FromContext(ctx) + logger.Info("Creating", "FeatureStore", featurestore) + err := k8sClient.Create(ctx, featurestore) + logger.Info("Got", "err", err) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring(matcher)) +} + +func onlineStoreWithAbsolutePathForPvc(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: "/data/online_store.db", + PvcConfig: &feastdevv1alpha1.PvcConfig{}, + }, + }, + }, + } + return fsCopy +} +func onlineStoreWithRelativePathForEphemeral(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: "data/online_store.db", + }, + }, + }, + } + return fsCopy +} + +func onlineStoreWithObjectStoreBucketForPvc(path string, featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + Path: path, + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: "/data/online", + }, + }, + }, + }, + } + return fsCopy +} + +func offlineStoreWithUnmanagedFileType(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + Type: "unmanaged", + }, + }, + }, + } + return fsCopy +} + +func registryWithAbsolutePathForPvc(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: "/data/registry.db", + PvcConfig: &feastdevv1alpha1.PvcConfig{}, + }}, + }, + }, + } + return fsCopy +} +func registryWithRelativePathForEphemeral(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: "data/online_store.db", + }, + }, + }, + }, + } + return fsCopy +} +func registryWithObjectStoreBucketForPvc(path string, featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: path, + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: "/data/registry", + }, + }, + }, + }, + }, + } + return fsCopy +} +func registryWithS3AdditionalKeywordsForFile(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: "/data/online_store.db", + S3AdditionalKwargs: &map[string]string{}, + }, + }, + }, + }, + } + return fsCopy +} +func registryWithS3AdditionalKeywordsForGsBucket(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + Path: "gs://online_store.db", + S3AdditionalKwargs: &map[string]string{}, + }, + }, + }, + }, + } + return fsCopy +} + +func pvcConfigWithNeitherRefNorCreate(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + PvcConfig: &feastdevv1alpha1.PvcConfig{}, + }, + }, + }, + } + return fsCopy +} +func pvcConfigWithBothRefAndCreate(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Ref: &corev1.LocalObjectReference{ + Name: "pvc", + }, + Create: &feastdevv1alpha1.PvcCreate{}, + }, + }, + }, + }, + } + return fsCopy +} + +func pvcConfigWithNoResources(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OfflineStoreFilePersistence{ + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: "/data/offline", + }, + }, + }, + }, + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + FilePersistence: &feastdevv1alpha1.OnlineStoreFilePersistence{ + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: "/data/online", + }, + }, + }, + }, + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + FilePersistence: &feastdevv1alpha1.RegistryFilePersistence{ + PvcConfig: &feastdevv1alpha1.PvcConfig{ + Create: &feastdevv1alpha1.PvcCreate{}, + MountPath: "/data/registry", + }, + }, + }, + }, + }, + } + return fsCopy +} + +func pvcConfigWithResources(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := pvcConfigWithNoResources(featureStore) + fsCopy.Spec.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.Resources = corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + } + fsCopy.Spec.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.Resources = corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + } + fsCopy.Spec.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.Resources = corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("500Mi"), + }, + } + return fsCopy +} + +func authzConfigWithKubernetes(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + if fsCopy.Spec.AuthzConfig == nil { + fsCopy.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{} + } + fsCopy.Spec.AuthzConfig.KubernetesAuthz = &feastdevv1alpha1.KubernetesAuthz{ + Roles: []string{}, + } + return fsCopy +} +func authzConfigWithOidc(featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + if fsCopy.Spec.AuthzConfig == nil { + fsCopy.Spec.AuthzConfig = &feastdevv1alpha1.AuthzConfig{} + } + fsCopy.Spec.AuthzConfig.OidcAuthz = &feastdevv1alpha1.OidcAuthz{} + return fsCopy +} + +func onlineStoreWithDBPersistenceType(dbPersistenceType string, featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OnlineStore: &feastdevv1alpha1.OnlineStore{ + Persistence: &feastdevv1alpha1.OnlineStorePersistence{ + DBPersistence: &feastdevv1alpha1.OnlineStoreDBStorePersistence{ + Type: dbPersistenceType, + }, + }, + }, + } + return fsCopy +} + +func offlineStoreWithDBPersistenceType(dbPersistenceType string, featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + OfflineStore: &feastdevv1alpha1.OfflineStore{ + Persistence: &feastdevv1alpha1.OfflineStorePersistence{ + DBPersistence: &feastdevv1alpha1.OfflineStoreDBStorePersistence{ + Type: dbPersistenceType, + }, + }, + }, + } + return fsCopy +} + +func registryStoreWithDBPersistenceType(dbPersistenceType string, featureStore *feastdevv1alpha1.FeatureStore) *feastdevv1alpha1.FeatureStore { + fsCopy := featureStore.DeepCopy() + fsCopy.Spec.Services = &feastdevv1alpha1.FeatureStoreServices{ + Registry: &feastdevv1alpha1.Registry{ + Local: &feastdevv1alpha1.LocalRegistryConfig{ + Persistence: &feastdevv1alpha1.RegistryPersistence{ + DBPersistence: &feastdevv1alpha1.RegistryDBStorePersistence{ + Type: dbPersistenceType, + }, + }, + }, + }, + } + return fsCopy +} + +func quotedSlice(stringSlice []string) string { + quotedSlice := make([]string, len(stringSlice)) + + for i, str := range stringSlice { + quotedSlice[i] = fmt.Sprintf("\"%s\"", str) + } + + return strings.Join(quotedSlice, ", ") +} + +const resourceName = "test-resource" +const namespaceName = "default" + +var typeNamespacedName = types.NamespacedName{ + Name: resourceName, + Namespace: "default", +} + +func initContext() (context.Context, *feastdevv1alpha1.FeatureStore) { + ctx := context.Background() + + featurestore := createFeatureStore() + + BeforeEach(func() { + By("verifying the custom resource FeatureStore is not there") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + Expect(err != nil && errors.IsNotFound(err)).To(BeTrue()) + }) + AfterEach(func() { + By("verifying the custom resource FeatureStore is not there") + err := k8sClient.Get(ctx, typeNamespacedName, featurestore) + Expect(err != nil && errors.IsNotFound(err)).To(BeTrue()) + }) + + return ctx, featurestore +} + +var _ = Describe("FeatureStore API", func() { + Context("When creating an invalid Online Store", func() { + ctx, featurestore := initContext() + + It("should fail when PVC persistence has absolute path", func() { + attemptInvalidCreationAndAsserts(ctx, onlineStoreWithAbsolutePathForPvc(featurestore), "PVC path must be a file name only") + }) + It("should fail when ephemeral persistence has relative path", func() { + attemptInvalidCreationAndAsserts(ctx, onlineStoreWithRelativePathForEphemeral(featurestore), "Ephemeral stores must have absolute paths") + }) + It("should fail when PVC persistence has object store bucket", func() { + attemptInvalidCreationAndAsserts(ctx, onlineStoreWithObjectStoreBucketForPvc("s3://bucket/online_store.db", featurestore), "Online store does not support S3 or GS") + attemptInvalidCreationAndAsserts(ctx, onlineStoreWithObjectStoreBucketForPvc("gs://bucket/online_store.db", featurestore), "Online store does not support S3 or GS") + }) + + It("should fail when db persistence type is invalid", func() { + attemptInvalidCreationAndAsserts(ctx, onlineStoreWithDBPersistenceType("invalid", featurestore), "Unsupported value: \"invalid\": supported values: "+quotedSlice(feastdevv1alpha1.ValidOnlineStoreDBStorePersistenceTypes)) + }) + }) + + Context("When creating an invalid Offline Store", func() { + ctx, featurestore := initContext() + + It("should fail when PVC persistence has absolute path", func() { + attemptInvalidCreationAndAsserts(ctx, offlineStoreWithUnmanagedFileType(featurestore), "Unsupported value") + }) + It("should fail when db persistence type is invalid", func() { + attemptInvalidCreationAndAsserts(ctx, offlineStoreWithDBPersistenceType("invalid", featurestore), "Unsupported value: \"invalid\": supported values: "+quotedSlice(feastdevv1alpha1.ValidOfflineStoreDBStorePersistenceTypes)) + }) + }) + + Context("When creating an invalid Registry", func() { + ctx, featurestore := initContext() + + It("should fail when PVC persistence has absolute path", func() { + attemptInvalidCreationAndAsserts(ctx, registryWithAbsolutePathForPvc(featurestore), "PVC path must be a file name only") + }) + It("should fail when ephemeral persistence has relative path", func() { + attemptInvalidCreationAndAsserts(ctx, registryWithRelativePathForEphemeral(featurestore), "Registry files must use absolute paths or be S3 ('s3://') or GS ('gs://')") + }) + It("should fail when PVC persistence has object store bucket", func() { + attemptInvalidCreationAndAsserts(ctx, registryWithObjectStoreBucketForPvc("s3://bucket/registry.db", featurestore), "PVC persistence does not support S3 or GS object store URIs") + attemptInvalidCreationAndAsserts(ctx, registryWithObjectStoreBucketForPvc("gs://bucket/registry.db", featurestore), "PVC persistence does not support S3 or GS object store URIs") + }) + It("should fail when additional S3 settings are provided to non S3 bucket", func() { + attemptInvalidCreationAndAsserts(ctx, registryWithS3AdditionalKeywordsForFile(featurestore), "Additional S3 settings are available only for S3 object store URIs") + attemptInvalidCreationAndAsserts(ctx, registryWithS3AdditionalKeywordsForGsBucket(featurestore), "Additional S3 settings are available only for S3 object store URIs") + }) + It("should fail when db persistence type is invalid", func() { + attemptInvalidCreationAndAsserts(ctx, registryStoreWithDBPersistenceType("invalid", featurestore), "Unsupported value: \"invalid\": supported values: "+quotedSlice(feastdevv1alpha1.ValidRegistryDBStorePersistenceTypes)) + }) + }) + + Context("When creating an invalid PvcConfig", func() { + ctx, featurestore := initContext() + + It("should fail when neither ref nor create settings are given", func() { + attemptInvalidCreationAndAsserts(ctx, pvcConfigWithNeitherRefNorCreate(featurestore), "One selection is required") + }) + It("should fail when both ref and create settings are given", func() { + attemptInvalidCreationAndAsserts(ctx, pvcConfigWithBothRefAndCreate(featurestore), "One selection is required") + }) + }) + + Context("When creating a valid PvcConfig", func() { + _, featurestore := initContext() + + It("should set the expected defaults", func() { + resource := pvcConfigWithNoResources(featurestore) + services.ApplyDefaultsToStatus(resource) + + storage := resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.Resources.Requests.Storage().String() + Expect(storage).To(Equal("20Gi")) + storage = resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.Resources.Requests.Storage().String() + Expect(storage).To(Equal("5Gi")) + storage = resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.Resources.Requests.Storage().String() + Expect(storage).To(Equal("5Gi")) + }) + It("should not override the configured resources", func() { + resource := pvcConfigWithResources(featurestore) + services.ApplyDefaultsToStatus(resource) + storage := resource.Status.Applied.Services.OfflineStore.Persistence.FilePersistence.PvcConfig.Create.Resources.Requests.Storage().String() + Expect(storage).To(Equal("10Gi")) + storage = resource.Status.Applied.Services.OnlineStore.Persistence.FilePersistence.PvcConfig.Create.Resources.Requests.Storage().String() + Expect(storage).To(Equal("1Gi")) + storage = resource.Status.Applied.Services.Registry.Local.Persistence.FilePersistence.PvcConfig.Create.Resources.Requests.Storage().String() + Expect(storage).To(Equal("500Mi")) + }) + }) + Context("When omitting the AuthzConfig PvcConfig", func() { + _, featurestore := initContext() + It("should keep an empty AuthzConfig", func() { + resource := featurestore + services.ApplyDefaultsToStatus(resource) + Expect(resource.Status.Applied.AuthzConfig).To(BeNil()) + }) + }) + Context("When configuring the AuthzConfig", func() { + ctx, featurestore := initContext() + It("should fail when both kubernetes and oidc settings are given", func() { + attemptInvalidCreationAndAsserts(ctx, authzConfigWithOidc(authzConfigWithKubernetes(featurestore)), "One selection required between kubernetes or oidc") + }) + }) +}) diff --git a/infra/feast-operator/test/api/suite_test.go b/infra/feast-operator/test/api/suite_test.go new file mode 100644 index 00000000000..e8c46a240c1 --- /dev/null +++ b/infra/feast-operator/test/api/suite_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 Feast Community. + +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 api + +import ( + "fmt" + "path/filepath" + "runtime" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment + +func TestApis(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Api Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", + fmt.Sprintf("1.29.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = feastdevv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/infra/feast-operator/test/data-source-types/data-source-types.py b/infra/feast-operator/test/data-source-types/data-source-types.py new file mode 100644 index 00000000000..be7d70e5ede --- /dev/null +++ b/infra/feast-operator/test/data-source-types/data-source-types.py @@ -0,0 +1,18 @@ +import os +from feast.repo_config import REGISTRY_CLASS_FOR_TYPE, OFFLINE_STORE_CLASS_FOR_TYPE, ONLINE_STORE_CLASS_FOR_TYPE, LEGACY_ONLINE_STORE_CLASS_FOR_TYPE + +def save_in_script_directory(filename: str, typedict: dict[str, str]): + script_dir = os.path.dirname(os.path.abspath(__file__)) + file_path = os.path.join(script_dir, filename) + + with open(file_path, 'w') as file: + for k in typedict.keys(): + file.write(k+"\n") + +for legacyType in LEGACY_ONLINE_STORE_CLASS_FOR_TYPE.keys(): + if legacyType in ONLINE_STORE_CLASS_FOR_TYPE: + del ONLINE_STORE_CLASS_FOR_TYPE[legacyType] + +save_in_script_directory("registry.out", REGISTRY_CLASS_FOR_TYPE) +save_in_script_directory("online-store.out", ONLINE_STORE_CLASS_FOR_TYPE) +save_in_script_directory("offline-store.out", OFFLINE_STORE_CLASS_FOR_TYPE) diff --git a/infra/feast-operator/test/data-source-types/data_source_types_test.go b/infra/feast-operator/test/data-source-types/data_source_types_test.go new file mode 100644 index 00000000000..8448b2c4212 --- /dev/null +++ b/infra/feast-operator/test/data-source-types/data_source_types_test.go @@ -0,0 +1,88 @@ +package datasources + +import ( + "bufio" + "os" + "slices" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + feastdevv1alpha1 "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" + "github.com/feast-dev/feast/infra/feast-operator/internal/controller/services" +) + +func TestDataSourceTypes(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Data Source Suite") +} + +var _ = Describe("FeatureStore Data Source Types", func() { + Context("When checking against the python code in feast.repo_config", func() { + It("should match defined registry persistence types in the operator", func() { + registryFilePersistenceTypes := []string{string(services.RegistryFileConfigType)} + registryPersistenceTypes := append(feastdevv1alpha1.ValidRegistryDBStorePersistenceTypes, registryFilePersistenceTypes...) + checkPythonPersistenceTypes("registry.out", registryPersistenceTypes) + }) + It("should match defined onlineStore persistence types in the operator", func() { + onlineFilePersistenceTypes := []string{string(services.OnlineSqliteConfigType)} + onlinePersistenceTypes := append(feastdevv1alpha1.ValidOnlineStoreDBStorePersistenceTypes, onlineFilePersistenceTypes...) + checkPythonPersistenceTypes("online-store.out", onlinePersistenceTypes) + }) + It("should match defined offlineStore persistence types in the operator", func() { + offlinePersistenceTypes := append(feastdevv1alpha1.ValidOfflineStoreDBStorePersistenceTypes, feastdevv1alpha1.ValidOfflineStoreFilePersistenceTypes...) + checkPythonPersistenceTypes("offline-store.out", offlinePersistenceTypes) + }) + }) +}) + +func checkPythonPersistenceTypes(fileName string, operatorDsTypes []string) { + feastDsTypes, err := readFileLines(fileName) + Expect(err).NotTo(HaveOccurred()) + + // Add remote type to slice, as its not a file or db type and we want to limit its use to registry service when deploying with the operator + operatorDsTypes = append(operatorDsTypes, "remote") + missingFeastTypes := []string{} + for _, ods := range operatorDsTypes { + if len(ods) > 0 { + if !slices.Contains(feastDsTypes, ods) { + missingFeastTypes = append(missingFeastTypes, ods) + } + } + } + Expect(missingFeastTypes).To(BeEmpty()) + + missingOperatorTypes := []string{} + for _, fds := range feastDsTypes { + if len(fds) > 0 { + if !slices.Contains(operatorDsTypes, fds) { + missingOperatorTypes = append(missingOperatorTypes, fds) + } + } + } + Expect(missingOperatorTypes).To(BeEmpty()) +} + +func readFileLines(filePath string) ([]string, error) { + file, err := os.Open(filePath) + Expect(err).NotTo(HaveOccurred()) + defer closeFile(file) + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + err = scanner.Err() + Expect(err).NotTo(HaveOccurred()) + + return lines, nil +} + +func closeFile(file *os.File) { + err := file.Close() + Expect(err).NotTo(HaveOccurred()) +} diff --git a/infra/feast-operator/test/e2e/e2e_suite_test.go b/infra/feast-operator/test/e2e/e2e_suite_test.go index 8e46d8a5063..c45853a0073 100644 --- a/infra/feast-operator/test/e2e/e2e_suite_test.go +++ b/infra/feast-operator/test/e2e/e2e_suite_test.go @@ -27,6 +27,6 @@ import ( // Run e2e tests using the Ginkgo runner. func TestE2E(t *testing.T) { RegisterFailHandler(Fail) - fmt.Fprintf(GinkgoWriter, "Starting feast-operator suite\n") + _, _ = fmt.Fprintf(GinkgoWriter, "Starting feast-operator suite\n") RunSpecs(t, "e2e suite") } diff --git a/infra/feast-operator/test/e2e/e2e_test.go b/infra/feast-operator/test/e2e/e2e_test.go index b46b3105d22..d1051900ae5 100644 --- a/infra/feast-operator/test/e2e/e2e_test.go +++ b/infra/feast-operator/test/e2e/e2e_test.go @@ -17,106 +17,38 @@ limitations under the License. package e2e import ( - "fmt" - "os/exec" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/feast-dev/feast/infra/feast-operator/test/utils" + . "github.com/onsi/ginkgo/v2" ) -const namespace = "feast-operator-system" - var _ = Describe("controller", Ordered, func() { - BeforeAll(func() { - By("installing prometheus operator") - Expect(utils.InstallPrometheusOperator()).To(Succeed()) - - By("installing the cert-manager") - Expect(utils.InstallCertManager()).To(Succeed()) + featureStoreName := "simple-feast-setup" + feastResourceName := utils.FeastPrefix + featureStoreName + feastK8sResourceNames := []string{ + feastResourceName + "-online", + feastResourceName + "-offline", + feastResourceName + "-ui", + } + + runTestDeploySimpleCRFunc := utils.GetTestDeploySimpleCRFunc("/test/e2e", + "test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml", + featureStoreName, feastResourceName, feastK8sResourceNames) + + runTestWithRemoteRegistryFunction := utils.GetTestWithRemoteRegistryFunc("/test/e2e", + "test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml", + "test/testdata/feast_integration_test_crs/v1alpha1_remote_registry_featurestore.yaml", + featureStoreName, feastResourceName, feastK8sResourceNames) - By("creating manager namespace") - cmd := exec.Command("kubectl", "create", "ns", namespace) - _, _ = utils.Run(cmd) + BeforeAll(func() { + utils.DeployOperatorFromCode("/test/e2e", false) }) AfterAll(func() { - By("uninstalling the Prometheus manager bundle") - utils.UninstallPrometheusOperator() - - By("uninstalling the cert-manager bundle") - utils.UninstallCertManager() - - By("removing manager namespace") - cmd := exec.Command("kubectl", "delete", "ns", namespace) - _, _ = utils.Run(cmd) + utils.DeleteOperatorDeployment("/test/e2e") }) - Context("Operator", func() { - It("should run successfully", func() { - var controllerPodName string - var err error - - // projectimage stores the name of the image used in the example - var projectimage = "example.com/feast-operator:v0.0.1" - - By("building the manager(Operator) image") - cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("loading the the manager(Operator) image on Kind") - err = utils.LoadImageToKindClusterWithName(projectimage) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("installing CRDs") - cmd = exec.Command("make", "install") - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("deploying the controller-manager") - cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage)) - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("validating that the controller-manager pod is running as expected") - verifyControllerUp := func() error { - // Get pod name - - cmd = exec.Command("kubectl", "get", - "pods", "-l", "control-plane=controller-manager", - "-o", "go-template={{ range .items }}"+ - "{{ if not .metadata.deletionTimestamp }}"+ - "{{ .metadata.name }}"+ - "{{ \"\\n\" }}{{ end }}{{ end }}", - "-n", namespace, - ) - - podOutput, err := utils.Run(cmd) - ExpectWithOffset(2, err).NotTo(HaveOccurred()) - podNames := utils.GetNonEmptyLines(string(podOutput)) - if len(podNames) != 1 { - return fmt.Errorf("expect 1 controller pods running, but got %d", len(podNames)) - } - controllerPodName = podNames[0] - ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("controller-manager")) - - // Validate pod status - cmd = exec.Command("kubectl", "get", - "pods", controllerPodName, "-o", "jsonpath={.status.phase}", - "-n", namespace, - ) - status, err := utils.Run(cmd) - ExpectWithOffset(2, err).NotTo(HaveOccurred()) - if string(status) != "Running" { - return fmt.Errorf("controller pod in %s status", status) - } - return nil - } - EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) - - }) + Context("Operator E2E Tests", func() { + It("Should be able to deploy and run a default feature store CR successfully", runTestDeploySimpleCRFunc) + It("Should be able to deploy and run a feature store with remote registry CR successfully", runTestWithRemoteRegistryFunction) }) }) diff --git a/infra/feast-operator/test/previous-version/previous_version_suite_test.go b/infra/feast-operator/test/previous-version/previous_version_suite_test.go new file mode 100644 index 00000000000..cd14c89d2d6 --- /dev/null +++ b/infra/feast-operator/test/previous-version/previous_version_suite_test.go @@ -0,0 +1,32 @@ +/* +Copyright 2024 Feast Community. + +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 previous_version + +import ( + "fmt" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Run upgrade tests using the Ginkgo runner. +func TestPreviousVersion(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting test previous version suite\n") + RunSpecs(t, "previous version operator") +} diff --git a/infra/feast-operator/test/previous-version/previous_version_test.go b/infra/feast-operator/test/previous-version/previous_version_test.go new file mode 100644 index 00000000000..9775d239bcc --- /dev/null +++ b/infra/feast-operator/test/previous-version/previous_version_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 Feast Community. + +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 previous_version + +import ( + "github.com/feast-dev/feast/infra/feast-operator/test/utils" + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("previous version operator", Ordered, func() { + BeforeAll(func() { + utils.DeployPreviousVersionOperator() + }) + + AfterAll(func() { + utils.DeleteOperatorDeployment("/test/upgrade") + }) + + Context("Previous version operator Tests", func() { + feastK8sResourceNames := []string{ + utils.FeastResourceName + "-online", + utils.FeastResourceName + "-offline", + utils.FeastResourceName + "-ui", + } + + runTestDeploySimpleCRFunc := utils.GetTestDeploySimpleCRFunc("/test/upgrade", utils.GetSimplePreviousVerCR(), + utils.FeatureStoreName, utils.FeastResourceName, feastK8sResourceNames) + runTestWithRemoteRegistryFunction := utils.GetTestWithRemoteRegistryFunc("/test/upgrade", utils.GetSimplePreviousVerCR(), + utils.GetRemoteRegistryPreviousVerCR(), utils.FeatureStoreName, utils.FeastResourceName, feastK8sResourceNames) + + // Run Test on previous version operator + It("Should be able to deploy and run a default feature store CR successfully", runTestDeploySimpleCRFunc) + It("Should be able to deploy and run a feature store with remote registry CR successfully", runTestWithRemoteRegistryFunction) + }) +}) diff --git a/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml b/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml new file mode 100644 index 00000000000..9edf1dd9664 --- /dev/null +++ b/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml @@ -0,0 +1,13 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: simple-feast-setup +spec: + feastProject: my_project + services: + offlineStore: + server: {} + registry: + local: + server: {} + ui: {} diff --git a/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_remote_registry_featurestore.yaml b/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_remote_registry_featurestore.yaml new file mode 100644 index 00000000000..9746e3819a5 --- /dev/null +++ b/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_remote_registry_featurestore.yaml @@ -0,0 +1,15 @@ +apiVersion: feast.dev/v1alpha1 +kind: FeatureStore +metadata: + name: simple-feast-remote-setup +spec: + feastProject: my_project + services: + offlineStore: + server: {} + ui: {} + registry: + remote: + feastRef: + name: simple-feast-setup + namespace: default \ No newline at end of file diff --git a/infra/feast-operator/test/upgrade/upgrade_suite_test.go b/infra/feast-operator/test/upgrade/upgrade_suite_test.go new file mode 100644 index 00000000000..bd0da7ab177 --- /dev/null +++ b/infra/feast-operator/test/upgrade/upgrade_suite_test.go @@ -0,0 +1,32 @@ +/* +Copyright 2024 Feast Community. + +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 previous_version + +import ( + "fmt" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Run upgrade tests using the Ginkgo runner. +func TestUpgrade(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting upgrade operator suite\n") + RunSpecs(t, "operator upgrade") +} diff --git a/infra/feast-operator/test/upgrade/upgrade_test.go b/infra/feast-operator/test/upgrade/upgrade_test.go new file mode 100644 index 00000000000..313fa41213c --- /dev/null +++ b/infra/feast-operator/test/upgrade/upgrade_test.go @@ -0,0 +1,44 @@ +/* +Copyright 2024 Feast Community. + +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 previous_version + +import ( + "github.com/feast-dev/feast/infra/feast-operator/test/utils" + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("operator upgrade", Ordered, func() { + BeforeAll(func() { + utils.DeployPreviousVersionOperator() + utils.DeployOperatorFromCode("/test/e2e", true) + }) + + AfterAll(func() { + utils.DeleteOperatorDeployment("/test/e2e") + }) + + Context("Operator upgrade Tests", func() { + runTestDeploySimpleCRFunc := utils.GetTestDeploySimpleCRFunc("/test/upgrade", utils.GetSimplePreviousVerCR(), + utils.FeatureStoreName, utils.FeastResourceName, []string{}) + runTestWithRemoteRegistryFunction := utils.GetTestWithRemoteRegistryFunc("/test/upgrade", utils.GetSimplePreviousVerCR(), + utils.GetRemoteRegistryPreviousVerCR(), utils.FeatureStoreName, utils.FeastResourceName, []string{}) + + // Run Test on current version operator with previous version CR + It("Should be able to deploy and run a default feature store CR successfully", runTestDeploySimpleCRFunc) + It("Should be able to deploy and run a feature store with remote registry CR successfully", runTestWithRemoteRegistryFunction) + }) +}) diff --git a/infra/feast-operator/test/utils/test_util.go b/infra/feast-operator/test/utils/test_util.go new file mode 100644 index 00000000000..b34c4272c46 --- /dev/null +++ b/infra/feast-operator/test/utils/test_util.go @@ -0,0 +1,448 @@ +package utils + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + + "github.com/feast-dev/feast/infra/feast-operator/api/feastversion" + "github.com/feast-dev/feast/infra/feast-operator/api/v1alpha1" +) + +const ( + FeastControllerNamespace = "feast-operator-system" + Timeout = 3 * time.Minute + ControllerDeploymentName = "feast-operator-controller-manager" + FeastPrefix = "feast-" + FeatureStoreName = "simple-feast-setup" + FeastResourceName = FeastPrefix + FeatureStoreName +) + +// dynamically checks if all conditions of custom resource featurestore are in "Ready" state. +func checkIfFeatureStoreCustomResourceConditionsInReady(featureStoreName, namespace string) error { + // Wait 10 seconds to lets the feature store status update + time.Sleep(1 * time.Minute) + + cmd := exec.Command("kubectl", "get", "featurestore", featureStoreName, "-n", namespace, "-o", "json") + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to get resource %s in namespace %s. Error: %v. Stderr: %s", + featureStoreName, namespace, err, stderr.String()) + } + + // Parse the JSON into FeatureStore + var resource v1alpha1.FeatureStore + if err := json.Unmarshal(out.Bytes(), &resource); err != nil { + return fmt.Errorf("failed to parse the resource JSON. Error: %v", err) + } + + // Validate all conditions + for _, condition := range resource.Status.Conditions { + if condition.Status != "True" { + return fmt.Errorf(" FeatureStore=%s condition '%s' is not in 'Ready' state. Status: %s", + featureStoreName, condition.Type, condition.Status) + } + } + + return nil +} + +// CheckIfDeploymentExistsAndAvailable - validates if a deployment exists and also in the availability state as True. +func CheckIfDeploymentExistsAndAvailable(namespace string, deploymentName string, timeout time.Duration) error { + var output, errOutput bytes.Buffer + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeoutChan := time.After(timeout) + + for { + select { + case <-timeoutChan: + return fmt.Errorf("timed out waiting for deployment %s to become available", deploymentName) + case <-ticker.C: + // Run kubectl command + cmd := exec.Command("kubectl", "get", "deployment", deploymentName, "-n", namespace, "-o", "json") + cmd.Stdout = &output + cmd.Stderr = &errOutput + + if err := cmd.Run(); err != nil { + // Log error and retry + fmt.Printf("Deployment not yet found, we may try again to find the updated status: %s\n", errOutput.String()) + continue + } + + // Parse the JSON output into Deployment + var result appsv1.Deployment + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + return fmt.Errorf("failed to parse deployment JSON: %v", err) + } + + // Check for Available condition + for _, condition := range result.Status.Conditions { + if condition.Type == "Available" && condition.Status == "True" { + return nil // Deployment is available + } + } + + // Reset buffers for the next loop iteration + output.Reset() + errOutput.Reset() + } + } +} + +// validates if a service account exists using the kubectl CLI. +func checkIfServiceAccountExists(namespace, saName string) error { + cmd := exec.Command("kubectl", "get", "sa", saName, "-n", namespace) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to find service account %s in namespace %s. Error: %v. Stderr: %s", + saName, namespace, err, stderr.String()) + } + + // Check the output to confirm presence + if !strings.Contains(out.String(), saName) { + return fmt.Errorf("service account %s not found in namespace %s", saName, namespace) + } + + return nil +} + +// validates if a config map exists using the kubectl CLI. +func checkIfConfigMapExists(namespace, configMapName string) error { + cmd := exec.Command("kubectl", "get", "cm", configMapName, "-n", namespace) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to find config map %s in namespace %s. Error: %v. Stderr: %s", + configMapName, namespace, err, stderr.String()) + } + + // Check the output to confirm presence + if !strings.Contains(out.String(), configMapName) { + return fmt.Errorf("config map %s not found in namespace %s", configMapName, namespace) + } + + return nil +} + +// validates if a kubernetes service exists using the kubectl CLI. +func checkIfKubernetesServiceExists(namespace, serviceName string) error { + cmd := exec.Command("kubectl", "get", "service", serviceName, "-n", namespace) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to find kubernetes service %s in namespace %s. Error: %v. Stderr: %s", + serviceName, namespace, err, stderr.String()) + } + + // Check the output to confirm presence + if !strings.Contains(out.String(), serviceName) { + return fmt.Errorf("kubernetes service %s not found in namespace %s", serviceName, namespace) + } + + return nil +} + +func isFeatureStoreHavingRemoteRegistry(namespace, featureStoreName string) (bool, error) { + timeout := time.Second * 30 + interval := time.Second * 2 // Poll every 2 seconds + startTime := time.Now() + + for time.Since(startTime) < timeout { + cmd := exec.Command("kubectl", "get", "featurestore", featureStoreName, "-n", namespace, + "-o=jsonpath='{.status.applied.services.registry}'") + + output, err := cmd.Output() + if err != nil { + // Retry only on transient errors + if _, ok := err.(*exec.ExitError); ok { + time.Sleep(interval) + continue + } + return false, err // Return immediately on non-transient errors + } + + // Convert output to string and trim any extra spaces + result := strings.TrimSpace(string(output)) + + // Remove single quotes if present + if strings.HasPrefix(result, "'") && strings.HasSuffix(result, "'") { + result = strings.Trim(result, "'") + } + + if result == "" { + time.Sleep(interval) // Retry if result is empty + continue + } + + // Parse the JSON into a map + var registryConfig v1alpha1.Registry + if err := json.Unmarshal([]byte(result), ®istryConfig); err != nil { + return false, err // Return false on JSON parsing failure + } + + if registryConfig.Remote == nil { + return false, nil + } + + hasHostname := registryConfig.Remote.Hostname != nil + hasValidFeastRef := registryConfig.Remote.FeastRef != nil && + registryConfig.Remote.FeastRef.Name != "" + + return hasHostname || hasValidFeastRef, nil + } + + return false, errors.New("timeout waiting for featurestore registry status to be ready") +} + +func validateTheFeatureStoreCustomResource(namespace string, featureStoreName string, feastResourceName string, feastK8sResourceNames []string, timeout time.Duration) { + hasRemoteRegistry, err := isFeatureStoreHavingRemoteRegistry(namespace, featureStoreName) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "Error occurred while checking FeatureStore %s is having remote registry or not. \nError: %v\n", + featureStoreName, err)) + + k8sResourceNames := []string{feastResourceName} + + if !hasRemoteRegistry { + feastK8sResourceNames = append(feastK8sResourceNames, feastResourceName+"-registry") + } + + for _, deploymentName := range k8sResourceNames { + By(fmt.Sprintf("validate the feast deployment: %s is up and in availability state.", deploymentName)) + err = CheckIfDeploymentExistsAndAvailable(namespace, deploymentName, timeout) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "Deployment %s is not available but expected to be available. \nError: %v\n", + deploymentName, err, + )) + fmt.Printf("Feast Deployment %s is available\n", deploymentName) + } + + By("Check if the feast client - kubernetes config map exists.") + configMapName := feastResourceName + "-client" + err = checkIfConfigMapExists(namespace, configMapName) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "config map %s is not available but expected to be available. \nError: %v\n", + configMapName, err, + )) + fmt.Printf("Feast Deployment client config map %s is available\n", configMapName) + + for _, serviceAccountName := range k8sResourceNames { + By(fmt.Sprintf("validate the feast service account: %s is available.", serviceAccountName)) + err = checkIfServiceAccountExists(namespace, serviceAccountName) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "Service account %s does not exist in namespace %s. Error: %v", + serviceAccountName, namespace, err, + )) + fmt.Printf("Service account %s exists in namespace %s\n", serviceAccountName, namespace) + } + + for _, serviceName := range feastK8sResourceNames { + By(fmt.Sprintf("validate the kubernetes service name: %s is available.", serviceName)) + err = checkIfKubernetesServiceExists(namespace, serviceName) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "kubernetes service %s is not available but expected to be available. \nError: %v\n", + serviceName, err, + )) + fmt.Printf("kubernetes service %s is available\n", serviceName) + } + + By(fmt.Sprintf("Checking FeatureStore customer resource: %s is in Ready Status.", featureStoreName)) + err = checkIfFeatureStoreCustomResourceConditionsInReady(featureStoreName, namespace) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "FeatureStore custom resource %s all conditions are not in ready state. \nError: %v\n", + featureStoreName, err, + )) + fmt.Printf("FeatureStore custom resource %s conditions are in Ready State\n", featureStoreName) +} + +// GetTestDeploySimpleCRFunc - returns a simple CR deployment function +func GetTestDeploySimpleCRFunc(testDir string, crYaml string, featureStoreName string, feastResourceName string, feastK8sResourceNames []string) func() { + return func() { + By("deploying the Simple Feast Custom Resource to Kubernetes") + namespace := "default" + + cmd := exec.Command("kubectl", "apply", "-f", crYaml, "-n", namespace) + _, cmdOutputerr := Run(cmd, testDir) + ExpectWithOffset(1, cmdOutputerr).NotTo(HaveOccurred()) + + validateTheFeatureStoreCustomResource(namespace, featureStoreName, feastResourceName, feastK8sResourceNames, Timeout) + + By("deleting the feast deployment") + cmd = exec.Command("kubectl", "delete", "-f", crYaml) + _, cmdOutputerr = Run(cmd, testDir) + ExpectWithOffset(1, cmdOutputerr).NotTo(HaveOccurred()) + } +} + +// GetTestWithRemoteRegistryFunc - returns a CR deployment with a remote registry function +func GetTestWithRemoteRegistryFunc(testDir string, crYaml string, remoteRegistryCRYaml string, featureStoreName string, feastResourceName string, feastK8sResourceNames []string) func() { + return func() { + By("deploying the Simple Feast Custom Resource to Kubernetes") + namespace := "default" + cmd := exec.Command("kubectl", "apply", "-f", crYaml, "-n", namespace) + _, cmdOutputErr := Run(cmd, testDir) + ExpectWithOffset(1, cmdOutputErr).NotTo(HaveOccurred()) + + validateTheFeatureStoreCustomResource(namespace, featureStoreName, feastResourceName, feastK8sResourceNames, Timeout) + + var remoteRegistryNs = "remote-registry" + By(fmt.Sprintf("Creating the remote registry namespace=%s", remoteRegistryNs)) + cmd = exec.Command("kubectl", "create", "ns", remoteRegistryNs) + _, _ = Run(cmd, testDir) + + By("deploying the Simple Feast remote registry Custom Resource on Kubernetes") + cmd = exec.Command("kubectl", "apply", "-f", remoteRegistryCRYaml, "-n", remoteRegistryNs) + _, cmdOutputErr = Run(cmd, testDir) + ExpectWithOffset(1, cmdOutputErr).NotTo(HaveOccurred()) + + remoteFeatureStoreName := "simple-feast-remote-setup" + remoteFeastResourceName := FeastPrefix + remoteFeatureStoreName + fixRemoteFeastK8sResourceNames(feastK8sResourceNames, remoteFeastResourceName) + validateTheFeatureStoreCustomResource(remoteRegistryNs, remoteFeatureStoreName, remoteFeastResourceName, feastK8sResourceNames, Timeout) + + By("deleting the feast remote registry deployment") + cmd = exec.Command("kubectl", "delete", "-f", remoteRegistryCRYaml, "-n", remoteRegistryNs) + _, cmdOutputErr = Run(cmd, testDir) + ExpectWithOffset(1, cmdOutputErr).NotTo(HaveOccurred()) + + By("deleting the feast deployment") + cmd = exec.Command("kubectl", "delete", "-f", crYaml, "-n", namespace) + _, cmdOutputErr = Run(cmd, testDir) + ExpectWithOffset(1, cmdOutputErr).NotTo(HaveOccurred()) + } +} + +func fixRemoteFeastK8sResourceNames(feastK8sResourceNames []string, remoteFeastResourceName string) { + for i, feastK8sResourceName := range feastK8sResourceNames { + if index := strings.LastIndex(feastK8sResourceName, "-"); index != -1 { + feastK8sResourceNames[i] = remoteFeastResourceName + feastK8sResourceName[index:] + } + } +} + +// DeployOperatorFromCode - Creates the images for the operator and deploys it +func DeployOperatorFromCode(testDir string, skipBuilds bool) { + _, isRunOnOpenShiftCI := os.LookupEnv("RUN_ON_OPENSHIFT_CI") + if !isRunOnOpenShiftCI { + By("creating manager namespace") + cmd := exec.Command("kubectl", "create", "ns", FeastControllerNamespace) + _, _ = Run(cmd, testDir) + + var err error + // projectimage stores the name of the image used in the example + var projectimage = "localhost/feast-operator:v0.0.1" + + // this image will be built in above make target. + var feastImage = "feastdev/feature-server:dev" + var feastLocalImage = "localhost/feastdev/feature-server:dev" + + if !skipBuilds { + By("building the manager(Operator) image") + cmd = exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) + _, err = Run(cmd, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("loading the the manager(Operator) image on Kind") + err = LoadImageToKindClusterWithName(projectimage, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("building the feast image") + cmd = exec.Command("make", "feast-ci-dev-docker-img") + _, err = Run(cmd, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("Tag the local feast image for the integration tests") + cmd = exec.Command("docker", "image", "tag", feastImage, feastLocalImage) + _, err = Run(cmd, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("loading the the feast image on Kind cluster") + err = LoadImageToKindClusterWithName(feastLocalImage, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("installing CRDs") + cmd = exec.Command("make", "install") + _, err = Run(cmd, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + By("deploying the controller-manager") + cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage), fmt.Sprintf("FS_IMG=%s", feastLocalImage)) + _, err = Run(cmd, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } + + By("Validating that the controller-manager deployment is in available state") + err := CheckIfDeploymentExistsAndAvailable(FeastControllerNamespace, ControllerDeploymentName, Timeout) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "Deployment %s is not available but expected to be available. \nError: %v\n", + ControllerDeploymentName, err, + )) + fmt.Printf("Feast Control Manager Deployment %s is available\n", ControllerDeploymentName) +} + +// DeleteOperatorDeployment - Deletes the operator deployment +func DeleteOperatorDeployment(testDir string) { + _, isRunOnOpenShiftCI := os.LookupEnv("RUN_ON_OPENSHIFT_CI") + if !isRunOnOpenShiftCI { + By("Uninstalling the feast CRD") + cmd := exec.Command("kubectl", "delete", "deployment", ControllerDeploymentName, "-n", FeastControllerNamespace) + _, err := Run(cmd, testDir) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + } +} + +// DeployPreviousVersionOperator - Deploys the previous version of the operator +func DeployPreviousVersionOperator() { + var err error + + cmd := exec.Command("kubectl", "apply", "-f", fmt.Sprintf("https://raw.githubusercontent.com/feast-dev/feast/refs/tags/v%s/infra/feast-operator/dist/install.yaml", feastversion.FeastVersion)) + _, err = Run(cmd, "/test/upgrade") + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + err = CheckIfDeploymentExistsAndAvailable(FeastControllerNamespace, ControllerDeploymentName, Timeout) + Expect(err).ToNot(HaveOccurred(), fmt.Sprintf( + "Deployment %s is not available but expected to be available. \nError: %v\n", + ControllerDeploymentName, err, + )) + fmt.Printf("Feast Control Manager Deployment %s is available\n", ControllerDeploymentName) +} + +// GetSimplePreviousVerCR - Get The previous version simple CR for tests +func GetSimplePreviousVerCR() string { + return fmt.Sprintf("https://raw.githubusercontent.com/feast-dev/feast/refs/tags/v%s/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_default_featurestore.yaml", feastversion.FeastVersion) +} + +// GetRemoteRegistryPreviousVerCR - Get The previous version remote registry CR for tests +func GetRemoteRegistryPreviousVerCR() string { + return fmt.Sprintf("https://raw.githubusercontent.com/feast-dev/feast/refs/tags/v%s/infra/feast-operator/test/testdata/feast_integration_test_crs/v1alpha1_remote_registry_featurestore.yaml", feastversion.FeastVersion) +} diff --git a/infra/feast-operator/test/utils/utils.go b/infra/feast-operator/test/utils/utils.go index cfd9e595823..7529a3a0f50 100644 --- a/infra/feast-operator/test/utils/utils.go +++ b/infra/feast-operator/test/utils/utils.go @@ -35,29 +35,29 @@ const ( ) func warnError(err error) { - fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) + _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) } // InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. -func InstallPrometheusOperator() error { +func InstallPrometheusOperator(testDir string) error { url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) cmd := exec.Command("kubectl", "create", "-f", url) - _, err := Run(cmd) + _, err := Run(cmd, testDir) return err } // Run executes the provided command within this context -func Run(cmd *exec.Cmd) ([]byte, error) { - dir, _ := GetProjectDir() +func Run(cmd *exec.Cmd, testDir string) ([]byte, error) { + dir, _ := GetProjectDir(testDir) cmd.Dir = dir if err := os.Chdir(cmd.Dir); err != nil { - fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) + _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) } cmd.Env = append(os.Environ(), "GO111MODULE=on") command := strings.Join(cmd.Args, " ") - fmt.Fprintf(GinkgoWriter, "running: %s\n", command) + _, _ = fmt.Fprintf(GinkgoWriter, "running: %s\n", command) output, err := cmd.CombinedOutput() if err != nil { return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) @@ -67,28 +67,28 @@ func Run(cmd *exec.Cmd) ([]byte, error) { } // UninstallPrometheusOperator uninstalls the prometheus -func UninstallPrometheusOperator() { +func UninstallPrometheusOperator(testDir string) { url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) cmd := exec.Command("kubectl", "delete", "-f", url) - if _, err := Run(cmd); err != nil { + if _, err := Run(cmd, testDir); err != nil { warnError(err) } } // UninstallCertManager uninstalls the cert manager -func UninstallCertManager() { +func UninstallCertManager(testDir string) { url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) cmd := exec.Command("kubectl", "delete", "-f", url) - if _, err := Run(cmd); err != nil { + if _, err := Run(cmd, testDir); err != nil { warnError(err) } } // InstallCertManager installs the cert manager bundle. -func InstallCertManager() error { +func InstallCertManager(testDir string) error { url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) cmd := exec.Command("kubectl", "apply", "-f", url) - if _, err := Run(cmd); err != nil { + if _, err := Run(cmd, testDir); err != nil { return err } // Wait for cert-manager-webhook to be ready, which can take time if cert-manager @@ -99,19 +99,20 @@ func InstallCertManager() error { "--timeout", "5m", ) - _, err := Run(cmd) + _, err := Run(cmd, testDir) return err } // LoadImageToKindCluster loads a local docker image to the kind cluster -func LoadImageToKindClusterWithName(name string) error { +func LoadImageToKindClusterWithName(name string, testDir string) error { cluster := "kind" if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { cluster = v } + fmt.Println("cluster used in the test is -", cluster) kindOptions := []string{"load", "docker-image", name, "--name", cluster} cmd := exec.Command("kind", kindOptions...) - _, err := Run(cmd) + _, err := Run(cmd, testDir) return err } @@ -130,11 +131,11 @@ func GetNonEmptyLines(output string) []string { } // GetProjectDir will return the directory where the project is -func GetProjectDir() (string, error) { +func GetProjectDir(projectDir string) (string, error) { wd, err := os.Getwd() if err != nil { return wd, err } - wd = strings.Replace(wd, "/test/e2e", "", -1) + wd = strings.Replace(wd, projectDir, "", -1) return wd, nil } diff --git a/infra/scripts/pixi/pixi.lock b/infra/scripts/pixi/pixi.lock index 1ca8742026c..5f957f508c9 100644 --- a/infra/scripts/pixi/pixi.lock +++ b/infra/scripts/pixi/pixi.lock @@ -1,4 +1,4 @@ -version: 5 +version: 6 environments: default: channels: @@ -7,16 +7,16 @@ environments: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h807b86a_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h807b86a_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-h95c4c6d_6.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.2.0-h767d61c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.2.0-h767d61c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-14.2.0-h8f9b012_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.6.3-h0f3a69f_0.conda osx-64: - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-17.0.6-heb59cac_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.1.45-h4e38c46_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-19.1.7-hf95d169_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.6.3-h8de1528_0.conda osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.45-hc069d6b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-19.1.7-ha82da77_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.6.3-h668ec48_0.conda py310: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -28,11 +28,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h41732ed_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h807b86a_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h807b86a_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.2.0-h767d61c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.2.0-h69a702a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.2.0-h767d61c_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-h95c4c6d_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-14.2.0-h8f9b012_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda @@ -42,12 +43,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.6.3-h0f3a69f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 osx-64: - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h10d778d_5.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ca-certificates-2024.7.4-h8857fd0_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-17.0.6-heb59cac_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-19.1.7-hf95d169_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.4.2-h0d85af4_5.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.46.0-h1b8f9f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-h87427d6_1.conda @@ -57,12 +58,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h9e318b2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h1abcd95_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.1.45-h4e38c46_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.6.3-h8de1528_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/xz-5.2.6-h775f41a_0.tar.bz2 osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-19.1.7-ha82da77_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-hfb2fe0b_6.conda @@ -72,7 +73,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.45-hc069d6b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.6.3-h668ec48_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 py311: channels: @@ -86,11 +87,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-hc881cc4_6.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-hc881cc4_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.2.0-h767d61c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.2.0-h69a702a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.2.0-h767d61c_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-h95c4c6d_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-14.2.0-h8f9b012_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda @@ -100,12 +102,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.6.3-h0f3a69f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 osx-64: - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h10d778d_5.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ca-certificates-2024.7.4-h8857fd0_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-17.0.6-heb59cac_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-19.1.7-hf95d169_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.6.2-h73e2aa4_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.4.2-h0d85af4_5.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.46.0-h1b8f9f3_0.conda @@ -116,12 +118,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h9e318b2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h1abcd95_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.1.45-h4e38c46_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.6.3-h8de1528_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/xz-5.2.6-h775f41a_0.tar.bz2 osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-19.1.7-ha82da77_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda @@ -132,7 +134,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.45-hc069d6b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.6.3-h668ec48_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 py39: channels: @@ -145,11 +147,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h41732ed_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h807b86a_5.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h807b86a_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.2.0-h767d61c_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.2.0-h69a702a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.2.0-h767d61c_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-h95c4c6d_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-14.2.0-h8f9b012_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda @@ -159,12 +162,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.6.3-h0f3a69f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 osx-64: - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h10d778d_5.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ca-certificates-2024.7.4-h8857fd0_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-17.0.6-heb59cac_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-19.1.7-hf95d169_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.4.2-h0d85af4_5.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.46.0-h1b8f9f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-h87427d6_1.conda @@ -174,12 +177,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h9e318b2_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h1abcd95_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.1.45-h4e38c46_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.6.3-h8de1528_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/xz-5.2.6-h775f41a_0.tar.bz2 osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-19.1.7-ha82da77_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-hfb2fe0b_6.conda @@ -189,27 +192,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.45-hc069d6b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.6.3-h668ec48_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 packages: -- kind: conda - name: _libgcc_mutex - version: '0.1' - build: conda_forge - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 +- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 md5: d7c89558ba9fa0495403155b64376d81 license: None size: 2562 timestamp: 1578324546067 -- kind: conda - name: _openmp_mutex - version: '4.5' - build: 2_gnu +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 build_number: 16 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 md5: 73aaf86a425cc6e73fcf236a5a46396d depends: @@ -221,86 +214,48 @@ packages: license_family: BSD size: 23621 timestamp: 1650670423406 -- kind: conda - name: bzip2 - version: 1.0.8 - build: h10d778d_5 - build_number: 5 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h10d778d_5.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + sha256: 242c0c324507ee172c0e0dd2045814e746bb303d1eb78870d182ceb0abc726a8 + md5: 69b8b6202a07720f448be700e300ccf4 + depends: + - libgcc-ng >=12 + license: bzip2-1.0.6 + license_family: BSD + size: 254228 + timestamp: 1699279927352 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h10d778d_5.conda sha256: 61fb2b488928a54d9472113e1280b468a309561caa54f33825a3593da390b242 md5: 6097a6ca9ada32699b5fc4312dd6ef18 license: bzip2-1.0.6 license_family: BSD size: 127885 timestamp: 1699280178474 -- kind: conda - name: bzip2 - version: 1.0.8 - build: h93a5062_5 - build_number: 5 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda sha256: bfa84296a638bea78a8bb29abc493ee95f2a0218775642474a840411b950fe5f md5: 1bbc659ca658bfd49a481b5ef7a0f40f license: bzip2-1.0.6 license_family: BSD size: 122325 timestamp: 1699280294368 -- kind: conda - name: bzip2 - version: 1.0.8 - build: hd590300_5 - build_number: 5 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda - sha256: 242c0c324507ee172c0e0dd2045814e746bb303d1eb78870d182ceb0abc726a8 - md5: 69b8b6202a07720f448be700e300ccf4 - depends: - - libgcc-ng >=12 - license: bzip2-1.0.6 - license_family: BSD - size: 254228 - timestamp: 1699279927352 -- kind: conda - name: ca-certificates - version: 2024.2.2 - build: hbcca054_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda sha256: 91d81bfecdbb142c15066df70cc952590ae8991670198f92c66b62019b251aeb md5: 2f4327a1cbe7f022401b236e915a5fef license: ISC size: 155432 timestamp: 1706843687645 -- kind: conda - name: ca-certificates - version: 2024.2.2 - build: hf0a4a13_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda - sha256: 49bc3439816ac72d0c0e0f144b8cc870fdcc4adec2e861407ec818d8116b2204 - md5: fb416a1795f18dcc5a038bc2dc54edf9 - license: ISC - size: 155725 - timestamp: 1706844034242 -- kind: conda - name: ca-certificates - version: 2024.7.4 - build: h8857fd0_0 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/ca-certificates-2024.7.4-h8857fd0_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-64/ca-certificates-2024.7.4-h8857fd0_0.conda sha256: d16f46c489cb3192305c7d25b795333c5fc17bb0986de20598ed519f8c9cc9e4 md5: 7df874a4b05b2d2b82826190170eaa0f license: ISC size: 154473 timestamp: 1720077510541 -- kind: conda - name: ld_impl_linux-64 - version: '2.40' - build: h41732ed_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h41732ed_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + sha256: 49bc3439816ac72d0c0e0f144b8cc870fdcc4adec2e861407ec818d8116b2204 + md5: fb416a1795f18dcc5a038bc2dc54edf9 + license: ISC + size: 155725 + timestamp: 1706844034242 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h41732ed_0.conda sha256: f6cc89d887555912d6c61b295d398cff9ec982a3417d38025c45d5dd9b9e79cd md5: 7aca3059a1729aa76c597603f10b0dd3 constrains: @@ -309,12 +264,7 @@ packages: license_family: GPL size: 704696 timestamp: 1674833944779 -- kind: conda - name: ld_impl_linux-64 - version: '2.40' - build: h55db66e_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda sha256: ef969eee228cfb71e55146eaecc6af065f468cb0bc0a5239bc053b39db0b5f09 md5: 10569984e7db886e4f1abc2b47ad79a1 constrains: @@ -323,41 +273,29 @@ packages: license_family: GPL size: 713322 timestamp: 1713651222435 -- kind: conda - name: libcxx - version: 17.0.6 - build: h5f092b4_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda - sha256: 119d3d9306f537d4c89dc99ed99b94c396d262f0b06f7833243646f68884f2c2 - md5: a96fd5dda8ce56c86a971e0fa02751d0 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-19.1.7-hf95d169_0.conda + sha256: 6b2fa3fb1e8cd2000b0ed259e0c4e49cbef7b76890157fac3e494bc659a20330 + md5: 4b8f8dc448d814169dbc58fc7286057d depends: - - __osx >=11.0 + - __osx >=10.13 + arch: x86_64 + platform: osx license: Apache-2.0 WITH LLVM-exception license_family: Apache - size: 1248885 - timestamp: 1715020154867 -- kind: conda - name: libcxx - version: 17.0.6 - build: heb59cac_3 - build_number: 3 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/libcxx-17.0.6-heb59cac_3.conda - sha256: 9df841c64b19a3843869467ff8ff2eb3f6c5491ebaac8fd94fb8029a5b00dcbf - md5: ef15f182e353155497e13726b915bfc4 + size: 527924 + timestamp: 1736877256721 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-19.1.7-ha82da77_0.conda + sha256: 776092346da87a2a23502e14d91eb0c32699c4a1522b7331537bd1c3751dcff5 + md5: 5b3e1610ff8bd5443476b91d618f5b77 depends: - - __osx >=10.13 + - __osx >=11.0 + arch: arm64 + platform: osx license: Apache-2.0 WITH LLVM-exception license_family: Apache - size: 1250659 - timestamp: 1720040263499 -- kind: conda - name: libexpat - version: 2.6.2 - build: h59595ed_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + size: 523505 + timestamp: 1736877862502 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda sha256: 331bb7c7c05025343ebd79f86ae612b9e1e74d2687b8f3179faec234f986ce19 md5: e7ba12deb7020dd080c6c70e7b6f6a3d depends: @@ -368,12 +306,7 @@ packages: license_family: MIT size: 73730 timestamp: 1710362120304 -- kind: conda - name: libexpat - version: 2.6.2 - build: h73e2aa4_0 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.6.2-h73e2aa4_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.6.2-h73e2aa4_0.conda sha256: a188a77b275d61159a32ab547f7d17892226e7dac4518d2c6ac3ac8fc8dfde92 md5: 3d1d51c8f716d97c864d12f7af329526 constrains: @@ -382,12 +315,7 @@ packages: license_family: MIT size: 69246 timestamp: 1710362566073 -- kind: conda - name: libexpat - version: 2.6.2 - build: hebf3989_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda sha256: ba7173ac30064ea901a4c9fb5a51846dcc25512ceb565759be7d18cbf3e5415e md5: e3cde7cfa87f82f7cb13d482d5e0ad09 constrains: @@ -396,119 +324,67 @@ packages: license_family: MIT size: 63655 timestamp: 1710362424980 -- kind: conda - name: libffi - version: 3.4.2 - build: h0d85af4_5 - build_number: 5 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.4.2-h0d85af4_5.tar.bz2 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + sha256: ab6e9856c21709b7b517e940ae7028ae0737546122f83c2aa5d692860c3b149e + md5: d645c6d2ac96843a2bfaccd2d62b3ac3 + depends: + - libgcc-ng >=9.4.0 + license: MIT + license_family: MIT + size: 58292 + timestamp: 1636488182923 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.4.2-h0d85af4_5.tar.bz2 sha256: 7a2d27a936ceee6942ea4d397f9c7d136f12549d86f7617e8b6bad51e01a941f md5: ccb34fb14960ad8b125962d3d79b31a9 license: MIT license_family: MIT size: 51348 timestamp: 1636488394370 -- kind: conda - name: libffi - version: 3.4.2 - build: h3422bc3_5 - build_number: 5 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 sha256: 41b3d13efb775e340e4dba549ab5c029611ea6918703096b2eaa9c015c0750ca md5: 086914b672be056eb70fd4285b6783b6 license: MIT license_family: MIT size: 39020 timestamp: 1636488587153 -- kind: conda - name: libffi - version: 3.4.2 - build: h7f98852_5 - build_number: 5 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 - sha256: ab6e9856c21709b7b517e940ae7028ae0737546122f83c2aa5d692860c3b149e - md5: d645c6d2ac96843a2bfaccd2d62b3ac3 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-14.2.0-h767d61c_2.conda + sha256: 3a572d031cb86deb541d15c1875aaa097baefc0c580b54dc61f5edab99215792 + md5: ef504d1acbd74b7cc6849ef8af47dd03 depends: - - libgcc-ng >=9.4.0 - license: MIT - license_family: MIT - size: 58292 - timestamp: 1636488182923 -- kind: conda - name: libgcc-ng - version: 13.2.0 - build: h807b86a_5 - build_number: 5 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h807b86a_5.conda - sha256: d32f78bfaac282cfe5205f46d558704ad737b8dbf71f9227788a5ca80facaba4 - md5: d4ff227c46917d3b4565302a2bbb276b - depends: - - _libgcc_mutex 0.1 conda_forge + - __glibc >=2.17,<3.0.a0 - _openmp_mutex >=4.5 constrains: - - libgomp 13.2.0 h807b86a_5 + - libgomp 14.2.0 h767d61c_2 + - libgcc-ng ==14.2.0=*_2 + arch: x86_64 + platform: linux license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL - size: 770506 - timestamp: 1706819192021 -- kind: conda - name: libgcc-ng - version: 13.2.0 - build: hc881cc4_6 - build_number: 6 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-hc881cc4_6.conda - sha256: 836a0057525f1414de43642d357d0ab21ac7f85e24800b010dbc17d132e6efec - md5: df88796bd09a0d2ed292e59101478ad8 - depends: - - _libgcc_mutex 0.1 conda_forge - - _openmp_mutex >=4.5 - constrains: - - libgomp 13.2.0 hc881cc4_6 + size: 847885 + timestamp: 1740240653082 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-14.2.0-h69a702a_2.conda + sha256: fb7558c328b38b2f9d2e412c48da7890e7721ba018d733ebdfea57280df01904 + md5: a2222a6ada71fb478682efe483ce0f92 + depends: + - libgcc 14.2.0 h767d61c_2 + arch: x86_64 + platform: linux license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL - size: 777315 - timestamp: 1713755001744 -- kind: conda - name: libgomp - version: 13.2.0 - build: h807b86a_5 - build_number: 5 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h807b86a_5.conda - sha256: 0d3d4b1b0134283ea02d58e8eb5accf3655464cf7159abf098cc694002f8d34e - md5: d211c42b9ce49aee3734fdc828731689 - depends: - - _libgcc_mutex 0.1 conda_forge - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 419751 - timestamp: 1706819107383 -- kind: conda - name: libgomp - version: 13.2.0 - build: hc881cc4_6 - build_number: 6 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-hc881cc4_6.conda - sha256: e722b19b23b31a14b1592d5eceabb38dc52452ff5e4d346e330526971c22e52a - md5: aae89d3736661c36a5591788aebd0817 - depends: - - _libgcc_mutex 0.1 conda_forge + size: 53758 + timestamp: 1740240660904 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-14.2.0-h767d61c_2.conda + sha256: 1a3130e0b9267e781b89399580f3163632d59fe5b0142900d63052ab1a53490e + md5: 06d02030237f4d5b3d9a7e7d348fe3c6 + depends: + - __glibc >=2.17,<3.0.a0 + arch: x86_64 + platform: linux license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL - size: 422363 - timestamp: 1713754915251 -- kind: conda - name: libnsl - version: 2.0.1 - build: hd590300_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + size: 459862 + timestamp: 1740240588123 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda sha256: 26d77a3bb4dceeedc2a41bd688564fe71bf2d149fdcf117049970bc02ff1add6 md5: 30fd6e37fe21f86f4bd26d6ee73eeec7 depends: @@ -517,25 +393,7 @@ packages: license_family: GPL size: 33408 timestamp: 1697359010159 -- kind: conda - name: libsqlite - version: 3.45.3 - build: h091b4b1_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda - sha256: 4337f466eb55bbdc74e168b52ec8c38f598e3664244ec7a2536009036e2066cc - md5: c8c1186c7f3351f6ffddb97b1f54fc58 - depends: - - libzlib >=1.2.13,<2.0.0a0 - license: Unlicense - size: 824794 - timestamp: 1713367748819 -- kind: conda - name: libsqlite - version: 3.45.3 - build: h2797004_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda sha256: e2273d6860eadcf714a759ffb6dc24a69cfd01f2a0ea9d6c20f86049b9334e0c md5: b3316cbe90249da4f8e84cd66e1cc55b depends: @@ -544,12 +402,7 @@ packages: license: Unlicense size: 859858 timestamp: 1713367435849 -- kind: conda - name: libsqlite - version: 3.46.0 - build: h1b8f9f3_0 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.46.0-h1b8f9f3_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.46.0-h1b8f9f3_0.conda sha256: 63af1a9e3284c7e4952364bafe7267e41e2d9d8bcc0e85a4ea4b0ec02d3693f6 md5: 5dadfbc1a567fe6e475df4ce3148be09 depends: @@ -558,25 +411,27 @@ packages: license: Unlicense size: 908643 timestamp: 1718050720117 -- kind: conda - name: libstdcxx-ng - version: 13.2.0 - build: h95c4c6d_6 - build_number: 6 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-h95c4c6d_6.conda - sha256: 2616dbf9d28431eea20b6e307145c6a92ea0328a047c725ff34b0316de2617da - md5: 3cfab3e709f77e9f1b3d380eb622494a +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda + sha256: 4337f466eb55bbdc74e168b52ec8c38f598e3664244ec7a2536009036e2066cc + md5: c8c1186c7f3351f6ffddb97b1f54fc58 + depends: + - libzlib >=1.2.13,<2.0.0a0 + license: Unlicense + size: 824794 + timestamp: 1713367748819 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-14.2.0-h8f9b012_2.conda + sha256: 8f5bd92e4a24e1d35ba015c5252e8f818898478cb3bc50bd8b12ab54707dc4da + md5: a78c856b6dc6bf4ea8daeb9beaaa3fb0 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 14.2.0 h767d61c_2 + arch: x86_64 + platform: linux license: GPL-3.0-only WITH GCC-exception-3.1 license_family: GPL - size: 3842900 - timestamp: 1713755068572 -- kind: conda - name: libuuid - version: 2.38.1 - build: h0b41bf4_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + size: 3884556 + timestamp: 1740240685253 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda sha256: 787eb542f055a2b3de553614b25f09eefb0a0931b0c87dbcce6efdfd92f04f18 md5: 40b61aab5c7ba9ff276c41cfffe6b80b depends: @@ -585,13 +440,7 @@ packages: license_family: BSD size: 33601 timestamp: 1680112270483 -- kind: conda - name: libxcrypt - version: 4.4.36 - build: hd590300_1 - build_number: 1 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c md5: 5aa797f8787fe7a17d1b0821485b5adc depends: @@ -599,13 +448,7 @@ packages: license: LGPL-2.1-or-later size: 100393 timestamp: 1702724383534 -- kind: conda - name: libzlib - version: 1.2.13 - build: hd590300_5 - build_number: 5 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda sha256: 370c7c5893b737596fd6ca0d9190c9715d89d888b8c88537ae1ef168c25e82e4 md5: f36c115f1ee199da648e0597ec2047ad depends: @@ -616,30 +459,7 @@ packages: license_family: Other size: 61588 timestamp: 1686575217516 -- kind: conda - name: libzlib - version: 1.2.13 - build: hfb2fe0b_6 - build_number: 6 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-hfb2fe0b_6.conda - sha256: 8b29a2386d99b8f58178951dcf19117b532cd9c4aa07623bf1667eae99755d32 - md5: 9c4e121cd926cab631bd1c4a61d18b17 - depends: - - __osx >=11.0 - constrains: - - zlib 1.2.13 *_6 - license: Zlib - license_family: Other - size: 46768 - timestamp: 1716874151980 -- kind: conda - name: libzlib - version: 1.3.1 - build: h87427d6_1 - build_number: 1 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-h87427d6_1.conda +- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-h87427d6_1.conda sha256: 80a62db652b1da0ccc100812a1d86e94f75028968991bfb17f9536f3aa72d91d md5: b7575b5aa92108dcc9aaab0f05f2dbce depends: @@ -650,12 +470,18 @@ packages: license_family: Other size: 57372 timestamp: 1716874211519 -- kind: conda - name: ncurses - version: 6.4.20240210 - build: h59595ed_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-hfb2fe0b_6.conda + sha256: 8b29a2386d99b8f58178951dcf19117b532cd9c4aa07623bf1667eae99755d32 + md5: 9c4e121cd926cab631bd1c4a61d18b17 + depends: + - __osx >=11.0 + constrains: + - zlib 1.2.13 *_6 + license: Zlib + license_family: Other + size: 46768 + timestamp: 1716874151980 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda sha256: aa0f005b6727aac6507317ed490f0904430584fa8ca722657e7f0fb94741de81 md5: 97da8860a0da5413c7c98a3b3838a645 depends: @@ -663,35 +489,19 @@ packages: license: X11 AND BSD-3-Clause size: 895669 timestamp: 1710866638986 -- kind: conda - name: ncurses - version: '6.5' - build: h5846eda_0 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h5846eda_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h5846eda_0.conda sha256: 6ecc73db0e49143092c0934355ac41583a5d5a48c6914c5f6ca48e562d3a4b79 md5: 02a888433d165c99bf09784a7b14d900 license: X11 AND BSD-3-Clause size: 823601 timestamp: 1715195267791 -- kind: conda - name: ncurses - version: '6.5' - build: hb89a1cb_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-hb89a1cb_0.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-hb89a1cb_0.conda sha256: 87d7cf716d9d930dab682cb57b3b8d3a61940b47d6703f3529a155c938a6990a md5: b13ad5724ac9ae98b6b4fd87e4500ba4 license: X11 AND BSD-3-Clause size: 795131 timestamp: 1715194898402 -- kind: conda - name: openssl - version: 3.2.1 - build: hd590300_1 - build_number: 1 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.2.1-hd590300_1.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.2.1-hd590300_1.conda sha256: 2c689444ed19a603be457284cf2115ee728a3fafb7527326e96054dee7cdc1a7 md5: 9d731343cff6ee2e5a25c4a091bf8e2a depends: @@ -703,12 +513,7 @@ packages: license_family: Apache size: 2865379 timestamp: 1710793235846 -- kind: conda - name: openssl - version: 3.3.0 - build: hd590300_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda sha256: fdbf05e4db88c592366c90bb82e446edbe33c6e49e5130d51c580b2629c0b5d5 md5: c0f3abb4a16477208bbd43a39bd56f18 depends: @@ -720,50 +525,33 @@ packages: license_family: Apache size: 2895187 timestamp: 1714466138265 -- kind: conda - name: openssl - version: 3.3.0 - build: hfb2fe0b_3 - build_number: 3 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-hfb2fe0b_3.conda - sha256: 6f41c163ab57e7499dff092be4498614651f0f6432e12c2b9f06859a8bc39b75 - md5: 730f618b008b3c13c1e3f973408ddd67 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.3.1-h87427d6_1.conda + sha256: 60eed5d771207bcef05e0547c8f93a61d0ad1dcf75e19f8f8d9ded8094d78477 + md5: d838ffe9ec3c6d971f110e04487466ff depends: - - __osx >=11.0 + - __osx >=10.13 - ca-certificates constrains: - pyopenssl >=22.1 license: Apache-2.0 license_family: Apache - size: 2893954 - timestamp: 1716468329572 -- kind: conda - name: openssl - version: 3.3.1 - build: h87427d6_1 - build_number: 1 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.3.1-h87427d6_1.conda - sha256: 60eed5d771207bcef05e0547c8f93a61d0ad1dcf75e19f8f8d9ded8094d78477 - md5: d838ffe9ec3c6d971f110e04487466ff + size: 2551950 + timestamp: 1719364820943 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-hfb2fe0b_3.conda + sha256: 6f41c163ab57e7499dff092be4498614651f0f6432e12c2b9f06859a8bc39b75 + md5: 730f618b008b3c13c1e3f973408ddd67 depends: - - __osx >=10.13 + - __osx >=11.0 - ca-certificates constrains: - pyopenssl >=22.1 license: Apache-2.0 license_family: Apache - size: 2551950 - timestamp: 1719364820943 -- kind: conda - name: python - version: 3.9.19 - build: h0755675_0_cpython - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/python-3.9.19-h0755675_0_cpython.conda - sha256: b9253ca9ca5427e6da4b1d43353a110e0f2edfab9c951afb4bf01cbae2825b31 - md5: d9ee3647fbd9e8595b8df759b2bbefb8 + size: 2893954 + timestamp: 1716468329572 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.10.14-hd12c33a_0_cpython.conda + sha256: 76a5d12e73542678b70a94570f7b0f7763f9a938f77f0e75d9ea615ef22aa84c + md5: 2b4ba962994e8bd4be9ff5b64b75aff2 depends: - bzip2 >=1.0.8,<2.0a0 - ld_impl_linux-64 >=2.36.1 @@ -781,23 +569,24 @@ packages: - tzdata - xz >=5.2.6,<6.0a0 constrains: - - python_abi 3.9.* *_cp39 + - python_abi 3.10.* *_cp310 license: Python-2.0 - size: 23800555 - timestamp: 1710940120866 -- kind: conda - name: python - version: 3.9.19 - build: h7a9c478_0_cpython - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/python-3.9.19-h7a9c478_0_cpython.conda - sha256: 58b76be84683bc03112b3ed7e377e99af24844ebf7d7568f6466a2dae7a887fe - md5: 7d53d366acd9dbfb498c69326ccb520a + size: 25517742 + timestamp: 1710939725109 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.9-hb806964_0_cpython.conda + sha256: 177f33a1fb8d3476b38f73c37b42f01c0b014fa0e039a701fd9f83d83aae6d40 + md5: ac68acfa8b558ed406c75e98d3428d7b depends: - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.6.2,<3.0a0 - libffi >=3.4,<4.0a0 - - libsqlite >=3.45.2,<4.0a0 - - libzlib >=1.2.13,<2.0.0a0 + - libgcc-ng >=12 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.45.3,<4.0a0 + - libuuid >=2.38.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.2.13,<1.3.0a0 - ncurses >=6.4.20240210,<7.0a0 - openssl >=3.2.1,<4.0a0 - readline >=8.2,<9.0a0 @@ -805,23 +594,23 @@ packages: - tzdata - xz >=5.2.6,<6.0a0 constrains: - - python_abi 3.9.* *_cp39 + - python_abi 3.11.* *_cp311 license: Python-2.0 - size: 12372436 - timestamp: 1710940037648 -- kind: conda - name: python - version: 3.9.19 - build: hd7ebdb9_0_cpython - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.9.19-hd7ebdb9_0_cpython.conda - sha256: 3b93f7a405f334043758dfa8aaca050429a954a37721a6462ebd20e94ef7c5a0 - md5: 45c4d173b12154f746be3b49b1190634 + size: 30884494 + timestamp: 1713553104915 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.9.19-h0755675_0_cpython.conda + sha256: b9253ca9ca5427e6da4b1d43353a110e0f2edfab9c951afb4bf01cbae2825b31 + md5: d9ee3647fbd9e8595b8df759b2bbefb8 depends: - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 - libffi >=3.4,<4.0a0 + - libgcc-ng >=12 + - libnsl >=2.0.1,<2.1.0a0 - libsqlite >=3.45.2,<4.0a0 - - libzlib >=1.2.13,<2.0.0a0 + - libuuid >=2.38.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.2.13,<1.3.0a0 - ncurses >=6.4.20240210,<7.0a0 - openssl >=3.2.1,<4.0a0 - readline >=8.2,<9.0a0 @@ -831,14 +620,9 @@ packages: constrains: - python_abi 3.9.* *_cp39 license: Python-2.0 - size: 11847835 - timestamp: 1710939779164 -- kind: conda - name: python - version: 3.10.14 - build: h00d2728_0_cpython - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/python-3.10.14-h00d2728_0_cpython.conda + size: 23800555 + timestamp: 1710940120866 +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.10.14-h00d2728_0_cpython.conda sha256: 00c1de2d46ede26609ef4e84a44b83be7876ba6a0215b7c83bff41a0656bf694 md5: 0a1cddc4382c5c171e791c70740546dd depends: @@ -857,18 +641,15 @@ packages: license: Python-2.0 size: 11890228 timestamp: 1710940046031 -- kind: conda - name: python - version: 3.10.14 - build: h2469fbe_0_cpython - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.10.14-h2469fbe_0_cpython.conda - sha256: 454d609fe25daedce9e886efcbfcadad103ed0362e7cb6d2bcddec90b1ecd3ee - md5: 4ae999c8227c6d8c7623d32d51d25ea9 +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.11.9-h657bba9_0_cpython.conda + sha256: 3b50a5abb3b812875beaa9ab792dbd1bf44f335c64e9f9fedcf92d953995651c + md5: 612763bc5ede9552e4233ec518b9c9fb depends: + - __osx >=10.9 - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.6.2,<3.0a0 - libffi >=3.4,<4.0a0 - - libsqlite >=3.45.2,<4.0a0 + - libsqlite >=3.45.3,<4.0a0 - libzlib >=1.2.13,<2.0.0a0 - ncurses >=6.4.20240210,<7.0a0 - openssl >=3.2.1,<4.0a0 @@ -877,28 +658,18 @@ packages: - tzdata - xz >=5.2.6,<6.0a0 constrains: - - python_abi 3.10.* *_cp310 + - python_abi 3.11.* *_cp311 license: Python-2.0 - size: 12336005 - timestamp: 1710939659384 -- kind: conda - name: python - version: 3.10.14 - build: hd12c33a_0_cpython - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/python-3.10.14-hd12c33a_0_cpython.conda - sha256: 76a5d12e73542678b70a94570f7b0f7763f9a938f77f0e75d9ea615ef22aa84c - md5: 2b4ba962994e8bd4be9ff5b64b75aff2 + size: 15503226 + timestamp: 1713553747073 +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.9.19-h7a9c478_0_cpython.conda + sha256: 58b76be84683bc03112b3ed7e377e99af24844ebf7d7568f6466a2dae7a887fe + md5: 7d53d366acd9dbfb498c69326ccb520a depends: - bzip2 >=1.0.8,<2.0a0 - - ld_impl_linux-64 >=2.36.1 - libffi >=3.4,<4.0a0 - - libgcc-ng >=12 - - libnsl >=2.0.1,<2.1.0a0 - libsqlite >=3.45.2,<4.0a0 - - libuuid >=2.38.1,<3.0a0 - - libxcrypt >=4.4.36 - - libzlib >=1.2.13,<1.3.0a0 + - libzlib >=1.2.13,<2.0.0a0 - ncurses >=6.4.20240210,<7.0a0 - openssl >=3.2.1,<4.0a0 - readline >=8.2,<9.0a0 @@ -906,24 +677,17 @@ packages: - tzdata - xz >=5.2.6,<6.0a0 constrains: - - python_abi 3.10.* *_cp310 + - python_abi 3.9.* *_cp39 license: Python-2.0 - size: 25517742 - timestamp: 1710939725109 -- kind: conda - name: python - version: 3.11.9 - build: h657bba9_0_cpython - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/python-3.11.9-h657bba9_0_cpython.conda - sha256: 3b50a5abb3b812875beaa9ab792dbd1bf44f335c64e9f9fedcf92d953995651c - md5: 612763bc5ede9552e4233ec518b9c9fb + size: 12372436 + timestamp: 1710940037648 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.10.14-h2469fbe_0_cpython.conda + sha256: 454d609fe25daedce9e886efcbfcadad103ed0362e7cb6d2bcddec90b1ecd3ee + md5: 4ae999c8227c6d8c7623d32d51d25ea9 depends: - - __osx >=10.9 - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.6.2,<3.0a0 - libffi >=3.4,<4.0a0 - - libsqlite >=3.45.3,<4.0a0 + - libsqlite >=3.45.2,<4.0a0 - libzlib >=1.2.13,<2.0.0a0 - ncurses >=6.4.20240210,<7.0a0 - openssl >=3.2.1,<4.0a0 @@ -932,16 +696,11 @@ packages: - tzdata - xz >=5.2.6,<6.0a0 constrains: - - python_abi 3.11.* *_cp311 + - python_abi 3.10.* *_cp310 license: Python-2.0 - size: 15503226 - timestamp: 1713553747073 -- kind: conda - name: python - version: 3.11.9 - build: h932a869_0_cpython - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.9-h932a869_0_cpython.conda + size: 12336005 + timestamp: 1710939659384 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.9-h932a869_0_cpython.conda sha256: a436ceabde1f056a0ac3e347dadc780ee2a135a421ddb6e9a469370769829e3c md5: 293e0713ae804b5527a673e7605c04fc depends: @@ -962,25 +721,14 @@ packages: license: Python-2.0 size: 14644189 timestamp: 1713552154779 -- kind: conda - name: python - version: 3.11.9 - build: hb806964_0_cpython - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.9-hb806964_0_cpython.conda - sha256: 177f33a1fb8d3476b38f73c37b42f01c0b014fa0e039a701fd9f83d83aae6d40 - md5: ac68acfa8b558ed406c75e98d3428d7b +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.9.19-hd7ebdb9_0_cpython.conda + sha256: 3b93f7a405f334043758dfa8aaca050429a954a37721a6462ebd20e94ef7c5a0 + md5: 45c4d173b12154f746be3b49b1190634 depends: - bzip2 >=1.0.8,<2.0a0 - - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.6.2,<3.0a0 - libffi >=3.4,<4.0a0 - - libgcc-ng >=12 - - libnsl >=2.0.1,<2.1.0a0 - - libsqlite >=3.45.3,<4.0a0 - - libuuid >=2.38.1,<3.0a0 - - libxcrypt >=4.4.36 - - libzlib >=1.2.13,<1.3.0a0 + - libsqlite >=3.45.2,<4.0a0 + - libzlib >=1.2.13,<2.0.0a0 - ncurses >=6.4.20240210,<7.0a0 - openssl >=3.2.1,<4.0a0 - readline >=8.2,<9.0a0 @@ -988,17 +736,11 @@ packages: - tzdata - xz >=5.2.6,<6.0a0 constrains: - - python_abi 3.11.* *_cp311 + - python_abi 3.9.* *_cp39 license: Python-2.0 - size: 30884494 - timestamp: 1713553104915 -- kind: conda - name: readline - version: '8.2' - build: h8228510_1 - build_number: 1 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + size: 11847835 + timestamp: 1710939779164 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda sha256: 5435cf39d039387fbdc977b0a762357ea909a7694d9528ab40f005e9208744d7 md5: 47d31b792659ce70f470b5c82fdfb7a4 depends: @@ -1008,13 +750,16 @@ packages: license_family: GPL size: 281456 timestamp: 1679532220005 -- kind: conda - name: readline - version: '8.2' - build: h92ec313_1 - build_number: 1 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda +- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h9e318b2_1.conda + sha256: 41e7d30a097d9b060037f0c6a2b1d4c4ae7e942c06c943d23f9d481548478568 + md5: f17f77f2acf4d344734bda76829ce14e + depends: + - ncurses >=6.3,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 255870 + timestamp: 1679532707590 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda sha256: a1dfa679ac3f6007362386576a704ad2d0d7a02e98f5d0b115f207a2da63e884 md5: 8cbb776a2f641b943d413b3e19df71f4 depends: @@ -1023,28 +768,17 @@ packages: license_family: GPL size: 250351 timestamp: 1679532511311 -- kind: conda - name: readline - version: '8.2' - build: h9e318b2_1 - build_number: 1 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/readline-8.2-h9e318b2_1.conda - sha256: 41e7d30a097d9b060037f0c6a2b1d4c4ae7e942c06c943d23f9d481548478568 - md5: f17f77f2acf4d344734bda76829ce14e +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + sha256: e0569c9caa68bf476bead1bed3d79650bb080b532c64a4af7d8ca286c08dea4e + md5: d453b98d9c83e71da0741bb0ff4d76bc depends: - - ncurses >=6.3,<7.0a0 - license: GPL-3.0-only - license_family: GPL - size: 255870 - timestamp: 1679532707590 -- kind: conda - name: tk - version: 8.6.13 - build: h1abcd95_1 - build_number: 1 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h1abcd95_1.conda + - libgcc-ng >=12 + - libzlib >=1.2.13,<1.3.0a0 + license: TCL + license_family: BSD + size: 3318875 + timestamp: 1699202167581 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h1abcd95_1.conda sha256: 30412b2e9de4ff82d8c2a7e5d06a15f4f4fef1809a72138b6ccb53a33b26faf5 md5: bf830ba5afc507c6232d4ef0fb1a882d depends: @@ -1053,13 +787,7 @@ packages: license_family: BSD size: 3270220 timestamp: 1699202389792 -- kind: conda - name: tk - version: 8.6.13 - build: h5083fa2_1 - build_number: 1 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda sha256: 72457ad031b4c048e5891f3f6cb27a53cb479db68a52d965f796910e71a403a8 md5: b50a57ba89c32b62428b71a875291c9b depends: @@ -1068,86 +796,53 @@ packages: license_family: BSD size: 3145523 timestamp: 1699202432999 -- kind: conda - name: tk - version: 8.6.13 - build: noxft_h4845f30_101 - build_number: 101 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda - sha256: e0569c9caa68bf476bead1bed3d79650bb080b532c64a4af7d8ca286c08dea4e - md5: d453b98d9c83e71da0741bb0ff4d76bc - depends: - - libgcc-ng >=12 - - libzlib >=1.2.13,<1.3.0a0 - license: TCL - license_family: BSD - size: 3318875 - timestamp: 1699202167581 -- kind: conda - name: tzdata - version: 2024a - build: h0c530f3_0 - subdir: noarch - noarch: generic - url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda sha256: 7b2b69c54ec62a243eb6fba2391b5e443421608c3ae5dbff938ad33ca8db5122 md5: 161081fc7cec0bfda0d86d7cb595f8d8 license: LicenseRef-Public-Domain size: 119815 timestamp: 1706886945727 -- kind: conda - name: uv - version: 0.1.39 - build: h0ea3d13_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda - sha256: 763d149b6f4f5c70c91e4106d3a48409c48283ed2e27392578998fb2441f23d8 - md5: c3206e7ca254e50b3556917886f9b12b +- conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.6.3-h0f3a69f_0.conda + sha256: fc33719d8cccf555748c2cb17bede5c0c06637269a0be3979f0eaebcca9f4eb0 + md5: bfee7af0ca5d4b0397bbd9ddf386d14b depends: - - libgcc-ng >=12 - - libstdcxx-ng >=12 + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + constrains: + - __glibc >=2.17 + arch: x86_64 + platform: linux license: Apache-2.0 OR MIT - size: 11891252 - timestamp: 1714233659570 -- kind: conda - name: uv - version: 0.1.45 - build: h4e38c46_0 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/uv-0.1.45-h4e38c46_0.conda - sha256: 8c11774ca1940dcd90187ce240afea26b76e2942f9b18d65f6d4b483534193fd - md5: 754ce8a22c94a30c7bbd42274c7fae31 + size: 11471064 + timestamp: 1740442105821 +- conda: https://conda.anaconda.org/conda-forge/osx-64/uv-0.6.3-h8de1528_0.conda + sha256: e61ed82bb71264dc7dcf9ca1528796907b515ceec508d5c6f4d6b79e1716e0ea + md5: 861adce9aeb74e0124187afbde2f4d4a depends: - __osx >=10.13 - - libcxx >=16 + - libcxx >=18 constrains: - - __osx >=10.12 + - __osx >=10.13 + arch: x86_64 + platform: osx license: Apache-2.0 OR MIT - size: 8937335 - timestamp: 1716265195083 -- kind: conda - name: uv - version: 0.1.45 - build: hc069d6b_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.45-hc069d6b_0.conda - sha256: 80dfc19f2ef473e86e718361847d1d598e95ffd0c0f5de7d07cda35d25f6aef5 - md5: 9192238a60bc6da9c41092990c31eb41 + size: 11024081 + timestamp: 1740443179556 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.6.3-h668ec48_0.conda + sha256: b1ada7b2d26f82effe5dfddfe31f5674a39196d6ddca40f02cfd23390d5446f0 + md5: 0a3cd436a7e106362489ae2ff09db1c4 depends: - __osx >=11.0 - - libcxx >=16 + - libcxx >=18 constrains: - __osx >=11.0 + arch: arm64 + platform: osx license: Apache-2.0 OR MIT - size: 9231858 - timestamp: 1716265232676 -- kind: conda - name: xz - version: 5.2.6 - build: h166bdaf_0 - subdir: linux-64 - url: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + size: 9968257 + timestamp: 1740443196241 +- conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 sha256: 03a6d28ded42af8a347345f82f3eebdd6807a08526d47899a42d62d319609162 md5: 2161070d867d1b1204ea749c8eec4ef0 depends: @@ -1155,25 +850,15 @@ packages: license: LGPL-2.1 and GPL-2.0 size: 418368 timestamp: 1660346797927 -- kind: conda - name: xz - version: 5.2.6 - build: h57fd34a_0 - subdir: osx-arm64 - url: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 - sha256: 59d78af0c3e071021cfe82dc40134c19dab8cdf804324b62940f5c8cd71803ec - md5: 39c6b54e94014701dd157f4f576ed211 - license: LGPL-2.1 and GPL-2.0 - size: 235693 - timestamp: 1660346961024 -- kind: conda - name: xz - version: 5.2.6 - build: h775f41a_0 - subdir: osx-64 - url: https://conda.anaconda.org/conda-forge/osx-64/xz-5.2.6-h775f41a_0.tar.bz2 +- conda: https://conda.anaconda.org/conda-forge/osx-64/xz-5.2.6-h775f41a_0.tar.bz2 sha256: eb09823f34cc2dd663c0ec4ab13f246f45dcd52e5b8c47b9864361de5204a1c8 md5: a72f9d4ea13d55d745ff1ed594747f10 license: LGPL-2.1 and GPL-2.0 size: 238119 timestamp: 1660346964847 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + sha256: 59d78af0c3e071021cfe82dc40134c19dab8cdf804324b62940f5c8cd71803ec + md5: 39c6b54e94014701dd157f4f576ed211 + license: LGPL-2.1 and GPL-2.0 + size: 235693 + timestamp: 1660346961024 diff --git a/infra/scripts/pixi/pixi.toml b/infra/scripts/pixi/pixi.toml index 487c6f7def1..89b9f0376f8 100644 --- a/infra/scripts/pixi/pixi.toml +++ b/infra/scripts/pixi/pixi.toml @@ -6,7 +6,7 @@ platforms = ["linux-64", "osx-arm64", "osx-64"] [tasks] [dependencies] -uv = ">=0.1.39,<0.2" +uv = ">=0.6.3" [feature.py39.dependencies] python = "~=3.9.0" diff --git a/infra/scripts/release/files_to_bump.txt b/infra/scripts/release/files_to_bump.txt index 652bc3cad10..71cf1746b6d 100644 --- a/infra/scripts/release/files_to_bump.txt +++ b/infra/scripts/release/files_to_bump.txt @@ -14,6 +14,9 @@ infra/feast-helm-operator/Makefile 6 infra/feast-helm-operator/config/manager/kustomization.yaml 8 infra/feast-operator/Makefile 6 infra/feast-operator/config/manager/kustomization.yaml 8 +infra/feast-operator/config/component_metadata.yaml 4 +infra/feast-operator/config/overlays/odh/params.env 1 2 infra/feast-operator/api/feastversion/version.go 20 java/pom.xml 38 +sdk/python/feast/infra/feature_servers/multicloud/requirements.txt 2 ui/package.json 3 diff --git a/java/datatypes/pom.xml b/java/datatypes/pom.xml index b0ba049c575..967262d0e01 100644 --- a/java/datatypes/pom.xml +++ b/java/datatypes/pom.xml @@ -118,6 +118,11 @@ grpc-stub ${grpc.version} + + io.grpc + grpc-api + ${grpc.version} + javax.annotation javax.annotation-api diff --git a/java/pom.xml b/java/pom.xml index 416ebe59786..d7076ef501e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -35,15 +35,15 @@ - 0.41.0 + 0.46.0 https://github.com/feast-dev/feast UTF-8 UTF-8 - 1.30.2 + 1.63.0 3.12.2 - 3.16.1 + 3.25.5 1.111.1 0.8.0 1.9.10 @@ -61,15 +61,15 @@ 1.5.24 3.14.7 3.10 - 2.14.0 + 2.15.0 2.3.1 1.3.2 2.0.1.Final 0.21.0 1.6.6 - 30.1-jre + 32.0.0-jre 3.4.34 - 4.1.101.Final + 4.1.96.Final src/main/java/**/BatchLoadsWithResult.java - + @@ -365,7 +365,7 @@ [11.0,) - + @@ -376,7 +376,7 @@ - + diff --git a/java/serving-client/pom.xml b/java/serving-client/pom.xml index 7b8838a009c..dc611b4a76e 100644 --- a/java/serving-client/pom.xml +++ b/java/serving-client/pom.xml @@ -50,6 +50,11 @@ grpc-testing ${grpc.version} + + io.grpc + grpc-api + ${grpc.version} + com.google.protobuf protobuf-java-util diff --git a/java/serving/pom.xml b/java/serving/pom.xml index ca7f8a73b5f..1be4da1b622 100644 --- a/java/serving/pom.xml +++ b/java/serving/pom.xml @@ -126,7 +126,7 @@ com.azure azure-storage-blob - 12.25.2 + 12.26.1 com.azure @@ -164,6 +164,11 @@ grpc-stub ${grpc.version} + + io.grpc + grpc-api + ${grpc.version} + io.grpc grpc-netty-shaded @@ -192,7 +197,7 @@ io.jaegertracing jaeger-client - 1.3.2 + 1.8.1 io.opentracing @@ -240,7 +245,7 @@ com.google.cloud google-cloud-storage - 1.118.0 + 2.43.1 @@ -253,13 +258,13 @@ com.amazonaws aws-java-sdk-s3 - 1.12.261 + 1.12.546 com.amazonaws aws-java-sdk-sts - 1.12.476 + 1.12.546 @@ -378,7 +383,7 @@ io.lettuce lettuce-core - 6.0.2.RELEASE + 6.5.1.RELEASE org.apache.commons diff --git a/protos/feast/core/DataSource.proto b/protos/feast/core/DataSource.proto index d129086f451..9c31851823d 100644 --- a/protos/feast/core/DataSource.proto +++ b/protos/feast/core/DataSource.proto @@ -268,3 +268,7 @@ message DataSource { AthenaOptions athena_options = 35; } } + +message DataSourceList { + repeated DataSource datasources = 1; +} \ No newline at end of file diff --git a/protos/feast/core/Entity.proto b/protos/feast/core/Entity.proto index d8d8bedc5eb..915402804fc 100644 --- a/protos/feast/core/Entity.proto +++ b/protos/feast/core/Entity.proto @@ -58,3 +58,7 @@ message EntityMeta { google.protobuf.Timestamp created_timestamp = 1; google.protobuf.Timestamp last_updated_timestamp = 2; } + +message EntityList { + repeated Entity entities = 1; +} diff --git a/protos/feast/core/Feature.proto b/protos/feast/core/Feature.proto index 882de47eb9c..8a56d67905a 100644 --- a/protos/feast/core/Feature.proto +++ b/protos/feast/core/Feature.proto @@ -35,6 +35,11 @@ message FeatureSpecV2 { map tags = 3; // Description of the feature. - string description = 4; + + // Field indicating the vector will be indexed for vector similarity search + bool vector_index = 5; + + // Metric used for vector similarity search. + string vector_search_metric = 6; } diff --git a/protos/feast/core/FeatureService.proto b/protos/feast/core/FeatureService.proto index 80d32eb4dec..380b2dc3718 100644 --- a/protos/feast/core/FeatureService.proto +++ b/protos/feast/core/FeatureService.proto @@ -61,6 +61,7 @@ message LoggingConfig { SnowflakeDestination snowflake_destination = 6; CustomDestination custom_destination = 7; AthenaDestination athena_destination = 8; + CouchbaseColumnarDestination couchbase_columnar_destination = 9; } message FileDestination { @@ -95,4 +96,17 @@ message LoggingConfig { string kind = 1; map config = 2; } + + message CouchbaseColumnarDestination { + // Destination database name + string database = 1; + // Destination scope name + string scope = 2; + // Destination collection name + string collection = 3; + } } + +message FeatureServiceList { + repeated FeatureService featureservices = 1; +} \ No newline at end of file diff --git a/protos/feast/core/FeatureView.proto b/protos/feast/core/FeatureView.proto index c9e38bf3448..3e9aa17256f 100644 --- a/protos/feast/core/FeatureView.proto +++ b/protos/feast/core/FeatureView.proto @@ -92,3 +92,7 @@ message MaterializationInterval { google.protobuf.Timestamp start_time = 1; google.protobuf.Timestamp end_time = 2; } + +message FeatureViewList { + repeated FeatureView featureviews = 1; +} diff --git a/protos/feast/core/OnDemandFeatureView.proto b/protos/feast/core/OnDemandFeatureView.proto index 65e8018473e..3ed8ffe4aed 100644 --- a/protos/feast/core/OnDemandFeatureView.proto +++ b/protos/feast/core/OnDemandFeatureView.proto @@ -101,3 +101,7 @@ message UserDefinedFunction { // The string representation of the udf string body_text = 3; } + +message OnDemandFeatureViewList { + repeated OnDemandFeatureView ondemandfeatureviews = 1; +} \ No newline at end of file diff --git a/protos/feast/registry/RegistryServer.proto b/protos/feast/registry/RegistryServer.proto index 6685bc0baa1..fb68d519dd9 100644 --- a/protos/feast/registry/RegistryServer.proto +++ b/protos/feast/registry/RegistryServer.proto @@ -17,6 +17,8 @@ import "feast/core/InfraObject.proto"; import "feast/core/Permission.proto"; import "feast/core/Project.proto"; +option go_package = "github.com/feast-dev/feast/go/protos/feast/registry"; + service RegistryServer{ // Entity RPCs rpc ApplyEntity (ApplyEntityRequest) returns (google.protobuf.Empty) {} diff --git a/protos/feast/serving/GrpcServer.proto b/protos/feast/serving/GrpcServer.proto index 34edb4ebe9c..b30e1e9d74d 100644 --- a/protos/feast/serving/GrpcServer.proto +++ b/protos/feast/serving/GrpcServer.proto @@ -2,6 +2,8 @@ syntax = "proto3"; import "feast/serving/ServingService.proto"; +option go_package = "github.com/feast-dev/feast/go/protos/feast/serving"; + message PushRequest { map features = 1; string stream_feature_view = 2; diff --git a/pyproject.toml b/pyproject.toml index 2a051231e2a..9eec118099a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,170 @@ +[project] +name = "feast" +description = "Python SDK for Feast" +readme = "README.md" +requires-python = ">=3.9.0" +license = {file = "LICENSE"} +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9" +] +dynamic = [ + "version", +] +dependencies = [ + "click>=7.0.0,<9.0.0", + "colorama>=0.3.9,<1", + "dill~=0.3.0", + "protobuf>=4.24.0", + "Jinja2>=2,<4", + "jsonschema", + "mmh3", + "numpy>=1.22,<2", + "pandas>=1.4.3,<3", + "pyarrow<18.1.0", + "pydantic>=2.0.0", + "pygments>=2.12.0,<3", + "PyYAML>=5.4.0,<7", + "requests", + "SQLAlchemy[mypy]>1", + "tabulate>=0.8.0,<1", + "tenacity>=7,<9", + "toml>=0.10.0,<1", + "tqdm>=4,<5", + "typeguard>=4.0.0", + "fastapi>=0.68.0", + "uvicorn[standard]>=0.14.0,<1", + "uvicorn-worker", + "gunicorn; platform_system != 'Windows'", + "dask[dataframe]>=2024.2.1", + "prometheus_client", + "psutil", + "bigtree>=0.19.2", + "pyjwt", +] + +[project.optional-dependencies] +aws = ["boto3>=1.17.0,<2", "fsspec<=2024.9.0", "aiobotocore>2,<3"] +azure = [ + "azure-storage-blob>=0.37.0", + "azure-identity>=1.6.1", + "SQLAlchemy>=1.4.19", + "pyodbc>=4.0.30", + "pymssql" +] +cassandra = ["cassandra-driver>=3.24.0,<4"] +couchbase = ["couchbase==4.3.2", "couchbase-columnar==1.0.0"] +delta = ["deltalake"] +docling = ["docling>=2.23.0"] +duckdb = ["ibis-framework[duckdb]>=9.0.0,<10"] +elasticsearch = ["elasticsearch>=8.13.0"] +faiss = ["faiss-cpu>=1.7.0,<2"] +gcp = [ + "google-api-core>=1.23.0,<3", + "googleapis-common-protos>=1.52.0,<2", + "google-cloud-bigquery[pandas]>=2,<4", + "google-cloud-bigquery-storage >= 2.0.0,<3", + "google-cloud-datastore>=2.16.0,<3", + "google-cloud-storage>=1.34.0,<3", + "google-cloud-bigtable>=2.11.0,<3", + "fsspec<=2024.9.0", +] +ge = ["great_expectations>=0.15.41,<1"] +go = ["cffi>=1.15.0"] +grpcio = [ + "grpcio>=1.56.2,<2", + "grpcio-reflection>=1.56.2,<2", + "grpcio-health-checking>=1.56.2,<2", +] +hazelcast = ["hazelcast-python-client>=5.1"] +hbase = ["happybase>=1.2.0,<3"] +ibis = [ + "ibis-framework>=9.0.0,<10", + "ibis-substrait>=4.0.0", +] +ikv = [ + "ikvpy>=0.0.36", +] +k8s = ["kubernetes<=20.13.0"] +milvus = ["pymilvus"] +mssql = ["ibis-framework[mssql]>=9.0.0,<10"] +mysql = ["pymysql", "types-PyMySQL"] +opentelemetry = ["prometheus_client", "psutil"] +spark = ["pyspark>=3.0.0,<4"] +trino = ["trino>=0.305.0,<0.400.0", "regex"] +postgres = ["psycopg[binary,pool]>=3.0.0,<4"] +pytorch = ["torch>=2.2.2", "torchvision>=0.17.2"] +qdrant = ["qdrant-client>=1.12.0"] +redis = [ + "redis>=4.2.2,<5", + "hiredis>=2.0.0,<3", +] +singlestore = ["singlestoredb<1.8.0"] +snowflake = [ + "snowflake-connector-python[pandas]>=3.7,<4", +] +sqlite_vec = ["sqlite-vec==v0.1.6"] + +ci = [ + "build", + "virtualenv==20.23.0", + "cryptography>=43.0,<44", + "ruff>=0.8.0", + "mypy-protobuf>=3.1", + "grpcio-tools>=1.56.2,<2", + "grpcio-testing>=1.56.2,<2", + # FastAPI does not correctly pull starlette dependency on httpx see thread(https://github.com/tiangolo/fastapi/issues/5656). + "httpx==0.27.2", + "minio==7.2.11", + "mock==2.0.0", + "moto<5", + "mypy>=1.4.1,<1.11.3", + "urllib3>=1.25.4,<3", + "psutil==5.9.0", + "py>=1.11.0", # https://github.com/pytest-dev/pytest/issues/10420 + "pytest>=6.0.0,<8", + "pytest-asyncio<=0.24.0", + "pytest-cov", + "pytest-xdist", + "pytest-benchmark>=3.4.1,<4", + "pytest-lazy-fixture==0.6.3", + "pytest-timeout==1.4.2", + "pytest-ordering~=0.6.0", + "pytest-mock==1.10.4", + "pytest-env", + "Sphinx>4.0.0,<7", + "testcontainers==4.8.2", + "python-keycloak==4.2.2", + "pre-commit<3.3.2", + "assertpy==1.1", + "pip-tools", + "pybindgen", + "types-protobuf~=3.19.22", + "types-python-dateutil", + "types-pytz", + "types-PyYAML", + "types-redis", + "types-requests<2.31.0", + "types-setuptools", + "types-tabulate", + "virtualenv<20.24.2", + "feast[aws, azure, cassandra, couchbase, delta, docling, duckdb, elasticsearch, faiss, gcp, ge, go, grpcio, hazelcast, hbase, ibis, ikv, k8s, milvus, mssql, mysql, opentelemetry, spark, trino, postgres, pytorch, qdrant, redis, singlestore, snowflake, sqlite_vec]" +] +nlp = ["feast[docling, milvus, pytorch]"] +dev = ["feast[ci]"] +docs = ["feast[ci]"] + +[project.urls] +Homepage = "https://github.com/feast-dev/feast" + +[[project.authors]] +name = "Feast" + +[project.scripts] +feast = "feast.cli:cli" + [build-system] requires = [ "pybindgen==0.22.0", @@ -7,8 +174,15 @@ requires = [ "wheel", ] +[tool.setuptools] +packages = {find = {where = ["sdk/python"], exclude = ["java", "infra", "sdk/python/tests", "ui"]}} + [tool.setuptools_scm] -# Including this section is comparable to supplying use_scm_version=True in setup.py. +# Add Support for parsing tags that have a prefix containing '/' (ie 'sdk/go') to setuptools_scm. +# Regex modified from default tag regex in: +# https://github.com/pypa/setuptools_scm/blob/2a1b46d38fb2b8aeac09853e660bcd0d7c1bc7be/src/setuptools_scm/config.py#L9 +tag_regex = "^(?:[\\/\\w-]+)?(?P[vV]?\\d+(?:\\.\\d+){0,2}[^\\+]*)(?:\\+.*)?$" + [tool.ruff] line-length = 88 diff --git a/sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.rst b/sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.rst new file mode 100644 index 00000000000..7104b02bb66 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.rst @@ -0,0 +1,37 @@ +feast.infra.offline\_stores.contrib.couchbase\_offline\_store package +===================================================================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + feast.infra.offline_stores.contrib.couchbase_offline_store.tests + +Submodules +---------- + +feast.infra.offline\_stores.contrib.couchbase\_offline\_store.couchbase module +------------------------------------------------------------------------------ + +.. automodule:: feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase + :members: + :undoc-members: + :show-inheritance: + +feast.infra.offline\_stores.contrib.couchbase\_offline\_store.couchbase\_source module +-------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase_source + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.offline_stores.contrib.couchbase_offline_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.tests.rst b/sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.tests.rst new file mode 100644 index 00000000000..41566b5359a --- /dev/null +++ b/sdk/python/docs/source/feast.infra.offline_stores.contrib.couchbase_offline_store.tests.rst @@ -0,0 +1,21 @@ +feast.infra.offline\_stores.contrib.couchbase\_offline\_store.tests package +=========================================================================== + +Submodules +---------- + +feast.infra.offline\_stores.contrib.couchbase\_offline\_store.tests.data\_source module +--------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.offline_stores.contrib.couchbase_offline_store.tests.data_source + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.offline_stores.contrib.couchbase_offline_store.tests + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.offline_stores.contrib.rst b/sdk/python/docs/source/feast.infra.offline_stores.contrib.rst index ec74ddab05c..61e797bd6a9 100644 --- a/sdk/python/docs/source/feast.infra.offline_stores.contrib.rst +++ b/sdk/python/docs/source/feast.infra.offline_stores.contrib.rst @@ -8,6 +8,7 @@ Subpackages :maxdepth: 4 feast.infra.offline_stores.contrib.athena_offline_store + feast.infra.offline_stores.contrib.couchbase_offline_store feast.infra.offline_stores.contrib.mssql_offline_store feast.infra.offline_stores.contrib.postgres_offline_store feast.infra.offline_stores.contrib.spark_offline_store @@ -24,6 +25,14 @@ feast.infra.offline\_stores.contrib.athena\_repo\_configuration module :undoc-members: :show-inheritance: +feast.infra.offline\_stores.contrib.couchbase\_columnar\_repo\_configuration module +----------------------------------------------------------------------------------- + +.. automodule:: feast.infra.offline_stores.contrib.couchbase_columnar_repo_configuration + :members: + :undoc-members: + :show-inheritance: + feast.infra.offline\_stores.contrib.mssql\_repo\_configuration module --------------------------------------------------------------------- diff --git a/sdk/python/docs/source/feast.infra.online_stores.cassandra_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.cassandra_online_store.rst new file mode 100644 index 00000000000..7c5c3d371a7 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.cassandra_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.cassandra\_online\_store package +=========================================================== + +Submodules +---------- + +feast.infra.online\_stores.cassandra\_online\_store.cassandra\_online\_store module +----------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.cassandra_online_store.cassandra_online_store + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.cassandra\_online\_store.cassandra\_repo\_configuration module +----------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.cassandra_online_store.cassandra_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.cassandra_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.couchbase_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.couchbase_online_store.rst new file mode 100644 index 00000000000..29d51304928 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.couchbase_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.couchbase\_online\_store package +=========================================================== + +Submodules +---------- + +feast.infra.online\_stores.couchbase\_online\_store.couchbase module +-------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.couchbase_online_store.couchbase + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.couchbase\_online\_store.couchbase\_repo\_configuration module +----------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.couchbase_online_store.couchbase_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.couchbase_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.elasticsearch_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.elasticsearch_online_store.rst new file mode 100644 index 00000000000..d470e3301d0 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.elasticsearch_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.elasticsearch\_online\_store package +=============================================================== + +Submodules +---------- + +feast.infra.online\_stores.elasticsearch\_online\_store.elasticsearch module +---------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.elasticsearch_online_store.elasticsearch + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.elasticsearch\_online\_store.elasticsearch\_repo\_configuration module +------------------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.elasticsearch_online_store.elasticsearch_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.elasticsearch_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.hazelcast_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.hazelcast_online_store.rst new file mode 100644 index 00000000000..9cb565ca132 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.hazelcast_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.hazelcast\_online\_store package +=========================================================== + +Submodules +---------- + +feast.infra.online\_stores.hazelcast\_online\_store.hazelcast\_online\_store module +----------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.hazelcast_online_store.hazelcast_online_store + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.hazelcast\_online\_store.hazelcast\_repo\_configuration module +----------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.hazelcast_online_store.hazelcast_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.hazelcast_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.hbase_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.hbase_online_store.rst new file mode 100644 index 00000000000..50ad80e0a9e --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.hbase_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.hbase\_online\_store package +======================================================= + +Submodules +---------- + +feast.infra.online\_stores.hbase\_online\_store.hbase module +------------------------------------------------------------ + +.. automodule:: feast.infra.online_stores.hbase_online_store.hbase + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.hbase\_online\_store.hbase\_repo\_configuration module +--------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.hbase_online_store.hbase_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.hbase_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.ikv_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.ikv_online_store.rst new file mode 100644 index 00000000000..391af17024f --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.ikv_online_store.rst @@ -0,0 +1,21 @@ +feast.infra.online\_stores.ikv\_online\_store package +===================================================== + +Submodules +---------- + +feast.infra.online\_stores.ikv\_online\_store.ikv module +-------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.ikv_online_store.ikv + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.ikv_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.milvus_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.milvus_online_store.rst new file mode 100644 index 00000000000..5ae3015bf37 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.milvus_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.milvus\_online\_store package +======================================================== + +Submodules +---------- + +feast.infra.online\_stores.milvus\_online\_store.milvus module +-------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.milvus_online_store.milvus + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.milvus\_online\_store.milvus\_repo\_configuration module +----------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.milvus_online_store.milvus_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.milvus_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.mysql_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.mysql_online_store.rst new file mode 100644 index 00000000000..b1a9ea4f802 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.mysql_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.mysql\_online\_store package +======================================================= + +Submodules +---------- + +feast.infra.online\_stores.mysql\_online\_store.mysql module +------------------------------------------------------------ + +.. automodule:: feast.infra.online_stores.mysql_online_store.mysql + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.mysql\_online\_store.mysql\_repo\_configuration module +--------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.mysql_online_store.mysql_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.mysql_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.postgres_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.postgres_online_store.rst new file mode 100644 index 00000000000..9dfd200a4e1 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.postgres_online_store.rst @@ -0,0 +1,37 @@ +feast.infra.online\_stores.postgres\_online\_store package +========================================================== + +Submodules +---------- + +feast.infra.online\_stores.postgres\_online\_store.pgvector\_repo\_configuration module +--------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.postgres_online_store.pgvector_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.postgres\_online\_store.postgres module +------------------------------------------------------------------ + +.. automodule:: feast.infra.online_stores.postgres_online_store.postgres + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.postgres\_online\_store.postgres\_repo\_configuration module +--------------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.postgres_online_store.postgres_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.postgres_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.qdrant_online_store.rst b/sdk/python/docs/source/feast.infra.online_stores.qdrant_online_store.rst new file mode 100644 index 00000000000..5c210d4124d --- /dev/null +++ b/sdk/python/docs/source/feast.infra.online_stores.qdrant_online_store.rst @@ -0,0 +1,29 @@ +feast.infra.online\_stores.qdrant\_online\_store package +======================================================== + +Submodules +---------- + +feast.infra.online\_stores.qdrant\_online\_store.qdrant module +-------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.qdrant_online_store.qdrant + :members: + :undoc-members: + :show-inheritance: + +feast.infra.online\_stores.qdrant\_online\_store.qdrant\_repo\_configuration module +----------------------------------------------------------------------------------- + +.. automodule:: feast.infra.online_stores.qdrant_online_store.qdrant_repo_configuration + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.online_stores.qdrant_online_store + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.online_stores.rst b/sdk/python/docs/source/feast.infra.online_stores.rst index ea714e45c5b..c07c7e0c279 100644 --- a/sdk/python/docs/source/feast.infra.online_stores.rst +++ b/sdk/python/docs/source/feast.infra.online_stores.rst @@ -7,7 +7,16 @@ Subpackages .. toctree:: :maxdepth: 4 - feast.infra.online_stores + feast.infra.online_stores.cassandra_online_store + feast.infra.online_stores.couchbase_online_store + feast.infra.online_stores.elasticsearch_online_store + feast.infra.online_stores.hazelcast_online_store + feast.infra.online_stores.hbase_online_store + feast.infra.online_stores.ikv_online_store + feast.infra.online_stores.milvus_online_store + feast.infra.online_stores.mysql_online_store + feast.infra.online_stores.postgres_online_store + feast.infra.online_stores.qdrant_online_store Submodules ---------- @@ -36,6 +45,14 @@ feast.infra.online\_stores.dynamodb module :undoc-members: :show-inheritance: +feast.infra.online\_stores.faiss\_online\_store module +------------------------------------------------------ + +.. automodule:: feast.infra.online_stores.faiss_online_store + :members: + :undoc-members: + :show-inheritance: + feast.infra.online\_stores.helpers module ----------------------------------------- diff --git a/sdk/python/docs/source/feast.infra.rst b/sdk/python/docs/source/feast.infra.rst index b0046a2719e..791a4ace832 100644 --- a/sdk/python/docs/source/feast.infra.rst +++ b/sdk/python/docs/source/feast.infra.rst @@ -51,6 +51,14 @@ feast.infra.provider module :undoc-members: :show-inheritance: +feast.infra.supported\_async\_methods module +-------------------------------------------- + +.. automodule:: feast.infra.supported_async_methods + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/sdk/python/docs/source/feast.infra.utils.couchbase.rst b/sdk/python/docs/source/feast.infra.utils.couchbase.rst new file mode 100644 index 00000000000..d6d2025c428 --- /dev/null +++ b/sdk/python/docs/source/feast.infra.utils.couchbase.rst @@ -0,0 +1,21 @@ +feast.infra.utils.couchbase package +=================================== + +Submodules +---------- + +feast.infra.utils.couchbase.couchbase\_utils module +--------------------------------------------------- + +.. automodule:: feast.infra.utils.couchbase.couchbase_utils + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: feast.infra.utils.couchbase + :members: + :undoc-members: + :show-inheritance: diff --git a/sdk/python/docs/source/feast.infra.utils.rst b/sdk/python/docs/source/feast.infra.utils.rst index 083259bfaae..cfa82dc5fd2 100644 --- a/sdk/python/docs/source/feast.infra.utils.rst +++ b/sdk/python/docs/source/feast.infra.utils.rst @@ -7,6 +7,7 @@ Subpackages .. toctree:: :maxdepth: 4 + feast.infra.utils.couchbase feast.infra.utils.postgres feast.infra.utils.snowflake diff --git a/sdk/python/docs/source/feast.rst b/sdk/python/docs/source/feast.rst index ea34c3d8dd9..fdb91b2342d 100644 --- a/sdk/python/docs/source/feast.rst +++ b/sdk/python/docs/source/feast.rst @@ -332,6 +332,14 @@ feast.saved\_dataset module :undoc-members: :show-inheritance: +feast.ssl\_ca\_trust\_store\_setup module +----------------------------------------- + +.. automodule:: feast.ssl_ca_trust_store_setup + :members: + :undoc-members: + :show-inheritance: + feast.stream\_feature\_view module ---------------------------------- diff --git a/sdk/python/feast/batch_feature_view.py b/sdk/python/feast/batch_feature_view.py index af7a5e68fd6..c66af0db18e 100644 --- a/sdk/python/feast/batch_feature_view.py +++ b/sdk/python/feast/batch_feature_view.py @@ -1,6 +1,10 @@ +import functools import warnings from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple +from types import FunctionType +from typing import Dict, List, Optional, Tuple, Union + +import dill from feast import flags_helper from feast.data_source import DataSource @@ -8,6 +12,8 @@ from feast.feature_view import FeatureView from feast.field import Field from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.transformation.base import Transformation +from feast.transformation.mode import TransformationMode warnings.simplefilter("once", RuntimeWarning) @@ -42,6 +48,7 @@ class BatchFeatureView(FeatureView): """ name: str + mode: Union[TransformationMode, str] entities: List[str] ttl: Optional[timedelta] source: DataSource @@ -54,11 +61,15 @@ class BatchFeatureView(FeatureView): owner: str timestamp_field: str materialization_intervals: List[Tuple[datetime, datetime]] + udf: Optional[FunctionType] + udf_string: Optional[str] + feature_transformation: Transformation def __init__( self, *, name: str, + mode: Union[TransformationMode, str] = TransformationMode.PYTHON, source: DataSource, entities: Optional[List[Entity]] = None, ttl: Optional[timedelta] = None, @@ -67,6 +78,9 @@ def __init__( description: str = "", owner: str = "", schema: Optional[List[Field]] = None, + udf: Optional[FunctionType] = None, + udf_string: Optional[str] = "", + feature_transformation: Optional[Transformation] = None, ): if not flags_helper.is_test(): warnings.warn( @@ -84,6 +98,13 @@ def __init__( f"or CUSTOM_SOURCE, got {type(source).__name__}: {source.name} instead " ) + self.mode = mode + self.udf = udf + self.udf_string = udf_string + self.feature_transformation = ( + feature_transformation or self.get_feature_transformation() + ) + super().__init__( name=name, entities=entities, @@ -95,3 +116,79 @@ def __init__( schema=schema, source=source, ) + + def get_feature_transformation(self) -> Transformation: + if not self.udf: + raise ValueError( + "Either a UDF or a feature transformation must be provided for BatchFeatureView" + ) + if self.mode in ( + TransformationMode.PANDAS, + TransformationMode.PYTHON, + TransformationMode.SQL, + ) or self.mode in ("pandas", "python", "sql"): + return Transformation( + mode=self.mode, udf=self.udf, udf_string=self.udf_string or "" + ) + else: + raise ValueError( + f"Unsupported transformation mode: {self.mode} for StreamFeatureView" + ) + + +def batch_feature_view( + *, + name: Optional[str] = None, + mode: Union[TransformationMode, str] = TransformationMode.PYTHON, + entities: Optional[List[str]] = None, + ttl: Optional[timedelta] = None, + source: Optional[DataSource] = None, + tags: Optional[Dict[str, str]] = None, + online: bool = True, + description: str = "", + owner: str = "", + schema: Optional[List[Field]] = None, +): + """ + Args: + name: + entities: + ttl: + source: + tags: + online: + description: + owner: + schema: + + Returns: + + """ + + def mainify(obj): + # Needed to allow dill to properly serialize the udf. Otherwise, clients will need to have a file with the same + # name as the original file defining the sfv. + if obj.__module__ != "__main__": + obj.__module__ = "__main__" + + def decorator(user_function): + udf_string = dill.source.getsource(user_function) + mainify(user_function) + batch_feature_view_obj = BatchFeatureView( + name=name or user_function.__name__, + mode=mode, + entities=entities, + ttl=ttl, + source=source, + tags=tags, + online=online, + description=description, + owner=owner, + schema=schema, + udf=user_function, + udf_string=udf_string, + ) + functools.update_wrapper(wrapper=batch_feature_view_obj, wrapped=user_function) + return batch_feature_view_obj + + return decorator diff --git a/sdk/python/feast/cli.py b/sdk/python/feast/cli.py index 06db93d6803..890c79aa856 100644 --- a/sdk/python/feast/cli.py +++ b/sdk/python/feast/cli.py @@ -137,6 +137,24 @@ def version(): print(f'Feast SDK Version: "{importlib_version("feast")}"') +@cli.command() +@click.pass_context +def configuration(ctx: click.Context): + """ + Display Feast configuration + """ + repo = ctx.obj["CHDIR"] + fs_yaml_file = ctx.obj["FS_YAML_FILE"] + cli_check_repo(repo, fs_yaml_file) + repo_config = load_repo_config(repo, fs_yaml_file) + if repo_config: + config_dict = repo_config.model_dump(by_alias=True, exclude_unset=True) + config_dict.pop("repo_path", None) + print(yaml.dump(config_dict, default_flow_style=False, sort_keys=False)) + else: + print("No configuration found.") + + @cli.command() @click.option( "--host", @@ -865,6 +883,7 @@ def materialize_incremental_command(ctx: click.Context, end_ts: str, views: List "cassandra", "hazelcast", "ikv", + "couchbase", ], case_sensitive=False, ), @@ -981,7 +1000,6 @@ def serve_command( raise click.BadParameter( "Please pass --cert and --key args to start the feature server in TLS mode." ) - store = create_feature_store(ctx) store.serve( @@ -1114,16 +1132,40 @@ def serve_registry_command( default=DEFAULT_OFFLINE_SERVER_PORT, help="Specify a port for the server", ) +@click.option( + "--key", + "-k", + "tls_key_path", + type=click.STRING, + default="", + show_default=False, + help="path to TLS certificate private key. You need to pass --cert as well to start server in TLS mode", +) +@click.option( + "--cert", + "-c", + "tls_cert_path", + type=click.STRING, + default="", + show_default=False, + help="path to TLS certificate public key. You need to pass --key as well to start server in TLS mode", +) @click.pass_context def serve_offline_command( ctx: click.Context, host: str, port: int, + tls_key_path: str, + tls_cert_path: str, ): """Start a remote server locally on a given host, port.""" + if (tls_key_path and not tls_cert_path) or (not tls_key_path and tls_cert_path): + raise click.BadParameter( + "Please pass --cert and --key args to start the offline server in TLS mode." + ) store = create_feature_store(ctx) - store.serve_offline(host, port) + store.serve_offline(host, port, tls_key_path, tls_cert_path) @cli.command("validate") diff --git a/sdk/python/feast/driver_test_data.py b/sdk/python/feast/driver_test_data.py index 23f1f124774..d96c9c6d387 100644 --- a/sdk/python/feast/driver_test_data.py +++ b/sdk/python/feast/driver_test_data.py @@ -2,10 +2,10 @@ import itertools from datetime import timedelta, timezone from enum import Enum +from zoneinfo import ZoneInfo import numpy as np import pandas as pd -from zoneinfo import ZoneInfo from feast.infra.offline_stores.offline_utils import ( DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL, diff --git a/sdk/python/feast/embedded_go/online_features_service.py b/sdk/python/feast/embedded_go/online_features_service.py index 867431fcf85..8dd7b5ba0a1 100644 --- a/sdk/python/feast/embedded_go/online_features_service.py +++ b/sdk/python/feast/embedded_go/online_features_service.py @@ -1,3 +1,4 @@ +import logging from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union @@ -36,6 +37,8 @@ MILLI_SECOND = 1000 * MICRO_SECOND SECOND = 1000 * MILLI_SECOND +logger = logging.getLogger(__name__) + class EmbeddedOnlineFeatureServer: def __init__( @@ -243,28 +246,32 @@ def transformation_callback( output_schema_ptr: int, full_feature_names: bool, ) -> int: - odfv = fs.get_on_demand_feature_view(on_demand_feature_view_name) + try: + odfv = fs.get_on_demand_feature_view(on_demand_feature_view_name) - input_record = pa.RecordBatch._import_from_c(input_arr_ptr, input_schema_ptr) + input_record = pa.RecordBatch._import_from_c(input_arr_ptr, input_schema_ptr) - # For some reason, the callback is called with `full_feature_names` as a 1 if True or 0 if false. This handles - # the typeguard requirement. - full_feature_names = bool(full_feature_names) + # For some reason, the callback is called with `full_feature_names` as a 1 if True or 0 if false. This handles + # the typeguard requirement. + full_feature_names = bool(full_feature_names) - if odfv.mode != "pandas": - raise Exception( - f"OnDemandFeatureView mode '{odfv.mode} not supported by EmbeddedOnlineFeatureServer." - ) + if odfv.mode != "pandas": + raise Exception( + f"OnDemandFeatureView mode '{odfv.mode} not supported by EmbeddedOnlineFeatureServer." + ) - output = odfv.get_transformed_features_df( # type: ignore - input_record.to_pandas(), full_feature_names=full_feature_names - ) - output_record = pa.RecordBatch.from_pandas(output) + output = odfv.get_transformed_features_df( # type: ignore + input_record.to_pandas(), full_feature_names=full_feature_names + ) + output_record = pa.RecordBatch.from_pandas(output) - output_record.schema._export_to_c(output_schema_ptr) - output_record._export_to_c(output_arr_ptr) + output_record.schema._export_to_c(output_schema_ptr) + output_record._export_to_c(output_arr_ptr) - return output_record.num_rows + return output_record.num_rows + except Exception as e: + logger.exception(f"transformation callback failed with exception: {e}", e) + return 0 def logging_callback( diff --git a/sdk/python/feast/entity.py b/sdk/python/feast/entity.py index 290e6307a42..7f4eadc6352 100644 --- a/sdk/python/feast/entity.py +++ b/sdk/python/feast/entity.py @@ -11,6 +11,7 @@ # 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. +import warnings from datetime import datetime from typing import Dict, List, Optional @@ -79,6 +80,13 @@ def __init__( ValueError: Parameters are specified incorrectly. """ self.name = name + if value_type is None: + warnings.warn( + "Entity value_type will be mandatory in the next release. " + "Please specify a value_type for entity '%s'." % name, + DeprecationWarning, + stacklevel=2, + ) self.value_type = value_type or ValueType.UNKNOWN if join_keys and len(join_keys) > 1: @@ -165,13 +173,12 @@ def from_proto(cls, entity_proto: EntityProto): entity = cls( name=entity_proto.spec.name, join_keys=[entity_proto.spec.join_key], + value_type=ValueType(entity_proto.spec.value_type), description=entity_proto.spec.description, tags=dict(entity_proto.spec.tags), owner=entity_proto.spec.owner, ) - entity.value_type = ValueType(entity_proto.spec.value_type) - if entity_proto.meta.HasField("created_timestamp"): entity.created_timestamp = entity_proto.meta.created_timestamp.ToDatetime() if entity_proto.meta.HasField("last_updated_timestamp"): diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py index 84a9f12ec4f..8c72422f44e 100644 --- a/sdk/python/feast/errors.py +++ b/sdk/python/feast/errors.py @@ -32,7 +32,7 @@ def __str__(self) -> str: def __repr__(self) -> str: if hasattr(self, "__overridden_message__"): - return f"{type(self).__name__}('{getattr(self,'__overridden_message__')}')" + return f"{type(self).__name__}('{getattr(self, '__overridden_message__')}')" return super().__repr__() def to_error_detail(self) -> str: diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index 1f4918fe7a5..434efa7e44b 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -1,17 +1,29 @@ +import asyncio +import os import sys import threading import time import traceback from contextlib import asynccontextmanager +from importlib import resources as importlib_resources from typing import Any, Dict, List, Optional import pandas as pd import psutil from dateutil import parser -from fastapi import Depends, FastAPI, Request, Response, status +from fastapi import ( + Depends, + FastAPI, + Request, + Response, + WebSocket, + WebSocketDisconnect, + status, +) from fastapi.concurrency import run_in_threadpool from fastapi.logger import logger from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from google.protobuf.json_format import MessageToDict from prometheus_client import Gauge, start_http_server from pydantic import BaseModel @@ -74,12 +86,83 @@ class GetOnlineFeaturesRequest(BaseModel): feature_service: Optional[str] = None features: Optional[List[str]] = None full_feature_names: bool = False + query_embedding: Optional[List[float]] = None + query_string: Optional[str] = None + + +class ChatMessage(BaseModel): + role: str + content: str + + +class ChatRequest(BaseModel): + messages: List[ChatMessage] + + +def _get_features(request: GetOnlineFeaturesRequest, store: "feast.FeatureStore"): + if request.feature_service: + feature_service = store.get_feature_service( + request.feature_service, allow_cache=True + ) + assert_permissions( + resource=feature_service, actions=[AuthzedAction.READ_ONLINE] + ) + features = feature_service # type: ignore + else: + all_feature_views, all_on_demand_feature_views = ( + utils._get_feature_views_to_use( + store.registry, + store.project, + request.features, + allow_cache=True, + hide_dummy_entity=False, + ) + ) + for feature_view in all_feature_views: + assert_permissions( + resource=feature_view, actions=[AuthzedAction.READ_ONLINE] + ) + for od_feature_view in all_on_demand_feature_views: + assert_permissions( + resource=od_feature_view, actions=[AuthzedAction.READ_ONLINE] + ) + features = request.features # type: ignore + return features def get_app( store: "feast.FeatureStore", registry_ttl_sec: int = DEFAULT_FEATURE_SERVER_REGISTRY_TTL, ): + """ + Creates a FastAPI app that can be used to start a feature server. + + Args: + store: The FeatureStore to use for serving features + registry_ttl_sec: The TTL in seconds for the registry cache + + Returns: + A FastAPI app + + Example: + ```python + from feast import FeatureStore + + store = FeatureStore(repo_path="feature_repo") + app = get_app(store) + ``` + + The app provides the following endpoints: + - `/get-online-features`: Get online features + - `/retrieve-online-documents`: Retrieve online documents + - `/push`: Push features to the feature store + - `/write-to-online-store`: Write to the online store + - `/health`: Health check + - `/materialize`: Materialize features + - `/materialize-incremental`: Materialize features incrementally + - `/chat`: Chat UI + - `/ws/chat`: WebSocket endpoint for chat + """ proto_json.patch() # Asynchronously refresh registry, notifying shutdown and canceling the active timer if the app is shutting down registry_proto = None @@ -121,33 +204,7 @@ async def lifespan(app: FastAPI): ) async def get_online_features(request: GetOnlineFeaturesRequest) -> Dict[str, Any]: # Initialize parameters for FeatureStore.get_online_features(...) call - if request.feature_service: - feature_service = store.get_feature_service( - request.feature_service, allow_cache=True - ) - assert_permissions( - resource=feature_service, actions=[AuthzedAction.READ_ONLINE] - ) - features = feature_service # type: ignore - else: - all_feature_views, all_on_demand_feature_views = ( - utils._get_feature_views_to_use( - store.registry, - store.project, - request.features, - allow_cache=True, - hide_dummy_entity=False, - ) - ) - for feature_view in all_feature_views: - assert_permissions( - resource=feature_view, actions=[AuthzedAction.READ_ONLINE] - ) - for od_feature_view in all_on_demand_feature_views: - assert_permissions( - resource=od_feature_view, actions=[AuthzedAction.READ_ONLINE] - ) - features = request.features # type: ignore + features = await run_in_threadpool(_get_features, request, store) read_params = dict( features=features, @@ -163,9 +220,47 @@ async def get_online_features(request: GetOnlineFeaturesRequest) -> Dict[str, An ) # Convert the Protobuf object to JSON and return it - return MessageToDict( - response.proto, preserving_proto_field_name=True, float_precision=18 + response_dict = await run_in_threadpool( + MessageToDict, + response.proto, + preserving_proto_field_name=True, + float_precision=18, ) + return response_dict + + @app.post( + "/retrieve-online-documents", + dependencies=[Depends(inject_user_details)], + ) + async def retrieve_online_documents( + request: GetOnlineFeaturesRequest, + ) -> Dict[str, Any]: + logger.warn( + "This endpoint is in alpha and will be moved to /get-online-features when stable." + ) + # Initialize parameters for FeatureStore.retrieve_online_documents_v2(...) call + features = await run_in_threadpool(_get_features, request, store) + + read_params = dict( + features=features, + entity_rows=request.entities, + full_feature_names=request.full_feature_names, + query=request.query_embedding, + query_string=request.query_string, + ) + + response = await run_in_threadpool( + lambda: store.retrieve_online_documents_v2(**read_params) # type: ignore + ) + + # Convert the Protobuf object to JSON and return it + response_dict = await run_in_threadpool( + MessageToDict, + response.proto, + preserving_proto_field_name=True, + float_precision=18, + ) + return response_dict @app.post("/push", dependencies=[Depends(inject_user_details)]) async def push(request: PushFeaturesRequest) -> None: @@ -252,6 +347,21 @@ async def health(): else Response(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) ) + @app.post("/chat") + async def chat(request: ChatRequest): + # Process the chat request + # For now, just return dummy text + return {"response": "This is a dummy response from the Feast feature server."} + + @app.get("/chat") + async def chat_ui(): + # Serve the chat UI + static_dir_ref = importlib_resources.files(__spec__.parent) / "static/chat" # type: ignore[name-defined, arg-type] + with importlib_resources.as_file(static_dir_ref) as static_dir: + with open(os.path.join(static_dir, "index.html")) as f: + content = f.read() + return Response(content=content, media_type="text/html") + @app.post("/materialize", dependencies=[Depends(inject_user_details)]) def materialize(request: MaterializeRequest) -> None: for feature_view in request.feature_views or []: @@ -292,6 +402,46 @@ async def rest_exception_handler(request: Request, exc: Exception): content=str(exc), ) + # Chat WebSocket connection manager + class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def send_message(self, message: str, websocket: WebSocket): + await websocket.send_text(message) + + manager = ConnectionManager() + + @app.websocket("/ws/chat") + async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + message = await websocket.receive_text() + # Process the received message (currently unused but kept for future implementation) + # For now, just return dummy text + response = f"You sent: '{message}'. This is a dummy response from the Feast feature server." + + # Stream the response word by word + words = response.split() + for word in words: + await manager.send_message(word + " ", websocket) + await asyncio.sleep(0.1) # Add a small delay between words + except WebSocketDisconnect: + manager.disconnect(websocket) + + # Mount static files + static_dir_ref = importlib_resources.files(__spec__.parent) / "static" # type: ignore[name-defined, arg-type] + with importlib_resources.as_file(static_dir_ref) as static_dir: + app.mount("/static", StaticFiles(directory=static_dir), name="static") + return app diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index e6cdf90b4a3..7073a20d1e0 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -62,6 +62,7 @@ from feast.feast_object import FeastObject from feast.feature_service import FeatureService from feast.feature_view import DUMMY_ENTITY, DUMMY_ENTITY_NAME, FeatureView +from feast.field import Field from feast.inference import ( update_data_sources_with_inferred_event_timestamp_col, update_feature_views_with_inferred_features_and_entities, @@ -86,9 +87,11 @@ from feast.repo_config import RepoConfig, load_repo_config from feast.repo_contents import RepoContents from feast.saved_dataset import SavedDataset, SavedDatasetStorage, ValidationReference +from feast.ssl_ca_trust_store_setup import configure_ca_trust_store_env_variables from feast.stream_feature_view import StreamFeatureView +from feast.transformation.pandas_transformation import PandasTransformation +from feast.transformation.python_transformation import PythonTransformation from feast.utils import _utc_now -from feast.version import get_version warnings.simplefilter("once", DeprecationWarning) @@ -130,6 +133,8 @@ def __init__( if fs_yaml_file is not None and config is not None: raise ValueError("You cannot specify both fs_yaml_file and config.") + configure_ca_trust_store_env_variables() + if repo_path: self.repo_path = Path(repo_path) else: @@ -171,10 +176,6 @@ def __init__( self._provider = get_provider(self.config) - def version(self) -> str: - """Returns the version of the current Feast SDK/CLI.""" - return get_version() - def __repr__(self) -> str: return ( f"FeatureStore(\n" @@ -867,8 +868,7 @@ def apply( views_to_update = [ ob for ob in objects - if - ( + if ( # BFVs are not handled separately from FVs right now. (isinstance(ob, FeatureView) or isinstance(ob, BatchFeatureView)) and not isinstance(ob, StreamFeatureView) @@ -1548,6 +1548,64 @@ def _get_feature_view_and_df_for_online_write( df = pd.DataFrame(df) except Exception as _: raise DataFrameSerializationError(df) + + # # Apply transformations if this is an OnDemandFeatureView with write_to_online_store=True + if ( + isinstance(feature_view, OnDemandFeatureView) + and feature_view.write_to_online_store + ): + if ( + feature_view.mode == "python" + and isinstance( + feature_view.feature_transformation, PythonTransformation + ) + and df is not None + ): + input_dict = ( + df.to_dict(orient="records")[0] + if feature_view.singleton + else df.to_dict(orient="list") + ) + transformed_data = feature_view.feature_transformation.udf(input_dict) + if feature_view.write_to_online_store: + entities = [ + self.get_entity(entity) + for entity in (feature_view.entities or []) + ] + join_keys = [entity.join_key for entity in entities if entity] + join_keys = [k for k in join_keys if k in input_dict.keys()] + transformed_df = pd.DataFrame(transformed_data) + input_df = pd.DataFrame(input_dict) + if input_df.shape[0] == transformed_df.shape[0]: + for k in input_dict: + if k not in transformed_data: + transformed_data[k] = input_dict[k] + transformed_df = pd.DataFrame(transformed_data) + else: + transformed_df = pd.merge( + transformed_df, + input_df, + how="left", + on=join_keys, + ) + else: + # overwrite any transformed features and update the dictionary + for k in input_dict: + if k not in transformed_data: + transformed_data[k] = input_dict[k] + df = pd.DataFrame(transformed_data) + elif feature_view.mode == "pandas" and isinstance( + feature_view.feature_transformation, PandasTransformation + ): + transformed_df = feature_view.feature_transformation.udf(df) + if df is not None: + for col in df.columns: + transformed_df[col] = df[col] + df = transformed_df + + else: + raise Exception("Unsupported OnDemandFeatureView mode") + return feature_view, df def write_to_online_store( @@ -1755,10 +1813,11 @@ async def get_online_features_async( def retrieve_online_documents( self, - feature: str, + feature: Optional[str], query: Union[str, List[float]], top_k: int, - distance_metric: Optional[str] = None, + features: Optional[List[str]] = None, + distance_metric: Optional[str] = "L2", ) -> OnlineResponse: """ Retrieves the top k closest document features. Note, embeddings are a subset of features. @@ -1767,6 +1826,7 @@ def retrieve_online_documents( feature: The list of document features that should be retrieved from the online document store. These features can be specified either as a list of string document feature references or as a feature service. String feature references must have format "feature_view:feature", e.g, "document_fv:document_embeddings". + features: The list of features that should be retrieved from the online store. query: The query to retrieve the closest document features for. top_k: The number of closest document features to retrieve. distance_metric: The distance metric to use for retrieval. @@ -1775,18 +1835,44 @@ def retrieve_online_documents( raise ValueError( "Using embedding functionality is not supported for document retrieval. Please embed the query before calling retrieve_online_documents." ) + feature_list: List[str] = ( + features + if features is not None + else ([feature] if feature is not None else []) + ) + ( available_feature_views, _, ) = utils._get_feature_views_to_use( registry=self._registry, project=self.project, - features=[feature], + features=feature_list, allow_cache=True, hide_dummy_entity=False, ) + if features: + feature_view_set = set() + for feature in features: + feature_view_name = feature.split(":")[0] + feature_view = self.get_feature_view(feature_view_name) + feature_view_set.add(feature_view.name) + if len(feature_view_set) > 1: + raise ValueError( + "Document retrieval only supports a single feature view." + ) + requested_feature = None + requested_features = [ + f.split(":")[1] for f in features if isinstance(f, str) and ":" in f + ] + else: + requested_feature = ( + feature.split(":")[1] if isinstance(feature, str) else feature + ) + requested_features = [requested_feature] if requested_feature else [] + requested_feature_view_name = ( - feature.split(":")[0] if isinstance(feature, str) else feature + feature.split(":")[0] if feature else list(feature_view_set)[0] ) for feature_view in available_feature_views: if feature_view.name == requested_feature_view_name: @@ -1795,19 +1881,19 @@ def retrieve_online_documents( raise ValueError( f"Feature view {requested_feature_view} not found in the registry." ) - requested_feature = ( - feature.split(":")[1] if isinstance(feature, str) else feature - ) + + requested_feature_view = available_feature_views[0] + provider = self._get_provider() document_features = self._retrieve_from_online_store( provider, requested_feature_view, requested_feature, + requested_features, query, top_k, distance_metric, ) - # TODO currently not return the vector value since it is same as feature value, if embedding is supported, # the feature value can be raw text before embedded entity_key_vals = [feature[1] for feature in document_features] @@ -1824,6 +1910,7 @@ def retrieve_online_documents( document_feature_vals = [feature[4] for feature in document_features] document_feature_distance_vals = [feature[5] for feature in document_features] online_features_response = GetOnlineFeaturesResponse(results=[]) + requested_feature = requested_feature or requested_features[0] utils._populate_result_rows_from_columnar( online_features_response=online_features_response, data={ @@ -1834,11 +1921,81 @@ def retrieve_online_documents( ) return OnlineResponse(online_features_response) + def retrieve_online_documents_v2( + self, + features: List[str], + top_k: int, + query: Optional[List[float]] = None, + query_string: Optional[str] = None, + distance_metric: Optional[str] = "L2", + ) -> OnlineResponse: + """ + Retrieves the top k closest document features. Note, embeddings are a subset of features. + + Args: + features: The list of features that should be retrieved from the online document store. These features can be + specified either as a list of string document feature references or as a feature service. String feature + references must have format "feature_view:feature", e.g, "document_fv:document_embeddings". + query: The embeded query to retrieve the closest document features for (optional) + top_k: The number of closest document features to retrieve. + distance_metric: The distance metric to use for retrieval. + query_string: The query string to retrieve the closest document features using keyword search (bm25). + """ + assert query is not None or query_string is not None, ( + "Either query or query_string must be provided." + ) + + ( + available_feature_views, + available_odfv_views, + ) = utils._get_feature_views_to_use( + registry=self._registry, + project=self.project, + features=features, + allow_cache=True, + hide_dummy_entity=False, + ) + feature_view_set = set() + for feature in features: + feature_view_name = feature.split(":")[0] + if feature_view_name in [fv.name for fv in available_odfv_views]: + feature_view: Union[OnDemandFeatureView, FeatureView] = ( + self.get_on_demand_feature_view(feature_view_name) + ) + else: + feature_view = self.get_feature_view(feature_view_name) + feature_view_set.add(feature_view.name) + if len(feature_view_set) > 1: + raise ValueError("Document retrieval only supports a single feature view.") + requested_features = [ + f.split(":")[1] for f in features if isinstance(f, str) and ":" in f + ] + if len(available_feature_views) == 0: + available_feature_views.extend(available_odfv_views) # type: ignore[arg-type] + + requested_feature_view = available_feature_views[0] + if not requested_feature_view: + raise ValueError( + f"Feature view {requested_feature_view} not found in the registry." + ) + + provider = self._get_provider() + return self._retrieve_from_online_store_v2( + provider, + requested_feature_view, + requested_features, + query, + top_k, + distance_metric, + query_string, + ) + def _retrieve_from_online_store( self, provider: Provider, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], query: List[float], top_k: int, distance_metric: Optional[str], @@ -1850,10 +2007,15 @@ def _retrieve_from_online_store( """ Search and return document features from the online document store. """ + vector_field_metadata = _get_feature_view_vector_field_metadata(table) + if vector_field_metadata: + distance_metric = vector_field_metadata.vector_search_metric + documents = provider.retrieve_online_documents( config=self.config, table=table, requested_feature=requested_feature, + requested_features=requested_features, query=query, top_k=top_k, distance_metric=distance_metric, @@ -1862,7 +2024,7 @@ def _retrieve_from_online_store( read_row_protos = [] row_ts_proto = Timestamp() - for row_ts, entity_key, feature_val, vector_value, distance_val in documents: + for row_ts, entity_key, feature_val, vector_value, distance_val in documents: # type: ignore[misc] # Reset timestamp to default or update if row_ts is not None if row_ts is not None: row_ts_proto.FromDatetime(row_ts) @@ -1887,6 +2049,73 @@ def _retrieve_from_online_store( ) return read_row_protos + def _retrieve_from_online_store_v2( + self, + provider: Provider, + table: FeatureView, + requested_features: List[str], + query: Optional[List[float]], + top_k: int, + distance_metric: Optional[str], + query_string: Optional[str], + ) -> OnlineResponse: + """ + Search and return document features from the online document store. + """ + vector_field_metadata = _get_feature_view_vector_field_metadata(table) + if vector_field_metadata: + distance_metric = vector_field_metadata.vector_search_metric + + documents = provider.retrieve_online_documents_v2( + config=self.config, + table=table, + requested_features=requested_features, + query=query, + top_k=top_k, + distance_metric=distance_metric, + query_string=query_string, + ) + + entity_key_dict: Dict[str, List[ValueProto]] = {} + datevals, entityvals, list_of_feature_dicts = [], [], [] + for row_ts, entity_key, feature_dict in documents: # type: ignore[misc] + datevals.append(row_ts) + entityvals.append(entity_key) + list_of_feature_dicts.append(feature_dict) + if entity_key: + for key, value in zip(entity_key.join_keys, entity_key.entity_values): + python_value = value + if key not in entity_key_dict: + entity_key_dict[key] = [] + entity_key_dict[key].append(python_value) + + table_entity_values, idxs, output_len = utils._get_unique_entities_from_values( + entity_key_dict, + ) + + features_to_request: List[str] = [] + if requested_features: + features_to_request = requested_features + ["distance"] + else: + features_to_request = ["distance"] + feature_data = utils._convert_rows_to_protobuf( + requested_features=features_to_request, + read_rows=list(zip(datevals, list_of_feature_dicts)), + ) + + online_features_response = GetOnlineFeaturesResponse(results=[]) + utils._populate_response_from_feature_data( + feature_data=feature_data, + indexes=idxs, + online_features_response=online_features_response, + full_feature_names=False, + requested_features=features_to_request, + table=table, + output_len=output_len, + ) + + return OnlineResponse(online_features_response) + def serve( self, host: str, @@ -1963,11 +2192,17 @@ def serve_registry( self, port=port, tls_key_path=tls_key_path, tls_cert_path=tls_cert_path ) - def serve_offline(self, host: str, port: int) -> None: + def serve_offline( + self, + host: str, + port: int, + tls_key_path: str = "", + tls_cert_path: str = "", + ) -> None: """Start offline server locally on a given port.""" from feast import offline_server - offline_server.start_server(self, host, port) + offline_server.start_server(self, host, port, tls_key_path, tls_cert_path) def serve_transformations(self, port: int) -> None: """Start the feature transformation server locally on a given port.""" @@ -1995,9 +2230,9 @@ def write_logged_features( if not isinstance(source, FeatureService): raise ValueError("Only feature service is currently supported as a source") - assert ( - source.logging_config is not None - ), "Feature service must be configured with logging config in order to use this functionality" + assert source.logging_config is not None, ( + "Feature service must be configured with logging config in order to use this functionality" + ) assert isinstance(logs, (pa.Table, Path)) @@ -2230,3 +2465,16 @@ def _validate_data_sources(data_sources: List[DataSource]): raise DataSourceRepeatNamesException(case_insensitive_ds_name) else: ds_names.add(case_insensitive_ds_name) + + +def _get_feature_view_vector_field_metadata( + feature_view: FeatureView, +) -> Optional[Field]: + vector_fields = [field for field in feature_view.schema if field.vector_index] + if len(vector_fields) > 1: + raise ValueError( + f"Feature view {feature_view.name} has multiple vector fields. Only one vector field per feature view is supported." + ) + if not vector_fields: + return None + return vector_fields[0] diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index 4aeb9a9c1dc..49b74893451 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -48,6 +48,7 @@ DUMMY_ENTITY = Entity( name=DUMMY_ENTITY_NAME, join_keys=[DUMMY_ENTITY_ID], + value_type=ValueType.UNKNOWN, ) DUMMY_ENTITY_FIELD = Field( name=DUMMY_ENTITY_ID, @@ -191,6 +192,10 @@ def __init__( else: features.append(field) + assert len([f for f in features if f.vector_index]) < 2, ( + f"Only one vector feature is allowed per feature view. Please update {self.name}." + ) + # TODO(felixwang9817): Add more robust validation of features. cols = [field.name for field in schema] for col in cols: @@ -343,12 +348,11 @@ def to_proto(self) -> FeatureViewProto: if self.stream_source: stream_source_proto = self.stream_source.to_proto() stream_source_proto.data_source_class_type = f"{self.stream_source.__class__.__module__}.{self.stream_source.__class__.__name__}" - spec = FeatureViewSpecProto( name=self.name, entities=self.entities, entity_columns=[field.to_proto() for field in self.entity_columns], - features=[field.to_proto() for field in self.features], + features=[feature.to_proto() for feature in self.features], description=self.description, tags=self.tags, owner=self.owner, diff --git a/sdk/python/feast/field.py b/sdk/python/feast/field.py index a41dcf5d5e6..fda1fbffe54 100644 --- a/sdk/python/feast/field.py +++ b/sdk/python/feast/field.py @@ -32,12 +32,16 @@ class Field: dtype: The type of the field, such as string or float. description: A human-readable description. tags: User-defined metadata in dictionary form. + vector_index: If set to True the field will be indexed for vector similarity search. + vector_search_metric: The metric used for vector similarity search. """ name: str dtype: FeastType description: str tags: Dict[str, str] + vector_index: bool + vector_search_metric: Optional[str] def __init__( self, @@ -46,6 +50,8 @@ def __init__( dtype: FeastType, description: str = "", tags: Optional[Dict[str, str]] = None, + vector_index: bool = False, + vector_search_metric: Optional[str] = None, ): """ Creates a Field object. @@ -55,11 +61,15 @@ def __init__( dtype: The type of the field, such as string or float. description (optional): A human-readable description. tags (optional): User-defined metadata in dictionary form. + vector_index (optional): If set to True the field will be indexed for vector similarity search. + vector_search_metric (optional): The metric used for vector similarity search. """ self.name = name self.dtype = dtype self.description = description self.tags = tags or {} + self.vector_index = vector_index + self.vector_search_metric = vector_search_metric def __eq__(self, other): if type(self) != type(other): @@ -70,6 +80,8 @@ def __eq__(self, other): or self.dtype != other.dtype or self.description != other.description or self.tags != other.tags + # or self.vector_index != other.vector_index + # or self.vector_search_metric != other.vector_search_metric ): return False return True @@ -87,6 +99,8 @@ def __repr__(self): f" dtype={self.dtype!r},\n" f" description={self.description!r},\n" f" tags={self.tags!r}\n" + f" vector_index={self.vector_index!r}\n" + f" vector_search_metric={self.vector_search_metric!r}\n" f")" ) @@ -96,11 +110,14 @@ def __str__(self): def to_proto(self) -> FieldProto: """Converts a Field object to its protobuf representation.""" value_type = self.dtype.to_value_type() + vector_search_metric = self.vector_search_metric or "" return FieldProto( name=self.name, value_type=value_type.value, description=self.description, tags=self.tags, + vector_index=self.vector_index, + vector_search_metric=vector_search_metric, ) @classmethod @@ -112,11 +129,15 @@ def from_proto(cls, field_proto: FieldProto): field_proto: FieldProto protobuf object """ value_type = ValueType(field_proto.value_type) + vector_search_metric = getattr(field_proto, "vector_search_metric", "") + vector_index = getattr(field_proto, "vector_index", False) return cls( name=field_proto.name, dtype=from_value_type(value_type=value_type), tags=dict(field_proto.tags), description=field_proto.description, + vector_index=vector_index, + vector_search_metric=vector_search_metric, ) @classmethod diff --git a/sdk/python/feast/infra/feature_servers/local_process/config.py b/sdk/python/feast/infra/feature_servers/local_process/config.py index 3d97912e4bd..942927ec2e8 100644 --- a/sdk/python/feast/infra/feature_servers/local_process/config.py +++ b/sdk/python/feast/infra/feature_servers/local_process/config.py @@ -4,5 +4,8 @@ class LocalFeatureServerConfig(BaseFeatureServerConfig): + # Feature server type selector. type: Literal["local"] = "local" - """Feature server type selector.""" + + # The endpoint definition for transformation_service + transformation_service_endpoint: str = "localhost:6569" diff --git a/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile b/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile index df8e1c94d77..f4096e8494d 100644 --- a/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile +++ b/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile @@ -1,22 +1,7 @@ -FROM debian:11-slim -RUN apt update && \ - apt install -y \ - jq \ - python3 \ - python3-pip \ - python3-dev \ - build-essential +FROM registry.access.redhat.com/ubi8/python-311:1 -RUN pip install pip --upgrade -RUN pip install "feast[aws,gcp,snowflake,redis,go,mysql,postgres,opentelemetry,grpcio]" +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt - -RUN apt update -RUN apt install -y -V ca-certificates lsb-release wget -RUN wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb -RUN apt install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb -RUN apt update -RUN apt -y install libarrow-dev # modify permissions to support running with a random uid -RUN mkdir -m 775 /.cache -RUN chmod g+w $(python3 -c "import feast.ui as _; print(_.__path__)" | tr -d "[']")/build/projects-list.json +RUN chmod g+w $(python -c "import feast.ui as ui; print(ui.__path__)" | tr -d "[']")/build/projects-list.json diff --git a/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev b/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev index ce01c9809ba..31ac4a6366a 100644 --- a/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev +++ b/sdk/python/feast/infra/feature_servers/multicloud/Dockerfile.dev @@ -1,24 +1,34 @@ -FROM debian:11-slim +FROM registry.access.redhat.com/ubi8/python-311:1 -RUN apt update && \ - apt install -y \ - jq \ - python3 \ - python3-pip \ - python3-dev \ - build-essential +USER 0 +RUN npm install -g yarn yalc && rm -rf .npm +USER default -RUN pip install pip --upgrade -COPY . . +COPY --chown=default .git ${APP_ROOT}/src/.git +COPY --chown=default setup.py pyproject.toml README.md Makefile ${APP_ROOT}/src/ +COPY --chown=default protos ${APP_ROOT}/src/protos +COPY --chown=default ui ${APP_ROOT}/src/ui +COPY --chown=default sdk/python ${APP_ROOT}/src/sdk/python -RUN pip install "feast[aws,gcp,snowflake,redis,go,mysql,postgres,opentelemetry,grpcio]" +WORKDIR ${APP_ROOT}/src/ui +RUN npm install && \ + npm run build:lib-dev && \ + rm -rf node_modules && \ + npm cache clean --force + +WORKDIR ${APP_ROOT}/src/sdk/python/feast/ui +RUN yalc add @feast-dev/feast-ui && \ + git diff package.json && \ + yarn install && \ + npm run build --omit=dev && \ + rm -rf node_modules && \ + npm cache clean --force && \ + yarn cache clean --all + +WORKDIR ${APP_ROOT}/src +RUN pip install --no-cache-dir pip-tools && \ + make install-python-ci-dependencies && \ + pip uninstall -y pip-tools -RUN apt update -RUN apt install -y -V ca-certificates lsb-release wget -RUN wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb -RUN apt install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb -RUN apt update -RUN apt -y install libarrow-dev # modify permissions to support running with a random uid -RUN mkdir -m 775 /.cache -RUN chmod g+w $(python3 -c "import feast.ui as _; print(_.__path__)" | tr -d "[']")/build/projects-list.json +RUN chmod g+w $(python -c "import feast.ui as ui; print(ui.__path__)" | tr -d "[']")/build/projects-list.json diff --git a/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt b/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt new file mode 100644 index 00000000000..20789a976d7 --- /dev/null +++ b/sdk/python/feast/infra/feature_servers/multicloud/requirements.txt @@ -0,0 +1,2 @@ +# keep VERSION on line #2, this is critical to release CI +feast[aws,gcp,snowflake,redis,go,mysql,postgres,opentelemetry,grpcio,k8s,duckdb,milvus] == 0.46.0 diff --git a/sdk/python/feast/infra/key_encoding_utils.py b/sdk/python/feast/infra/key_encoding_utils.py index 1f9ffeef140..ce2692a4955 100644 --- a/sdk/python/feast/infra/key_encoding_utils.py +++ b/sdk/python/feast/infra/key_encoding_utils.py @@ -1,5 +1,7 @@ import struct -from typing import List, Tuple +from typing import List, Tuple, Union + +from google.protobuf.internal.containers import RepeatedScalarFieldContainer from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto @@ -81,6 +83,8 @@ def serialize_entity_key( ) output: List[bytes] = [] + if entity_key_serialization_version > 2: + output.append(struct.pack(" 2: @@ -120,7 +124,11 @@ def deserialize_entity_key( offset = 0 keys = [] values = [] - while offset < len(serialized_entity_key): + + num_keys = struct.unpack_from(" bytes: + """serializes a list of floats into a compact "raw bytes" format""" + return struct.pack(f"{vector_length}f", *vector) + + +def deserialize_f32(byte_vector: bytes, vector_length: int) -> List[float]: + """deserializes a list of floats from a compact "raw bytes" format""" + num_floats = vector_length // 4 # 4 bytes per float + return list(struct.unpack(f"{num_floats}f", byte_vector)) diff --git a/sdk/python/feast/infra/materialization/kubernetes/k8s_materialization_engine.py b/sdk/python/feast/infra/materialization/kubernetes/k8s_materialization_engine.py index a0ccbcd768b..96064409459 100644 --- a/sdk/python/feast/infra/materialization/kubernetes/k8s_materialization_engine.py +++ b/sdk/python/feast/infra/materialization/kubernetes/k8s_materialization_engine.py @@ -278,7 +278,7 @@ def _print_pod_logs(self, job_id, feature_view, offset=0): label_selector=f"job-name={job_id}", ).items for i, pod in enumerate(pods_list): - logger.info(f"Logging output for {feature_view.name} pod {offset+i}") + logger.info(f"Logging output for {feature_view.name} pod {offset + i}") try: logger.info( self.v1.read_namespaced_pod_log(pod.metadata.name, self.namespace) diff --git a/sdk/python/feast/infra/materialization/snowflake_engine.py b/sdk/python/feast/infra/materialization/snowflake_engine.py index f4be9dd7da5..2b18515ae44 100644 --- a/sdk/python/feast/infra/materialization/snowflake_engine.py +++ b/sdk/python/feast/infra/materialization/snowflake_engine.py @@ -206,9 +206,9 @@ def __init__( online_store: OnlineStore, **kwargs, ): - assert ( - repo_config.offline_store.type == "snowflake.offline" - ), "To use SnowflakeMaterializationEngine, you must use Snowflake as an offline store." + assert repo_config.offline_store.type == "snowflake.offline", ( + "To use SnowflakeMaterializationEngine, you must use Snowflake as an offline store." + ) super().__init__( repo_config=repo_config, @@ -241,10 +241,11 @@ def _materialize_one( project: str, tqdm_builder: Callable[[int], tqdm], ): - assert ( - isinstance(feature_view, BatchFeatureView) - or isinstance(feature_view, FeatureView) - ), "Snowflake can only materialize FeatureView & BatchFeatureView feature view types." + assert isinstance(feature_view, BatchFeatureView) or isinstance( + feature_view, FeatureView + ), ( + "Snowflake can only materialize FeatureView & BatchFeatureView feature view types." + ) entities = [] for entity_name in feature_view.entities: @@ -420,7 +421,7 @@ def generate_snowflake_materialization_query( {serial_func.upper()}({entity_names}, {entity_data}, {entity_types}) AS "entity_key", {features_str}, "{feature_view.batch_source.timestamp_field}" - {fv_created_str if fv_created_str else ''} + {fv_created_str if fv_created_str else ""} FROM ( {fv_latest_mapped_values_sql} ) @@ -460,7 +461,7 @@ def materialize_to_snowflake_online_store( "feature_name", "feature_value" AS "value", "{feature_view.batch_source.timestamp_field}" AS "event_ts" - {fv_created_str + ' AS "created_ts"' if fv_created_str else ''} + {fv_created_str + ' AS "created_ts"' if fv_created_str else ""} FROM ( {materialization_sql} ) @@ -472,16 +473,16 @@ def materialize_to_snowflake_online_store( online_table."feature_name" = latest_values."feature_name", online_table."value" = latest_values."value", online_table."event_ts" = latest_values."event_ts" - {',online_table."created_ts" = latest_values."created_ts"' if fv_created_str else ''} + {',online_table."created_ts" = latest_values."created_ts"' if fv_created_str else ""} WHEN NOT MATCHED THEN - INSERT ("entity_feature_key", "entity_key", "feature_name", "value", "event_ts" {', "created_ts"' if fv_created_str else ''}) + INSERT ("entity_feature_key", "entity_key", "feature_name", "value", "event_ts" {', "created_ts"' if fv_created_str else ""}) VALUES ( latest_values."entity_feature_key", latest_values."entity_key", latest_values."feature_name", latest_values."value", latest_values."event_ts" - {',latest_values."created_ts"' if fv_created_str else ''} + {',latest_values."created_ts"' if fv_created_str else ""} ) """ diff --git a/sdk/python/feast/infra/offline_stores/bigquery.py b/sdk/python/feast/infra/offline_stores/bigquery.py index 23f80d79ff2..f0516b594ee 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery.py +++ b/sdk/python/feast/infra/offline_stores/bigquery.py @@ -901,7 +901,10 @@ def arrow_schema_to_bq_schema(arrow_schema: pyarrow.Schema) -> List[SchemaField] {{ featureview.created_timestamp_column ~ ' as created_timestamp,' if featureview.created_timestamp_column else '' }} {{ featureview.entity_selections | join(', ')}}{% if featureview.entity_selections %},{% else %}{% endif %} {% for feature in featureview.features %} - {{ feature }} as {% if full_feature_names %}{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %}{% if loop.last %}{% else %}, {% endif %} + {{ feature | backticks }} as {% if full_feature_names %} + {{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %} + {{ featureview.field_mapping.get(feature, feature) | backticks }}{% endif %} + {% if loop.last %}{% else %}, {% endif %} {% endfor %} FROM {{ featureview.table_subquery }} WHERE {{ featureview.timestamp_field }} <= '{{ featureview.max_event_timestamp }}' @@ -995,14 +998,14 @@ def arrow_schema_to_bq_schema(arrow_schema: pyarrow.Schema) -> List[SchemaField] The entity_dataframe dataset being our source of truth here. */ -SELECT {{ final_output_feature_names | join(', ')}} +SELECT {{ final_output_feature_names | backticks | join(', ')}} FROM entity_dataframe {% for featureview in featureviews %} LEFT JOIN ( SELECT {{featureview.name}}__entity_row_unique_id {% for feature in featureview.features %} - ,{% if full_feature_names %}{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %} + ,{% if full_feature_names %}{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}{% else %}{{ featureview.field_mapping.get(feature, feature) | backticks }}{% endif %} {% endfor %} FROM {{ featureview.name }}__cleaned ) USING ({{featureview.name}}__entity_row_unique_id) diff --git a/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena.py b/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena.py index ea0d6386cba..f49bfddb81d 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena.py +++ b/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena.py @@ -110,8 +110,8 @@ def pull_latest_from_table_or_query( SELECT {field_string}, ROW_NUMBER() OVER({partition_by_join_key_string} ORDER BY {timestamp_desc_string}) AS _feast_row FROM {from_expression} - WHERE {timestamp_field} BETWEEN TIMESTAMP '{start_date.strftime('%Y-%m-%d %H:%M:%S')}' AND TIMESTAMP '{end_date.strftime('%Y-%m-%d %H:%M:%S')}' - {"AND "+date_partition_column+" >= '"+start_date.strftime('%Y-%m-%d')+"' AND "+date_partition_column+" <= '"+end_date.strftime('%Y-%m-%d')+"' " if date_partition_column != "" and date_partition_column is not None else ''} + WHERE {timestamp_field} BETWEEN TIMESTAMP '{start_date.strftime("%Y-%m-%d %H:%M:%S")}' AND TIMESTAMP '{end_date.strftime("%Y-%m-%d %H:%M:%S")}' + {"AND " + date_partition_column + " >= '" + start_date.strftime("%Y-%m-%d") + "' AND " + date_partition_column + " <= '" + end_date.strftime("%Y-%m-%d") + "' " if date_partition_column != "" and date_partition_column is not None else ""} ) WHERE _feast_row = 1 """ @@ -151,7 +151,7 @@ def pull_all_from_table_or_query( SELECT {field_string} FROM {from_expression} WHERE {timestamp_field} BETWEEN TIMESTAMP '{start_date.astimezone(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]}' AND TIMESTAMP '{end_date.astimezone(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]}' - {"AND "+date_partition_column+" >= '"+start_date.strftime('%Y-%m-%d')+"' AND "+date_partition_column+" <= '"+end_date.strftime('%Y-%m-%d')+"' " if date_partition_column != "" and date_partition_column is not None else ''} + {"AND " + date_partition_column + " >= '" + start_date.strftime("%Y-%m-%d") + "' AND " + date_partition_column + " <= '" + end_date.strftime("%Y-%m-%d") + "' " if date_partition_column != "" and date_partition_column is not None else ""} """ return AthenaRetrievalJob( diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_columnar_repo_configuration.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_columnar_repo_configuration.py new file mode 100644 index 00000000000..745a074a757 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/couchbase_columnar_repo_configuration.py @@ -0,0 +1,20 @@ +from feast.infra.offline_stores.contrib.couchbase_offline_store.tests.data_source import ( + CouchbaseColumnarDataSourceCreator, +) +from tests.integration.feature_repos.integration_test_repo_config import ( + IntegrationTestRepoConfig, +) +from tests.integration.feature_repos.repo_configuration import REDIS_CONFIG +from tests.integration.feature_repos.universal.online_store.redis import ( + RedisOnlineStoreCreator, +) + +FULL_REPO_CONFIGS = [ + IntegrationTestRepoConfig( + provider="aws", + offline_store_creator=CouchbaseColumnarDataSourceCreator, + ), +] + +AVAILABLE_OFFLINE_STORES = [("aws", CouchbaseColumnarDataSourceCreator)] +AVAILABLE_ONLINE_STORES = {"redis": (REDIS_CONFIG, RedisOnlineStoreCreator)} diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/__init__.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase.py new file mode 100644 index 00000000000..a90d6c2172b --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase.py @@ -0,0 +1,729 @@ +import contextlib +import warnings +from dataclasses import asdict +from datetime import datetime, timedelta +from typing import ( + Any, + Callable, + ContextManager, + Dict, + Iterator, + KeysView, + List, + Literal, + Optional, + Tuple, + Union, + cast, +) + +import numpy as np +import pandas as pd +import pyarrow as pa +from couchbase_columnar.cluster import Cluster +from couchbase_columnar.common.result import BlockingQueryResult +from couchbase_columnar.credential import Credential +from couchbase_columnar.options import ClusterOptions, QueryOptions, TimeoutOptions +from jinja2 import BaseLoader, Environment +from pydantic import StrictFloat, StrictStr + +from feast.data_source import DataSource +from feast.errors import InvalidEntityType, ZeroRowsQueryResult +from feast.feature_view import DUMMY_ENTITY_ID, DUMMY_ENTITY_VAL, FeatureView +from feast.infra.offline_stores.offline_store import ( + OfflineStore, + RetrievalJob, + RetrievalMetadata, +) +from feast.infra.registry.base_registry import BaseRegistry +from feast.infra.utils.couchbase.couchbase_utils import normalize_timestamp +from feast.on_demand_feature_view import OnDemandFeatureView +from feast.repo_config import FeastConfigBaseModel, RepoConfig +from feast.saved_dataset import SavedDatasetStorage + +from ... import offline_utils +from .couchbase_source import ( + CouchbaseColumnarSource, + SavedDatasetCouchbaseColumnarStorage, +) + +# Only prints out runtime warnings once. +warnings.simplefilter("once", RuntimeWarning) + + +class CouchbaseColumnarOfflineStoreConfig(FeastConfigBaseModel): + """Offline store config for Couchbase Columnar""" + + type: Literal["couchbase.offline"] = "couchbase.offline" + + connection_string: Optional[StrictStr] = None + user: Optional[StrictStr] = None + password: Optional[StrictStr] = None + timeout: StrictFloat = 120 + + +class CouchbaseColumnarOfflineStore(OfflineStore): + @staticmethod + def pull_latest_from_table_or_query( + config: RepoConfig, + data_source: DataSource, + join_key_columns: List[str], + feature_name_columns: List[str], + timestamp_field: str, + created_timestamp_column: Optional[str], + start_date: datetime, + end_date: datetime, + ) -> RetrievalJob: + """ + Fetch the latest rows for each join key. + """ + warnings.warn( + "This offline store is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) + assert isinstance(config.offline_store, CouchbaseColumnarOfflineStoreConfig) + assert isinstance(data_source, CouchbaseColumnarSource) + from_expression = data_source.get_table_query_string() + + partition_by_join_key_string = ", ".join(_append_alias(join_key_columns, "a")) + if partition_by_join_key_string != "": + partition_by_join_key_string = ( + "PARTITION BY " + partition_by_join_key_string + ) + timestamps = [timestamp_field] + if created_timestamp_column: + timestamps.append(created_timestamp_column) + timestamp_desc_string = " DESC, ".join(_append_alias(timestamps, "a")) + " DESC" + a_field_string = ", ".join( + _append_alias(join_key_columns + feature_name_columns + timestamps, "a") + ) + b_field_string = ", ".join( + _append_alias(join_key_columns + feature_name_columns + timestamps, "b") + ) + + start_date_normalized = normalize_timestamp(start_date) + end_date_normalized = normalize_timestamp(end_date) + + query = f""" + SELECT + {b_field_string} + {f", {repr(DUMMY_ENTITY_VAL)} AS {DUMMY_ENTITY_ID}" if not join_key_columns else ""} + FROM ( + SELECT {a_field_string}, + ROW_NUMBER() OVER({partition_by_join_key_string} ORDER BY {timestamp_desc_string}) AS _feast_row + FROM {from_expression} a + WHERE a.{timestamp_field} BETWEEN '{start_date_normalized}' AND '{end_date_normalized}' + ) b + WHERE _feast_row = 1 + """ + + return CouchbaseColumnarRetrievalJob( + query=query, + config=config, + full_feature_names=False, + on_demand_feature_views=None, + timestamp_field=timestamp_field, + ) + + @staticmethod + def get_historical_features( + config: RepoConfig, + feature_views: List[FeatureView], + feature_refs: List[str], + entity_df: Union[pd.DataFrame, str], + registry: BaseRegistry, + project: str, + full_feature_names: bool = False, + ) -> RetrievalJob: + """ + Retrieve historical features using point-in-time joins. + """ + warnings.warn( + "This offline store is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) + assert isinstance(config.offline_store, CouchbaseColumnarOfflineStoreConfig) + for fv in feature_views: + assert isinstance(fv.batch_source, CouchbaseColumnarSource) + + entity_schema = _get_entity_schema(entity_df, config) + + entity_df_event_timestamp_col = ( + offline_utils.infer_event_timestamp_from_entity_df(entity_schema) + ) + + entity_df_event_timestamp_range = _get_entity_df_event_timestamp_range( + entity_df, entity_df_event_timestamp_col, config + ) + + @contextlib.contextmanager + def query_generator() -> Iterator[str]: + source = cast(CouchbaseColumnarSource, feature_views[0].batch_source) + database = source.database + scope = source.scope + + table_name = ( + f"{database}.{scope}.{offline_utils.get_temp_entity_table_name()}" + ) + + _upload_entity_df(config, entity_df, table_name) + + expected_join_keys = offline_utils.get_expected_join_keys( + project, feature_views, registry + ) + + offline_utils.assert_expected_columns_in_entity_df( + entity_schema, expected_join_keys, entity_df_event_timestamp_col + ) + + query_context = offline_utils.get_feature_view_query_context( + feature_refs, + feature_views, + registry, + project, + entity_df_event_timestamp_range, + ) + + query_context_dict = [asdict(context) for context in query_context] + + try: + query = build_point_in_time_query( + query_context_dict, + left_table_query_string=table_name, + entity_df_event_timestamp_col=entity_df_event_timestamp_col, + entity_df_columns=entity_schema.keys(), + query_template=MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN, + full_feature_names=full_feature_names, + ) + yield query + finally: + if table_name: + _execute_query( + config.offline_store, + f"DROP COLLECTION {table_name} IF EXISTS", + ) + + return CouchbaseColumnarRetrievalJob( + query=query_generator, + config=config, + full_feature_names=full_feature_names, + on_demand_feature_views=OnDemandFeatureView.get_requested_odfvs( + feature_refs, project, registry + ), + metadata=RetrievalMetadata( + features=feature_refs, + keys=list(entity_schema.keys() - {entity_df_event_timestamp_col}), + min_event_timestamp=entity_df_event_timestamp_range[0], + max_event_timestamp=entity_df_event_timestamp_range[1], + ), + timestamp_field=entity_df_event_timestamp_col, + ) + + @staticmethod + def pull_all_from_table_or_query( + config: RepoConfig, + data_source: DataSource, + join_key_columns: List[str], + feature_name_columns: List[str], + timestamp_field: str, + start_date: datetime, + end_date: datetime, + ) -> RetrievalJob: + """ + Fetch all rows from the specified table or query within the time range. + """ + warnings.warn( + "This offline store is an experimental feature in alpha development. " + "Some functionality may still be unstable so functionality can change in the future.", + RuntimeWarning, + ) + assert isinstance(config.offline_store, CouchbaseColumnarOfflineStoreConfig) + assert isinstance(data_source, CouchbaseColumnarSource) + from_expression = data_source.get_table_query_string() + + field_string = ", ".join( + join_key_columns + feature_name_columns + [timestamp_field] + ) + + start_date_normalized = normalize_timestamp(start_date) + end_date_normalized = normalize_timestamp(end_date) + + query = f""" + SELECT {field_string} + FROM {from_expression} + WHERE `{timestamp_field}` BETWEEN '{start_date_normalized}' AND '{end_date_normalized}' + """ + + return CouchbaseColumnarRetrievalJob( + query=query, + config=config, + full_feature_names=False, + on_demand_feature_views=None, + timestamp_field=timestamp_field, + ) + + +class CouchbaseColumnarRetrievalJob(RetrievalJob): + def __init__( + self, + query: Union[str, Callable[[], ContextManager[str]]], + config: RepoConfig, + full_feature_names: bool, + timestamp_field: str, + on_demand_feature_views: Optional[List[OnDemandFeatureView]] = None, + metadata: Optional[RetrievalMetadata] = None, + ): + if not isinstance(query, str): + self._query_generator = query + else: + + @contextlib.contextmanager + def query_generator() -> Iterator[str]: + assert isinstance(query, str) + yield query + + self._query_generator = query_generator + self._config = config + self._full_feature_names = full_feature_names + self._on_demand_feature_views = on_demand_feature_views or [] + self._metadata = metadata + self._timestamp_field = timestamp_field + + @property + def full_feature_names(self) -> bool: + return self._full_feature_names + + @property + def on_demand_feature_views(self) -> List[OnDemandFeatureView]: + return self._on_demand_feature_views + + def _to_df_internal(self, timeout: Optional[int] = None) -> pd.DataFrame: + # Use PyArrow to convert the result to a pandas DataFrame + return self._to_arrow_internal(timeout).to_pandas() + + def to_sql(self) -> str: + with self._query_generator() as query: + return query + + def _to_arrow_internal(self, timeout: Optional[int] = None) -> pa.Table: + with self._query_generator() as query: + res = _execute_query(self._config.offline_store, query) + rows = res.get_all_rows() + + processed_rows = [] + for row in rows: + processed_row = {} + for key, value in row.items(): + if key == self._timestamp_field and value is not None: + # Parse and ensure timezone-aware datetime + processed_row[key] = pd.to_datetime(value, utc=True) + else: + processed_row[key] = np.nan if value is None else value + processed_rows.append(processed_row) + + # Convert to PyArrow table + table = pa.Table.from_pylist(processed_rows) + return table + + @property + def metadata(self) -> Optional[RetrievalMetadata]: + return self._metadata + + def persist( + self, + storage: SavedDatasetStorage, + allow_overwrite: Optional[bool] = False, + timeout: Optional[int] = None, + ): + assert isinstance(storage, SavedDatasetCouchbaseColumnarStorage) + table_name = f"{storage.couchbase_options._database}.{storage.couchbase_options._scope}.{offline_utils.get_temp_entity_table_name()}" + df_to_columnar(self.to_df(), table_name, self._config.offline_store) + + +def _get_columnar_cluster(config: CouchbaseColumnarOfflineStoreConfig) -> Cluster: + assert config.connection_string is not None + assert config.user is not None + assert config.password is not None + + cred = Credential.from_username_and_password(config.user, config.password) + timeout_opts = TimeoutOptions(dispatch_timeout=timedelta(seconds=120)) + return Cluster.create_instance( + config.connection_string, cred, ClusterOptions(timeout_options=timeout_opts) + ) + + +def _execute_query( + config: CouchbaseColumnarOfflineStoreConfig, + query: str, + named_params: Optional[Dict[str, Any]] = None, +) -> BlockingQueryResult: + cluster = _get_columnar_cluster(config) + return cluster.execute_query( + query, + QueryOptions( + named_parameters=named_params, timeout=timedelta(seconds=config.timeout) + ), + ) + + +def df_to_columnar( + df: pd.DataFrame, + table_name: str, + offline_store: CouchbaseColumnarOfflineStoreConfig, +): + df_copy = df.copy() + insert_values = df_copy.apply( + lambda row: { + col: ( + normalize_timestamp(row[col], "%Y-%m-%dT%H:%M:%S.%f+00:00") + if isinstance(row[col], pd.Timestamp) + else row[col] + ) + for col in df_copy.columns + }, + axis=1, + ).tolist() + + create_collection_query = f"CREATE COLLECTION {table_name} IF NOT EXISTS PRIMARY KEY(pk: UUID) AUTOGENERATED;" + insert_query = f"INSERT INTO {table_name} ({insert_values});" + + _execute_query(offline_store, create_collection_query) + _execute_query(offline_store, insert_query) + + +def _upload_entity_df( + config: RepoConfig, entity_df: Union[pd.DataFrame, str], table_name: str +): + if isinstance(entity_df, pd.DataFrame): + df_to_columnar(entity_df, table_name, config.offline_store) + elif isinstance(entity_df, str): + # If the entity_df is a string (SQL query), create a Columnar collection out of it + create_collection_query = f""" + CREATE COLLECTION {table_name} IF NOT EXISTS + PRIMARY KEY(pk: UUID) AUTOGENERATED + AS {entity_df} + """ + _execute_query(config.offline_store, create_collection_query) + else: + raise InvalidEntityType(type(entity_df)) + + +def _get_entity_df_event_timestamp_range( + entity_df: Union[pd.DataFrame, str], + entity_df_event_timestamp_col: str, + config: RepoConfig, +) -> Tuple[datetime, datetime]: + if isinstance(entity_df, pd.DataFrame): + entity_df_event_timestamp = entity_df.loc[ + :, entity_df_event_timestamp_col + ].infer_objects() + if pd.api.types.is_string_dtype(entity_df_event_timestamp): + entity_df_event_timestamp = pd.to_datetime( + entity_df_event_timestamp, utc=True + ) + entity_df_event_timestamp_range = ( + entity_df_event_timestamp.min().to_pydatetime(), + entity_df_event_timestamp.max().to_pydatetime(), + ) + + elif isinstance(entity_df, str): + query = f""" + SELECT + MIN({entity_df_event_timestamp_col}) AS min, + MAX({entity_df_event_timestamp_col}) AS max + FROM ({entity_df}) AS tmp_alias + """ + + res = _execute_query(config.offline_store, query) + rows = res.get_all_rows() + + if not rows: + raise ZeroRowsQueryResult(query) + + # Convert the string timestamps to datetime objects + min_ts = pd.to_datetime(rows[0]["min"], utc=True).to_pydatetime() + max_ts = pd.to_datetime(rows[0]["max"], utc=True).to_pydatetime() + entity_df_event_timestamp_range = (min_ts, max_ts) + else: + raise InvalidEntityType(type(entity_df)) + return entity_df_event_timestamp_range + + +def _escape_column(column: str) -> str: + """Wrap column names in backticks to handle reserved words.""" + return f"`{column}`" + + +def _append_alias(field_names: List[str], alias: str) -> List[str]: + """Append alias to escaped column names.""" + return [f"{alias}.{_escape_column(field_name)}" for field_name in field_names] + + +def build_point_in_time_query( + feature_view_query_contexts: List[dict], + left_table_query_string: str, + entity_df_event_timestamp_col: str, + entity_df_columns: KeysView[str], + query_template: str, + full_feature_names: bool = False, +) -> str: + """Build point-in-time query between each feature view table and the entity dataframe for Couchbase Columnar""" + template = Environment(loader=BaseLoader()).from_string(source=query_template) + final_output_feature_names = list(entity_df_columns) + final_output_feature_names.extend( + [ + ( + f"{fv['name']}__{fv['field_mapping'].get(feature, feature)}" + if full_feature_names + else fv["field_mapping"].get(feature, feature) + ) + for fv in feature_view_query_contexts + for feature in fv["features"] + ] + ) + + # Add additional fields to dict + template_context = { + "left_table_query_string": left_table_query_string, + "entity_df_event_timestamp_col": entity_df_event_timestamp_col, + "unique_entity_keys": set( + [entity for fv in feature_view_query_contexts for entity in fv["entities"]] + ), + "featureviews": feature_view_query_contexts, + "full_feature_names": full_feature_names, + "final_output_feature_names": final_output_feature_names, + } + + query = template.render(template_context) + return query + + +def get_couchbase_query_schema(config, entity_df: str) -> Dict[str, np.dtype]: + df_query = f"({entity_df}) AS sub" + res = _execute_query(config.offline_store, f"SELECT sub.* FROM {df_query} LIMIT 1") + rows = res.get_all_rows() + + if rows and len(rows) > 0: + # Get the first row + first_row = rows[0] + # Create dictionary mapping each column to dtype('O') + return {key: np.dtype("O") for key in first_row.keys()} + + return {} + + +def _get_entity_schema( + entity_df: Union[pd.DataFrame, str], + config: RepoConfig, +) -> Dict[str, np.dtype]: + if isinstance(entity_df, pd.DataFrame): + return dict(zip(entity_df.columns, entity_df.dtypes)) + + elif isinstance(entity_df, str): + return get_couchbase_query_schema(config, entity_df) + else: + raise InvalidEntityType(type(entity_df)) + + +MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN = """ +WITH entity_dataframe AS ( + SELECT e.*, + e.`{{entity_df_event_timestamp_col}}` AS entity_timestamp + {% for featureview in featureviews -%} + {% if featureview.entities -%} + ,CONCAT( + {% for entity in featureview.entities -%} + TOSTRING(e.`{{entity}}`), + {% endfor -%} + TOSTRING(e.`{{entity_df_event_timestamp_col}}`) + ) AS `{{featureview.name}}__entity_row_unique_id` + {% else -%} + ,TOSTRING(e.`{{entity_df_event_timestamp_col}}`) AS `{{featureview.name}}__entity_row_unique_id` + {% endif -%} + {% endfor %} + FROM {{ left_table_query_string }} e +), + +{% for featureview in featureviews %} + +`{{ featureview.name }}__entity_dataframe` AS ( + SELECT + {% if featureview.entities %}`{{ featureview.entities | join('`, `') }}`,{% endif %} + entity_timestamp, + `{{featureview.name}}__entity_row_unique_id` + FROM entity_dataframe + GROUP BY + {% if featureview.entities %}`{{ featureview.entities | join('`, `')}}`,{% endif %} + entity_timestamp, + `{{featureview.name}}__entity_row_unique_id` +), + +/* + This query template performs the point-in-time correctness join for a single feature set table + to the provided entity table. + + 1. We first join the current feature_view to the entity dataframe that has been passed. + This JOIN has the following logic: + - For each row of the entity dataframe, only keep the rows where the `timestamp_field` + is less than the one provided in the entity dataframe + - If there a TTL for the current feature_view, also keep the rows where the `timestamp_field` + is higher the the one provided minus the TTL + - For each row, Join on the entity key and retrieve the `entity_row_unique_id` that has been + computed previously + + The output of this CTE will contain all the necessary information and already filtered out most + of the data that is not relevant. +*/ +`{{ featureview.name }}__subquery` AS ( + LET max_ts = (SELECT RAW MAX(entity_timestamp) FROM entity_dataframe)[0] + SELECT s.* FROM ( + LET min_ts = (SELECT RAW MIN(entity_timestamp) FROM entity_dataframe)[0] + SELECT + `{{ featureview.timestamp_field }}` as event_timestamp, + {{ '`' ~ featureview.created_timestamp_column ~ '` as created_timestamp,' if featureview.created_timestamp_column else '' }} + {{ featureview.entity_selections | join(', ')}}{% if featureview.entity_selections %},{% else %}{% endif %} + {% for feature in featureview.features -%} + `{{ feature }}` as {% if full_feature_names %}`{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}`{% else %}`{{ featureview.field_mapping.get(feature, feature) }}`{% endif %}{% if not loop.last %}, {% endif %} + {%- endfor %} + FROM {{ featureview.table_subquery }} AS sub + WHERE `{{ featureview.timestamp_field }}` <= max_ts + {% if featureview.ttl == 0 %}{% else %} + AND date_diff_str(min_ts, `{{ featureview.timestamp_field }}`, "second") <= {{ featureview.ttl }} + {% endif %} + ) s +), + +`{{ featureview.name }}__base` AS ( + SELECT + subquery.*, + entity_dataframe.entity_timestamp, + entity_dataframe.`{{featureview.name}}__entity_row_unique_id` + FROM `{{ featureview.name }}__subquery` AS subquery + INNER JOIN `{{ featureview.name }}__entity_dataframe` AS entity_dataframe + ON TRUE + AND subquery.event_timestamp <= entity_dataframe.entity_timestamp + {% if featureview.ttl == 0 %}{% else %} + AND date_diff_str(entity_dataframe.entity_timestamp, subquery.event_timestamp, "second") <= {{ featureview.ttl }} + {% endif %} + {% for entity in featureview.entities %} + AND subquery.`{{ entity }}` = entity_dataframe.`{{ entity }}` + {% endfor %} +), + +/* + 2. If the `created_timestamp_column` has been set, we need to + deduplicate the data first. This is done by calculating the + `MAX(created_at_timestamp)` for each event_timestamp. + We then join the data on the next CTE +*/ +{% if featureview.created_timestamp_column %} +`{{ featureview.name }}__dedup` AS ( + SELECT + `{{featureview.name}}__entity_row_unique_id`, + event_timestamp, + MAX(created_timestamp) AS created_timestamp + FROM `{{ featureview.name }}__base` + GROUP BY `{{featureview.name}}__entity_row_unique_id`, event_timestamp +), +{% endif %} + +/* + 3. The data has been filtered during the first CTE "*__base" + Thus we only need to compute the latest timestamp of each feature. +*/ +`{{ featureview.name }}__latest` AS ( + SELECT + event_timestamp + {% if featureview.created_timestamp_column %},created_timestamp{% endif %}, + `{{featureview.name}}__entity_row_unique_id` + FROM ( + SELECT base.*, + ROW_NUMBER() OVER( + PARTITION BY base.`{{featureview.name}}__entity_row_unique_id` + ORDER BY base.event_timestamp DESC + {% if featureview.created_timestamp_column %}, base.created_timestamp DESC{% endif %} + ) AS row_number + FROM `{{ featureview.name }}__base` base + {% if featureview.created_timestamp_column %} + INNER JOIN `{{ featureview.name }}__dedup` dedup + ON base.`{{featureview.name}}__entity_row_unique_id` = dedup.`{{featureview.name}}__entity_row_unique_id` + AND base.event_timestamp = dedup.event_timestamp + AND base.created_timestamp = dedup.created_timestamp + {% endif %} + ) AS sub + WHERE sub.row_number = 1 +), + +/* + 4. Once we know the latest value of each feature for a given timestamp, + we can join again the data back to the original "base" dataset +*/ +`{{ featureview.name }}__cleaned` AS ( + SELECT base.* + FROM `{{ featureview.name }}__base` AS base + INNER JOIN `{{ featureview.name }}__latest` AS latest + ON base.`{{featureview.name}}__entity_row_unique_id` = latest.`{{featureview.name}}__entity_row_unique_id` + AND base.event_timestamp = latest.event_timestamp + {% if featureview.created_timestamp_column %} + AND base.created_timestamp = latest.created_timestamp + {% endif %} +){% if not loop.last %},{% endif %} + +{% endfor %} + +/* + Joins the outputs of multiple time travel joins to a single table. + The entity_dataframe dataset being our source of truth here. + */ +SELECT DISTINCT + {%- set fields = [] %} + {%- for feature_name in final_output_feature_names %} + {%- if '__' not in feature_name %} + {%- set ns = namespace(found=false) %} + {%- for fv in featureviews %} + {%- for feature in fv.features %} + {%- if feature == feature_name %} + {%- set ns.found = true %} + {%- if full_feature_names %} + {%- set _ = fields.append('IFMISSINGORNULL(`' ~ fv.name ~ '_final`.`' ~ fv.name ~ '__' ~ feature ~ '`, null) AS `' ~ fv.name ~ '__' ~ feature ~ '`') %} + {%- else %} + {%- set _ = fields.append('IFMISSINGORNULL(`' ~ fv.name ~ '_final`.`' ~ feature ~ '`, null) AS `' ~ feature ~ '`') %} + {%- endif %} + {%- endif %} + {%- endfor %} + {%- endfor %} + {%- if not ns.found %} + {%- if feature_name == 'feature_name' %} + {%- set _ = fields.append('IFMISSINGORNULL(`field_mapping_final`.`' ~ feature_name ~ '`, null) AS `' ~ feature_name ~ '`') %} + {%- else %} + {%- set _ = fields.append('main_entity.`' ~ feature_name ~ '`') %} + {%- endif %} + {%- endif %} + {%- else %} + {%- set feature_parts = feature_name.split('__') %} + {%- set fv_name = feature_parts[0] %} + {%- set feature = feature_parts[1] %} + {%- if feature_name == 'field_mapping__feature_name' %} + {%- set _ = fields.append('IFMISSINGORNULL(`field_mapping_final`.`field_mapping__feature_name`, null) AS `field_mapping__feature_name`') %} + {%- else %} + {%- set _ = fields.append('IFMISSINGORNULL(`' ~ fv_name ~ '_final`.`' ~ feature_name ~ '`, null) AS `' ~ feature_name ~ '`') %} + {%- endif %} + {%- endif %} + {%- endfor %} + {{ fields | reject('none') | join(',\n ') }} +FROM entity_dataframe AS main_entity + +{%- for featureview in featureviews %} +LEFT JOIN ( + SELECT + `{{featureview.name}}__entity_row_unique_id`, + {% for feature in featureview.features -%} + IFMISSINGORNULL(`{% if full_feature_names %}{{ featureview.name }}__{{ featureview.field_mapping.get(feature, feature) }}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %}`, null) AS `{% if full_feature_names %}{{ featureview.name }}__{{ featureview.field_mapping.get(feature, feature) }}{% else %}{{ featureview.field_mapping.get(feature, feature) }}{% endif %}`{% if not loop.last %},{% endif %} + {% endfor %} + FROM `{{ featureview.name }}__cleaned` +) AS `{{featureview.name}}_final` +ON main_entity.`{{featureview.name}}__entity_row_unique_id` = `{{featureview.name}}_final`.`{{featureview.name}}__entity_row_unique_id` +{% endfor %} +""" diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py new file mode 100644 index 00000000000..89e4aa2332e --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py @@ -0,0 +1,406 @@ +import json +from datetime import timedelta +from typing import Any, Callable, Dict, Iterable, Optional, Tuple + +from couchbase_columnar.cluster import Cluster +from couchbase_columnar.credential import Credential +from couchbase_columnar.options import ClusterOptions, QueryOptions, TimeoutOptions +from typeguard import typechecked + +from feast.data_source import DataSource +from feast.errors import DataSourceNoNameException, ZeroColumnQueryResult +from feast.feature_logging import LoggingDestination +from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto +from feast.protos.feast.core.FeatureService_pb2 import ( + LoggingConfig as LoggingConfigProto, +) +from feast.protos.feast.core.SavedDataset_pb2 import ( + SavedDatasetStorage as SavedDatasetStorageProto, +) +from feast.repo_config import RepoConfig +from feast.saved_dataset import SavedDatasetStorage +from feast.type_map import ValueType, cb_columnar_type_to_feast_value_type + + +@typechecked +class CouchbaseColumnarSource(DataSource): + """A CouchbaseColumnarSource object defines a data source that a CouchbaseColumnarOfflineStore class can use.""" + + def __init__( + self, + name: Optional[str] = None, + query: Optional[str] = None, + database: Optional[str] = "Default", + scope: Optional[str] = "Default", + collection: Optional[str] = None, + timestamp_field: Optional[str] = "", + created_timestamp_column: Optional[str] = "", + field_mapping: Optional[Dict[str, str]] = None, + description: Optional[str] = "", + tags: Optional[Dict[str, str]] = None, + owner: Optional[str] = "", + ): + """Creates a CouchbaseColumnarSource object. + + Args: + name: Name of CouchbaseColumnarSource, which should be unique within a project. + query: SQL++ query that will be used to fetch the data. + database: Columnar database name. + scope: Columnar scope name. + collection: Columnar collection name. + timestamp_field (optional): Event timestamp field used for point-in-time joins of + feature values. + created_timestamp_column (optional): Timestamp column indicating when the row + was created, used for deduplicating rows. + field_mapping (optional): A dictionary mapping of field names in this data + source to feature names in a feature table or view. Only used for feature + fields, not entity or timestamp fields. + description (optional): A human-readable description. + tags (optional): A dictionary of key-value pairs to store arbitrary metadata. + owner (optional): The owner of the data source, typically the email of the primary + maintainer. + """ + self._couchbase_options = CouchbaseColumnarOptions( + name=name, + query=query, + database=database, + scope=scope, + collection=collection, + ) + + # If no name, use the collection as the default name. + if name is None and collection is None: + raise DataSourceNoNameException() + name = name or collection + assert name + + super().__init__( + name=name, + timestamp_field=timestamp_field, + created_timestamp_column=created_timestamp_column, + field_mapping=field_mapping, + description=description, + tags=tags, + owner=owner, + ) + + def __hash__(self): + return super().__hash__() + + def __eq__(self, other): + if not isinstance(other, CouchbaseColumnarSource): + raise TypeError( + "Comparisons should only involve CouchbaseColumnarSource class objects." + ) + + return ( + super().__eq__(other) + and self._couchbase_options._query == other._couchbase_options._query + and self.timestamp_field == other.timestamp_field + and self.created_timestamp_column == other.created_timestamp_column + and self.field_mapping == other.field_mapping + ) + + @staticmethod + def from_proto(data_source: DataSourceProto): + assert data_source.HasField("custom_options") + + couchbase_options = json.loads(data_source.custom_options.configuration) + + return CouchbaseColumnarSource( + name=couchbase_options["name"], + query=couchbase_options["query"], + database=couchbase_options["database"], + scope=couchbase_options["scope"], + collection=couchbase_options["collection"], + field_mapping=dict(data_source.field_mapping), + timestamp_field=data_source.timestamp_field, + created_timestamp_column=data_source.created_timestamp_column, + description=data_source.description, + tags=dict(data_source.tags), + owner=data_source.owner, + ) + + def to_proto(self) -> DataSourceProto: + data_source_proto = DataSourceProto( + name=self.name, + type=DataSourceProto.CUSTOM_SOURCE, + data_source_class_type="feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase_source.CouchbaseColumnarSource", + field_mapping=self.field_mapping, + custom_options=self._couchbase_options.to_proto(), + description=self.description, + tags=self.tags, + owner=self.owner, + ) + + data_source_proto.timestamp_field = self.timestamp_field + data_source_proto.created_timestamp_column = self.created_timestamp_column + + return data_source_proto + + def validate(self, config: RepoConfig): + pass + + @staticmethod + def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: + # Define the type conversion for Couchbase fields to Feast ValueType as needed + return cb_columnar_type_to_feast_value_type + + def _infer_composite_type(self, field: Dict[str, Any]) -> str: + """ + Infers type signature for a field, rejecting complex nested structures that + aren't compatible with Feast's type system. + + Args: + field: Dictionary containing field information including type and nested structures + + Returns: + String representation of the type, or raises ValueError for incompatible types + + Raises: + ValueError: If field contains complex nested structures not supported by Feast + """ + base_type = field.get("field-type", "unknown").lower() + + if base_type == "array": + if "list" not in field or not field["list"]: + return "array" + + item_type = field["list"][0] + if item_type.get("field-type") == "object": + raise ValueError( + "Complex object types in arrays are not supported by Feast. " + "Arrays must contain homogeneous primitive values." + ) + + # Only allow arrays of primitive types + inner_type = item_type.get("field-type", "unknown") + if inner_type in ["array", "multiset", "object"]: + raise ValueError( + "Nested collection types are not supported by Feast. " + "Arrays can only be one level deep." + ) + + return f"array<{inner_type}>" + + elif base_type == "object": + raise ValueError( + "Complex object types are not supported by Feast. " + "Only primitive types and homogeneous arrays are allowed." + ) + + elif base_type == "multiset": + raise ValueError( + "Multiset types are not supported by Feast. " + "Only primitive types and homogeneous arrays are allowed." + ) + + return base_type + + def get_table_column_names_and_types( + self, config: RepoConfig + ) -> Iterable[Tuple[str, str]]: + cred = Credential.from_username_and_password( + config.offline_store.user, config.offline_store.password + ) + timeout_opts = TimeoutOptions(dispatch_timeout=timedelta(seconds=120)) + cluster = Cluster.create_instance( + config.offline_store.connection_string, + cred, + ClusterOptions(timeout_options=timeout_opts), + ) + + query_context = self.get_table_query_string() + query = f""" + SELECT get_object_fields( + CASE WHEN ARRAY_LENGTH(OBJECT_PAIRS(t)) = 1 AND OBJECT_PAIRS(t)[0].`value` IS NOT MISSING + THEN OBJECT_PAIRS(t)[0].`value` + ELSE t + END + ) AS field_types + FROM {query_context} AS t + LIMIT 1; + """ + + result = cluster.execute_query( + query, QueryOptions(timeout=timedelta(seconds=config.offline_store.timeout)) + ) + if not result: + raise ZeroColumnQueryResult(query) + + rows = result.get_all_rows() + field_type_pairs = [] + if rows and rows[0]: + # Accessing the "field_types" array from the first row + field_types_list = rows[0].get("field_types", []) + for field in field_types_list: + field_name = field.get("field-name", "unknown") + field_type = field.get("field-type", "unknown") + # drop uuid fields to ensure schema matches dataframe + if field_type == "uuid": + continue + field_type = self._infer_composite_type(field) + field_type_pairs.append((field_name, field_type)) + return field_type_pairs + + def get_table_query_string(self) -> str: + if ( + self._couchbase_options._database + and self._couchbase_options._scope + and self._couchbase_options._collection + ): + return f"`{self._couchbase_options._database}`.`{self._couchbase_options._scope}`.`{self._couchbase_options._collection}`" + else: + return f"({self._couchbase_options._query})" + + @property + def database(self) -> str: + """Returns the database name.""" + return self._couchbase_options._database + + @property + def scope(self) -> str: + """Returns the scope name.""" + return self._couchbase_options._scope + + +class CouchbaseColumnarOptions: + def __init__( + self, + name: Optional[str], + query: Optional[str], + database: Optional[str], + scope: Optional[str], + collection: Optional[str], + ): + self._name = name or "" + self._query = query or "" + self._database = database or "" + self._scope = scope or "" + self._collection = collection or "" + + @classmethod + def from_proto(cls, couchbase_options_proto: DataSourceProto.CustomSourceOptions): + config = json.loads(couchbase_options_proto.configuration.decode("utf8")) + couchbase_options = cls( + name=config["name"], + query=config["query"], + database=config["database"], + scope=config["scope"], + collection=config["collection"], + ) + + return couchbase_options + + def to_proto(self) -> DataSourceProto.CustomSourceOptions: + couchbase_options_proto = DataSourceProto.CustomSourceOptions( + configuration=json.dumps( + { + "name": self._name, + "query": self._query, + "database": self._database, + "scope": self._scope, + "collection": self._collection, + } + ).encode() + ) + return couchbase_options_proto + + +class SavedDatasetCouchbaseColumnarStorage(SavedDatasetStorage): + _proto_attr_name = "custom_storage" + + couchbase_options: CouchbaseColumnarOptions + + def __init__(self, database_ref: str, scope_ref: str, collection_ref: str): + self.couchbase_options = CouchbaseColumnarOptions( + database=database_ref, + scope=scope_ref, + collection=collection_ref, + name=None, + query=None, + ) + + @staticmethod + def from_proto(storage_proto: SavedDatasetStorageProto) -> SavedDatasetStorage: + return SavedDatasetCouchbaseColumnarStorage( + database_ref=CouchbaseColumnarOptions.from_proto( + storage_proto.custom_storage + )._database, + scope_ref=CouchbaseColumnarOptions.from_proto( + storage_proto.custom_storage + )._scope, + collection_ref=CouchbaseColumnarOptions.from_proto( + storage_proto.custom_storage + )._collection, + ) + + def to_proto(self) -> SavedDatasetStorageProto: + return SavedDatasetStorageProto( + custom_storage=self.couchbase_options.to_proto() + ) + + def to_data_source(self) -> DataSource: + return CouchbaseColumnarSource( + database=self.couchbase_options._database, + scope=self.couchbase_options._scope, + collection=self.couchbase_options._collection, + ) + + +class CouchbaseColumnarLoggingDestination(LoggingDestination): + """ + Couchbase Columnar implementation of a logging destination. + """ + + database: str + scope: str + table_name: str + + _proto_kind = "couchbase_columnar_destination" + + def __init__(self, *, database: str, scope: str, table_name: str): + """ + Args: + database: The Couchbase database name + scope: The Couchbase scope name + table_name: The Couchbase collection name to log features into + """ + self.database = database + self.scope = scope + self.table_name = table_name + + def to_data_source(self) -> DataSource: + """ + Returns a data source object representing the logging destination. + """ + return CouchbaseColumnarSource( + database=self.database, + scope=self.scope, + collection=self.table_name, + ) + + def to_proto(self) -> LoggingConfigProto: + """ + Converts the logging destination to its protobuf representation. + """ + return LoggingConfigProto( + couchbase_columnar_destination=LoggingConfigProto.CouchbaseColumnarDestination( + database=self.database, + scope=self.scope, + collection=self.table_name, + ) + ) + + @classmethod + def from_proto( + cls, config_proto: LoggingConfigProto + ) -> "CouchbaseColumnarLoggingDestination": + """ + Creates a CouchbaseColumnarLoggingDestination from its protobuf representation. + """ + return CouchbaseColumnarLoggingDestination( + database=config_proto.CouchbaseColumnarDestination.database, + scope=config_proto.CouchbaseColumnarDestination.scope, + table_name=config_proto.CouchbaseColumnarDestination.collection, + ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/__init__.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/data_source.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/data_source.py new file mode 100644 index 00000000000..c23a8301a76 --- /dev/null +++ b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/tests/data_source.py @@ -0,0 +1,213 @@ +import atexit +import json +import os +import signal +import threading +import uuid +from datetime import timedelta +from typing import Dict, List, Optional + +import pandas as pd +from couchbase_columnar.cluster import Cluster +from couchbase_columnar.credential import Credential +from couchbase_columnar.options import ClusterOptions, QueryOptions, TimeoutOptions + +from feast.data_source import DataSource +from feast.feature_logging import LoggingDestination +from feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase import ( + CouchbaseColumnarOfflineStoreConfig, +) +from feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase_source import ( + CouchbaseColumnarLoggingDestination, + CouchbaseColumnarSource, +) +from feast.infra.utils.couchbase.couchbase_utils import normalize_timestamp +from feast.repo_config import FeastConfigBaseModel +from tests.integration.feature_repos.universal.data_source_creator import ( + DataSourceCreator, +) + +COUCHBASE_COLUMNAR_DATABASE = "Default" +COUCHBASE_COLUMNAR_SCOPE = "Default" + + +class CouchbaseColumnarDataSourceCreator(DataSourceCreator): + _shutting_down = False + _cluster = None + _cluster_lock = threading.Lock() + + @classmethod + def get_cluster(cls): + with cls._cluster_lock: + if cls._cluster is None: + cred = Credential.from_username_and_password( + os.environ["COUCHBASE_COLUMNAR_USER"], + os.environ["COUCHBASE_COLUMNAR_PASSWORD"], + ) + timeout_opts = TimeoutOptions(dispatch_timeout=timedelta(seconds=120)) + cls._cluster = Cluster.create_instance( + os.environ["COUCHBASE_COLUMNAR_CONNECTION_STRING"], + cred, + ClusterOptions(timeout_options=timeout_opts), + ) + return cls._cluster + + def __init__(self, project_name: str, **kwargs): + super().__init__(project_name) + self.project_name = project_name + self.collections: List[str] = [] + + self.offline_store_config = CouchbaseColumnarOfflineStoreConfig( + type="couchbase.offline", + connection_string=os.environ["COUCHBASE_COLUMNAR_CONNECTION_STRING"], + user=os.environ["COUCHBASE_COLUMNAR_USER"], + password=os.environ["COUCHBASE_COLUMNAR_PASSWORD"], + timeout=120, + ) + + def create_data_source( + self, + df: pd.DataFrame, + destination_name: str, + created_timestamp_column="created_ts", + field_mapping: Optional[Dict[str, str]] = None, + timestamp_field: Optional[str] = "ts", + ) -> DataSource: + def format_row(row): + """Convert row to dictionary, handling NaN and timestamps""" + return { + col: ( + normalize_timestamp(row[col]) + if isinstance(row[col], pd.Timestamp) + else None + if pd.isna(row[col]) + else row[col] + ) + for col in row.index + } + + collection_name = self.get_prefixed_collection_name(destination_name) + + create_cluster_query = f"CREATE ANALYTICS COLLECTION {COUCHBASE_COLUMNAR_DATABASE}.{COUCHBASE_COLUMNAR_SCOPE}.{collection_name} IF NOT EXISTS PRIMARY KEY(pk: UUID) AUTOGENERATED;" + self.get_cluster().execute_query( + create_cluster_query, + QueryOptions(timeout=timedelta(seconds=self.offline_store_config.timeout)), + ) + + values_list = df.apply(format_row, axis=1).apply(json.dumps).tolist() + values_clause = ",\n ".join(values_list) + + insert_query = f""" + INSERT INTO `{COUCHBASE_COLUMNAR_DATABASE}`.`{COUCHBASE_COLUMNAR_SCOPE}`.`{collection_name}` ([ + {values_clause} + ]) + """ + self.get_cluster().execute_query( + insert_query, + QueryOptions(timeout=timedelta(seconds=self.offline_store_config.timeout)), + ) + + self.collections.append(collection_name) + + return CouchbaseColumnarSource( + name=collection_name, + query=f"SELECT VALUE v FROM {COUCHBASE_COLUMNAR_DATABASE}.{COUCHBASE_COLUMNAR_SCOPE}.`{collection_name}` v", + database=COUCHBASE_COLUMNAR_DATABASE, + scope=COUCHBASE_COLUMNAR_SCOPE, + collection=collection_name, + timestamp_field=timestamp_field, + created_timestamp_column=created_timestamp_column, + field_mapping=field_mapping or {"ts_1": "ts"}, + ) + + def create_saved_dataset_destination(self): + raise NotImplementedError + + def create_logged_features_destination(self) -> LoggingDestination: + collection = self.get_prefixed_collection_name( + f"logged_features_{str(uuid.uuid4()).replace('-', '_')}" + ) + self.collections.append(collection) + return CouchbaseColumnarLoggingDestination( + table_name=collection, + database=COUCHBASE_COLUMNAR_DATABASE, + scope=COUCHBASE_COLUMNAR_SCOPE, + ) + + def create_offline_store_config(self) -> FeastConfigBaseModel: + return self.offline_store_config + + def get_prefixed_collection_name(self, suffix: str) -> str: + return f"{self.project_name}_{suffix}" + + @classmethod + def get_dangling_collections(cls) -> List[str]: + query = """ + SELECT VALUE d.DatabaseName || '.' || d.DataverseName || '.' || d.DatasetName + FROM System.Metadata.`Dataset` d + WHERE d.DataverseName <> "Metadata" + AND (REGEXP_CONTAINS(d.DatasetName, "integration_test_.*") + OR REGEXP_CONTAINS(d.DatasetName, "feast_entity_df_.*")); + """ + try: + res = cls.get_cluster().execute_query(query) + return res.get_all_rows() + except Exception as e: + print(f"Error fetching collections: {e}") + return [] + + @classmethod + def cleanup_all(cls): + if cls._shutting_down: + return + cls._shutting_down = True + try: + collections = cls.get_dangling_collections() + if len(collections) == 0: + print("No collections to clean up.") + return + + print(f"Found {len(collections)} collections to clean up.") + if len(collections) > 5: + print("This may take a few minutes...") + for collection in collections: + try: + query = f"DROP COLLECTION {collection} IF EXISTS;" + cls.get_cluster().execute_query(query) + print(f"Dropped collection: {collection}") + except Exception as e: + print(f"Error dropping collection {collection}: {e}") + finally: + print("Cleanup complete.") + cls._shutting_down = False + + def teardown(self): + for collection in self.collections: + query = f"DROP COLLECTION {COUCHBASE_COLUMNAR_DATABASE}.{COUCHBASE_COLUMNAR_SCOPE}.`{collection}` IF EXISTS;" + try: + self.get_cluster().execute_query( + query, + QueryOptions( + timeout=timedelta(seconds=self.offline_store_config.timeout) + ), + ) + print(f"Successfully dropped collection: {collection}") + except Exception as e: + print(f"Error dropping collection {collection}: {e}") + + +def cleanup_handler(signum, frame): + print("\nCleaning up dangling resources...") + try: + CouchbaseColumnarDataSourceCreator.cleanup_all() + except Exception as e: + print(f"Error during cleanup: {e}") + finally: + # Re-raise the signal to properly exit + signal.default_int_handler(signum, frame) + + +# Register both SIGINT and SIGTERM handlers +signal.signal(signal.SIGINT, cleanup_handler) +signal.signal(signal.SIGTERM, cleanup_handler) +atexit.register(CouchbaseColumnarDataSourceCreator.cleanup_all) diff --git a/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres.py b/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres.py index 5239cfb474d..ec6b713941c 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres.py +++ b/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres.py @@ -156,7 +156,7 @@ def query_generator() -> Iterator[str]: # Hack for query_context.entity_selections to support uppercase in columns for context in query_context_dict: context["entity_selections"] = [ - f""""{entity_selection.replace(' AS ', '" AS "')}\"""" + f""""{entity_selection.replace(" AS ", '" AS "')}\"""" for entity_selection in context["entity_selections"] ] @@ -370,7 +370,7 @@ def build_point_in_time_query( final_output_feature_names.extend( [ ( - f'{fv["name"]}__{fv["field_mapping"].get(feature, feature)}' + f"{fv['name']}__{fv['field_mapping'].get(feature, feature)}" if full_feature_names else fv["field_mapping"].get(feature, feature) ) diff --git a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark.py b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark.py index aeb9e3cd68b..41c180f5c3c 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark.py +++ b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark.py @@ -99,6 +99,8 @@ def pull_latest_from_table_or_query( fields_as_string = ", ".join(fields_with_aliases) aliases_as_string = ", ".join(aliases) + date_partition_column = data_source.date_partition_column + start_date_str = _format_datetime(start_date) end_date_str = _format_datetime(end_date) query = f""" @@ -109,7 +111,7 @@ def pull_latest_from_table_or_query( SELECT {fields_as_string}, ROW_NUMBER() OVER({partition_by_join_key_string} ORDER BY {timestamp_desc_string}) AS feast_row_ FROM {from_expression} t1 - WHERE {timestamp_field} BETWEEN TIMESTAMP('{start_date_str}') AND TIMESTAMP('{end_date_str}') + WHERE {timestamp_field} BETWEEN TIMESTAMP('{start_date_str}') AND TIMESTAMP('{end_date_str}'){" AND " + date_partition_column + " >= '" + start_date.strftime("%Y-%m-%d") + "' AND " + date_partition_column + " <= '" + end_date.strftime("%Y-%m-%d") + "' " if date_partition_column != "" and date_partition_column is not None else ""} ) t2 WHERE feast_row_ = 1 """ @@ -641,8 +643,15 @@ def _cast_data_frame( {% endfor %} FROM {{ featureview.table_subquery }} WHERE {{ featureview.timestamp_field }} <= '{{ featureview.max_event_timestamp }}' + {% if featureview.date_partition_column != "" and featureview.date_partition_column is not none %} + AND {{ featureview.date_partition_column }} <= '{{ featureview.max_event_timestamp[:10] }}' + {% endif %} + {% if featureview.ttl == 0 %}{% else %} AND {{ featureview.timestamp_field }} >= '{{ featureview.min_event_timestamp }}' + {% if featureview.date_partition_column != "" and featureview.date_partition_column is not none %} + AND {{ featureview.date_partition_column }} >= '{{ featureview.min_event_timestamp[:10] }}' + {% endif %} {% endif %} ), diff --git a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py index 209e3b87e8b..7ad331239ff 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py @@ -45,6 +45,7 @@ def __init__( tags: Optional[Dict[str, str]] = None, owner: Optional[str] = "", timestamp_field: Optional[str] = None, + date_partition_column: Optional[str] = None, ): """Creates a SparkSource object. @@ -64,6 +65,8 @@ def __init__( maintainer. timestamp_field: Event timestamp field used for point-in-time joins of feature values. + date_partition_column: The column to partition the data on for faster + retrieval. This is useful for large tables and will limit the number ofi """ # If no name, use the table as the default name. if name is None and table is None: @@ -77,6 +80,7 @@ def __init__( created_timestamp_column=created_timestamp_column, field_mapping=field_mapping, description=description, + date_partition_column=date_partition_column, tags=tags, owner=owner, ) @@ -135,6 +139,7 @@ def from_proto(data_source: DataSourceProto) -> Any: query=spark_options.query, path=spark_options.path, file_format=spark_options.file_format, + date_partition_column=data_source.date_partition_column, timestamp_field=data_source.timestamp_field, created_timestamp_column=data_source.created_timestamp_column, description=data_source.description, @@ -148,6 +153,7 @@ def to_proto(self) -> DataSourceProto: type=DataSourceProto.BATCH_SPARK, data_source_class_type="feast.infra.offline_stores.contrib.spark_offline_store.spark_source.SparkSource", field_mapping=self.field_mapping, + date_partition_column=self.date_partition_column, spark_options=self.spark_options.to_proto(), description=self.description, tags=self.tags, diff --git a/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino.py b/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino.py index b034d4f9923..9667f4e4720 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino.py +++ b/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino.py @@ -65,8 +65,8 @@ class JWTAuthModel(FeastConfigBaseModel): class CertificateAuthModel(FeastConfigBaseModel): - cert: FilePath = Field(default=None, alias="cert-file") - key: FilePath = Field(default=None, alias="key-file") + cert: Optional[FilePath] = Field(default=None, alias="cert-file") + key: Optional[FilePath] = Field(default=None, alias="key-file") CLASSES_BY_AUTH_TYPE = { diff --git a/sdk/python/feast/infra/offline_stores/dask.py b/sdk/python/feast/infra/offline_stores/dask.py index d26e8609bae..01efc492f7c 100644 --- a/sdk/python/feast/infra/offline_stores/dask.py +++ b/sdk/python/feast/infra/offline_stores/dask.py @@ -100,11 +100,9 @@ def persist( # Check if the specified location already exists. if not allow_overwrite and os.path.exists(storage.file_options.uri): raise SavedDatasetLocationAlreadyExists(location=storage.file_options.uri) - - if not Path(storage.file_options.uri).is_absolute(): - absolute_path = Path(self.repo_path) / storage.file_options.uri - else: - absolute_path = Path(storage.file_options.uri) + absolute_path = FileSource.get_uri_for_file_path( + repo_path=self.repo_path, uri=storage.file_options.uri + ) filesystem, path = FileSource.create_filesystem_and_path( str(absolute_path), @@ -193,9 +191,7 @@ def evaluate_historical_retrieval(): ): # Make sure all event timestamp fields are tz-aware. We default tz-naive fields to UTC entity_df_with_features[entity_df_event_timestamp_col] = ( - entity_df_with_features[ - entity_df_event_timestamp_col - ].apply( + entity_df_with_features[entity_df_event_timestamp_col].apply( lambda x: x if x.tzinfo is not None else x.replace(tzinfo=timezone.utc) diff --git a/sdk/python/feast/infra/offline_stores/duckdb.py b/sdk/python/feast/infra/offline_stores/duckdb.py index e64da029a6a..b2e3c03cb55 100644 --- a/sdk/python/feast/infra/offline_stores/duckdb.py +++ b/sdk/python/feast/infra/offline_stores/duckdb.py @@ -51,10 +51,9 @@ def _write_data_source( file_options = data_source.file_options - if not Path(file_options.uri).is_absolute(): - absolute_path = Path(repo_path) / file_options.uri - else: - absolute_path = Path(file_options.uri) + absolute_path = FileSource.get_uri_for_file_path( + repo_path=repo_path, uri=file_options.uri + ) if ( mode == "overwrite" diff --git a/sdk/python/feast/infra/offline_stores/file_source.py b/sdk/python/feast/infra/offline_stores/file_source.py index 5912cbdf3fb..af33338265b 100644 --- a/sdk/python/feast/infra/offline_stores/file_source.py +++ b/sdk/python/feast/infra/offline_stores/file_source.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional, Tuple +from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union +from urllib.parse import urlparse import pyarrow from packaging import version @@ -154,17 +155,21 @@ def validate(self, config: RepoConfig): def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]: return type_map.pa_to_feast_value_type + @staticmethod + def get_uri_for_file_path(repo_path: Union[Path, str, None], uri: str) -> str: + parsed_uri = urlparse(uri) + if parsed_uri.scheme and parsed_uri.netloc: + return uri # Keep remote URIs as they are + if repo_path is not None and not Path(uri).is_absolute(): + return str(Path(repo_path) / uri) + return str(Path(uri)) + def get_table_column_names_and_types( self, config: RepoConfig ) -> Iterable[Tuple[str, str]]: - if ( - config.repo_path is not None - and not Path(self.file_options.uri).is_absolute() - ): - absolute_path = config.repo_path / self.file_options.uri - else: - absolute_path = Path(self.file_options.uri) - + absolute_path = self.get_uri_for_file_path( + repo_path=config.repo_path, uri=self.file_options.uri + ) filesystem, path = FileSource.create_filesystem_and_path( str(absolute_path), self.file_options.s3_endpoint_override ) diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index 2d4fa268e40..5b12636782f 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -118,6 +118,10 @@ def get_feature_view_query_context( query_context = [] for feature_view, features in feature_views_to_feature_map.items(): + reverse_field_mapping = { + v: k for k, v in feature_view.batch_source.field_mapping.items() + } + join_keys: List[str] = [] entity_selections: List[str] = [] for entity_column in feature_view.entity_columns: @@ -125,16 +129,16 @@ def get_feature_view_query_context( entity_column.name, entity_column.name ) join_keys.append(join_key) - entity_selections.append(f"{entity_column.name} AS {join_key}") + entity_selections.append( + f"{reverse_field_mapping.get(entity_column.name, entity_column.name)} " + f"AS {join_key}" + ) if isinstance(feature_view.ttl, timedelta): ttl_seconds = int(feature_view.ttl.total_seconds()) else: ttl_seconds = 0 - reverse_field_mapping = { - v: k for k, v in feature_view.batch_source.field_mapping.items() - } features = [reverse_field_mapping.get(feature, feature) for feature in features] timestamp_field = reverse_field_mapping.get( feature_view.batch_source.timestamp_field, @@ -186,7 +190,9 @@ def build_point_in_time_query( full_feature_names: bool = False, ) -> str: """Build point-in-time query between each feature view table and the entity dataframe for Bigquery and Redshift""" - template = Environment(loader=BaseLoader()).from_string(source=query_template) + env = Environment(loader=BaseLoader()) + env.filters["backticks"] = enclose_in_backticks + template = env.from_string(source=query_template) final_output_feature_names = list(entity_df_columns) final_output_feature_names.extend( @@ -252,3 +258,11 @@ def get_pyarrow_schema_from_batch_source( column_names.append(column_name) return pa.schema(pa_schema), column_names + + +def enclose_in_backticks(value): + # Check if the input is a list + if isinstance(value, list): + return [f"`{v}`" for v in value] + else: + return f"`{value}`" diff --git a/sdk/python/feast/infra/offline_stores/remote.py b/sdk/python/feast/infra/offline_stores/remote.py index 7ee018ac6d9..d11fb4673db 100644 --- a/sdk/python/feast/infra/offline_stores/remote.py +++ b/sdk/python/feast/infra/offline_stores/remote.py @@ -70,22 +70,45 @@ def list_actions(self, options: FlightCallOptions = None): return super().list_actions(options) -def build_arrow_flight_client(host: str, port, auth_config: AuthConfig): +def build_arrow_flight_client( + scheme: str, host: str, port, auth_config: AuthConfig, cert: str = "" +): + arrow_scheme = "grpc+tcp" + if scheme == "https": + logger.info( + "Scheme is https so going to connect offline server in SSL(TLS) mode." + ) + arrow_scheme = "grpc+tls" + + kwargs = {} + if cert: + with open(cert, "rb") as root_certs: + kwargs["tls_root_certs"] = root_certs.read() + if auth_config.type != AuthType.NONE.value: middlewares = [FlightAuthInterceptorFactory(auth_config)] - return FeastFlightClient(f"grpc://{host}:{port}", middleware=middlewares) + return FeastFlightClient( + f"{arrow_scheme}://{host}:{port}", middleware=middlewares, **kwargs + ) - return FeastFlightClient(f"grpc://{host}:{port}") + return FeastFlightClient(f"{arrow_scheme}://{host}:{port}", **kwargs) class RemoteOfflineStoreConfig(FeastConfigBaseModel): type: Literal["remote"] = "remote" + + scheme: Literal["http", "https"] = "http" + host: StrictStr """ str: remote offline store server port, e.g. the host URL for offline store of arrow flight server. """ port: Optional[StrictInt] = None """ str: remote offline store server port.""" + cert: StrictStr = "" + """ str: Path to the public certificate when the offline server starts in TLS(SSL) mode. This may be needed if the offline server started with a self-signed certificate, typically this file ends with `*.crt`, `*.cer`, or `*.pem`. + If type is 'remote', then this configuration is needed to connect to remote offline server in TLS mode. """ + class RemoteRetrievalJob(RetrievalJob): def __init__( @@ -178,7 +201,11 @@ def get_historical_features( assert isinstance(config.offline_store, RemoteOfflineStoreConfig) client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + scheme=config.offline_store.scheme, + host=config.offline_store.host, + port=config.offline_store.port, + auth_config=config.auth_config, + cert=config.offline_store.cert, ) feature_view_names = [fv.name for fv in feature_views] @@ -214,7 +241,11 @@ def pull_all_from_table_or_query( # Initialize the client connection client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + scheme=config.offline_store.scheme, + host=config.offline_store.host, + port=config.offline_store.port, + auth_config=config.auth_config, + cert=config.offline_store.cert, ) api_parameters = { @@ -247,7 +278,11 @@ def pull_latest_from_table_or_query( # Initialize the client connection client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + config.offline_store.scheme, + config.offline_store.host, + config.offline_store.port, + config.auth_config, + cert=config.offline_store.cert, ) api_parameters = { @@ -282,7 +317,11 @@ def write_logged_features( # Initialize the client connection client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + config.offline_store.scheme, + config.offline_store.host, + config.offline_store.port, + config.auth_config, + config.offline_store.cert, ) api_parameters = { @@ -308,7 +347,11 @@ def offline_write_batch( # Initialize the client connection client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + config.offline_store.scheme, + config.offline_store.host, + config.offline_store.port, + config.auth_config, + config.offline_store.cert, ) feature_view_names = [feature_view.name] @@ -336,7 +379,11 @@ def validate_data_source( assert isinstance(config.offline_store, RemoteOfflineStoreConfig) client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + config.offline_store.scheme, + config.offline_store.host, + config.offline_store.port, + config.auth_config, + config.offline_store.cert, ) api_parameters = { @@ -357,7 +404,11 @@ def get_table_column_names_and_types_from_data_source( assert isinstance(config.offline_store, RemoteOfflineStoreConfig) client = build_arrow_flight_client( - config.offline_store.host, config.offline_store.port, config.auth_config + config.offline_store.scheme, + config.offline_store.host, + config.offline_store.port, + config.auth_config, + config.offline_store.cert, ) api_parameters = { diff --git a/sdk/python/feast/infra/offline_stores/snowflake.py b/sdk/python/feast/infra/offline_stores/snowflake.py index 3d23682769b..101685cec6f 100644 --- a/sdk/python/feast/infra/offline_stores/snowflake.py +++ b/sdk/python/feast/infra/offline_stores/snowflake.py @@ -716,8 +716,8 @@ def _get_entity_df_event_timestamp_range( MULTIPLE_FEATURE_VIEW_POINT_IN_TIME_JOIN = """ /* - Compute a deterministic hash for the `left_table_query_string` that will be used throughout - all the logic as the field to GROUP BY the data + 0. Compute a deterministic hash for the `left_table_query_string` that will be used throughout + all the logic as the field to GROUP BY the data. */ WITH "entity_dataframe" AS ( SELECT *, @@ -739,6 +739,10 @@ def _get_entity_df_event_timestamp_range( {% for featureview in featureviews %} +/* + 1. Only select the required columns with entities of the featureview. +*/ + "{{ featureview.name }}__entity_dataframe" AS ( SELECT {{ featureview.entities | map('tojson') | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} @@ -752,20 +756,7 @@ def _get_entity_df_event_timestamp_range( ), /* - This query template performs the point-in-time correctness join for a single feature set table - to the provided entity table. - - 1. We first join the current feature_view to the entity dataframe that has been passed. - This JOIN has the following logic: - - For each row of the entity dataframe, only keep the rows where the `timestamp_field` - is less than the one provided in the entity dataframe - - If there a TTL for the current feature_view, also keep the rows where the `timestamp_field` - is higher the the one provided minus the TTL - - For each row, Join on the entity key and retrieve the `entity_row_unique_id` that has been - computed previously - - The output of this CTE will contain all the necessary information and already filtered out most - of the data that is not relevant. +2. Use subquery to prepare event_timestamp, created_timestamp, entity columns and feature columns. */ "{{ featureview.name }}__subquery" AS ( @@ -777,94 +768,61 @@ def _get_entity_df_event_timestamp_range( "{{ feature }}" as {% if full_feature_names %}"{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}"{% else %}"{{ featureview.field_mapping.get(feature, feature) }}"{% endif %}{% if loop.last %}{% else %}, {% endif %} {% endfor %} FROM {{ featureview.table_subquery }} - WHERE "{{ featureview.timestamp_field }}" <= '{{ featureview.max_event_timestamp }}' - {% if featureview.ttl == 0 %}{% else %} - AND "{{ featureview.timestamp_field }}" >= '{{ featureview.min_event_timestamp }}' - {% endif %} -), - -"{{ featureview.name }}__base" AS ( - SELECT - "subquery".*, - "entity_dataframe"."entity_timestamp", - "entity_dataframe"."{{featureview.name}}__entity_row_unique_id" - FROM "{{ featureview.name }}__subquery" AS "subquery" - INNER JOIN "{{ featureview.name }}__entity_dataframe" AS "entity_dataframe" - ON TRUE - AND "subquery"."event_timestamp" <= "entity_dataframe"."entity_timestamp" - - {% if featureview.ttl == 0 %}{% else %} - AND "subquery"."event_timestamp" >= TIMESTAMPADD(second,-{{ featureview.ttl }},"entity_dataframe"."entity_timestamp") - {% endif %} - - {% for entity in featureview.entities %} - AND "subquery"."{{ entity }}" = "entity_dataframe"."{{ entity }}" - {% endfor %} ), /* - 2. If the `created_timestamp_column` has been set, we need to - deduplicate the data first. This is done by calculating the - `MAX(created_at_timestamp)` for each event_timestamp. - We then join the data on the next CTE +3. If the `created_timestamp_column` has been set, we need to +deduplicate the data first. This is done by calculating the +`MAX(created_at_timestamp)` for each event_timestamp and joining back on the subquery. +Otherwise, the ASOF JOIN can have unstable side effects +https://docs.snowflake.com/en/sql-reference/constructs/asof-join#expected-behavior-when-ties-exist-in-the-right-table */ + {% if featureview.created_timestamp_column %} "{{ featureview.name }}__dedup" AS ( - SELECT - "{{featureview.name}}__entity_row_unique_id", - "event_timestamp", - MAX("created_timestamp") AS "created_timestamp" - FROM "{{ featureview.name }}__base" - GROUP BY "{{featureview.name}}__entity_row_unique_id", "event_timestamp" + SELECT * + FROM "{{ featureview.name }}__subquery" + INNER JOIN ( + SELECT + {{ featureview.entities | map('tojson') | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} + "event_timestamp", + MAX("created_timestamp") AS "created_timestamp" + FROM "{{ featureview.name }}__subquery" + GROUP BY {{ featureview.entities | map('tojson') | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} "event_timestamp" + ) + USING({{ featureview.entities | map('tojson') | join(', ')}}{% if featureview.entities %},{% else %}{% endif %} "event_timestamp", "created_timestamp") ), {% endif %} /* - 3. The data has been filtered during the first CTE "*__base" - Thus we only need to compute the latest timestamp of each feature. +4. Make ASOF JOIN of deduplicated feature CTE on reduced entity dataframe. */ -"{{ featureview.name }}__latest" AS ( + +"{{ featureview.name }}__asof_join" AS ( SELECT - "event_timestamp", - {% if featureview.created_timestamp_column %}"created_timestamp",{% endif %} - "{{featureview.name}}__entity_row_unique_id" - FROM - ( - SELECT *, - ROW_NUMBER() OVER( - PARTITION BY "{{featureview.name}}__entity_row_unique_id" - ORDER BY "event_timestamp" DESC{% if featureview.created_timestamp_column %},"created_timestamp" DESC{% endif %} - ) AS "row_number" - FROM "{{ featureview.name }}__base" - {% if featureview.created_timestamp_column %} - INNER JOIN "{{ featureview.name }}__dedup" - USING ("{{featureview.name}}__entity_row_unique_id", "event_timestamp", "created_timestamp") - {% endif %} - ) - WHERE "row_number" = 1 + e.*, + v.* + FROM "{{ featureview.name }}__entity_dataframe" e + ASOF JOIN {% if featureview.created_timestamp_column %}"{{ featureview.name }}__dedup"{% else %}"{{ featureview.name }}__subquery"{% endif %} v + MATCH_CONDITION (e."entity_timestamp" >= v."event_timestamp") + {% if featureview.entities %} USING({{ featureview.entities | map('tojson') | join(', ')}}) {% endif %} ), /* - 4. Once we know the latest value of each feature for a given timestamp, - we can join again the data back to the original "base" dataset +5. If TTL is configured filter the CTE to remove rows where the feature values are older than the configured ttl. */ -"{{ featureview.name }}__cleaned" AS ( - SELECT "base".* - FROM "{{ featureview.name }}__base" AS "base" - INNER JOIN "{{ featureview.name }}__latest" - USING( - "{{featureview.name}}__entity_row_unique_id", - "event_timestamp" - {% if featureview.created_timestamp_column %} - ,"created_timestamp" - {% endif %} - ) -){% if loop.last %}{% else %}, {% endif %} +"{{ featureview.name }}__ttl" AS ( + SELECT * + FROM "{{ featureview.name }}__asof_join" + {% if featureview.ttl == 0 %}{% else %} + WHERE "event_timestamp" >= TIMESTAMPADD(second,-{{ featureview.ttl }},"entity_timestamp") + {% endif %} +){% if loop.last %}{% else %}, {% endif %} {% endfor %} /* - Joins the outputs of multiple time travel joins to a single table. + Join the outputs of multiple time travel joins to a single table. The entity_dataframe dataset being our source of truth here. */ @@ -877,7 +835,7 @@ def _get_entity_df_event_timestamp_range( {% for feature in featureview.features %} ,{% if full_feature_names %}"{{ featureview.name }}__{{featureview.field_mapping.get(feature, feature)}}"{% else %}"{{ featureview.field_mapping.get(feature, feature) }}"{% endif %} {% endfor %} - FROM "{{ featureview.name }}__cleaned" -) "{{ featureview.name }}__cleaned" USING ("{{featureview.name}}__entity_row_unique_id") + FROM "{{ featureview.name }}__ttl" +) "{{ featureview.name }}__ttl" USING ("{{featureview.name}}__entity_row_unique_id") {% endfor %} """ diff --git a/sdk/python/feast/infra/offline_stores/snowflake_source.py b/sdk/python/feast/infra/offline_stores/snowflake_source.py index 1d43fecc03c..b4fcd89af7e 100644 --- a/sdk/python/feast/infra/offline_stores/snowflake_source.py +++ b/sdk/python/feast/infra/offline_stores/snowflake_source.py @@ -285,15 +285,15 @@ def get_table_column_names_and_types( row["snowflake_type"] = "NUMBERwSCALE" elif row["type_code"] in [5, 9, 12]: - error = snowflake_unsupported_map[row["type_code"]] + datatype = snowflake_unsupported_map[row["type_code"]] raise NotImplementedError( - f"The following Snowflake Data Type is not supported: {error}" + f"The datatype of column {row['column_name']} is of type {datatype} in datasource {query}. This type is not supported. Try converting to VARCHAR." ) elif row["type_code"] in [1, 2, 3, 4, 6, 7, 8, 10, 11, 13]: row["snowflake_type"] = snowflake_type_code_map[row["type_code"]] else: raise NotImplementedError( - f"The following Snowflake Column is not supported: {row['column_name']} (type_code: {row['type_code']})" + f"The datatype of column {row['column_name']} in datasource {query} is not supported." ) return [ @@ -317,9 +317,9 @@ def get_table_column_names_and_types( } snowflake_unsupported_map = { - 5: "VARIANT -- Try converting to VARCHAR", - 9: "OBJECT -- Try converting to VARCHAR", - 12: "TIME -- Try converting to VARCHAR", + 5: "VARIANT", + 9: "OBJECT", + 12: "TIME", } python_int_to_snowflake_type_map = { diff --git a/sdk/python/feast/infra/online_stores/couchbase_online_store/README.md b/sdk/python/feast/infra/online_stores/couchbase_online_store/README.md index df1b7a1382d..8f95884fe03 100644 --- a/sdk/python/feast/infra/online_stores/couchbase_online_store/README.md +++ b/sdk/python/feast/infra/online_stores/couchbase_online_store/README.md @@ -28,14 +28,14 @@ cd feature_repo #### Edit `feature_store.yaml` -Set the `online_store` type to `couchbase`, and fill in the required fields as shown below. +Set the `online_store` type to `couchbase.online`, and fill in the required fields as shown below. ```yaml project: feature_repo registry: data/registry.db provider: local online_store: - type: couchbase + type: couchbase.online connection_string: couchbase://127.0.0.1 # Couchbase connection string, copied from 'Connect' page in Couchbase Capella console user: Administrator # Couchbase username from access credentials password: password # Couchbase password from access credentials diff --git a/sdk/python/feast/infra/online_stores/couchbase_online_store/couchbase.py b/sdk/python/feast/infra/online_stores/couchbase_online_store/couchbase.py index 91ce56a5caf..c80f9e1285c 100644 --- a/sdk/python/feast/infra/online_stores/couchbase_online_store/couchbase.py +++ b/sdk/python/feast/infra/online_stores/couchbase_online_store/couchbase.py @@ -31,7 +31,7 @@ class CouchbaseOnlineStoreConfig(FeastConfigBaseModel): Configuration for the Couchbase online store. """ - type: Literal["couchbase"] = "couchbase" + type: Literal["couchbase.online"] = "couchbase.online" connection_string: Optional[StrictStr] = None user: Optional[StrictStr] = None diff --git a/sdk/python/feast/infra/online_stores/elasticsearch_online_store/elasticsearch.py b/sdk/python/feast/infra/online_stores/elasticsearch_online_store/elasticsearch.py index 0152ca330c9..af328141520 100644 --- a/sdk/python/feast/infra/online_stores/elasticsearch_online_store/elasticsearch.py +++ b/sdk/python/feast/infra/online_stores/elasticsearch_online_store/elasticsearch.py @@ -213,7 +213,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], embedding: List[float], top_k: int, *args, diff --git a/sdk/python/feast/infra/online_stores/faiss_online_store.py b/sdk/python/feast/infra/online_stores/faiss_online_store.py index cc2e75800e6..fd4d6768abd 100644 --- a/sdk/python/feast/infra/online_stores/faiss_online_store.py +++ b/sdk/python/feast/infra/online_stores/faiss_online_store.py @@ -176,7 +176,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_featres: Optional[List[str]], embedding: List[float], top_k: int, distance_metric: Optional[str] = None, diff --git a/sdk/python/feast/infra/online_stores/milvus_online_store/__init__.py b/sdk/python/feast/infra/online_stores/milvus_online_store/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/online_stores/milvus_online_store/milvus.py b/sdk/python/feast/infra/online_stores/milvus_online_store/milvus.py new file mode 100644 index 00000000000..91e432a74fa --- /dev/null +++ b/sdk/python/feast/infra/online_stores/milvus_online_store/milvus.py @@ -0,0 +1,632 @@ +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union + +from pydantic import StrictStr +from pymilvus import ( + CollectionSchema, + DataType, + FieldSchema, + MilvusClient, +) + +from feast import Entity +from feast.feature_view import FeatureView +from feast.infra.infra_object import InfraObject +from feast.infra.key_encoding_utils import ( + deserialize_entity_key, + serialize_entity_key, +) +from feast.infra.online_stores.online_store import OnlineStore +from feast.infra.online_stores.vector_store import VectorStoreConfig +from feast.protos.feast.core.Registry_pb2 import Registry as RegistryProto +from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto +from feast.protos.feast.types.Value_pb2 import Value as ValueProto +from feast.repo_config import FeastConfigBaseModel, RepoConfig +from feast.type_map import ( + PROTO_VALUE_TO_VALUE_TYPE_MAP, + VALUE_TYPE_TO_PROTO_VALUE_MAP, + feast_value_type_to_python_type, +) +from feast.types import ( + VALUE_TYPES_TO_FEAST_TYPES, + Array, + ComplexFeastType, + PrimitiveFeastType, + ValueType, + from_feast_type, +) +from feast.utils import ( + _serialize_vector_to_float_list, + to_naive_utc, +) + +PROTO_TO_MILVUS_TYPE_MAPPING: Dict[ValueType, DataType] = { + PROTO_VALUE_TO_VALUE_TYPE_MAP["bytes_val"]: DataType.VARCHAR, + PROTO_VALUE_TO_VALUE_TYPE_MAP["bool_val"]: DataType.BOOL, + PROTO_VALUE_TO_VALUE_TYPE_MAP["string_val"]: DataType.VARCHAR, + PROTO_VALUE_TO_VALUE_TYPE_MAP["float_val"]: DataType.FLOAT, + PROTO_VALUE_TO_VALUE_TYPE_MAP["double_val"]: DataType.DOUBLE, + PROTO_VALUE_TO_VALUE_TYPE_MAP["int32_val"]: DataType.INT32, + PROTO_VALUE_TO_VALUE_TYPE_MAP["int64_val"]: DataType.INT64, + PROTO_VALUE_TO_VALUE_TYPE_MAP["float_list_val"]: DataType.FLOAT_VECTOR, + PROTO_VALUE_TO_VALUE_TYPE_MAP["int32_list_val"]: DataType.FLOAT_VECTOR, + PROTO_VALUE_TO_VALUE_TYPE_MAP["int64_list_val"]: DataType.FLOAT_VECTOR, + PROTO_VALUE_TO_VALUE_TYPE_MAP["double_list_val"]: DataType.FLOAT_VECTOR, + PROTO_VALUE_TO_VALUE_TYPE_MAP["bool_list_val"]: DataType.BINARY_VECTOR, +} + +FEAST_PRIMITIVE_TO_MILVUS_TYPE_MAPPING: Dict[ + Union[PrimitiveFeastType, Array, ComplexFeastType], DataType +] = {} + +for value_type, feast_type in VALUE_TYPES_TO_FEAST_TYPES.items(): + if isinstance(feast_type, PrimitiveFeastType): + milvus_type = PROTO_TO_MILVUS_TYPE_MAPPING.get(value_type) + if milvus_type: + FEAST_PRIMITIVE_TO_MILVUS_TYPE_MAPPING[feast_type] = milvus_type + elif isinstance(feast_type, Array): + base_type = feast_type.base_type + base_value_type = base_type.to_value_type() + if base_value_type in [ + ValueType.INT32, + ValueType.INT64, + ValueType.FLOAT, + ValueType.DOUBLE, + ]: + FEAST_PRIMITIVE_TO_MILVUS_TYPE_MAPPING[feast_type] = DataType.FLOAT_VECTOR + elif base_value_type == ValueType.STRING: + FEAST_PRIMITIVE_TO_MILVUS_TYPE_MAPPING[feast_type] = DataType.VARCHAR + elif base_value_type == ValueType.BOOL: + FEAST_PRIMITIVE_TO_MILVUS_TYPE_MAPPING[feast_type] = DataType.BINARY_VECTOR + + +class MilvusOnlineStoreConfig(FeastConfigBaseModel, VectorStoreConfig): + """ + Configuration for the Milvus online store. + NOTE: The class *must* end with the `OnlineStoreConfig` suffix. + """ + + type: Literal["milvus"] = "milvus" + path: Optional[StrictStr] = "data/online_store.db" + host: Optional[StrictStr] = "localhost" + port: Optional[int] = 19530 + index_type: Optional[str] = "FLAT" + metric_type: Optional[str] = "COSINE" + embedding_dim: Optional[int] = 128 + vector_enabled: Optional[bool] = True + nlist: Optional[int] = 128 + username: Optional[StrictStr] = "" + password: Optional[StrictStr] = "" + + +class MilvusOnlineStore(OnlineStore): + """ + Milvus implementation of the online store interface. + + Attributes: + _collections: Dictionary to cache Milvus collections. + """ + + client: Optional[MilvusClient] = None + _collections: Dict[str, Any] = {} + + def _get_db_path(self, config: RepoConfig) -> str: + assert ( + config.online_store.type == "milvus" + or config.online_store.type.endswith("MilvusOnlineStore") + ) + + if config.repo_path and not Path(config.online_store.path).is_absolute(): + db_path = str(config.repo_path / config.online_store.path) + else: + db_path = config.online_store.path + return db_path + + def _connect(self, config: RepoConfig) -> MilvusClient: + if not self.client: + if config.provider == "local": + db_path = self._get_db_path(config) + print(f"Connecting to Milvus in local mode using {db_path}") + self.client = MilvusClient(db_path) + else: + self.client = MilvusClient( + url=f"{config.online_store.host}:{config.online_store.port}", + token=f"{config.online_store.username}:{config.online_store.password}" + if config.online_store.username and config.online_store.password + else "", + ) + return self.client + + def _get_or_create_collection( + self, config: RepoConfig, table: FeatureView + ) -> Dict[str, Any]: + self.client = self._connect(config) + vector_field_dict = {k.name: k for k in table.schema if k.vector_index} + collection_name = _table_id(config.project, table) + if collection_name not in self._collections: + # Create a composite key by combining entity fields + composite_key_name = _get_composite_key_name(table) + + fields = [ + FieldSchema( + name=composite_key_name, + dtype=DataType.VARCHAR, + max_length=512, + is_primary=True, + ), + FieldSchema(name="event_ts", dtype=DataType.INT64), + FieldSchema(name="created_ts", dtype=DataType.INT64), + ] + fields_to_exclude = [ + "event_ts", + "created_ts", + ] + fields_to_add = [f for f in table.schema if f.name not in fields_to_exclude] + for field in fields_to_add: + dtype = FEAST_PRIMITIVE_TO_MILVUS_TYPE_MAPPING.get(field.dtype) + if dtype: + if dtype == DataType.FLOAT_VECTOR: + fields.append( + FieldSchema( + name=field.name, + dtype=dtype, + dim=config.online_store.embedding_dim, + ) + ) + else: + fields.append( + FieldSchema( + name=field.name, + dtype=DataType.VARCHAR, + max_length=512, + ) + ) + + schema = CollectionSchema( + fields=fields, description="Feast feature view data" + ) + collection_exists = self.client.has_collection( + collection_name=collection_name + ) + if not collection_exists: + self.client.create_collection( + collection_name=collection_name, + dimension=config.online_store.embedding_dim, + schema=schema, + ) + index_params = self.client.prepare_index_params() + for vector_field in schema.fields: + if ( + vector_field.dtype + in [ + DataType.FLOAT_VECTOR, + DataType.BINARY_VECTOR, + ] + and vector_field.name in vector_field_dict + ): + metric = vector_field_dict[ + vector_field.name + ].vector_search_metric + index_params.add_index( + collection_name=collection_name, + field_name=vector_field.name, + metric_type=metric or config.online_store.metric_type, + index_type=config.online_store.index_type, + index_name=f"vector_index_{vector_field.name}", + params={"nlist": config.online_store.nlist}, + ) + self.client.create_index( + collection_name=collection_name, + index_params=index_params, + ) + else: + self.client.load_collection(collection_name) + self._collections[collection_name] = self.client.describe_collection( + collection_name + ) + return self._collections[collection_name] + + def online_write_batch( + self, + config: RepoConfig, + table: FeatureView, + data: List[ + Tuple[ + EntityKeyProto, + Dict[str, ValueProto], + datetime, + Optional[datetime], + ] + ], + progress: Optional[Callable[[int], Any]], + ) -> None: + self.client = self._connect(config) + collection = self._get_or_create_collection(config, table) + vector_cols = [f.name for f in table.features if f.vector_index] + entity_batch_to_insert = [] + for entity_key, values_dict, timestamp, created_ts in data: + # need to construct the composite primary key also need to handle the fact that entities are a list + entity_key_str = serialize_entity_key( + entity_key, + entity_key_serialization_version=config.entity_key_serialization_version, + ).hex() + # to recover the entity key just run: + # deserialize_entity_key(bytes.fromhex(entity_key_str), entity_key_serialization_version=3) + composite_key_name = _get_composite_key_name(table) + + timestamp_int = int(to_naive_utc(timestamp).timestamp() * 1e6) + created_ts_int = ( + int(to_naive_utc(created_ts).timestamp() * 1e6) if created_ts else 0 + ) + entity_dict = { + join_key: feast_value_type_to_python_type(value) + for join_key, value in zip( + entity_key.join_keys, entity_key.entity_values + ) + } + values_dict.update(entity_dict) + values_dict = _extract_proto_values_to_dict( + values_dict, + vector_cols=vector_cols, + serialize_to_string=True, + ) + + single_entity_record = { + composite_key_name: entity_key_str, + "event_ts": timestamp_int, + "created_ts": created_ts_int, + } + single_entity_record.update(values_dict) + entity_batch_to_insert.append(single_entity_record) + + if progress: + progress(1) + + self.client.insert( + collection_name=collection["collection_name"], + data=entity_batch_to_insert, + ) + + def online_read( + self, + config: RepoConfig, + table: FeatureView, + entity_keys: List[EntityKeyProto], + requested_features: Optional[List[str]] = None, + full_feature_names: bool = False, + ) -> List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]]: + self.client = self._connect(config) + collection_name = _table_id(config.project, table) + collection = self._get_or_create_collection(config, table) + + composite_key_name = _get_composite_key_name(table) + + output_fields = ( + [composite_key_name] + + (requested_features if requested_features else []) + + ["created_ts", "event_ts"] + ) + assert all( + field in [f["name"] for f in collection["fields"]] + for field in output_fields + ), ( + f"field(s) [{[field for field in output_fields if field not in [f['name'] for f in collection['fields']]]}] not found in collection schema" + ) + composite_entities = [] + for entity_key in entity_keys: + entity_key_str = serialize_entity_key( + entity_key, + entity_key_serialization_version=config.entity_key_serialization_version, + ).hex() + composite_entities.append(entity_key_str) + + query_filter_for_entities = ( + f"{composite_key_name} in [" + + ", ".join([f"'{e}'" for e in composite_entities]) + + "]" + ) + self.client.load_collection(collection_name) + results = self.client.query( + collection_name=collection_name, + filter=query_filter_for_entities, + output_fields=output_fields, + ) + # Group hits by composite key. + grouped_hits: Dict[str, Any] = {} + for hit in results: + key = hit.get(composite_key_name) + grouped_hits.setdefault(key, []).append(hit) + + # Map the features to their Feast types. + feature_name_feast_primitive_type_map = { + f.name: f.dtype for f in table.features + } + # Build a dictionary mapping composite key -> (res_ts, res) + results_dict: Dict[ + str, Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]] + ] = {} + + # here we need to map the data stored as characters back into the protobuf value + for hit in results: + key = hit.get(composite_key_name) + # Only take one hit per composite key (adjust if you need aggregation) + if key not in results_dict: + res = {} + res_ts = None + for field in output_fields: + val = ValueProto() + field_value = hit.get(field, None) + if field_value is None and ":" in field: + _, field_short = field.split(":", 1) + field_value = hit.get(field_short) + + if field in ["created_ts", "event_ts"]: + res_ts = datetime.fromtimestamp(field_value / 1e6) + elif field == composite_key_name: + # We do not return the composite key value + pass + else: + feature_feast_primitive_type = ( + feature_name_feast_primitive_type_map.get( + field, PrimitiveFeastType.INVALID + ) + ) + feature_fv_dtype = from_feast_type(feature_feast_primitive_type) + proto_attr = VALUE_TYPE_TO_PROTO_VALUE_MAP.get(feature_fv_dtype) + if proto_attr: + if proto_attr == "bytes_val": + setattr(val, proto_attr, field_value.encode()) + elif proto_attr in [ + "int32_val", + "int64_val", + "float_val", + "double_val", + ]: + setattr( + val, + proto_attr, + type(getattr(val, proto_attr))(field_value), + ) + elif proto_attr in [ + "int32_list_val", + "int64_list_val", + "float_list_val", + "double_list_val", + ]: + setattr( + val, + proto_attr, + list( + map( + type(getattr(val, proto_attr)).__args__[0], + field_value, + ) + ), + ) + else: + setattr(val, proto_attr, field_value) + else: + raise ValueError( + f"Unsupported ValueType: {feature_feast_primitive_type} with feature view value {field_value} for feature {field} with value {field_value}" + ) + # res[field] = val + key_to_use = field.split(":", 1)[-1] if ":" in field else field + res[key_to_use] = val + results_dict[key] = (res_ts, res if res else None) + + # Map the results back into a list matching the original order of composite_keys. + result_list = [ + results_dict.get(key, (None, None)) for key in composite_entities + ] + + return result_list + + def update( + self, + config: RepoConfig, + tables_to_delete: Sequence[FeatureView], + tables_to_keep: Sequence[FeatureView], + entities_to_delete: Sequence[Entity], + entities_to_keep: Sequence[Entity], + partial: bool, + ): + self.client = self._connect(config) + for table in tables_to_keep: + self._collections = self._get_or_create_collection(config, table) + + for table in tables_to_delete: + collection_name = _table_id(config.project, table) + if self._collections.get(collection_name, None): + self.client.drop_collection(collection_name) + self._collections.pop(collection_name, None) + + def plan( + self, config: RepoConfig, desired_registry_proto: RegistryProto + ) -> List[InfraObject]: + raise NotImplementedError + + def teardown( + self, + config: RepoConfig, + tables: Sequence[FeatureView], + entities: Sequence[Entity], + ): + self.client = self._connect(config) + for table in tables: + collection_name = _table_id(config.project, table) + if self._collections.get(collection_name, None): + self.client.drop_collection(collection_name) + self._collections.pop(collection_name, None) + + def retrieve_online_documents_v2( + self, + config: RepoConfig, + table: FeatureView, + requested_features: List[str], + embedding: Optional[List[float]], + top_k: int, + distance_metric: Optional[str] = None, + query_string: Optional[str] = None, + ) -> List[ + Tuple[ + Optional[datetime], + Optional[EntityKeyProto], + Optional[Dict[str, ValueProto]], + ] + ]: + assert embedding is not None, "Key Word Search not yet implemented for Milvus" + entity_name_feast_primitive_type_map = { + k.name: k.dtype for k in table.entity_columns + } + self.client = self._connect(config) + collection_name = _table_id(config.project, table) + collection = self._get_or_create_collection(config, table) + if not config.online_store.vector_enabled: + raise ValueError("Vector search is not enabled in the online store config") + + search_params = { + "metric_type": distance_metric or config.online_store.metric_type, + "params": {"nprobe": 10}, + } + + composite_key_name = _get_composite_key_name(table) + + output_fields = ( + [composite_key_name] + + (requested_features if requested_features else []) + + ["created_ts", "event_ts"] + ) + assert all( + field in [f["name"] for f in collection["fields"]] + for field in output_fields + ), ( + f"field(s) [{[field for field in output_fields if field not in [f['name'] for f in collection['fields']]]}] not found in collection schema" + ) + # Note we choose the first vector field as the field to search on. Not ideal but it's something. + ann_search_field = None + for field in collection["fields"]: + if ( + field["type"] in [DataType.FLOAT_VECTOR, DataType.BINARY_VECTOR] + and field["name"] in output_fields + ): + ann_search_field = field["name"] + break + + self.client.load_collection(collection_name) + results = self.client.search( + collection_name=collection_name, + data=[embedding], + anns_field=ann_search_field, + search_params=search_params, + limit=top_k, + output_fields=output_fields, + ) + + result_list = [] + for hits in results: + for hit in hits: + res = {} + res_ts = None + entity_key_bytes = bytes.fromhex( + hit.get("entity", {}).get(composite_key_name, None) + ) + entity_key_proto = ( + deserialize_entity_key(entity_key_bytes) + if entity_key_bytes + else None + ) + for field in output_fields: + val = ValueProto() + field_value = hit.get("entity", {}).get(field, None) + # entity_key_proto = None + if field in ["created_ts", "event_ts"]: + res_ts = datetime.fromtimestamp(field_value / 1e6) + elif field == ann_search_field: + serialized_embedding = _serialize_vector_to_float_list( + embedding + ) + res[ann_search_field] = serialized_embedding + elif entity_name_feast_primitive_type_map.get( + field, PrimitiveFeastType.INVALID + ) in [ + PrimitiveFeastType.STRING, + PrimitiveFeastType.INT64, + PrimitiveFeastType.INT32, + PrimitiveFeastType.BYTES, + ]: + res[field] = ValueProto(string_val=field_value) + elif field == composite_key_name: + pass + elif isinstance(field_value, bytes): + val.ParseFromString(field_value) + res[field] = val + else: + val.string_val = field_value + res[field] = val + distance = hit.get("distance", None) + res["distance"] = ( + ValueProto(float_val=distance) if distance else ValueProto() + ) + result_list.append((res_ts, entity_key_proto, res if res else None)) + return result_list + + +def _table_id(project: str, table: FeatureView) -> str: + return f"{project}_{table.name}" + + +def _get_composite_key_name(table: FeatureView) -> str: + return "_".join([field.name for field in table.entity_columns]) + "_pk" + + +def _extract_proto_values_to_dict( + input_dict: Dict[str, Any], + vector_cols: List[str], + serialize_to_string=False, +) -> Dict[str, Any]: + numeric_vector_list_types = [ + k + for k in PROTO_VALUE_TO_VALUE_TYPE_MAP.keys() + if k is not None and "list" in k and "string" not in k + ] + numeric_types = [ + "double_val", + "float_val", + "int32_val", + "int64_val", + "bool_val", + ] + output_dict = {} + for feature_name, feature_values in input_dict.items(): + for proto_val_type in PROTO_VALUE_TO_VALUE_TYPE_MAP: + if not isinstance(feature_values, (int, float, str)): + if feature_values.HasField(proto_val_type): + if proto_val_type in numeric_vector_list_types: + if serialize_to_string and feature_name not in vector_cols: + vector_values = getattr( + feature_values, proto_val_type + ).SerializeToString() + else: + vector_values = getattr(feature_values, proto_val_type).val + else: + if ( + serialize_to_string + and proto_val_type not in ["string_val"] + numeric_types + ): + vector_values = feature_values.SerializeToString().decode() + else: + if not isinstance(feature_values, str): + vector_values = str( + getattr(feature_values, proto_val_type) + ) + else: + vector_values = getattr(feature_values, proto_val_type) + output_dict[feature_name] = vector_values + else: + if serialize_to_string: + if not isinstance(feature_values, str): + feature_values = str(feature_values) + output_dict[feature_name] = feature_values + + return output_dict diff --git a/sdk/python/feast/infra/online_stores/milvus_online_store/milvus_repo_configuration.py b/sdk/python/feast/infra/online_stores/milvus_online_store/milvus_repo_configuration.py new file mode 100644 index 00000000000..8e8402862cb --- /dev/null +++ b/sdk/python/feast/infra/online_stores/milvus_online_store/milvus_repo_configuration.py @@ -0,0 +1,12 @@ +from tests.integration.feature_repos.integration_test_repo_config import ( + IntegrationTestRepoConfig, +) +from tests.integration.feature_repos.universal.online_store.milvus import ( + MilvusOnlineStoreCreator, +) + +FULL_REPO_CONFIGS = [ + IntegrationTestRepoConfig( + online_store="milvus", online_store_creator=MilvusOnlineStoreCreator + ), +] diff --git a/sdk/python/feast/infra/online_stores/online_store.py b/sdk/python/feast/infra/online_stores/online_store.py index 789885f82bc..5111bcd47bd 100644 --- a/sdk/python/feast/infra/online_stores/online_store.py +++ b/sdk/python/feast/infra/online_stores/online_store.py @@ -187,7 +187,7 @@ def get_online_features( for table, requested_features in grouped_refs: # Get the correct set of entity values with the correct join keys. - table_entity_values, idxs = utils._get_unique_entities( + table_entity_values, idxs, output_len = utils._get_unique_entities( table, join_key_values, entity_name_to_join_key_map, @@ -215,6 +215,7 @@ def get_online_features( full_feature_names, requested_features, table, + output_len, ) if requested_on_demand_feature_views: @@ -274,7 +275,7 @@ async def get_online_features_async( async def query_table(table, requested_features): # Get the correct set of entity values with the correct join keys. - table_entity_values, idxs = utils._get_unique_entities( + table_entity_values, idxs, output_len = utils._get_unique_entities( table, join_key_values, entity_name_to_join_key_map, @@ -290,7 +291,7 @@ async def query_table(table, requested_features): requested_features=requested_features, ) - return idxs, read_rows + return idxs, read_rows, output_len all_responses = await asyncio.gather( *[ @@ -299,7 +300,7 @@ async def query_table(table, requested_features): ] ) - for (idxs, read_rows), (table, requested_features) in zip( + for (idxs, read_rows, output_len), (table, requested_features) in zip( all_responses, grouped_refs ): feature_data = utils._convert_rows_to_protobuf( @@ -314,6 +315,7 @@ async def query_table(table, requested_features): full_feature_names, requested_features, table, + output_len, ) if requested_on_demand_feature_views: @@ -390,7 +392,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], embedding: List[float], top_k: int, distance_metric: Optional[str] = None, @@ -411,6 +414,7 @@ def retrieve_online_documents( config: The config for the current feature store. table: The feature view whose feature values should be read. requested_feature: The name of the feature whose embeddings should be used for retrieval. + requested_features: The list of features whose embeddings should be used for retrieval. embedding: The embeddings to use for retrieval. top_k: The number of documents to retrieve. @@ -419,6 +423,50 @@ def retrieve_online_documents( where the first item is the event timestamp for the row, and the second item is a dict of feature name to embeddings. """ + if not requested_feature and not requested_features: + raise ValueError( + "Either requested_feature or requested_features must be specified" + ) + raise NotImplementedError( + f"Online store {self.__class__.__name__} does not support online retrieval" + ) + + def retrieve_online_documents_v2( + self, + config: RepoConfig, + table: FeatureView, + requested_features: List[str], + embedding: Optional[List[float]], + top_k: int, + distance_metric: Optional[str] = None, + query_string: Optional[str] = None, + ) -> List[ + Tuple[ + Optional[datetime], + Optional[EntityKeyProto], + Optional[Dict[str, ValueProto]], + ] + ]: + """ + Retrieves online feature values for the specified embeddings. + + Args: + distance_metric: distance metric to use for retrieval. + config: The config for the current feature store. + table: The feature view whose feature values should be read. + requested_features: The list of features whose embeddings should be used for retrieval. + embedding: The embeddings to use for retrieval (optional) + top_k: The number of documents to retrieve. + query_string: The query string to search for using keyword search (bm25) (optional) + + Returns: + object: A list of top k closest documents to the specified embedding. Each item in the list is a tuple + where the first item is the event timestamp for the row, and the second item is a dict of feature + name to embeddings. + """ + assert embedding is not None or query_string is not None, ( + "Either embedding or query_string must be specified" + ) raise NotImplementedError( f"Online store {self.__class__.__name__} does not support online retrieval" ) diff --git a/sdk/python/feast/infra/online_stores/postgres_online_store/postgres.py b/sdk/python/feast/infra/online_stores/postgres_online_store/postgres.py index 7c099c80ecc..4f519003d61 100644 --- a/sdk/python/feast/infra/online_stores/postgres_online_store/postgres.py +++ b/sdk/python/feast/infra/online_stores/postgres_online_store/postgres.py @@ -58,7 +58,9 @@ class PostgreSQLOnlineStore(OnlineStore): _conn_pool_async: Optional[AsyncConnectionPool] = None @contextlib.contextmanager - def _get_conn(self, config: RepoConfig) -> Generator[Connection, Any, Any]: + def _get_conn( + self, config: RepoConfig, autocommit: bool = False + ) -> Generator[Connection, Any, Any]: assert config.online_store.type == "postgres" if config.online_store.conn_type == ConnectionType.pool: @@ -66,16 +68,18 @@ def _get_conn(self, config: RepoConfig) -> Generator[Connection, Any, Any]: self._conn_pool = _get_connection_pool(config.online_store) self._conn_pool.open() connection = self._conn_pool.getconn() + connection.set_autocommit(autocommit) yield connection self._conn_pool.putconn(connection) else: if not self._conn: self._conn = _get_conn(config.online_store) + self._conn.set_autocommit(autocommit) yield self._conn @contextlib.asynccontextmanager async def _get_conn_async( - self, config: RepoConfig + self, config: RepoConfig, autocommit: bool = False ) -> AsyncGenerator[AsyncConnection, Any]: if config.online_store.conn_type == ConnectionType.pool: if not self._conn_pool_async: @@ -84,11 +88,13 @@ async def _get_conn_async( ) await self._conn_pool_async.open() connection = await self._conn_pool_async.getconn() + await connection.set_autocommit(autocommit) yield connection await self._conn_pool_async.putconn(connection) else: if not self._conn_async: self._conn_async = await _get_conn_async(config.online_store) + await self._conn_async.set_autocommit(autocommit) yield self._conn_async def online_write_batch( @@ -161,7 +167,7 @@ def online_read( config, table, keys, requested_features ) - with self._get_conn(config) as conn, conn.cursor() as cur: + with self._get_conn(config, autocommit=True) as conn, conn.cursor() as cur: cur.execute(query, params) rows = cur.fetchall() @@ -179,7 +185,7 @@ async def online_read_async( config, table, keys, requested_features ) - async with self._get_conn_async(config) as conn: + async with self._get_conn_async(config, autocommit=True) as conn: async with conn.cursor() as cur: await cur.execute(query, params) rows = await cur.fetchall() @@ -339,6 +345,7 @@ def teardown( for table in tables: table_name = _table_id(project, table) cur.execute(_drop_table_and_index(table_name)) + conn.commit() except Exception: logging.exception("Teardown failed") raise @@ -347,7 +354,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], embedding: List[float], top_k: int, distance_metric: Optional[str] = "L2", @@ -366,6 +374,7 @@ def retrieve_online_documents( config: Feast configuration object table: FeatureView object as the table to search requested_feature: The requested feature as the column to search + requested_features: The list of features whose embeddings should be used for retrieval. embedding: The query embedding to search for top_k: The number of items to return distance_metric: The distance metric to use for the search.G @@ -396,7 +405,7 @@ def retrieve_online_documents( Optional[ValueProto], ] ] = [] - with self._get_conn(config) as conn, conn.cursor() as cur: + with self._get_conn(config, autocommit=True) as conn, conn.cursor() as cur: table_name = _table_id(project, table) # Search query template to find the top k items that are closest to the given embedding diff --git a/sdk/python/feast/infra/online_stores/qdrant_online_store/qdrant.py b/sdk/python/feast/infra/online_stores/qdrant_online_store/qdrant.py index 074c52ba5e8..81652c3e2a9 100644 --- a/sdk/python/feast/infra/online_stores/qdrant_online_store/qdrant.py +++ b/sdk/python/feast/infra/online_stores/qdrant_online_store/qdrant.py @@ -69,9 +69,9 @@ def _get_client(self, config: RepoConfig) -> QdrantClient: if self._client: return self._client online_store_config = config.online_store - assert isinstance( - online_store_config, QdrantOnlineStoreConfig - ), "Invalid type for online store config" + assert isinstance(online_store_config, QdrantOnlineStoreConfig), ( + "Invalid type for online store config" + ) assert online_store_config.similarity and ( online_store_config.similarity.lower() in DISTANCE_MAPPING @@ -248,7 +248,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], embedding: List[float], top_k: int, distance_metric: Optional[str] = "cosine", diff --git a/sdk/python/feast/infra/online_stores/singlestore_online_store/singlestore.py b/sdk/python/feast/infra/online_stores/singlestore_online_store/singlestore.py index d78289c8671..a1535589542 100644 --- a/sdk/python/feast/infra/online_stores/singlestore_online_store/singlestore.py +++ b/sdk/python/feast/infra/online_stores/singlestore_online_store/singlestore.py @@ -50,6 +50,7 @@ def _init_conn(self, config: RepoConfig) -> Connection: password=online_store_config.password or "test", database=online_store_config.database or "feast", port=online_store_config.port or 3306, + conn_attrs={"_connector_name": "SingleStore Feast Online Store"}, autocommit=True, ) diff --git a/sdk/python/feast/infra/online_stores/sqlite.py b/sdk/python/feast/infra/online_stores/sqlite.py index 1b79b1a94ba..15ef81188b0 100644 --- a/sdk/python/feast/infra/online_stores/sqlite.py +++ b/sdk/python/feast/infra/online_stores/sqlite.py @@ -15,19 +15,22 @@ import logging import os import sqlite3 -import struct import sys -from datetime import datetime +from datetime import date, datetime from pathlib import Path from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple, Union -from google.protobuf.internal.containers import RepeatedScalarFieldContainer from pydantic import StrictStr from feast import Entity from feast.feature_view import FeatureView +from feast.field import Field from feast.infra.infra_object import SQLITE_INFRA_OBJECT_CLASS_TYPE, InfraObject -from feast.infra.key_encoding_utils import serialize_entity_key +from feast.infra.key_encoding_utils import ( + deserialize_entity_key, + serialize_entity_key, + serialize_f32, +) from feast.infra.online_stores.online_store import OnlineStore from feast.infra.online_stores.vector_store import VectorStoreConfig from feast.protos.feast.core.InfraObject_pb2 import InfraObject as InfraObjectProto @@ -36,7 +39,53 @@ from feast.protos.feast.types.EntityKey_pb2 import EntityKey as EntityKeyProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto from feast.repo_config import FeastConfigBaseModel, RepoConfig -from feast.utils import _build_retrieve_online_document_record, to_naive_utc +from feast.type_map import feast_value_type_to_python_type +from feast.types import FEAST_VECTOR_TYPES, PrimitiveFeastType +from feast.utils import ( + _build_retrieve_online_document_record, + _serialize_vector_to_float_list, + to_naive_utc, +) + + +def adapt_date_iso(val: date): + """Adapt datetime.date to ISO 8601 date.""" + return val.isoformat() + + +def adapt_datetime_iso(val: datetime): + """Adapt datetime.datetime to timezone-naive ISO 8601 date.""" + return val.isoformat() + + +def adapt_datetime_epoch(val: datetime): + """Adapt datetime.datetime to Unix timestamp.""" + return int(val.timestamp()) + + +sqlite3.register_adapter(date, adapt_date_iso) +sqlite3.register_adapter(datetime, adapt_datetime_iso) +sqlite3.register_adapter(datetime, adapt_datetime_epoch) + + +def convert_date(val: bytes): + """Convert ISO 8601 date to datetime.date object.""" + return date.fromisoformat(val.decode()) + + +def convert_datetime(val: bytes): + """Convert ISO 8601 datetime to datetime.datetime object.""" + return datetime.fromisoformat(val.decode()) + + +def convert_timestamp(val: bytes): + """Convert Unix epoch timestamp to datetime.datetime object.""" + return datetime.fromtimestamp(int(val)) + + +sqlite3.register_converter("date", convert_date) +sqlite3.register_converter("datetime", convert_datetime) +sqlite3.register_converter("timestamp", convert_timestamp) class SqliteOnlineStoreConfig(FeastConfigBaseModel, VectorStoreConfig): @@ -50,6 +99,10 @@ class SqliteOnlineStoreConfig(FeastConfigBaseModel, VectorStoreConfig): path: StrictStr = "data/online.db" """ (optional) Path to sqlite db """ + vector_enabled: bool = False + vector_len: Optional[int] = None + text_search_enabled: bool = False + class SqliteOnlineStore(OnlineStore): """ @@ -75,14 +128,12 @@ def _get_db_path(config: RepoConfig) -> str: return db_path def _get_conn(self, config: RepoConfig): + enable_sqlite_vec = ( + sys.version_info[0:2] == (3, 10) and config.online_store.vector_enabled + ) if not self._conn: db_path = self._get_db_path(config) - self._conn = _initialize_conn(db_path) - if sys.version_info[0:2] == (3, 10) and config.online_store.vector_enabled: - import sqlite_vec # noqa: F401 - - self._conn.enable_load_extension(True) # type: ignore - sqlite_vec.load(self._conn) + self._conn = _initialize_conn(db_path, enable_sqlite_vec) return self._conn @@ -101,9 +152,8 @@ def online_write_batch( progress: Optional[Callable[[int], Any]], ) -> None: conn = self._get_conn(config) - project = config.project - + feature_type_dict = {f.name: f.dtype for f in table.features} with conn: for entity_key, values, timestamp, created_ts in data: entity_key_bin = serialize_entity_key( @@ -117,71 +167,53 @@ def online_write_batch( table_name = _table_id(project, table) for feature_name, val in values.items(): if config.online_store.vector_enabled: - vector_bin = serialize_f32( - val.float_list_val.val, config.online_store.vector_len - ) # type: ignore + if ( + feature_type_dict.get(feature_name, None) + in FEAST_VECTOR_TYPES + ): + val_bin = serialize_f32( + val.float_list_val.val, config.online_store.vector_len + ) # type: ignore + else: + val_bin = feast_value_type_to_python_type(val) conn.execute( f""" - UPDATE {table_name} - SET value = ?, vector_value = ?, event_ts = ?, created_ts = ? - WHERE (entity_key = ? AND feature_name = ?) - """, - ( - # SET - val.SerializeToString(), - vector_bin, - timestamp, - created_ts, - # WHERE - entity_key_bin, - feature_name, - ), - ) - - conn.execute( - f"""INSERT OR IGNORE INTO {table_name} - (entity_key, feature_name, value, vector_value, event_ts, created_ts) - VALUES (?, ?, ?, ?, ?, ?)""", + INSERT INTO {table_name} (entity_key, feature_name, value, vector_value, event_ts, created_ts) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(entity_key, feature_name) DO UPDATE SET + value = excluded.value, + vector_value = excluded.vector_value, + event_ts = excluded.event_ts, + created_ts = excluded.created_ts; + """, ( - entity_key_bin, - feature_name, - val.SerializeToString(), - vector_bin, - timestamp, - created_ts, + entity_key_bin, # entity_key + feature_name, # feature_name + val.SerializeToString(), # value + val_bin, # vector_value + timestamp, # event_ts + created_ts, # created_ts ), ) - else: conn.execute( f""" - UPDATE {table_name} - SET value = ?, event_ts = ?, created_ts = ? - WHERE (entity_key = ? AND feature_name = ?) + INSERT INTO {table_name} (entity_key, feature_name, value, event_ts, created_ts) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(entity_key, feature_name) DO UPDATE SET + value = excluded.value, + event_ts = excluded.event_ts, + created_ts = excluded.created_ts; """, ( - # SET - val.SerializeToString(), - timestamp, - created_ts, - # WHERE - entity_key_bin, - feature_name, + entity_key_bin, # entity_key + feature_name, # feature_name + val.SerializeToString(), # value + timestamp, # event_ts + created_ts, # created_ts ), ) - conn.execute( - f"""INSERT OR IGNORE INTO {table_name} - (entity_key, feature_name, value, event_ts, created_ts) - VALUES (?, ?, ?, ?, ?)""", - ( - entity_key_bin, - feature_name, - val.SerializeToString(), - timestamp, - created_ts, - ), - ) if progress: progress(1) @@ -197,22 +229,22 @@ def online_read( result: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]] = [] + serialized_entity_keys = [ + serialize_entity_key( + entity_key, + entity_key_serialization_version=config.entity_key_serialization_version, + ) + for entity_key in entity_keys + ] # Fetch all entities in one go cur.execute( f"SELECT entity_key, feature_name, value, event_ts " f"FROM {_table_id(config.project, table)} " f"WHERE entity_key IN ({','.join('?' * len(entity_keys))}) " f"ORDER BY entity_key", - [ - serialize_entity_key( - entity_key, - entity_key_serialization_version=config.entity_key_serialization_version, - ) - for entity_key in entity_keys - ], + serialized_entity_keys, ) rows = cur.fetchall() - rows = { k: list(group) for k, group in itertools.groupby(rows, key=lambda r: r[0]) } @@ -290,7 +322,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_featuers: Optional[List[str]], embedding: List[float], top_k: int, distance_metric: Optional[str] = None, @@ -325,10 +358,11 @@ def retrieve_online_documents( # Convert the embedding to a binary format instead of using SerializeToString() query_embedding_bin = serialize_f32(embedding, config.online_store.vector_len) table_name = _table_id(project, table) + vector_field = _get_vector_field(table) cur.execute( f""" - CREATE VIRTUAL TABLE vec_example using vec0( + CREATE VIRTUAL TABLE vec_table using vec0( vector_value float[{config.online_store.vector_len}] ); """ @@ -337,16 +371,17 @@ def retrieve_online_documents( # Currently I can only insert the embedding value without crashing SQLite, will report a bug cur.execute( f""" - INSERT INTO vec_example(rowid, vector_value) + INSERT INTO vec_table(rowid, vector_value) select rowid, vector_value from {table_name} + where feature_name = "{vector_field}" """ ) cur.execute( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS vec_table using vec0( + vector_value float[{config.online_store.vector_len}] + ); """ - INSERT INTO vec_example(rowid, vector_value) - VALUES (?, ?) - """, - (0, query_embedding_bin), ) # Have to join this with the {table_name} to get the feature name and entity_key @@ -364,7 +399,7 @@ def retrieve_online_documents( rowid, vector_value, distance - from vec_example + from vec_table where vector_value match ? order by distance limit ? @@ -392,6 +427,7 @@ def retrieve_online_documents( _build_retrieve_online_document_record( entity_key, string_value if string_value else b"", + # This may be a bug embedding, distance, event_ts, @@ -401,35 +437,251 @@ def retrieve_online_documents( return result + def retrieve_online_documents_v2( + self, + config: RepoConfig, + table: FeatureView, + requested_features: List[str], + query: Optional[List[float]], + top_k: int, + distance_metric: Optional[str] = None, + query_string: Optional[str] = None, + ) -> List[ + Tuple[ + Optional[datetime], + Optional[EntityKeyProto], + Optional[Dict[str, ValueProto]], + ] + ]: + """ + Retrieve documents using vector similarity search. + Args: + config: Feast configuration object + table: FeatureView object as the table to search + requested_features: List of requested features to retrieve + query: Query embedding to search for (optional) + top_k: Number of items to return + distance_metric: Distance metric to use (optional) + query_string: The query string to search for using keyword search (bm25) (optional) + Returns: + List of tuples containing the event timestamp, entity key, and feature values + """ + online_store = config.online_store + if not isinstance(online_store, SqliteOnlineStoreConfig): + raise ValueError("online_store must be SqliteOnlineStoreConfig") + if not online_store.vector_enabled and not online_store.text_search_enabled: + raise ValueError( + "You must enable either vector search or text search in the online store config" + ) -def _initialize_conn(db_path: str): - try: - import sqlite_vec # noqa: F401 - except ModuleNotFoundError: - logging.warning("Cannot use sqlite_vec for vector search") + conn = self._get_conn(config) + cur = conn.cursor() + + if online_store.vector_enabled and not online_store.vector_len: + raise ValueError("vector_len is not configured in the online store config") + + table_name = _table_id(config.project, table) + vector_field = _get_vector_field(table) + + if online_store.vector_enabled: + query_embedding_bin = serialize_f32(query, online_store.vector_len) # type: ignore + cur.execute( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS vec_table using vec0( + vector_value float[{online_store.vector_len}] + ); + """ + ) + cur.execute( + f""" + INSERT INTO vec_table (rowid, vector_value) + select rowid, vector_value from {table_name} + where feature_name = "{vector_field}" + """ + ) + elif online_store.text_search_enabled: + string_field_list = [ + f.name for f in table.features if f.dtype == PrimitiveFeastType.STRING + ] + string_fields = ", ".join(string_field_list) + # TODO: swap this for a value configurable in each Field() + BM25_DEFAULT_WEIGHTS = ", ".join( + [ + str(1.0) + for f in table.features + if f.dtype == PrimitiveFeastType.STRING + ] + ) + cur.execute( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS search_table using fts5( + entity_key, fv_rowid, {string_fields}, tokenize="porter unicode61" + ); + """ + ) + insert_query = _generate_bm25_search_insert_query( + table_name, string_field_list + ) + cur.execute(insert_query) + + else: + raise ValueError( + "Neither vector search nor text search are enabled in the online store config" + ) + + if online_store.vector_enabled: + cur.execute( + f""" + select + fv2.entity_key, + fv2.feature_name, + fv2.value, + fv.vector_value, + f.distance, + fv.event_ts, + fv.created_ts + from ( + select + rowid, + vector_value, + distance + from vec_table + where vector_value match ? + order by distance + limit ? + ) f + left join {table_name} fv + on f.rowid = fv.rowid + left join {table_name} fv2 + on fv.entity_key = fv2.entity_key + where fv2.feature_name != "{vector_field}" + """, + ( + query_embedding_bin, + top_k, + ), + ) + elif online_store.text_search_enabled: + cur.execute( + f""" + select + fv.entity_key, + fv.feature_name, + fv.value, + fv.vector_value, + f.distance, + fv.event_ts, + fv.created_ts + from {table_name} fv + inner join ( + select + fv_rowid, + entity_key, + {string_fields}, + bm25(search_table, {BM25_DEFAULT_WEIGHTS}) as distance + from search_table + where search_table match ? order by distance limit ? + ) f + on f.entity_key = fv.entity_key + """, + (query_string, top_k), + ) + + else: + raise ValueError( + "Neither vector search nor text search are enabled in the online store config" + ) + + rows = cur.fetchall() + results: List[ + Tuple[ + Optional[datetime], + Optional[EntityKeyProto], + Optional[Dict[str, ValueProto]], + ] + ] = [] + + entity_dict: Dict[ + str, Dict[str, Union[str, ValueProto, EntityKeyProto, datetime]] + ] = {} + for ( + entity_key, + feature_name, + value_bin, + vector_value, + distance, + event_ts, + created_ts, + ) in rows: + entity_key_proto = deserialize_entity_key( + entity_key, + entity_key_serialization_version=config.entity_key_serialization_version, + ) + if entity_key not in entity_dict: + entity_dict[entity_key] = {} + + feature_val = ValueProto() + feature_val.ParseFromString(value_bin) + entity_dict[entity_key]["entity_key_proto"] = entity_key_proto + entity_dict[entity_key][feature_name] = feature_val + if online_store.vector_enabled: + entity_dict[entity_key][vector_field] = _serialize_vector_to_float_list( + vector_value + ) + entity_dict[entity_key]["distance"] = ValueProto(float_val=distance) + entity_dict[entity_key]["event_ts"] = event_ts + entity_dict[entity_key]["created_ts"] = created_ts + + for entity_key_value in entity_dict: + res_event_ts: Optional[datetime] = None + res_entity_key_proto: Optional[EntityKeyProto] = None + if isinstance(entity_dict[entity_key_value]["event_ts"], datetime): + res_event_ts = entity_dict[entity_key_value]["event_ts"] # type: ignore[assignment] + + if isinstance( + entity_dict[entity_key_value]["entity_key_proto"], EntityKeyProto + ): + res_entity_key_proto = entity_dict[entity_key_value]["entity_key_proto"] # type: ignore[assignment] + + res_dict: Dict[str, ValueProto] = { + k: v + for k, v in entity_dict[entity_key_value].items() + if isinstance(v, ValueProto) and isinstance(k, str) + } + + results.append( + ( + res_event_ts, + res_entity_key_proto, + res_dict, + ) + ) + return results + + +def _initialize_conn( + db_path: str, enable_sqlite_vec: bool = False +) -> sqlite3.Connection: Path(db_path).parent.mkdir(exist_ok=True) - return sqlite3.connect( + db = sqlite3.connect( db_path, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES, check_same_thread=False, ) + if enable_sqlite_vec: + try: + import sqlite_vec # noqa: F401 + except ModuleNotFoundError: + logging.warning("Cannot use sqlite_vec for vector search") + db.enable_load_extension(True) + sqlite_vec.load(db) -def _table_id(project: str, table: FeatureView) -> str: - return f"{project}_{table.name}" - - -def serialize_f32( - vector: Union[RepeatedScalarFieldContainer[float], List[float]], vector_length: int -) -> bytes: - """serializes a list of floats into a compact "raw bytes" format""" - return struct.pack(f"{vector_length}f", *vector) + return db -def deserialize_f32(byte_vector: bytes, vector_length: int) -> List[float]: - """deserializes a list of floats from a compact "raw bytes" format""" - num_floats = vector_length // 4 # 4 bytes per float - return list(struct.unpack(f"{num_floats}f", byte_vector)) +def _table_id(project: str, table: FeatureView) -> str: + return f"{project}_{table.name}" class SqliteTable(InfraObject): @@ -487,7 +739,17 @@ def update(self): except ModuleNotFoundError: logging.warning("Cannot use sqlite_vec for vector search") self.conn.execute( - f"CREATE TABLE IF NOT EXISTS {self.name} (entity_key BLOB, feature_name TEXT, value BLOB, vector_value BLOB, event_ts timestamp, created_ts timestamp, PRIMARY KEY(entity_key, feature_name))" + f""" + CREATE TABLE IF NOT EXISTS {self.name} ( + entity_key BLOB, + feature_name TEXT, + value BLOB, + vector_value BLOB, + event_ts timestamp, + created_ts timestamp, + PRIMARY KEY(entity_key, feature_name) + ) + """ ) self.conn.execute( f"CREATE INDEX IF NOT EXISTS {self.name}_ek ON {self.name} (entity_key);" @@ -495,3 +757,48 @@ def update(self): def teardown(self): self.conn.execute(f"DROP TABLE IF EXISTS {self.name}") + + +def _get_vector_field(table: FeatureView) -> str: + """ + Get the vector field from the feature view. There can be only one. + """ + vector_fields: List[Field] = [ + f for f in table.features if getattr(f, "vector_index", None) + ] + assert len(vector_fields) > 0, ( + f"No vector field found, please update feature view = {table.name} to declare a vector field" + ) + assert len(vector_fields) < 2, ( + "Only one vector field is supported, please update feature view = {table.name} to declare one vector field" + ) + vector_field: str = vector_fields[0].name + return vector_field + + +def _generate_bm25_search_insert_query( + table_name: str, string_field_list: List[str] +) -> str: + """ + Generates an SQL insertion query for the given table and string fields. + + Args: + table_name (str): The name of the table to select data from. + string_field_list (List[str]): The list of string fields to be used in the insertion. + + Returns: + str: The generated SQL insertion query. + """ + _string_fields = ", ".join(string_field_list) + query = f"INSERT INTO search_table (entity_key, fv_rowid, {_string_fields})\nSELECT\n\tDISTINCT fv0.entity_key,\n\tfv0.rowid as fv_rowid" + from_query = f"\nFROM (select rowid, * from {table_name} where feature_name = '{string_field_list[0]}') fv0" + + for i, string_field in enumerate(string_field_list): + query += f"\n\t,fv{i}.value as {string_field}" + if i > 0: + from_query += ( + f"\nLEFT JOIN (select rowid, * from {table_name} where feature_name = '{string_field}') fv{i}" + + f"\n\tON fv0.entity_key = fv{i}.entity_key" + ) + + return query + from_query diff --git a/sdk/python/feast/infra/passthrough_provider.py b/sdk/python/feast/infra/passthrough_provider.py index 215b175eb2e..4e504997d2a 100644 --- a/sdk/python/feast/infra/passthrough_provider.py +++ b/sdk/python/feast/infra/passthrough_provider.py @@ -294,7 +294,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], query: List[float], top_k: int, distance_metric: Optional[str] = None, @@ -305,12 +306,36 @@ def retrieve_online_documents( config, table, requested_feature, + requested_features, query, top_k, distance_metric, ) return result + def retrieve_online_documents_v2( + self, + config: RepoConfig, + table: FeatureView, + requested_features: Optional[List[str]], + query: Optional[List[float]], + top_k: int, + distance_metric: Optional[str] = None, + query_string: Optional[str] = None, + ) -> List: + result = [] + if self.online_store: + result = self.online_store.retrieve_online_documents_v2( + config, + table, + requested_features, + query, + top_k, + distance_metric, + query_string, + ) + return result + @staticmethod def _prep_rows_to_write_for_ingestion( feature_view: Union[BaseFeatureView, FeatureView, OnDemandFeatureView], @@ -424,7 +449,7 @@ def materialize_single_feature_view( def get_historical_features( self, config: RepoConfig, - feature_views: List[FeatureView], + feature_views: List[Union[FeatureView, OnDemandFeatureView]], feature_refs: List[str], entity_df: Union[pd.DataFrame, str], registry: BaseRegistry, @@ -471,9 +496,9 @@ def write_feature_service_logs( config: RepoConfig, registry: BaseRegistry, ): - assert ( - feature_service.logging_config is not None - ), "Logging should be configured for the feature service before calling this function" + assert feature_service.logging_config is not None, ( + "Logging should be configured for the feature service before calling this function" + ) self.offline_store.write_logged_features( config=config, @@ -491,9 +516,9 @@ def retrieve_feature_service_logs( config: RepoConfig, registry: BaseRegistry, ) -> RetrievalJob: - assert ( - feature_service.logging_config is not None - ), "Logging should be configured for the feature service before calling this function" + assert feature_service.logging_config is not None, ( + "Logging should be configured for the feature service before calling this function" + ) logging_source = FeatureServiceLoggingSource(feature_service, config.project) schema = logging_source.get_schema(registry) diff --git a/sdk/python/feast/infra/provider.py b/sdk/python/feast/infra/provider.py index 8351f389ad9..18fbd051771 100644 --- a/sdk/python/feast/infra/provider.py +++ b/sdk/python/feast/infra/provider.py @@ -242,7 +242,7 @@ def materialize_single_feature_view( def get_historical_features( self, config: RepoConfig, - feature_views: List[FeatureView], + feature_views: List[Union[FeatureView, OnDemandFeatureView]], feature_refs: List[str], entity_df: Union[pd.DataFrame, str], registry: BaseRegistry, @@ -419,7 +419,8 @@ def retrieve_online_documents( self, config: RepoConfig, table: FeatureView, - requested_feature: str, + requested_feature: Optional[str], + requested_features: Optional[List[str]], query: List[float], top_k: int, distance_metric: Optional[str] = None, @@ -430,7 +431,7 @@ def retrieve_online_documents( Optional[ValueProto], Optional[ValueProto], Optional[ValueProto], - ] + ], ]: """ Searches for the top-k most similar documents in the online document store. @@ -440,6 +441,7 @@ def retrieve_online_documents( config: The config for the current feature store. table: The feature view whose embeddings should be searched. requested_feature: the requested document feature name. + requested_features: the requested document feature names. query: The query embedding to search for. top_k: The number of documents to return. @@ -448,6 +450,41 @@ def retrieve_online_documents( """ pass + @abstractmethod + def retrieve_online_documents_v2( + self, + config: RepoConfig, + table: FeatureView, + requested_features: List[str], + query: Optional[List[float]], + top_k: int, + distance_metric: Optional[str] = None, + query_string: Optional[str] = None, + ) -> List[ + Tuple[ + Optional[datetime], + Optional[EntityKeyProto], + Optional[Dict[str, ValueProto]], + ] + ]: + """ + Searches for the top-k most similar documents in the online document store. + + Args: + distance_metric: distance metric to use for the search. + config: The config for the current feature store. + table: The feature view whose embeddings should be searched. + requested_features: the requested document feature names. + query: The query embedding to search for (optional). + top_k: The number of documents to return. + query_string: The query string to search for using keyword search (bm25) (optional) + + Returns: + A list of dictionaries, where each dictionary contains the datetime, entitykey, and a dictionary + of feature key value pairs + """ + pass + @abstractmethod def validate_data_source( self, diff --git a/sdk/python/feast/infra/registry/caching_registry.py b/sdk/python/feast/infra/registry/caching_registry.py index 042eee06ab7..23ab80ee1d8 100644 --- a/sdk/python/feast/infra/registry/caching_registry.py +++ b/sdk/python/feast/infra/registry/caching_registry.py @@ -425,12 +425,24 @@ def list_projects( return self._list_projects(tags) def refresh(self, project: Optional[str] = None): - self.cached_registry_proto = self.proto() - self.cached_registry_proto_created = _utc_now() + if self._refresh_lock.locked(): + logger.info("Skipping refresh if already in progress") + return + try: + self.cached_registry_proto = self.proto() + self.cached_registry_proto_created = _utc_now() + except Exception as e: + logger.error(f"Error while refreshing registry: {e}", exc_info=True) def _refresh_cached_registry_if_necessary(self): if self.cache_mode == "sync": - with self._refresh_lock: + # Try acquiring the lock without blocking + if not self._refresh_lock.acquire(blocking=False): + logger.info( + "Skipping refresh if lock is already held by another thread" + ) + return + try: if self.cached_registry_proto == RegistryProto(): # Avoids the need to refresh the registry when cache is not populated yet # Specially during the __init__ phase @@ -454,6 +466,13 @@ def _refresh_cached_registry_if_necessary(self): if expired: logger.info("Registry cache expired, so refreshing") self.refresh() + except Exception as e: + logger.error( + f"Error in _refresh_cached_registry_if_necessary: {e}", + exc_info=True, + ) + finally: + self._refresh_lock.release() # Always release the lock safely def _start_thread_async_refresh(self, cache_ttl_seconds): self.refresh() diff --git a/sdk/python/feast/infra/registry/remote.py b/sdk/python/feast/infra/registry/remote.py index 6cc80d5dad1..590c0454b73 100644 --- a/sdk/python/feast/infra/registry/remote.py +++ b/sdk/python/feast/infra/registry/remote.py @@ -1,3 +1,4 @@ +import os from datetime import datetime from pathlib import Path from typing import List, Optional, Union @@ -59,6 +60,12 @@ class RemoteRegistryConfig(RegistryConfig): """ str: Path to the public certificate when the registry server starts in TLS(SSL) mode. This may be needed if the registry server started with a self-signed certificate, typically this file ends with `*.crt`, `*.cer`, or `*.pem`. If registry_type is 'remote', then this configuration is needed to connect to remote registry server in TLS mode. If the remote registry started in non-tls mode then this configuration is not needed.""" + is_tls: bool = False + """ bool: Set to `True` if you plan to connect to a registry server running in TLS (SSL) mode. + If you intend to add the public certificate to the trust store instead of passing it via the `cert` parameter, this field must be set to `True`. + If you are planning to add the public certificate as part of the trust store instead of passing it as a `cert` parameters then setting this field to `true` is mandatory. + """ + class RemoteRegistry(BaseRegistry): def __init__( @@ -70,20 +77,32 @@ def __init__( ): self.auth_config = auth_config assert isinstance(registry_config, RemoteRegistryConfig) - if registry_config.cert: - with open(registry_config.cert, "rb") as cert_file: - trusted_certs = cert_file.read() - tls_credentials = grpc.ssl_channel_credentials( - root_certificates=trusted_certs - ) - self.channel = grpc.secure_channel(registry_config.path, tls_credentials) - else: - self.channel = grpc.insecure_channel(registry_config.path) + self.channel = self._create_grpc_channel(registry_config) auth_header_interceptor = GrpcClientAuthHeaderInterceptor(auth_config) self.channel = grpc.intercept_channel(self.channel, auth_header_interceptor) self.stub = RegistryServer_pb2_grpc.RegistryServerStub(self.channel) + def _create_grpc_channel(self, registry_config): + assert isinstance(registry_config, RemoteRegistryConfig) + if registry_config.cert or registry_config.is_tls: + cafile = os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE") + if not cafile and not registry_config.cert: + raise EnvironmentError( + "SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable must be set to use secure TLS or set the cert parameter in feature_Store.yaml file under remote registry configuration." + ) + with open( + registry_config.cert if registry_config.cert else cafile, "rb" + ) as cert_file: + trusted_certs = cert_file.read() + tls_credentials = grpc.ssl_channel_credentials( + root_certificates=trusted_certs + ) + return grpc.secure_channel(registry_config.path, tls_credentials) + else: + # Create an insecure gRPC channel + return grpc.insecure_channel(registry_config.path) + def close(self): if self.channel: self.channel.close() diff --git a/sdk/python/feast/infra/utils/aws_utils.py b/sdk/python/feast/infra/utils/aws_utils.py index 0526cf8b65c..39fa815f7e3 100644 --- a/sdk/python/feast/infra/utils/aws_utils.py +++ b/sdk/python/feast/infra/utils/aws_utils.py @@ -1062,7 +1062,7 @@ def upload_arrow_table_to_athena( f"CREATE EXTERNAL TABLE {database}.{table_name} {'IF NOT EXISTS' if not fail_if_exists else ''}" f"({column_query_list}) " f"STORED AS PARQUET " - f"LOCATION '{s3_path[:s3_path.rfind('/')]}' " + f"LOCATION '{s3_path[: s3_path.rfind('/')]}' " f"TBLPROPERTIES('parquet.compress' = 'SNAPPY') " ) diff --git a/sdk/python/feast/infra/utils/couchbase/__init__.py b/sdk/python/feast/infra/utils/couchbase/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/utils/couchbase/couchbase_utils.py b/sdk/python/feast/infra/utils/couchbase/couchbase_utils.py new file mode 100644 index 00000000000..005729274e6 --- /dev/null +++ b/sdk/python/feast/infra/utils/couchbase/couchbase_utils.py @@ -0,0 +1,13 @@ +from datetime import datetime, timezone + + +def normalize_timestamp( + dt: datetime, target_format: str = "%Y-%m-%dT%H:%M:%S%z" +) -> str: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) # Assume UTC for naive datetimes + # Convert to UTC + utc_dt = dt.astimezone(timezone.utc) + # Format with strftime + formatted = utc_dt.strftime(target_format) + return formatted diff --git a/sdk/python/feast/infra/utils/snowflake/snowflake_utils.py b/sdk/python/feast/infra/utils/snowflake/snowflake_utils.py index b9035b40dbf..b9254e72699 100644 --- a/sdk/python/feast/infra/utils/snowflake/snowflake_utils.py +++ b/sdk/python/feast/infra/utils/snowflake/snowflake_utils.py @@ -513,7 +513,7 @@ def chunk_helper(lst: pd.DataFrame, n: int) -> Iterator[Tuple[int, pd.DataFrame] def parse_private_key_path( - private_key_passphrase: str, + private_key_passphrase: Optional[str] = None, key_path: Optional[str] = None, private_key_content: Optional[bytes] = None, ) -> bytes: @@ -521,14 +521,18 @@ def parse_private_key_path( if private_key_content: p_key = serialization.load_pem_private_key( private_key_content, - password=private_key_passphrase.encode(), + password=private_key_passphrase.encode() + if private_key_passphrase is not None + else None, backend=default_backend(), ) elif key_path: with open(key_path, "rb") as key: p_key = serialization.load_pem_private_key( key.read(), - password=private_key_passphrase.encode(), + password=private_key_passphrase.encode() + if private_key_passphrase is not None + else None, backend=default_backend(), ) else: diff --git a/sdk/python/feast/nlp_test_data.py b/sdk/python/feast/nlp_test_data.py new file mode 100644 index 00000000000..5c0a6af4d61 --- /dev/null +++ b/sdk/python/feast/nlp_test_data.py @@ -0,0 +1,67 @@ +from datetime import datetime +from typing import Dict + +import numpy as np +import pandas as pd + + +def create_document_chunks_df( + documents: Dict[str, str], + start_date: datetime, + end_date: datetime, + embedding_size: int = 60, +) -> pd.DataFrame: + """ + Example df generated by this function: + + | event_timestamp | document_id | chunk_id | chunk_text | embedding | created | + |------------------+-------------+----------+------------------+-----------+------------------| + | 2021-03-17 19:31 | doc_1 | chunk-1 | Hello world | [0.1, ...]| 2021-03-24 19:34 | + | 2021-03-17 19:31 | doc_1 | chunk-2 | How are you? | [0.2, ...]| 2021-03-24 19:34 | + | 2021-03-17 19:31 | doc_2 | chunk-1 | This is a test | [0.3, ...]| 2021-03-24 19:34 | + | 2021-03-17 19:31 | doc_2 | chunk-2 | Document chunk | [0.4, ...]| 2021-03-24 19:34 | + """ + df_hourly = pd.DataFrame( + { + "event_timestamp": [ + pd.Timestamp(dt, unit="ms").round("ms") + for dt in pd.date_range( + start=start_date, + end=end_date, + freq="1h", + inclusive="left", + tz="UTC", + ) + ] + + [ + pd.Timestamp( + year=2021, month=4, day=12, hour=7, minute=0, second=0, tz="UTC" + ) + ] + } + ) + df_all_chunks = pd.DataFrame() + + for doc_id, doc_text in documents.items(): + chunks = doc_text.split(". ") # Simple chunking by sentence + for chunk_id, chunk_text in enumerate(chunks, start=1): + df_hourly_copy = df_hourly.copy() + df_hourly_copy["document_id"] = doc_id + df_hourly_copy["chunk_id"] = f"chunk-{chunk_id}" + df_hourly_copy["chunk_text"] = chunk_text + df_all_chunks = pd.concat([df_hourly_copy, df_all_chunks]) + + df_all_chunks.reset_index(drop=True, inplace=True) + rows = df_all_chunks["event_timestamp"].count() + + # Generate random embeddings for each chunk + df_all_chunks["embedding"] = [ + np.random.rand(embedding_size).tolist() for _ in range(rows) + ] + df_all_chunks["created"] = pd.to_datetime(pd.Timestamp.now(tz=None).round("ms")) + + # Create duplicate rows that should be filtered by created timestamp + late_row = df_all_chunks[rows // 2 : rows // 2 + 1] + df_all_chunks = pd.concat([df_all_chunks, late_row, late_row], ignore_index=True) + + return df_all_chunks diff --git a/sdk/python/feast/offline_server.py b/sdk/python/feast/offline_server.py index cec043129e7..f3642e5812e 100644 --- a/sdk/python/feast/offline_server.py +++ b/sdk/python/feast/offline_server.py @@ -39,12 +39,21 @@ class OfflineServer(fl.FlightServerBase): - def __init__(self, store: FeatureStore, location: str, **kwargs): + def __init__( + self, + store: FeatureStore, + location: str, + host: str = "localhost", + tls_certificates: List = [], + **kwargs, + ): super(OfflineServer, self).__init__( - location, + location=location, middleware=self.arrow_flight_auth_middleware( str_to_auth_manager_type(store.config.auth_config.type) ), + tls_certificates=tls_certificates, + verify_client=False, # this is needed for when we don't need mTLS **kwargs, ) self._location = location @@ -52,6 +61,8 @@ def __init__(self, store: FeatureStore, location: str, **kwargs): self.flights: Dict[str, Any] = {} self.store = store self.offline_store = get_offline_store_from_config(store.config.offline_store) + self.host = host + self.tls_certificates = tls_certificates def arrow_flight_auth_middleware( self, @@ -81,8 +92,13 @@ def descriptor_to_key(self, descriptor: fl.FlightDescriptor): ) def _make_flight_info(self, key: Any, descriptor: fl.FlightDescriptor): - endpoints = [fl.FlightEndpoint(repr(key), [self._location])] - # TODO calculate actual schema from the given features + if len(self.tls_certificates) != 0: + location = fl.Location.for_grpc_tls(self.host, self.port) + else: + location = fl.Location.for_grpc_tcp(self.host, self.port) + endpoints = [ + fl.FlightEndpoint(repr(key), [location]), + ] schema = pa.schema([]) return fl.FlightInfo(schema, descriptor, endpoints, -1, -1) @@ -250,15 +266,15 @@ def do_get(self, context: fl.ServerCallContext, ticket: fl.Ticket): return fl.RecordBatchStream(table) def _validate_offline_write_batch_parameters(self, command: dict): - assert ( - "feature_view_names" in command - ), "feature_view_names is a mandatory parameter" + assert "feature_view_names" in command, ( + "feature_view_names is a mandatory parameter" + ) assert "name_aliases" in command, "name_aliases is a mandatory parameter" feature_view_names = command["feature_view_names"] - assert ( - len(feature_view_names) == 1 - ), "feature_view_names list should only have one item" + assert len(feature_view_names) == 1, ( + "feature_view_names list should only have one item" + ) name_aliases = command["name_aliases"] assert len(name_aliases) == 1, "name_aliases list should only have one item" @@ -300,9 +316,9 @@ def write_logged_features(self, command: dict, key: str): command["feature_service_name"] ) - assert ( - feature_service.logging_config is not None - ), "feature service must have logging_config set" + assert feature_service.logging_config is not None, ( + "feature service must have logging_config set" + ) assert_permissions( resource=feature_service, @@ -319,15 +335,15 @@ def write_logged_features(self, command: dict, key: str): ) def _validate_pull_all_from_table_or_query_parameters(self, command: dict): - assert ( - "data_source_name" in command - ), "data_source_name is a mandatory parameter" - assert ( - "join_key_columns" in command - ), "join_key_columns is a mandatory parameter" - assert ( - "feature_name_columns" in command - ), "feature_name_columns is a mandatory parameter" + assert "data_source_name" in command, ( + "data_source_name is a mandatory parameter" + ) + assert "join_key_columns" in command, ( + "join_key_columns is a mandatory parameter" + ) + assert "feature_name_columns" in command, ( + "feature_name_columns is a mandatory parameter" + ) assert "timestamp_field" in command, "timestamp_field is a mandatory parameter" assert "start_date" in command, "start_date is a mandatory parameter" assert "end_date" in command, "end_date is a mandatory parameter" @@ -348,15 +364,15 @@ def pull_all_from_table_or_query(self, command: dict): ) def _validate_pull_latest_from_table_or_query_parameters(self, command: dict): - assert ( - "data_source_name" in command - ), "data_source_name is a mandatory parameter" - assert ( - "join_key_columns" in command - ), "join_key_columns is a mandatory parameter" - assert ( - "feature_name_columns" in command - ), "feature_name_columns is a mandatory parameter" + assert "data_source_name" in command, ( + "data_source_name is a mandatory parameter" + ) + assert "join_key_columns" in command, ( + "join_key_columns is a mandatory parameter" + ) + assert "feature_name_columns" in command, ( + "feature_name_columns is a mandatory parameter" + ) assert "timestamp_field" in command, "timestamp_field is a mandatory parameter" assert "start_date" in command, "start_date is a mandatory parameter" assert "end_date" in command, "end_date is a mandatory parameter" @@ -549,11 +565,31 @@ def start_server( store: FeatureStore, host: str, port: int, + tls_key_path: str = "", + tls_cert_path: str = "", ): _init_auth_manager(store) - location = "grpc+tcp://{}:{}".format(host, port) - server = OfflineServer(store, location) + tls_certificates = [] + scheme = "grpc+tcp" + if tls_key_path and tls_cert_path: + logger.info( + "Found SSL certificates in the args so going to start offline server in TLS(SSL) mode." + ) + scheme = "grpc+tls" + with open(tls_cert_path, "rb") as cert_file: + tls_cert_chain = cert_file.read() + with open(tls_key_path, "rb") as key_file: + tls_private_key = key_file.read() + tls_certificates.append((tls_cert_chain, tls_private_key)) + + location = "{}://{}:{}".format(scheme, host, port) + server = OfflineServer( + store, + location=location, + host=host, + tls_certificates=tls_certificates, + ) try: logger.info(f"Offline store server serving at: {location}") server.serve() diff --git a/sdk/python/feast/on_demand_feature_view.py b/sdk/python/feast/on_demand_feature_view.py index 0ae87b5e35a..6397c9fb640 100644 --- a/sdk/python/feast/on_demand_feature_view.py +++ b/sdk/python/feast/on_demand_feature_view.py @@ -1,12 +1,10 @@ import copy import functools -import inspect import warnings from types import FunctionType -from typing import Any, List, Optional, Union, get_type_hints +from typing import Any, List, Optional, Union, cast import dill -import pandas as pd import pyarrow from typeguard import typechecked @@ -31,6 +29,8 @@ from feast.protos.feast.core.Transformation_pb2 import ( UserDefinedFunctionV2 as UserDefinedFunctionProto, ) +from feast.transformation.base import Transformation +from feast.transformation.mode import TransformationMode from feast.transformation.pandas_transformation import PandasTransformation from feast.transformation.python_transformation import PythonTransformation from feast.transformation.substrait_transformation import SubstraitTransformation @@ -66,15 +66,15 @@ class OnDemandFeatureView(BaseFeatureView): features: List[Field] source_feature_view_projections: dict[str, FeatureViewProjection] source_request_sources: dict[str, RequestSource] - feature_transformation: Union[ - PandasTransformation, PythonTransformation, SubstraitTransformation - ] + feature_transformation: Transformation mode: str description: str tags: dict[str, str] owner: str write_to_online_store: bool singleton: bool + udf: Optional[FunctionType] + udf_string: Optional[str] def __init__( # noqa: C901 self, @@ -90,10 +90,8 @@ def __init__( # noqa: C901 ] ], udf: Optional[FunctionType] = None, - udf_string: str = "", - feature_transformation: Union[ - PandasTransformation, PythonTransformation, SubstraitTransformation - ], + udf_string: Optional[str] = "", + feature_transformation: Optional[Transformation] = None, mode: str = "pandas", description: str = "", tags: Optional[dict[str, str]] = None, @@ -112,9 +110,9 @@ def __init__( # noqa: C901 sources: A map from input source names to the actual input sources, which may be feature views, or request data sources. These sources serve as inputs to the udf, which will refer to them by name. - udf (deprecated): The user defined transformation function, which must take pandas + udf: The user defined transformation function, which must take pandas dataframes as inputs. - udf_string (deprecated): The source code version of the udf (for diffing and displaying in Web UI) + udf_string: The source code version of the udf (for diffing and displaying in Web UI) feature_transformation: The user defined transformation. mode: Mode of execution (e.g., Pandas or Python native) description (optional): A human-readable description. @@ -136,29 +134,10 @@ def __init__( # noqa: C901 schema = schema or [] self.entities = [e.name for e in entities] if entities else [DUMMY_ENTITY_NAME] + self.sources = sources self.mode = mode.lower() - - if self.mode not in {"python", "pandas", "substrait"}: - raise ValueError( - f"Unknown mode {self.mode}. OnDemandFeatureView only supports python or pandas UDFs and substrait." - ) - - if not feature_transformation: - if udf: - warnings.warn( - "udf and udf_string parameters are deprecated. Please use transformation=PandasTransformation(udf, udf_string) instead.", - DeprecationWarning, - ) - # Note inspecting the return signature won't work with isinstance so this is the best alternative - if self.mode == "pandas": - feature_transformation = PandasTransformation(udf, udf_string) - elif self.mode == "python": - feature_transformation = PythonTransformation(udf, udf_string) - else: - raise ValueError( - "OnDemandFeatureView needs to be initialized with either feature_transformation or udf arguments" - ) - + self.udf = udf + self.udf_string = udf_string self.source_feature_view_projections: dict[str, FeatureViewProjection] = {} self.source_request_sources: dict[str, RequestSource] = {} for odfv_source in sources: @@ -206,12 +185,33 @@ def __init__( # noqa: C901 features.append(field) self.features = features - self.feature_transformation = feature_transformation + self.feature_transformation = ( + feature_transformation or self.get_feature_transformation() + ) self.write_to_online_store = write_to_online_store self.singleton = singleton if self.singleton and self.mode != "python": raise ValueError("Singleton is only supported for Python mode.") + def get_feature_transformation(self) -> Transformation: + if not self.udf: + raise ValueError( + "Either udf or feature_transformation must be provided to create an OnDemandFeatureView" + ) + if self.mode in ( + TransformationMode.PANDAS, + TransformationMode.PYTHON, + ) or self.mode in ("pandas", "python"): + return Transformation( + mode=self.mode, udf=self.udf, udf_string=self.udf_string or "" + ) + elif self.mode == TransformationMode.SUBSTRAIT or self.mode == "substrait": + return SubstraitTransformation.from_ibis(self.udf, self.sources) + else: + raise ValueError( + f"Unsupported transformation mode: {self.mode} for OnDemandFeatureView" + ) + @property def proto_class(self) -> type[OnDemandFeatureViewProto]: return OnDemandFeatureViewProto @@ -312,16 +312,25 @@ def to_proto(self) -> OnDemandFeatureViewProto: request_data_source=request_sources.to_proto() ) - feature_transformation = FeatureTransformationProto( - user_defined_function=self.feature_transformation.to_proto() + user_defined_function_proto = cast( + UserDefinedFunctionProto, + self.feature_transformation.to_proto() if isinstance( self.feature_transformation, (PandasTransformation, PythonTransformation), ) else None, - substrait_transformation=self.feature_transformation.to_proto() + ) + + substrait_transformation_proto = ( + self.feature_transformation.to_proto() if isinstance(self.feature_transformation, SubstraitTransformation) - else None, + else None + ) + + feature_transformation = FeatureTransformationProto( + user_defined_function=user_defined_function_proto, + substrait_transformation=substrait_transformation_proto, ) spec = OnDemandFeatureViewSpec( name=self.name, @@ -339,7 +348,6 @@ def to_proto(self) -> OnDemandFeatureViewProto: write_to_online_store=self.write_to_online_store, singleton=self.singleton if self.singleton else False, ) - return OnDemandFeatureViewProto(spec=spec, meta=meta) @classmethod @@ -454,6 +462,8 @@ def from_proto( Field( name=feature.name, dtype=from_value_type(ValueType(feature.value_type)), + vector_index=feature.vector_index, + vector_search_metric=feature.vector_search_metric, ) for feature in on_demand_feature_view_proto.spec.features ], @@ -640,13 +650,25 @@ def transform_dict( def infer_features(self) -> None: random_input = self._construct_random_input(singleton=self.singleton) - inferred_features = self.feature_transformation.infer_features(random_input) + inferred_features = self.feature_transformation.infer_features( + random_input=random_input, singleton=self.singleton + ) if self.features: missing_features = [] for specified_feature in self.features: - if specified_feature not in inferred_features: + if ( + specified_feature not in inferred_features + and "Array" not in specified_feature.dtype.__str__() + ): missing_features.append(specified_feature) + elif "Array" in specified_feature.dtype.__str__(): + if specified_feature.name not in [ + f.name for f in inferred_features + ]: + missing_features.append(specified_feature) + else: + pass if missing_features: raise SpecifiedFeaturesNotPresentError( missing_features, inferred_features, self.name @@ -722,6 +744,7 @@ def get_requested_odfvs( def on_demand_feature_view( *, + name: Optional[str] = None, entities: Optional[List[Entity]] = None, schema: list[Field], sources: list[ @@ -737,11 +760,13 @@ def on_demand_feature_view( owner: str = "", write_to_online_store: bool = False, singleton: bool = False, + explode: bool = False, ): """ Creates an OnDemandFeatureView object with the given user function as udf. Args: + name (optional): The name of the on demand feature view. If not provided, the name will be the name of the user function. entities (Optional): The list of names of entities that this feature view is associated with. schema: The list of features in the output of the on demand feature view, after the transformation has been applied. @@ -757,6 +782,7 @@ def on_demand_feature_view( the online store for faster retrieval. singleton (optional): A boolean that indicates whether the transformation is executed on a singleton (only applicable when mode="python"). + explode (optional): A boolean that indicates whether the transformation explodes the input data into multiple rows. """ def mainify(obj) -> None: @@ -766,35 +792,13 @@ def mainify(obj) -> None: obj.__module__ = "__main__" def decorator(user_function): - return_annotation = get_type_hints(user_function).get("return", inspect._empty) udf_string = dill.source.getsource(user_function) mainify(user_function) - if mode == "pandas": - if return_annotation not in (inspect._empty, pd.DataFrame): - raise TypeError( - f"return signature for {user_function} is {return_annotation} but should be pd.DataFrame" - ) - transformation = PandasTransformation(user_function, udf_string) - elif mode == "python": - if return_annotation not in (inspect._empty, dict[str, Any]): - raise TypeError( - f"return signature for {user_function} is {return_annotation} but should be dict[str, Any]" - ) - transformation = PythonTransformation(user_function, udf_string) - elif mode == "substrait": - from ibis.expr.types.relations import Table - - if return_annotation not in (inspect._empty, Table): - raise TypeError( - f"return signature for {user_function} is {return_annotation} but should be ibis.expr.types.relations.Table" - ) - transformation = SubstraitTransformation.from_ibis(user_function, sources) on_demand_feature_view_obj = OnDemandFeatureView( - name=user_function.__name__, + name=name if name is not None else user_function.__name__, sources=sources, schema=schema, - feature_transformation=transformation, mode=mode, description=description, tags=tags, @@ -802,6 +806,8 @@ def decorator(user_function): write_to_online_store=write_to_online_store, entities=entities, singleton=singleton, + udf=user_function, + udf_string=udf_string, ) functools.update_wrapper( wrapper=on_demand_feature_view_obj, wrapped=user_function diff --git a/sdk/python/feast/protos/feast/core/DataSource_pb2.py b/sdk/python/feast/protos/feast/core/DataSource_pb2.py index b58c33a3830..68bee8d7609 100644 --- a/sdk/python/feast/protos/feast/core/DataSource_pb2.py +++ b/sdk/python/feast/protos/feast/core/DataSource_pb2.py @@ -19,7 +19,7 @@ from feast.protos.feast.core import Feature_pb2 as feast_dot_core_dot_Feature__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66\x65\x61st/core/DataSource.proto\x12\nfeast.core\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1b\x66\x65\x61st/core/DataFormat.proto\x1a\x17\x66\x65\x61st/types/Value.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\"\xc0\x16\n\nDataSource\x12\x0c\n\x04name\x18\x14 \x01(\t\x12\x0f\n\x07project\x18\x15 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x17 \x01(\t\x12.\n\x04tags\x18\x18 \x03(\x0b\x32 .feast.core.DataSource.TagsEntry\x12\r\n\x05owner\x18\x19 \x01(\t\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.feast.core.DataSource.SourceType\x12?\n\rfield_mapping\x18\x02 \x03(\x0b\x32(.feast.core.DataSource.FieldMappingEntry\x12\x17\n\x0ftimestamp_field\x18\x03 \x01(\t\x12\x1d\n\x15\x64\x61te_partition_column\x18\x04 \x01(\t\x12 \n\x18\x63reated_timestamp_column\x18\x05 \x01(\t\x12\x1e\n\x16\x64\x61ta_source_class_type\x18\x11 \x01(\t\x12,\n\x0c\x62\x61tch_source\x18\x1a \x01(\x0b\x32\x16.feast.core.DataSource\x12/\n\x04meta\x18\x32 \x01(\x0b\x32!.feast.core.DataSource.SourceMeta\x12:\n\x0c\x66ile_options\x18\x0b \x01(\x0b\x32\".feast.core.DataSource.FileOptionsH\x00\x12\x42\n\x10\x62igquery_options\x18\x0c \x01(\x0b\x32&.feast.core.DataSource.BigQueryOptionsH\x00\x12<\n\rkafka_options\x18\r \x01(\x0b\x32#.feast.core.DataSource.KafkaOptionsH\x00\x12@\n\x0fkinesis_options\x18\x0e \x01(\x0b\x32%.feast.core.DataSource.KinesisOptionsH\x00\x12\x42\n\x10redshift_options\x18\x0f \x01(\x0b\x32&.feast.core.DataSource.RedshiftOptionsH\x00\x12I\n\x14request_data_options\x18\x12 \x01(\x0b\x32).feast.core.DataSource.RequestDataOptionsH\x00\x12\x44\n\x0e\x63ustom_options\x18\x10 \x01(\x0b\x32*.feast.core.DataSource.CustomSourceOptionsH\x00\x12\x44\n\x11snowflake_options\x18\x13 \x01(\x0b\x32\'.feast.core.DataSource.SnowflakeOptionsH\x00\x12:\n\x0cpush_options\x18\x16 \x01(\x0b\x32\".feast.core.DataSource.PushOptionsH\x00\x12<\n\rspark_options\x18\x1b \x01(\x0b\x32#.feast.core.DataSource.SparkOptionsH\x00\x12<\n\rtrino_options\x18\x1e \x01(\x0b\x32#.feast.core.DataSource.TrinoOptionsH\x00\x12>\n\x0e\x61thena_options\x18# \x01(\x0b\x32$.feast.core.DataSource.AthenaOptionsH\x00\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x33\n\x11\x46ieldMappingEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x82\x01\n\nSourceMeta\x12:\n\x16\x65\x61rliestEventTimestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14latestEventTimestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a\x65\n\x0b\x46ileOptions\x12+\n\x0b\x66ile_format\x18\x01 \x01(\x0b\x32\x16.feast.core.FileFormat\x12\x0b\n\x03uri\x18\x02 \x01(\t\x12\x1c\n\x14s3_endpoint_override\x18\x03 \x01(\t\x1a/\n\x0f\x42igQueryOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a,\n\x0cTrinoOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a\xae\x01\n\x0cKafkaOptions\x12\x1f\n\x17kafka_bootstrap_servers\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x30\n\x0emessage_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x12<\n\x19watermark_delay_threshold\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x66\n\x0eKinesisOptions\x12\x0e\n\x06region\x18\x01 \x01(\t\x12\x13\n\x0bstream_name\x18\x02 \x01(\t\x12/\n\rrecord_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x1aQ\n\x0fRedshiftOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\t\x1aT\n\rAthenaOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x61ta_source\x18\x04 \x01(\t\x1aX\n\x10SnowflakeOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\tJ\x04\x08\x05\x10\x06\x1aO\n\x0cSparkOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x13\n\x0b\x66ile_format\x18\x04 \x01(\t\x1a,\n\x13\x43ustomSourceOptions\x12\x15\n\rconfiguration\x18\x01 \x01(\x0c\x1a\xf7\x01\n\x12RequestDataOptions\x12Z\n\x11\x64\x65precated_schema\x18\x02 \x03(\x0b\x32?.feast.core.DataSource.RequestDataOptions.DeprecatedSchemaEntry\x12)\n\x06schema\x18\x03 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x1aT\n\x15\x44\x65precatedSchemaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum:\x02\x38\x01J\x04\x08\x01\x10\x02\x1a\x13\n\x0bPushOptionsJ\x04\x08\x01\x10\x02\"\xf8\x01\n\nSourceType\x12\x0b\n\x07INVALID\x10\x00\x12\x0e\n\nBATCH_FILE\x10\x01\x12\x13\n\x0f\x42\x41TCH_SNOWFLAKE\x10\x08\x12\x12\n\x0e\x42\x41TCH_BIGQUERY\x10\x02\x12\x12\n\x0e\x42\x41TCH_REDSHIFT\x10\x05\x12\x10\n\x0cSTREAM_KAFKA\x10\x03\x12\x12\n\x0eSTREAM_KINESIS\x10\x04\x12\x11\n\rCUSTOM_SOURCE\x10\x06\x12\x12\n\x0eREQUEST_SOURCE\x10\x07\x12\x0f\n\x0bPUSH_SOURCE\x10\t\x12\x0f\n\x0b\x42\x41TCH_TRINO\x10\n\x12\x0f\n\x0b\x42\x41TCH_SPARK\x10\x0b\x12\x10\n\x0c\x42\x41TCH_ATHENA\x10\x0c\x42\t\n\x07optionsJ\x04\x08\x06\x10\x0b\x42T\n\x10\x66\x65\x61st.proto.coreB\x0f\x44\x61taSourceProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x66\x65\x61st/core/DataSource.proto\x12\nfeast.core\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1b\x66\x65\x61st/core/DataFormat.proto\x1a\x17\x66\x65\x61st/types/Value.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\"\xc0\x16\n\nDataSource\x12\x0c\n\x04name\x18\x14 \x01(\t\x12\x0f\n\x07project\x18\x15 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x17 \x01(\t\x12.\n\x04tags\x18\x18 \x03(\x0b\x32 .feast.core.DataSource.TagsEntry\x12\r\n\x05owner\x18\x19 \x01(\t\x12/\n\x04type\x18\x01 \x01(\x0e\x32!.feast.core.DataSource.SourceType\x12?\n\rfield_mapping\x18\x02 \x03(\x0b\x32(.feast.core.DataSource.FieldMappingEntry\x12\x17\n\x0ftimestamp_field\x18\x03 \x01(\t\x12\x1d\n\x15\x64\x61te_partition_column\x18\x04 \x01(\t\x12 \n\x18\x63reated_timestamp_column\x18\x05 \x01(\t\x12\x1e\n\x16\x64\x61ta_source_class_type\x18\x11 \x01(\t\x12,\n\x0c\x62\x61tch_source\x18\x1a \x01(\x0b\x32\x16.feast.core.DataSource\x12/\n\x04meta\x18\x32 \x01(\x0b\x32!.feast.core.DataSource.SourceMeta\x12:\n\x0c\x66ile_options\x18\x0b \x01(\x0b\x32\".feast.core.DataSource.FileOptionsH\x00\x12\x42\n\x10\x62igquery_options\x18\x0c \x01(\x0b\x32&.feast.core.DataSource.BigQueryOptionsH\x00\x12<\n\rkafka_options\x18\r \x01(\x0b\x32#.feast.core.DataSource.KafkaOptionsH\x00\x12@\n\x0fkinesis_options\x18\x0e \x01(\x0b\x32%.feast.core.DataSource.KinesisOptionsH\x00\x12\x42\n\x10redshift_options\x18\x0f \x01(\x0b\x32&.feast.core.DataSource.RedshiftOptionsH\x00\x12I\n\x14request_data_options\x18\x12 \x01(\x0b\x32).feast.core.DataSource.RequestDataOptionsH\x00\x12\x44\n\x0e\x63ustom_options\x18\x10 \x01(\x0b\x32*.feast.core.DataSource.CustomSourceOptionsH\x00\x12\x44\n\x11snowflake_options\x18\x13 \x01(\x0b\x32\'.feast.core.DataSource.SnowflakeOptionsH\x00\x12:\n\x0cpush_options\x18\x16 \x01(\x0b\x32\".feast.core.DataSource.PushOptionsH\x00\x12<\n\rspark_options\x18\x1b \x01(\x0b\x32#.feast.core.DataSource.SparkOptionsH\x00\x12<\n\rtrino_options\x18\x1e \x01(\x0b\x32#.feast.core.DataSource.TrinoOptionsH\x00\x12>\n\x0e\x61thena_options\x18# \x01(\x0b\x32$.feast.core.DataSource.AthenaOptionsH\x00\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x33\n\x11\x46ieldMappingEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x82\x01\n\nSourceMeta\x12:\n\x16\x65\x61rliestEventTimestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x38\n\x14latestEventTimestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a\x65\n\x0b\x46ileOptions\x12+\n\x0b\x66ile_format\x18\x01 \x01(\x0b\x32\x16.feast.core.FileFormat\x12\x0b\n\x03uri\x18\x02 \x01(\t\x12\x1c\n\x14s3_endpoint_override\x18\x03 \x01(\t\x1a/\n\x0f\x42igQueryOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a,\n\x0cTrinoOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x1a\xae\x01\n\x0cKafkaOptions\x12\x1f\n\x17kafka_bootstrap_servers\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x30\n\x0emessage_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x12<\n\x19watermark_delay_threshold\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a\x66\n\x0eKinesisOptions\x12\x0e\n\x06region\x18\x01 \x01(\t\x12\x13\n\x0bstream_name\x18\x02 \x01(\t\x12/\n\rrecord_format\x18\x03 \x01(\x0b\x32\x18.feast.core.StreamFormat\x1aQ\n\x0fRedshiftOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\t\x1aT\n\rAthenaOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x03 \x01(\t\x12\x13\n\x0b\x64\x61ta_source\x18\x04 \x01(\t\x1aX\n\x10SnowflakeOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0e\n\x06schema\x18\x03 \x01(\t\x12\x10\n\x08\x64\x61tabase\x18\x04 \x01(\tJ\x04\x08\x05\x10\x06\x1aO\n\x0cSparkOptions\x12\r\n\x05table\x18\x01 \x01(\t\x12\r\n\x05query\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x13\n\x0b\x66ile_format\x18\x04 \x01(\t\x1a,\n\x13\x43ustomSourceOptions\x12\x15\n\rconfiguration\x18\x01 \x01(\x0c\x1a\xf7\x01\n\x12RequestDataOptions\x12Z\n\x11\x64\x65precated_schema\x18\x02 \x03(\x0b\x32?.feast.core.DataSource.RequestDataOptions.DeprecatedSchemaEntry\x12)\n\x06schema\x18\x03 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x1aT\n\x15\x44\x65precatedSchemaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum:\x02\x38\x01J\x04\x08\x01\x10\x02\x1a\x13\n\x0bPushOptionsJ\x04\x08\x01\x10\x02\"\xf8\x01\n\nSourceType\x12\x0b\n\x07INVALID\x10\x00\x12\x0e\n\nBATCH_FILE\x10\x01\x12\x13\n\x0f\x42\x41TCH_SNOWFLAKE\x10\x08\x12\x12\n\x0e\x42\x41TCH_BIGQUERY\x10\x02\x12\x12\n\x0e\x42\x41TCH_REDSHIFT\x10\x05\x12\x10\n\x0cSTREAM_KAFKA\x10\x03\x12\x12\n\x0eSTREAM_KINESIS\x10\x04\x12\x11\n\rCUSTOM_SOURCE\x10\x06\x12\x12\n\x0eREQUEST_SOURCE\x10\x07\x12\x0f\n\x0bPUSH_SOURCE\x10\t\x12\x0f\n\x0b\x42\x41TCH_TRINO\x10\n\x12\x0f\n\x0b\x42\x41TCH_SPARK\x10\x0b\x12\x10\n\x0c\x42\x41TCH_ATHENA\x10\x0c\x42\t\n\x07optionsJ\x04\x08\x06\x10\x0b\"=\n\x0e\x44\x61taSourceList\x12+\n\x0b\x64\x61tasources\x18\x01 \x03(\x0b\x32\x16.feast.core.DataSourceBT\n\x10\x66\x65\x61st.proto.coreB\x0f\x44\x61taSourceProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -69,4 +69,6 @@ _globals['_DATASOURCE_PUSHOPTIONS']._serialized_end=2801 _globals['_DATASOURCE_SOURCETYPE']._serialized_start=2804 _globals['_DATASOURCE_SOURCETYPE']._serialized_end=3052 + _globals['_DATASOURCELIST']._serialized_start=3071 + _globals['_DATASOURCELIST']._serialized_end=3132 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi b/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi index 94336638e19..aadec3fad4c 100644 --- a/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/DataSource_pb2.pyi @@ -557,3 +557,18 @@ class DataSource(google.protobuf.message.Message): def WhichOneof(self, oneof_group: typing_extensions.Literal["options", b"options"]) -> typing_extensions.Literal["file_options", "bigquery_options", "kafka_options", "kinesis_options", "redshift_options", "request_data_options", "custom_options", "snowflake_options", "push_options", "spark_options", "trino_options", "athena_options"] | None: ... global___DataSource = DataSource + +class DataSourceList(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + DATASOURCES_FIELD_NUMBER: builtins.int + @property + def datasources(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___DataSource]: ... + def __init__( + self, + *, + datasources: collections.abc.Iterable[global___DataSource] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["datasources", b"datasources"]) -> None: ... + +global___DataSourceList = DataSourceList diff --git a/sdk/python/feast/protos/feast/core/Entity_pb2.py b/sdk/python/feast/protos/feast/core/Entity_pb2.py index 5a192854cab..2b3e7806736 100644 --- a/sdk/python/feast/protos/feast/core/Entity_pb2.py +++ b/sdk/python/feast/protos/feast/core/Entity_pb2.py @@ -16,7 +16,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x66\x65\x61st/core/Entity.proto\x12\nfeast.core\x1a\x17\x66\x65\x61st/types/Value.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"V\n\x06\x45ntity\x12&\n\x04spec\x18\x01 \x01(\x0b\x32\x18.feast.core.EntitySpecV2\x12$\n\x04meta\x18\x02 \x01(\x0b\x32\x16.feast.core.EntityMeta\"\xf3\x01\n\x0c\x45ntitySpecV2\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\t \x01(\t\x12/\n\nvalue_type\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x10\n\x08join_key\x18\x04 \x01(\t\x12\x30\n\x04tags\x18\x08 \x03(\x0b\x32\".feast.core.EntitySpecV2.TagsEntry\x12\r\n\x05owner\x18\n \x01(\t\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x7f\n\nEntityMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampBP\n\x10\x66\x65\x61st.proto.coreB\x0b\x45ntityProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x66\x65\x61st/core/Entity.proto\x12\nfeast.core\x1a\x17\x66\x65\x61st/types/Value.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"V\n\x06\x45ntity\x12&\n\x04spec\x18\x01 \x01(\x0b\x32\x18.feast.core.EntitySpecV2\x12$\n\x04meta\x18\x02 \x01(\x0b\x32\x16.feast.core.EntityMeta\"\xf3\x01\n\x0c\x45ntitySpecV2\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\t \x01(\t\x12/\n\nvalue_type\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x10\n\x08join_key\x18\x04 \x01(\t\x12\x30\n\x04tags\x18\x08 \x03(\x0b\x32\".feast.core.EntitySpecV2.TagsEntry\x12\r\n\x05owner\x18\n \x01(\t\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x7f\n\nEntityMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"2\n\nEntityList\x12$\n\x08\x65ntities\x18\x01 \x03(\x0b\x32\x12.feast.core.EntityBP\n\x10\x66\x65\x61st.proto.coreB\x0b\x45ntityProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,4 +34,6 @@ _globals['_ENTITYSPECV2_TAGSENTRY']._serialized_end=429 _globals['_ENTITYMETA']._serialized_start=431 _globals['_ENTITYMETA']._serialized_end=558 + _globals['_ENTITYLIST']._serialized_start=560 + _globals['_ENTITYLIST']._serialized_end=610 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/Entity_pb2.pyi b/sdk/python/feast/protos/feast/core/Entity_pb2.pyi index 732b3e10326..025817edfee 100644 --- a/sdk/python/feast/protos/feast/core/Entity_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/Entity_pb2.pyi @@ -128,3 +128,18 @@ class EntityMeta(google.protobuf.message.Message): def ClearField(self, field_name: typing_extensions.Literal["created_timestamp", b"created_timestamp", "last_updated_timestamp", b"last_updated_timestamp"]) -> None: ... global___EntityMeta = EntityMeta + +class EntityList(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ENTITIES_FIELD_NUMBER: builtins.int + @property + def entities(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___Entity]: ... + def __init__( + self, + *, + entities: collections.abc.Iterable[global___Entity] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["entities", b"entities"]) -> None: ... + +global___EntityList = EntityList diff --git a/sdk/python/feast/protos/feast/core/FeatureService_pb2.py b/sdk/python/feast/protos/feast/core/FeatureService_pb2.py index cf6ac46ac54..7ef36079691 100644 --- a/sdk/python/feast/protos/feast/core/FeatureService_pb2.py +++ b/sdk/python/feast/protos/feast/core/FeatureService_pb2.py @@ -16,7 +16,7 @@ from feast.protos.feast.core import FeatureViewProjection_pb2 as feast_dot_core_dot_FeatureViewProjection__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x66\x65\x61st/core/FeatureService.proto\x12\nfeast.core\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&feast/core/FeatureViewProjection.proto\"l\n\x0e\x46\x65\x61tureService\x12,\n\x04spec\x18\x01 \x01(\x0b\x32\x1e.feast.core.FeatureServiceSpec\x12,\n\x04meta\x18\x02 \x01(\x0b\x32\x1e.feast.core.FeatureServiceMeta\"\xa4\x02\n\x12\x46\x65\x61tureServiceSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x33\n\x08\x66\x65\x61tures\x18\x03 \x03(\x0b\x32!.feast.core.FeatureViewProjection\x12\x36\n\x04tags\x18\x04 \x03(\x0b\x32(.feast.core.FeatureServiceSpec.TagsEntry\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\r\n\x05owner\x18\x06 \x01(\t\x12\x31\n\x0elogging_config\x18\x07 \x01(\x0b\x32\x19.feast.core.LoggingConfig\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x12\x46\x65\x61tureServiceMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x9a\x07\n\rLoggingConfig\x12\x13\n\x0bsample_rate\x18\x01 \x01(\x02\x12\x45\n\x10\x66ile_destination\x18\x03 \x01(\x0b\x32).feast.core.LoggingConfig.FileDestinationH\x00\x12M\n\x14\x62igquery_destination\x18\x04 \x01(\x0b\x32-.feast.core.LoggingConfig.BigQueryDestinationH\x00\x12M\n\x14redshift_destination\x18\x05 \x01(\x0b\x32-.feast.core.LoggingConfig.RedshiftDestinationH\x00\x12O\n\x15snowflake_destination\x18\x06 \x01(\x0b\x32..feast.core.LoggingConfig.SnowflakeDestinationH\x00\x12I\n\x12\x63ustom_destination\x18\x07 \x01(\x0b\x32+.feast.core.LoggingConfig.CustomDestinationH\x00\x12I\n\x12\x61thena_destination\x18\x08 \x01(\x0b\x32+.feast.core.LoggingConfig.AthenaDestinationH\x00\x1aS\n\x0f\x46ileDestination\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x1c\n\x14s3_endpoint_override\x18\x02 \x01(\t\x12\x14\n\x0cpartition_by\x18\x03 \x03(\t\x1a(\n\x13\x42igQueryDestination\x12\x11\n\ttable_ref\x18\x01 \x01(\t\x1a)\n\x13RedshiftDestination\x12\x12\n\ntable_name\x18\x01 \x01(\t\x1a\'\n\x11\x41thenaDestination\x12\x12\n\ntable_name\x18\x01 \x01(\t\x1a*\n\x14SnowflakeDestination\x12\x12\n\ntable_name\x18\x01 \x01(\t\x1a\x99\x01\n\x11\x43ustomDestination\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12G\n\x06\x63onfig\x18\x02 \x03(\x0b\x32\x37.feast.core.LoggingConfig.CustomDestination.ConfigEntry\x1a-\n\x0b\x43onfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0b\x64\x65stinationBX\n\x10\x66\x65\x61st.proto.coreB\x13\x46\x65\x61tureServiceProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x66\x65\x61st/core/FeatureService.proto\x12\nfeast.core\x1a\x1fgoogle/protobuf/timestamp.proto\x1a&feast/core/FeatureViewProjection.proto\"l\n\x0e\x46\x65\x61tureService\x12,\n\x04spec\x18\x01 \x01(\x0b\x32\x1e.feast.core.FeatureServiceSpec\x12,\n\x04meta\x18\x02 \x01(\x0b\x32\x1e.feast.core.FeatureServiceMeta\"\xa4\x02\n\x12\x46\x65\x61tureServiceSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x33\n\x08\x66\x65\x61tures\x18\x03 \x03(\x0b\x32!.feast.core.FeatureViewProjection\x12\x36\n\x04tags\x18\x04 \x03(\x0b\x32(.feast.core.FeatureServiceSpec.TagsEntry\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\r\n\x05owner\x18\x06 \x01(\t\x12\x31\n\x0elogging_config\x18\x07 \x01(\x0b\x32\x19.feast.core.LoggingConfig\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x12\x46\x65\x61tureServiceMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xd1\x08\n\rLoggingConfig\x12\x13\n\x0bsample_rate\x18\x01 \x01(\x02\x12\x45\n\x10\x66ile_destination\x18\x03 \x01(\x0b\x32).feast.core.LoggingConfig.FileDestinationH\x00\x12M\n\x14\x62igquery_destination\x18\x04 \x01(\x0b\x32-.feast.core.LoggingConfig.BigQueryDestinationH\x00\x12M\n\x14redshift_destination\x18\x05 \x01(\x0b\x32-.feast.core.LoggingConfig.RedshiftDestinationH\x00\x12O\n\x15snowflake_destination\x18\x06 \x01(\x0b\x32..feast.core.LoggingConfig.SnowflakeDestinationH\x00\x12I\n\x12\x63ustom_destination\x18\x07 \x01(\x0b\x32+.feast.core.LoggingConfig.CustomDestinationH\x00\x12I\n\x12\x61thena_destination\x18\x08 \x01(\x0b\x32+.feast.core.LoggingConfig.AthenaDestinationH\x00\x12`\n\x1e\x63ouchbase_columnar_destination\x18\t \x01(\x0b\x32\x36.feast.core.LoggingConfig.CouchbaseColumnarDestinationH\x00\x1aS\n\x0f\x46ileDestination\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x1c\n\x14s3_endpoint_override\x18\x02 \x01(\t\x12\x14\n\x0cpartition_by\x18\x03 \x03(\t\x1a(\n\x13\x42igQueryDestination\x12\x11\n\ttable_ref\x18\x01 \x01(\t\x1a)\n\x13RedshiftDestination\x12\x12\n\ntable_name\x18\x01 \x01(\t\x1a\'\n\x11\x41thenaDestination\x12\x12\n\ntable_name\x18\x01 \x01(\t\x1a*\n\x14SnowflakeDestination\x12\x12\n\ntable_name\x18\x01 \x01(\t\x1a\x99\x01\n\x11\x43ustomDestination\x12\x0c\n\x04kind\x18\x01 \x01(\t\x12G\n\x06\x63onfig\x18\x02 \x03(\x0b\x32\x37.feast.core.LoggingConfig.CustomDestination.ConfigEntry\x1a-\n\x0b\x43onfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1aS\n\x1c\x43ouchbaseColumnarDestination\x12\x10\n\x08\x64\x61tabase\x18\x01 \x01(\t\x12\r\n\x05scope\x18\x02 \x01(\t\x12\x12\n\ncollection\x18\x03 \x01(\tB\r\n\x0b\x64\x65stination\"I\n\x12\x46\x65\x61tureServiceList\x12\x33\n\x0f\x66\x65\x61tureservices\x18\x01 \x03(\x0b\x32\x1a.feast.core.FeatureServiceBX\n\x10\x66\x65\x61st.proto.coreB\x13\x46\x65\x61tureServiceProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -37,19 +37,23 @@ _globals['_FEATURESERVICEMETA']._serialized_start=526 _globals['_FEATURESERVICEMETA']._serialized_end=661 _globals['_LOGGINGCONFIG']._serialized_start=664 - _globals['_LOGGINGCONFIG']._serialized_end=1586 - _globals['_LOGGINGCONFIG_FILEDESTINATION']._serialized_start=1162 - _globals['_LOGGINGCONFIG_FILEDESTINATION']._serialized_end=1245 - _globals['_LOGGINGCONFIG_BIGQUERYDESTINATION']._serialized_start=1247 - _globals['_LOGGINGCONFIG_BIGQUERYDESTINATION']._serialized_end=1287 - _globals['_LOGGINGCONFIG_REDSHIFTDESTINATION']._serialized_start=1289 - _globals['_LOGGINGCONFIG_REDSHIFTDESTINATION']._serialized_end=1330 - _globals['_LOGGINGCONFIG_ATHENADESTINATION']._serialized_start=1332 - _globals['_LOGGINGCONFIG_ATHENADESTINATION']._serialized_end=1371 - _globals['_LOGGINGCONFIG_SNOWFLAKEDESTINATION']._serialized_start=1373 - _globals['_LOGGINGCONFIG_SNOWFLAKEDESTINATION']._serialized_end=1415 - _globals['_LOGGINGCONFIG_CUSTOMDESTINATION']._serialized_start=1418 - _globals['_LOGGINGCONFIG_CUSTOMDESTINATION']._serialized_end=1571 - _globals['_LOGGINGCONFIG_CUSTOMDESTINATION_CONFIGENTRY']._serialized_start=1526 - _globals['_LOGGINGCONFIG_CUSTOMDESTINATION_CONFIGENTRY']._serialized_end=1571 + _globals['_LOGGINGCONFIG']._serialized_end=1769 + _globals['_LOGGINGCONFIG_FILEDESTINATION']._serialized_start=1260 + _globals['_LOGGINGCONFIG_FILEDESTINATION']._serialized_end=1343 + _globals['_LOGGINGCONFIG_BIGQUERYDESTINATION']._serialized_start=1345 + _globals['_LOGGINGCONFIG_BIGQUERYDESTINATION']._serialized_end=1385 + _globals['_LOGGINGCONFIG_REDSHIFTDESTINATION']._serialized_start=1387 + _globals['_LOGGINGCONFIG_REDSHIFTDESTINATION']._serialized_end=1428 + _globals['_LOGGINGCONFIG_ATHENADESTINATION']._serialized_start=1430 + _globals['_LOGGINGCONFIG_ATHENADESTINATION']._serialized_end=1469 + _globals['_LOGGINGCONFIG_SNOWFLAKEDESTINATION']._serialized_start=1471 + _globals['_LOGGINGCONFIG_SNOWFLAKEDESTINATION']._serialized_end=1513 + _globals['_LOGGINGCONFIG_CUSTOMDESTINATION']._serialized_start=1516 + _globals['_LOGGINGCONFIG_CUSTOMDESTINATION']._serialized_end=1669 + _globals['_LOGGINGCONFIG_CUSTOMDESTINATION_CONFIGENTRY']._serialized_start=1624 + _globals['_LOGGINGCONFIG_CUSTOMDESTINATION_CONFIGENTRY']._serialized_end=1669 + _globals['_LOGGINGCONFIG_COUCHBASECOLUMNARDESTINATION']._serialized_start=1671 + _globals['_LOGGINGCONFIG_COUCHBASECOLUMNARDESTINATION']._serialized_end=1754 + _globals['_FEATURESERVICELIST']._serialized_start=1771 + _globals['_FEATURESERVICELIST']._serialized_end=1844 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/FeatureService_pb2.pyi b/sdk/python/feast/protos/feast/core/FeatureService_pb2.pyi index b3305b72df9..6d5879e52cb 100644 --- a/sdk/python/feast/protos/feast/core/FeatureService_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/FeatureService_pb2.pyi @@ -228,6 +228,27 @@ class LoggingConfig(google.protobuf.message.Message): ) -> None: ... def ClearField(self, field_name: typing_extensions.Literal["config", b"config", "kind", b"kind"]) -> None: ... + class CouchbaseColumnarDestination(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + DATABASE_FIELD_NUMBER: builtins.int + SCOPE_FIELD_NUMBER: builtins.int + COLLECTION_FIELD_NUMBER: builtins.int + database: builtins.str + """Destination database name""" + scope: builtins.str + """Destination scope name""" + collection: builtins.str + """Destination collection name""" + def __init__( + self, + *, + database: builtins.str = ..., + scope: builtins.str = ..., + collection: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["collection", b"collection", "database", b"database", "scope", b"scope"]) -> None: ... + SAMPLE_RATE_FIELD_NUMBER: builtins.int FILE_DESTINATION_FIELD_NUMBER: builtins.int BIGQUERY_DESTINATION_FIELD_NUMBER: builtins.int @@ -235,6 +256,7 @@ class LoggingConfig(google.protobuf.message.Message): SNOWFLAKE_DESTINATION_FIELD_NUMBER: builtins.int CUSTOM_DESTINATION_FIELD_NUMBER: builtins.int ATHENA_DESTINATION_FIELD_NUMBER: builtins.int + COUCHBASE_COLUMNAR_DESTINATION_FIELD_NUMBER: builtins.int sample_rate: builtins.float @property def file_destination(self) -> global___LoggingConfig.FileDestination: ... @@ -248,6 +270,8 @@ class LoggingConfig(google.protobuf.message.Message): def custom_destination(self) -> global___LoggingConfig.CustomDestination: ... @property def athena_destination(self) -> global___LoggingConfig.AthenaDestination: ... + @property + def couchbase_columnar_destination(self) -> global___LoggingConfig.CouchbaseColumnarDestination: ... def __init__( self, *, @@ -258,9 +282,25 @@ class LoggingConfig(google.protobuf.message.Message): snowflake_destination: global___LoggingConfig.SnowflakeDestination | None = ..., custom_destination: global___LoggingConfig.CustomDestination | None = ..., athena_destination: global___LoggingConfig.AthenaDestination | None = ..., + couchbase_columnar_destination: global___LoggingConfig.CouchbaseColumnarDestination | None = ..., ) -> None: ... - def HasField(self, field_name: typing_extensions.Literal["athena_destination", b"athena_destination", "bigquery_destination", b"bigquery_destination", "custom_destination", b"custom_destination", "destination", b"destination", "file_destination", b"file_destination", "redshift_destination", b"redshift_destination", "snowflake_destination", b"snowflake_destination"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["athena_destination", b"athena_destination", "bigquery_destination", b"bigquery_destination", "custom_destination", b"custom_destination", "destination", b"destination", "file_destination", b"file_destination", "redshift_destination", b"redshift_destination", "sample_rate", b"sample_rate", "snowflake_destination", b"snowflake_destination"]) -> None: ... - def WhichOneof(self, oneof_group: typing_extensions.Literal["destination", b"destination"]) -> typing_extensions.Literal["file_destination", "bigquery_destination", "redshift_destination", "snowflake_destination", "custom_destination", "athena_destination"] | None: ... + def HasField(self, field_name: typing_extensions.Literal["athena_destination", b"athena_destination", "bigquery_destination", b"bigquery_destination", "couchbase_columnar_destination", b"couchbase_columnar_destination", "custom_destination", b"custom_destination", "destination", b"destination", "file_destination", b"file_destination", "redshift_destination", b"redshift_destination", "snowflake_destination", b"snowflake_destination"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["athena_destination", b"athena_destination", "bigquery_destination", b"bigquery_destination", "couchbase_columnar_destination", b"couchbase_columnar_destination", "custom_destination", b"custom_destination", "destination", b"destination", "file_destination", b"file_destination", "redshift_destination", b"redshift_destination", "sample_rate", b"sample_rate", "snowflake_destination", b"snowflake_destination"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["destination", b"destination"]) -> typing_extensions.Literal["file_destination", "bigquery_destination", "redshift_destination", "snowflake_destination", "custom_destination", "athena_destination", "couchbase_columnar_destination"] | None: ... global___LoggingConfig = LoggingConfig + +class FeatureServiceList(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + FEATURESERVICES_FIELD_NUMBER: builtins.int + @property + def featureservices(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___FeatureService]: ... + def __init__( + self, + *, + featureservices: collections.abc.Iterable[global___FeatureService] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["featureservices", b"featureservices"]) -> None: ... + +global___FeatureServiceList = FeatureServiceList diff --git a/sdk/python/feast/protos/feast/core/FeatureView_pb2.py b/sdk/python/feast/protos/feast/core/FeatureView_pb2.py index f1480593d9a..80d04c1ec3f 100644 --- a/sdk/python/feast/protos/feast/core/FeatureView_pb2.py +++ b/sdk/python/feast/protos/feast/core/FeatureView_pb2.py @@ -18,7 +18,7 @@ from feast.protos.feast.core import Feature_pb2 as feast_dot_core_dot_Feature__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1c\x66\x65\x61st/core/FeatureView.proto\x12\nfeast.core\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1b\x66\x65\x61st/core/DataSource.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\"c\n\x0b\x46\x65\x61tureView\x12)\n\x04spec\x18\x01 \x01(\x0b\x32\x1b.feast.core.FeatureViewSpec\x12)\n\x04meta\x18\x02 \x01(\x0b\x32\x1b.feast.core.FeatureViewMeta\"\xbd\x03\n\x0f\x46\x65\x61tureViewSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x10\n\x08\x65ntities\x18\x03 \x03(\t\x12+\n\x08\x66\x65\x61tures\x18\x04 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x31\n\x0e\x65ntity_columns\x18\x0c \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x13\n\x0b\x64\x65scription\x18\n \x01(\t\x12\x33\n\x04tags\x18\x05 \x03(\x0b\x32%.feast.core.FeatureViewSpec.TagsEntry\x12\r\n\x05owner\x18\x0b \x01(\t\x12&\n\x03ttl\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12,\n\x0c\x62\x61tch_source\x18\x07 \x01(\x0b\x32\x16.feast.core.DataSource\x12-\n\rstream_source\x18\t \x01(\x0b\x32\x16.feast.core.DataSource\x12\x0e\n\x06online\x18\x08 \x01(\x08\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xcc\x01\n\x0f\x46\x65\x61tureViewMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x46\n\x19materialization_intervals\x18\x03 \x03(\x0b\x32#.feast.core.MaterializationInterval\"w\n\x17MaterializationInterval\x12.\n\nstart_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampBU\n\x10\x66\x65\x61st.proto.coreB\x10\x46\x65\x61tureViewProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1c\x66\x65\x61st/core/FeatureView.proto\x12\nfeast.core\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1b\x66\x65\x61st/core/DataSource.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\"c\n\x0b\x46\x65\x61tureView\x12)\n\x04spec\x18\x01 \x01(\x0b\x32\x1b.feast.core.FeatureViewSpec\x12)\n\x04meta\x18\x02 \x01(\x0b\x32\x1b.feast.core.FeatureViewMeta\"\xbd\x03\n\x0f\x46\x65\x61tureViewSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x10\n\x08\x65ntities\x18\x03 \x03(\t\x12+\n\x08\x66\x65\x61tures\x18\x04 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x31\n\x0e\x65ntity_columns\x18\x0c \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x13\n\x0b\x64\x65scription\x18\n \x01(\t\x12\x33\n\x04tags\x18\x05 \x03(\x0b\x32%.feast.core.FeatureViewSpec.TagsEntry\x12\r\n\x05owner\x18\x0b \x01(\t\x12&\n\x03ttl\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12,\n\x0c\x62\x61tch_source\x18\x07 \x01(\x0b\x32\x16.feast.core.DataSource\x12-\n\rstream_source\x18\t \x01(\x0b\x32\x16.feast.core.DataSource\x12\x0e\n\x06online\x18\x08 \x01(\x08\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xcc\x01\n\x0f\x46\x65\x61tureViewMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x46\n\x19materialization_intervals\x18\x03 \x03(\x0b\x32#.feast.core.MaterializationInterval\"w\n\x17MaterializationInterval\x12.\n\nstart_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"@\n\x0f\x46\x65\x61tureViewList\x12-\n\x0c\x66\x65\x61tureviews\x18\x01 \x03(\x0b\x32\x17.feast.core.FeatureViewBU\n\x10\x66\x65\x61st.proto.coreB\x10\x46\x65\x61tureViewProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -38,4 +38,6 @@ _globals['_FEATUREVIEWMETA']._serialized_end=918 _globals['_MATERIALIZATIONINTERVAL']._serialized_start=920 _globals['_MATERIALIZATIONINTERVAL']._serialized_end=1039 + _globals['_FEATUREVIEWLIST']._serialized_start=1041 + _globals['_FEATUREVIEWLIST']._serialized_end=1105 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/FeatureView_pb2.pyi b/sdk/python/feast/protos/feast/core/FeatureView_pb2.pyi index e1d4e2dfee8..57158fc2c6c 100644 --- a/sdk/python/feast/protos/feast/core/FeatureView_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/FeatureView_pb2.pyi @@ -192,3 +192,18 @@ class MaterializationInterval(google.protobuf.message.Message): def ClearField(self, field_name: typing_extensions.Literal["end_time", b"end_time", "start_time", b"start_time"]) -> None: ... global___MaterializationInterval = MaterializationInterval + +class FeatureViewList(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + FEATUREVIEWS_FIELD_NUMBER: builtins.int + @property + def featureviews(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___FeatureView]: ... + def __init__( + self, + *, + featureviews: collections.abc.Iterable[global___FeatureView] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["featureviews", b"featureviews"]) -> None: ... + +global___FeatureViewList = FeatureViewList diff --git a/sdk/python/feast/protos/feast/core/Feature_pb2.py b/sdk/python/feast/protos/feast/core/Feature_pb2.py index dd7c6008ef1..6b1081fe811 100644 --- a/sdk/python/feast/protos/feast/core/Feature_pb2.py +++ b/sdk/python/feast/protos/feast/core/Feature_pb2.py @@ -15,7 +15,7 @@ from feast.protos.feast.types import Value_pb2 as feast_dot_types_dot_Value__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x66\x65\x61st/core/Feature.proto\x12\nfeast.core\x1a\x17\x66\x65\x61st/types/Value.proto\"\xc3\x01\n\rFeatureSpecV2\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\nvalue_type\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum\x12\x31\n\x04tags\x18\x03 \x03(\x0b\x32#.feast.core.FeatureSpecV2.TagsEntry\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42Q\n\x10\x66\x65\x61st.proto.coreB\x0c\x46\x65\x61tureProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x66\x65\x61st/core/Feature.proto\x12\nfeast.core\x1a\x17\x66\x65\x61st/types/Value.proto\"\xf7\x01\n\rFeatureSpecV2\x12\x0c\n\x04name\x18\x01 \x01(\t\x12/\n\nvalue_type\x18\x02 \x01(\x0e\x32\x1b.feast.types.ValueType.Enum\x12\x31\n\x04tags\x18\x03 \x03(\x0b\x32#.feast.core.FeatureSpecV2.TagsEntry\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\x14\n\x0cvector_index\x18\x05 \x01(\x08\x12\x1c\n\x14vector_search_metric\x18\x06 \x01(\t\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42Q\n\x10\x66\x65\x61st.proto.coreB\x0c\x46\x65\x61tureProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -26,7 +26,7 @@ _globals['_FEATURESPECV2_TAGSENTRY']._options = None _globals['_FEATURESPECV2_TAGSENTRY']._serialized_options = b'8\001' _globals['_FEATURESPECV2']._serialized_start=66 - _globals['_FEATURESPECV2']._serialized_end=261 - _globals['_FEATURESPECV2_TAGSENTRY']._serialized_start=218 - _globals['_FEATURESPECV2_TAGSENTRY']._serialized_end=261 + _globals['_FEATURESPECV2']._serialized_end=313 + _globals['_FEATURESPECV2_TAGSENTRY']._serialized_start=270 + _globals['_FEATURESPECV2_TAGSENTRY']._serialized_end=313 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/Feature_pb2.pyi b/sdk/python/feast/protos/feast/core/Feature_pb2.pyi index f4235b0965b..451f1aa61ce 100644 --- a/sdk/python/feast/protos/feast/core/Feature_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/Feature_pb2.pyi @@ -53,6 +53,8 @@ class FeatureSpecV2(google.protobuf.message.Message): VALUE_TYPE_FIELD_NUMBER: builtins.int TAGS_FIELD_NUMBER: builtins.int DESCRIPTION_FIELD_NUMBER: builtins.int + VECTOR_INDEX_FIELD_NUMBER: builtins.int + VECTOR_SEARCH_METRIC_FIELD_NUMBER: builtins.int name: builtins.str """Name of the feature. Not updatable.""" value_type: feast.types.Value_pb2.ValueType.Enum.ValueType @@ -62,6 +64,10 @@ class FeatureSpecV2(google.protobuf.message.Message): """Tags for user defined metadata on a feature""" description: builtins.str """Description of the feature.""" + vector_index: builtins.bool + """Field indicating the vector will be indexed for vector similarity search""" + vector_search_metric: builtins.str + """Metric used for vector similarity search.""" def __init__( self, *, @@ -69,7 +75,9 @@ class FeatureSpecV2(google.protobuf.message.Message): value_type: feast.types.Value_pb2.ValueType.Enum.ValueType = ..., tags: collections.abc.Mapping[builtins.str, builtins.str] | None = ..., description: builtins.str = ..., + vector_index: builtins.bool = ..., + vector_search_metric: builtins.str = ..., ) -> None: ... - def ClearField(self, field_name: typing_extensions.Literal["description", b"description", "name", b"name", "tags", b"tags", "value_type", b"value_type"]) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["description", b"description", "name", b"name", "tags", b"tags", "value_type", b"value_type", "vector_index", b"vector_index", "vector_search_metric", b"vector_search_metric"]) -> None: ... global___FeatureSpecV2 = FeatureSpecV2 diff --git a/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.py b/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.py index 020515a6b89..926b54df288 100644 --- a/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.py +++ b/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.py @@ -20,7 +20,7 @@ from feast.protos.feast.core import Transformation_pb2 as feast_dot_core_dot_Transformation__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$feast/core/OnDemandFeatureView.proto\x12\nfeast.core\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1c\x66\x65\x61st/core/FeatureView.proto\x1a&feast/core/FeatureViewProjection.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\x1a\x1b\x66\x65\x61st/core/DataSource.proto\x1a\x1f\x66\x65\x61st/core/Transformation.proto\"{\n\x13OnDemandFeatureView\x12\x31\n\x04spec\x18\x01 \x01(\x0b\x32#.feast.core.OnDemandFeatureViewSpec\x12\x31\n\x04meta\x18\x02 \x01(\x0b\x32#.feast.core.OnDemandFeatureViewMeta\"\x90\x05\n\x17OnDemandFeatureViewSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12+\n\x08\x66\x65\x61tures\x18\x03 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x41\n\x07sources\x18\x04 \x03(\x0b\x32\x30.feast.core.OnDemandFeatureViewSpec.SourcesEntry\x12\x42\n\x15user_defined_function\x18\x05 \x01(\x0b\x32\x1f.feast.core.UserDefinedFunctionB\x02\x18\x01\x12\x43\n\x16\x66\x65\x61ture_transformation\x18\n \x01(\x0b\x32#.feast.core.FeatureTransformationV2\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12;\n\x04tags\x18\x07 \x03(\x0b\x32-.feast.core.OnDemandFeatureViewSpec.TagsEntry\x12\r\n\x05owner\x18\x08 \x01(\t\x12\x0c\n\x04mode\x18\x0b \x01(\t\x12\x1d\n\x15write_to_online_store\x18\x0c \x01(\x08\x12\x10\n\x08\x65ntities\x18\r \x03(\t\x12\x31\n\x0e\x65ntity_columns\x18\x0e \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x11\n\tsingleton\x18\x0f \x01(\x08\x1aJ\n\x0cSourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.core.OnDemandSource:\x02\x38\x01\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8c\x01\n\x17OnDemandFeatureViewMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc8\x01\n\x0eOnDemandSource\x12/\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureViewH\x00\x12\x44\n\x17\x66\x65\x61ture_view_projection\x18\x03 \x01(\x0b\x32!.feast.core.FeatureViewProjectionH\x00\x12\x35\n\x13request_data_source\x18\x02 \x01(\x0b\x32\x16.feast.core.DataSourceH\x00\x42\x08\n\x06source\"H\n\x13UserDefinedFunction\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\x0c\x12\x11\n\tbody_text\x18\x03 \x01(\t:\x02\x18\x01\x42]\n\x10\x66\x65\x61st.proto.coreB\x18OnDemandFeatureViewProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$feast/core/OnDemandFeatureView.proto\x12\nfeast.core\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1c\x66\x65\x61st/core/FeatureView.proto\x1a&feast/core/FeatureViewProjection.proto\x1a\x18\x66\x65\x61st/core/Feature.proto\x1a\x1b\x66\x65\x61st/core/DataSource.proto\x1a\x1f\x66\x65\x61st/core/Transformation.proto\"{\n\x13OnDemandFeatureView\x12\x31\n\x04spec\x18\x01 \x01(\x0b\x32#.feast.core.OnDemandFeatureViewSpec\x12\x31\n\x04meta\x18\x02 \x01(\x0b\x32#.feast.core.OnDemandFeatureViewMeta\"\x90\x05\n\x17OnDemandFeatureViewSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12+\n\x08\x66\x65\x61tures\x18\x03 \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x41\n\x07sources\x18\x04 \x03(\x0b\x32\x30.feast.core.OnDemandFeatureViewSpec.SourcesEntry\x12\x42\n\x15user_defined_function\x18\x05 \x01(\x0b\x32\x1f.feast.core.UserDefinedFunctionB\x02\x18\x01\x12\x43\n\x16\x66\x65\x61ture_transformation\x18\n \x01(\x0b\x32#.feast.core.FeatureTransformationV2\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12;\n\x04tags\x18\x07 \x03(\x0b\x32-.feast.core.OnDemandFeatureViewSpec.TagsEntry\x12\r\n\x05owner\x18\x08 \x01(\t\x12\x0c\n\x04mode\x18\x0b \x01(\t\x12\x1d\n\x15write_to_online_store\x18\x0c \x01(\x08\x12\x10\n\x08\x65ntities\x18\r \x03(\t\x12\x31\n\x0e\x65ntity_columns\x18\x0e \x03(\x0b\x32\x19.feast.core.FeatureSpecV2\x12\x11\n\tsingleton\x18\x0f \x01(\x08\x1aJ\n\x0cSourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.feast.core.OnDemandSource:\x02\x38\x01\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8c\x01\n\x17OnDemandFeatureViewMeta\x12\x35\n\x11\x63reated_timestamp\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12:\n\x16last_updated_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc8\x01\n\x0eOnDemandSource\x12/\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureViewH\x00\x12\x44\n\x17\x66\x65\x61ture_view_projection\x18\x03 \x01(\x0b\x32!.feast.core.FeatureViewProjectionH\x00\x12\x35\n\x13request_data_source\x18\x02 \x01(\x0b\x32\x16.feast.core.DataSourceH\x00\x42\x08\n\x06source\"H\n\x13UserDefinedFunction\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\x0c\x12\x11\n\tbody_text\x18\x03 \x01(\t:\x02\x18\x01\"X\n\x17OnDemandFeatureViewList\x12=\n\x14ondemandfeatureviews\x18\x01 \x03(\x0b\x32\x1f.feast.core.OnDemandFeatureViewB]\n\x10\x66\x65\x61st.proto.coreB\x18OnDemandFeatureViewProtoZ/github.com/feast-dev/feast/go/protos/feast/coreb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -50,4 +50,6 @@ _globals['_ONDEMANDSOURCE']._serialized_end=1371 _globals['_USERDEFINEDFUNCTION']._serialized_start=1373 _globals['_USERDEFINEDFUNCTION']._serialized_end=1445 + _globals['_ONDEMANDFEATUREVIEWLIST']._serialized_start=1447 + _globals['_ONDEMANDFEATUREVIEWLIST']._serialized_end=1535 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.pyi b/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.pyi index 3380779c97e..c9fca2f550d 100644 --- a/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.pyi +++ b/sdk/python/feast/protos/feast/core/OnDemandFeatureView_pb2.pyi @@ -233,3 +233,18 @@ class UserDefinedFunction(google.protobuf.message.Message): def ClearField(self, field_name: typing_extensions.Literal["body", b"body", "body_text", b"body_text", "name", b"name"]) -> None: ... global___UserDefinedFunction = UserDefinedFunction + +class OnDemandFeatureViewList(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ONDEMANDFEATUREVIEWS_FIELD_NUMBER: builtins.int + @property + def ondemandfeatureviews(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___OnDemandFeatureView]: ... + def __init__( + self, + *, + ondemandfeatureviews: collections.abc.Iterable[global___OnDemandFeatureView] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["ondemandfeatureviews", b"ondemandfeatureviews"]) -> None: ... + +global___OnDemandFeatureViewList = OnDemandFeatureViewList diff --git a/sdk/python/feast/protos/feast/registry/RegistryServer_pb2.py b/sdk/python/feast/protos/feast/registry/RegistryServer_pb2.py index e0cae3da4b7..2d5f7b020ab 100644 --- a/sdk/python/feast/protos/feast/registry/RegistryServer_pb2.py +++ b/sdk/python/feast/protos/feast/registry/RegistryServer_pb2.py @@ -28,13 +28,14 @@ from feast.protos.feast.core import Project_pb2 as feast_dot_core_dot_Project__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#feast/registry/RegistryServer.proto\x12\x0e\x66\x65\x61st.registry\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19\x66\x65\x61st/core/Registry.proto\x1a\x17\x66\x65\x61st/core/Entity.proto\x1a\x1b\x66\x65\x61st/core/DataSource.proto\x1a\x1c\x66\x65\x61st/core/FeatureView.proto\x1a\"feast/core/StreamFeatureView.proto\x1a$feast/core/OnDemandFeatureView.proto\x1a\x1f\x66\x65\x61st/core/FeatureService.proto\x1a\x1d\x66\x65\x61st/core/SavedDataset.proto\x1a\"feast/core/ValidationProfile.proto\x1a\x1c\x66\x65\x61st/core/InfraObject.proto\x1a\x1b\x66\x65\x61st/core/Permission.proto\x1a\x18\x66\x65\x61st/core/Project.proto\"!\n\x0eRefreshRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\"W\n\x12UpdateInfraRequest\x12 \n\x05infra\x18\x01 \x01(\x0b\x32\x11.feast.core.Infra\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"7\n\x0fGetInfraRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\"B\n\x1aListProjectMetadataRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\"T\n\x1bListProjectMetadataResponse\x12\x35\n\x10project_metadata\x18\x01 \x03(\x0b\x32\x1b.feast.core.ProjectMetadata\"\xcb\x01\n\x1b\x41pplyMaterializationRequest\x12-\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureView\x12\x0f\n\x07project\x18\x02 \x01(\t\x12.\n\nstart_date\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_date\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x63ommit\x18\x05 \x01(\x08\"Y\n\x12\x41pplyEntityRequest\x12\"\n\x06\x65ntity\x18\x01 \x01(\x0b\x32\x12.feast.core.Entity\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"F\n\x10GetEntityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xa5\x01\n\x13ListEntitiesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12;\n\x04tags\x18\x03 \x03(\x0b\x32-.feast.registry.ListEntitiesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"<\n\x14ListEntitiesResponse\x12$\n\x08\x65ntities\x18\x01 \x03(\x0b\x32\x12.feast.core.Entity\"D\n\x13\x44\x65leteEntityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"f\n\x16\x41pplyDataSourceRequest\x12+\n\x0b\x64\x61ta_source\x18\x01 \x01(\x0b\x32\x16.feast.core.DataSource\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"J\n\x14GetDataSourceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xab\x01\n\x16ListDataSourcesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12>\n\x04tags\x18\x03 \x03(\x0b\x32\x30.feast.registry.ListDataSourcesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"G\n\x17ListDataSourcesResponse\x12,\n\x0c\x64\x61ta_sources\x18\x01 \x03(\x0b\x32\x16.feast.core.DataSource\"H\n\x17\x44\x65leteDataSourceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"\x81\x02\n\x17\x41pplyFeatureViewRequest\x12/\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureViewH\x00\x12\x41\n\x16on_demand_feature_view\x18\x02 \x01(\x0b\x32\x1f.feast.core.OnDemandFeatureViewH\x00\x12<\n\x13stream_feature_view\x18\x03 \x01(\x0b\x32\x1d.feast.core.StreamFeatureViewH\x00\x12\x0f\n\x07project\x18\x04 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x05 \x01(\x08\x42\x13\n\x11\x62\x61se_feature_view\"K\n\x15GetFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xad\x01\n\x17ListFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12?\n\x04tags\x18\x03 \x03(\x0b\x32\x31.feast.registry.ListFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"J\n\x18ListFeatureViewsResponse\x12.\n\rfeature_views\x18\x01 \x03(\x0b\x32\x17.feast.core.FeatureView\"I\n\x18\x44\x65leteFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"\xd6\x01\n\x0e\x41nyFeatureView\x12/\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureViewH\x00\x12\x41\n\x16on_demand_feature_view\x18\x02 \x01(\x0b\x32\x1f.feast.core.OnDemandFeatureViewH\x00\x12<\n\x13stream_feature_view\x18\x03 \x01(\x0b\x32\x1d.feast.core.StreamFeatureViewH\x00\x42\x12\n\x10\x61ny_feature_view\"N\n\x18GetAnyFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"U\n\x19GetAnyFeatureViewResponse\x12\x38\n\x10\x61ny_feature_view\x18\x01 \x01(\x0b\x32\x1e.feast.registry.AnyFeatureView\"\xb3\x01\n\x1aListAllFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12\x42\n\x04tags\x18\x03 \x03(\x0b\x32\x34.feast.registry.ListAllFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"T\n\x1bListAllFeatureViewsResponse\x12\x35\n\rfeature_views\x18\x01 \x03(\x0b\x32\x1e.feast.registry.AnyFeatureView\"Q\n\x1bGetStreamFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xb9\x01\n\x1dListStreamFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12\x45\n\x04tags\x18\x03 \x03(\x0b\x32\x37.feast.registry.ListStreamFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"]\n\x1eListStreamFeatureViewsResponse\x12;\n\x14stream_feature_views\x18\x01 \x03(\x0b\x32\x1d.feast.core.StreamFeatureView\"S\n\x1dGetOnDemandFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xbd\x01\n\x1fListOnDemandFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12G\n\x04tags\x18\x03 \x03(\x0b\x32\x39.feast.registry.ListOnDemandFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"d\n ListOnDemandFeatureViewsResponse\x12@\n\x17on_demand_feature_views\x18\x01 \x03(\x0b\x32\x1f.feast.core.OnDemandFeatureView\"r\n\x1a\x41pplyFeatureServiceRequest\x12\x33\n\x0f\x66\x65\x61ture_service\x18\x01 \x01(\x0b\x32\x1a.feast.core.FeatureService\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"N\n\x18GetFeatureServiceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xb3\x01\n\x1aListFeatureServicesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12\x42\n\x04tags\x18\x03 \x03(\x0b\x32\x34.feast.registry.ListFeatureServicesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"S\n\x1bListFeatureServicesResponse\x12\x34\n\x10\x66\x65\x61ture_services\x18\x01 \x03(\x0b\x32\x1a.feast.core.FeatureService\"L\n\x1b\x44\x65leteFeatureServiceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"l\n\x18\x41pplySavedDatasetRequest\x12/\n\rsaved_dataset\x18\x01 \x01(\x0b\x32\x18.feast.core.SavedDataset\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"L\n\x16GetSavedDatasetRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xaf\x01\n\x18ListSavedDatasetsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12@\n\x04tags\x18\x03 \x03(\x0b\x32\x32.feast.registry.ListSavedDatasetsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"M\n\x19ListSavedDatasetsResponse\x12\x30\n\x0esaved_datasets\x18\x01 \x03(\x0b\x32\x18.feast.core.SavedDataset\"J\n\x19\x44\x65leteSavedDatasetRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"\x81\x01\n\x1f\x41pplyValidationReferenceRequest\x12=\n\x14validation_reference\x18\x01 \x01(\x0b\x32\x1f.feast.core.ValidationReference\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"S\n\x1dGetValidationReferenceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xbd\x01\n\x1fListValidationReferencesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12G\n\x04tags\x18\x03 \x03(\x0b\x32\x39.feast.registry.ListValidationReferencesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"b\n ListValidationReferencesResponse\x12>\n\x15validation_references\x18\x01 \x03(\x0b\x32\x1f.feast.core.ValidationReference\"Q\n DeleteValidationReferenceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"e\n\x16\x41pplyPermissionRequest\x12*\n\npermission\x18\x01 \x01(\x0b\x32\x16.feast.core.Permission\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"J\n\x14GetPermissionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xab\x01\n\x16ListPermissionsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12>\n\x04tags\x18\x03 \x03(\x0b\x32\x30.feast.registry.ListPermissionsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"F\n\x17ListPermissionsResponse\x12+\n\x0bpermissions\x18\x01 \x03(\x0b\x32\x16.feast.core.Permission\"H\n\x17\x44\x65letePermissionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"K\n\x13\x41pplyProjectRequest\x12$\n\x07project\x18\x01 \x01(\x0b\x32\x13.feast.core.Project\x12\x0e\n\x06\x63ommit\x18\x02 \x01(\x08\"6\n\x11GetProjectRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\"\x94\x01\n\x13ListProjectsRequest\x12\x13\n\x0b\x61llow_cache\x18\x01 \x01(\x08\x12;\n\x04tags\x18\x02 \x03(\x0b\x32-.feast.registry.ListProjectsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"=\n\x14ListProjectsResponse\x12%\n\x08projects\x18\x01 \x03(\x0b\x32\x13.feast.core.Project\"4\n\x14\x44\x65leteProjectRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x02 \x01(\x08\x32\xcb \n\x0eRegistryServer\x12K\n\x0b\x41pplyEntity\x12\".feast.registry.ApplyEntityRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\tGetEntity\x12 .feast.registry.GetEntityRequest\x1a\x12.feast.core.Entity\"\x00\x12[\n\x0cListEntities\x12#.feast.registry.ListEntitiesRequest\x1a$.feast.registry.ListEntitiesResponse\"\x00\x12M\n\x0c\x44\x65leteEntity\x12#.feast.registry.DeleteEntityRequest\x1a\x16.google.protobuf.Empty\"\x00\x12S\n\x0f\x41pplyDataSource\x12&.feast.registry.ApplyDataSourceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12O\n\rGetDataSource\x12$.feast.registry.GetDataSourceRequest\x1a\x16.feast.core.DataSource\"\x00\x12\x64\n\x0fListDataSources\x12&.feast.registry.ListDataSourcesRequest\x1a\'.feast.registry.ListDataSourcesResponse\"\x00\x12U\n\x10\x44\x65leteDataSource\x12\'.feast.registry.DeleteDataSourceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12U\n\x10\x41pplyFeatureView\x12\'.feast.registry.ApplyFeatureViewRequest\x1a\x16.google.protobuf.Empty\"\x00\x12W\n\x11\x44\x65leteFeatureView\x12(.feast.registry.DeleteFeatureViewRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x11GetAnyFeatureView\x12(.feast.registry.GetAnyFeatureViewRequest\x1a).feast.registry.GetAnyFeatureViewResponse\"\x00\x12p\n\x13ListAllFeatureViews\x12*.feast.registry.ListAllFeatureViewsRequest\x1a+.feast.registry.ListAllFeatureViewsResponse\"\x00\x12R\n\x0eGetFeatureView\x12%.feast.registry.GetFeatureViewRequest\x1a\x17.feast.core.FeatureView\"\x00\x12g\n\x10ListFeatureViews\x12\'.feast.registry.ListFeatureViewsRequest\x1a(.feast.registry.ListFeatureViewsResponse\"\x00\x12\x64\n\x14GetStreamFeatureView\x12+.feast.registry.GetStreamFeatureViewRequest\x1a\x1d.feast.core.StreamFeatureView\"\x00\x12y\n\x16ListStreamFeatureViews\x12-.feast.registry.ListStreamFeatureViewsRequest\x1a..feast.registry.ListStreamFeatureViewsResponse\"\x00\x12j\n\x16GetOnDemandFeatureView\x12-.feast.registry.GetOnDemandFeatureViewRequest\x1a\x1f.feast.core.OnDemandFeatureView\"\x00\x12\x7f\n\x18ListOnDemandFeatureViews\x12/.feast.registry.ListOnDemandFeatureViewsRequest\x1a\x30.feast.registry.ListOnDemandFeatureViewsResponse\"\x00\x12[\n\x13\x41pplyFeatureService\x12*.feast.registry.ApplyFeatureServiceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12[\n\x11GetFeatureService\x12(.feast.registry.GetFeatureServiceRequest\x1a\x1a.feast.core.FeatureService\"\x00\x12p\n\x13ListFeatureServices\x12*.feast.registry.ListFeatureServicesRequest\x1a+.feast.registry.ListFeatureServicesResponse\"\x00\x12]\n\x14\x44\x65leteFeatureService\x12+.feast.registry.DeleteFeatureServiceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12W\n\x11\x41pplySavedDataset\x12(.feast.registry.ApplySavedDatasetRequest\x1a\x16.google.protobuf.Empty\"\x00\x12U\n\x0fGetSavedDataset\x12&.feast.registry.GetSavedDatasetRequest\x1a\x18.feast.core.SavedDataset\"\x00\x12j\n\x11ListSavedDatasets\x12(.feast.registry.ListSavedDatasetsRequest\x1a).feast.registry.ListSavedDatasetsResponse\"\x00\x12Y\n\x12\x44\x65leteSavedDataset\x12).feast.registry.DeleteSavedDatasetRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x65\n\x18\x41pplyValidationReference\x12/.feast.registry.ApplyValidationReferenceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x16GetValidationReference\x12-.feast.registry.GetValidationReferenceRequest\x1a\x1f.feast.core.ValidationReference\"\x00\x12\x7f\n\x18ListValidationReferences\x12/.feast.registry.ListValidationReferencesRequest\x1a\x30.feast.registry.ListValidationReferencesResponse\"\x00\x12g\n\x19\x44\x65leteValidationReference\x12\x30.feast.registry.DeleteValidationReferenceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12S\n\x0f\x41pplyPermission\x12&.feast.registry.ApplyPermissionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12O\n\rGetPermission\x12$.feast.registry.GetPermissionRequest\x1a\x16.feast.core.Permission\"\x00\x12\x64\n\x0fListPermissions\x12&.feast.registry.ListPermissionsRequest\x1a\'.feast.registry.ListPermissionsResponse\"\x00\x12U\n\x10\x44\x65letePermission\x12\'.feast.registry.DeletePermissionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12M\n\x0c\x41pplyProject\x12#.feast.registry.ApplyProjectRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x46\n\nGetProject\x12!.feast.registry.GetProjectRequest\x1a\x13.feast.core.Project\"\x00\x12[\n\x0cListProjects\x12#.feast.registry.ListProjectsRequest\x1a$.feast.registry.ListProjectsResponse\"\x00\x12O\n\rDeleteProject\x12$.feast.registry.DeleteProjectRequest\x1a\x16.google.protobuf.Empty\"\x00\x12]\n\x14\x41pplyMaterialization\x12+.feast.registry.ApplyMaterializationRequest\x1a\x16.google.protobuf.Empty\"\x00\x12p\n\x13ListProjectMetadata\x12*.feast.registry.ListProjectMetadataRequest\x1a+.feast.registry.ListProjectMetadataResponse\"\x00\x12K\n\x0bUpdateInfra\x12\".feast.registry.UpdateInfraRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n\x08GetInfra\x12\x1f.feast.registry.GetInfraRequest\x1a\x11.feast.core.Infra\"\x00\x12:\n\x06\x43ommit\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x07Refresh\x12\x1e.feast.registry.RefreshRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x37\n\x05Proto\x12\x16.google.protobuf.Empty\x1a\x14.feast.core.Registry\"\x00\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#feast/registry/RegistryServer.proto\x12\x0e\x66\x65\x61st.registry\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x19\x66\x65\x61st/core/Registry.proto\x1a\x17\x66\x65\x61st/core/Entity.proto\x1a\x1b\x66\x65\x61st/core/DataSource.proto\x1a\x1c\x66\x65\x61st/core/FeatureView.proto\x1a\"feast/core/StreamFeatureView.proto\x1a$feast/core/OnDemandFeatureView.proto\x1a\x1f\x66\x65\x61st/core/FeatureService.proto\x1a\x1d\x66\x65\x61st/core/SavedDataset.proto\x1a\"feast/core/ValidationProfile.proto\x1a\x1c\x66\x65\x61st/core/InfraObject.proto\x1a\x1b\x66\x65\x61st/core/Permission.proto\x1a\x18\x66\x65\x61st/core/Project.proto\"!\n\x0eRefreshRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\"W\n\x12UpdateInfraRequest\x12 \n\x05infra\x18\x01 \x01(\x0b\x32\x11.feast.core.Infra\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"7\n\x0fGetInfraRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\"B\n\x1aListProjectMetadataRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\"T\n\x1bListProjectMetadataResponse\x12\x35\n\x10project_metadata\x18\x01 \x03(\x0b\x32\x1b.feast.core.ProjectMetadata\"\xcb\x01\n\x1b\x41pplyMaterializationRequest\x12-\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureView\x12\x0f\n\x07project\x18\x02 \x01(\t\x12.\n\nstart_date\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_date\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x63ommit\x18\x05 \x01(\x08\"Y\n\x12\x41pplyEntityRequest\x12\"\n\x06\x65ntity\x18\x01 \x01(\x0b\x32\x12.feast.core.Entity\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"F\n\x10GetEntityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xa5\x01\n\x13ListEntitiesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12;\n\x04tags\x18\x03 \x03(\x0b\x32-.feast.registry.ListEntitiesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"<\n\x14ListEntitiesResponse\x12$\n\x08\x65ntities\x18\x01 \x03(\x0b\x32\x12.feast.core.Entity\"D\n\x13\x44\x65leteEntityRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"f\n\x16\x41pplyDataSourceRequest\x12+\n\x0b\x64\x61ta_source\x18\x01 \x01(\x0b\x32\x16.feast.core.DataSource\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"J\n\x14GetDataSourceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xab\x01\n\x16ListDataSourcesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12>\n\x04tags\x18\x03 \x03(\x0b\x32\x30.feast.registry.ListDataSourcesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"G\n\x17ListDataSourcesResponse\x12,\n\x0c\x64\x61ta_sources\x18\x01 \x03(\x0b\x32\x16.feast.core.DataSource\"H\n\x17\x44\x65leteDataSourceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"\x81\x02\n\x17\x41pplyFeatureViewRequest\x12/\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureViewH\x00\x12\x41\n\x16on_demand_feature_view\x18\x02 \x01(\x0b\x32\x1f.feast.core.OnDemandFeatureViewH\x00\x12<\n\x13stream_feature_view\x18\x03 \x01(\x0b\x32\x1d.feast.core.StreamFeatureViewH\x00\x12\x0f\n\x07project\x18\x04 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x05 \x01(\x08\x42\x13\n\x11\x62\x61se_feature_view\"K\n\x15GetFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xad\x01\n\x17ListFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12?\n\x04tags\x18\x03 \x03(\x0b\x32\x31.feast.registry.ListFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"J\n\x18ListFeatureViewsResponse\x12.\n\rfeature_views\x18\x01 \x03(\x0b\x32\x17.feast.core.FeatureView\"I\n\x18\x44\x65leteFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"\xd6\x01\n\x0e\x41nyFeatureView\x12/\n\x0c\x66\x65\x61ture_view\x18\x01 \x01(\x0b\x32\x17.feast.core.FeatureViewH\x00\x12\x41\n\x16on_demand_feature_view\x18\x02 \x01(\x0b\x32\x1f.feast.core.OnDemandFeatureViewH\x00\x12<\n\x13stream_feature_view\x18\x03 \x01(\x0b\x32\x1d.feast.core.StreamFeatureViewH\x00\x42\x12\n\x10\x61ny_feature_view\"N\n\x18GetAnyFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"U\n\x19GetAnyFeatureViewResponse\x12\x38\n\x10\x61ny_feature_view\x18\x01 \x01(\x0b\x32\x1e.feast.registry.AnyFeatureView\"\xb3\x01\n\x1aListAllFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12\x42\n\x04tags\x18\x03 \x03(\x0b\x32\x34.feast.registry.ListAllFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"T\n\x1bListAllFeatureViewsResponse\x12\x35\n\rfeature_views\x18\x01 \x03(\x0b\x32\x1e.feast.registry.AnyFeatureView\"Q\n\x1bGetStreamFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xb9\x01\n\x1dListStreamFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12\x45\n\x04tags\x18\x03 \x03(\x0b\x32\x37.feast.registry.ListStreamFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"]\n\x1eListStreamFeatureViewsResponse\x12;\n\x14stream_feature_views\x18\x01 \x03(\x0b\x32\x1d.feast.core.StreamFeatureView\"S\n\x1dGetOnDemandFeatureViewRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xbd\x01\n\x1fListOnDemandFeatureViewsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12G\n\x04tags\x18\x03 \x03(\x0b\x32\x39.feast.registry.ListOnDemandFeatureViewsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"d\n ListOnDemandFeatureViewsResponse\x12@\n\x17on_demand_feature_views\x18\x01 \x03(\x0b\x32\x1f.feast.core.OnDemandFeatureView\"r\n\x1a\x41pplyFeatureServiceRequest\x12\x33\n\x0f\x66\x65\x61ture_service\x18\x01 \x01(\x0b\x32\x1a.feast.core.FeatureService\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"N\n\x18GetFeatureServiceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xb3\x01\n\x1aListFeatureServicesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12\x42\n\x04tags\x18\x03 \x03(\x0b\x32\x34.feast.registry.ListFeatureServicesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"S\n\x1bListFeatureServicesResponse\x12\x34\n\x10\x66\x65\x61ture_services\x18\x01 \x03(\x0b\x32\x1a.feast.core.FeatureService\"L\n\x1b\x44\x65leteFeatureServiceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"l\n\x18\x41pplySavedDatasetRequest\x12/\n\rsaved_dataset\x18\x01 \x01(\x0b\x32\x18.feast.core.SavedDataset\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"L\n\x16GetSavedDatasetRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xaf\x01\n\x18ListSavedDatasetsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12@\n\x04tags\x18\x03 \x03(\x0b\x32\x32.feast.registry.ListSavedDatasetsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"M\n\x19ListSavedDatasetsResponse\x12\x30\n\x0esaved_datasets\x18\x01 \x03(\x0b\x32\x18.feast.core.SavedDataset\"J\n\x19\x44\x65leteSavedDatasetRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"\x81\x01\n\x1f\x41pplyValidationReferenceRequest\x12=\n\x14validation_reference\x18\x01 \x01(\x0b\x32\x1f.feast.core.ValidationReference\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"S\n\x1dGetValidationReferenceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xbd\x01\n\x1fListValidationReferencesRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12G\n\x04tags\x18\x03 \x03(\x0b\x32\x39.feast.registry.ListValidationReferencesRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"b\n ListValidationReferencesResponse\x12>\n\x15validation_references\x18\x01 \x03(\x0b\x32\x1f.feast.core.ValidationReference\"Q\n DeleteValidationReferenceRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"e\n\x16\x41pplyPermissionRequest\x12*\n\npermission\x18\x01 \x01(\x0b\x32\x16.feast.core.Permission\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"J\n\x14GetPermissionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x03 \x01(\x08\"\xab\x01\n\x16ListPermissionsRequest\x12\x0f\n\x07project\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\x12>\n\x04tags\x18\x03 \x03(\x0b\x32\x30.feast.registry.ListPermissionsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"F\n\x17ListPermissionsResponse\x12+\n\x0bpermissions\x18\x01 \x03(\x0b\x32\x16.feast.core.Permission\"H\n\x17\x44\x65letePermissionRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07project\x18\x02 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x03 \x01(\x08\"K\n\x13\x41pplyProjectRequest\x12$\n\x07project\x18\x01 \x01(\x0b\x32\x13.feast.core.Project\x12\x0e\n\x06\x63ommit\x18\x02 \x01(\x08\"6\n\x11GetProjectRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x61llow_cache\x18\x02 \x01(\x08\"\x94\x01\n\x13ListProjectsRequest\x12\x13\n\x0b\x61llow_cache\x18\x01 \x01(\x08\x12;\n\x04tags\x18\x02 \x03(\x0b\x32-.feast.registry.ListProjectsRequest.TagsEntry\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"=\n\x14ListProjectsResponse\x12%\n\x08projects\x18\x01 \x03(\x0b\x32\x13.feast.core.Project\"4\n\x14\x44\x65leteProjectRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06\x63ommit\x18\x02 \x01(\x08\x32\xcb \n\x0eRegistryServer\x12K\n\x0b\x41pplyEntity\x12\".feast.registry.ApplyEntityRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\tGetEntity\x12 .feast.registry.GetEntityRequest\x1a\x12.feast.core.Entity\"\x00\x12[\n\x0cListEntities\x12#.feast.registry.ListEntitiesRequest\x1a$.feast.registry.ListEntitiesResponse\"\x00\x12M\n\x0c\x44\x65leteEntity\x12#.feast.registry.DeleteEntityRequest\x1a\x16.google.protobuf.Empty\"\x00\x12S\n\x0f\x41pplyDataSource\x12&.feast.registry.ApplyDataSourceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12O\n\rGetDataSource\x12$.feast.registry.GetDataSourceRequest\x1a\x16.feast.core.DataSource\"\x00\x12\x64\n\x0fListDataSources\x12&.feast.registry.ListDataSourcesRequest\x1a\'.feast.registry.ListDataSourcesResponse\"\x00\x12U\n\x10\x44\x65leteDataSource\x12\'.feast.registry.DeleteDataSourceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12U\n\x10\x41pplyFeatureView\x12\'.feast.registry.ApplyFeatureViewRequest\x1a\x16.google.protobuf.Empty\"\x00\x12W\n\x11\x44\x65leteFeatureView\x12(.feast.registry.DeleteFeatureViewRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x11GetAnyFeatureView\x12(.feast.registry.GetAnyFeatureViewRequest\x1a).feast.registry.GetAnyFeatureViewResponse\"\x00\x12p\n\x13ListAllFeatureViews\x12*.feast.registry.ListAllFeatureViewsRequest\x1a+.feast.registry.ListAllFeatureViewsResponse\"\x00\x12R\n\x0eGetFeatureView\x12%.feast.registry.GetFeatureViewRequest\x1a\x17.feast.core.FeatureView\"\x00\x12g\n\x10ListFeatureViews\x12\'.feast.registry.ListFeatureViewsRequest\x1a(.feast.registry.ListFeatureViewsResponse\"\x00\x12\x64\n\x14GetStreamFeatureView\x12+.feast.registry.GetStreamFeatureViewRequest\x1a\x1d.feast.core.StreamFeatureView\"\x00\x12y\n\x16ListStreamFeatureViews\x12-.feast.registry.ListStreamFeatureViewsRequest\x1a..feast.registry.ListStreamFeatureViewsResponse\"\x00\x12j\n\x16GetOnDemandFeatureView\x12-.feast.registry.GetOnDemandFeatureViewRequest\x1a\x1f.feast.core.OnDemandFeatureView\"\x00\x12\x7f\n\x18ListOnDemandFeatureViews\x12/.feast.registry.ListOnDemandFeatureViewsRequest\x1a\x30.feast.registry.ListOnDemandFeatureViewsResponse\"\x00\x12[\n\x13\x41pplyFeatureService\x12*.feast.registry.ApplyFeatureServiceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12[\n\x11GetFeatureService\x12(.feast.registry.GetFeatureServiceRequest\x1a\x1a.feast.core.FeatureService\"\x00\x12p\n\x13ListFeatureServices\x12*.feast.registry.ListFeatureServicesRequest\x1a+.feast.registry.ListFeatureServicesResponse\"\x00\x12]\n\x14\x44\x65leteFeatureService\x12+.feast.registry.DeleteFeatureServiceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12W\n\x11\x41pplySavedDataset\x12(.feast.registry.ApplySavedDatasetRequest\x1a\x16.google.protobuf.Empty\"\x00\x12U\n\x0fGetSavedDataset\x12&.feast.registry.GetSavedDatasetRequest\x1a\x18.feast.core.SavedDataset\"\x00\x12j\n\x11ListSavedDatasets\x12(.feast.registry.ListSavedDatasetsRequest\x1a).feast.registry.ListSavedDatasetsResponse\"\x00\x12Y\n\x12\x44\x65leteSavedDataset\x12).feast.registry.DeleteSavedDatasetRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x65\n\x18\x41pplyValidationReference\x12/.feast.registry.ApplyValidationReferenceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12j\n\x16GetValidationReference\x12-.feast.registry.GetValidationReferenceRequest\x1a\x1f.feast.core.ValidationReference\"\x00\x12\x7f\n\x18ListValidationReferences\x12/.feast.registry.ListValidationReferencesRequest\x1a\x30.feast.registry.ListValidationReferencesResponse\"\x00\x12g\n\x19\x44\x65leteValidationReference\x12\x30.feast.registry.DeleteValidationReferenceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12S\n\x0f\x41pplyPermission\x12&.feast.registry.ApplyPermissionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12O\n\rGetPermission\x12$.feast.registry.GetPermissionRequest\x1a\x16.feast.core.Permission\"\x00\x12\x64\n\x0fListPermissions\x12&.feast.registry.ListPermissionsRequest\x1a\'.feast.registry.ListPermissionsResponse\"\x00\x12U\n\x10\x44\x65letePermission\x12\'.feast.registry.DeletePermissionRequest\x1a\x16.google.protobuf.Empty\"\x00\x12M\n\x0c\x41pplyProject\x12#.feast.registry.ApplyProjectRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x46\n\nGetProject\x12!.feast.registry.GetProjectRequest\x1a\x13.feast.core.Project\"\x00\x12[\n\x0cListProjects\x12#.feast.registry.ListProjectsRequest\x1a$.feast.registry.ListProjectsResponse\"\x00\x12O\n\rDeleteProject\x12$.feast.registry.DeleteProjectRequest\x1a\x16.google.protobuf.Empty\"\x00\x12]\n\x14\x41pplyMaterialization\x12+.feast.registry.ApplyMaterializationRequest\x1a\x16.google.protobuf.Empty\"\x00\x12p\n\x13ListProjectMetadata\x12*.feast.registry.ListProjectMetadataRequest\x1a+.feast.registry.ListProjectMetadataResponse\"\x00\x12K\n\x0bUpdateInfra\x12\".feast.registry.UpdateInfraRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n\x08GetInfra\x12\x1f.feast.registry.GetInfraRequest\x1a\x11.feast.core.Infra\"\x00\x12:\n\x06\x43ommit\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x07Refresh\x12\x1e.feast.registry.RefreshRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x37\n\x05Proto\x12\x16.google.protobuf.Empty\x1a\x14.feast.core.Registry\"\x00\x42\x35Z3github.com/feast-dev/feast/go/protos/feast/registryb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'feast.registry.RegistryServer_pb2', _globals) if _descriptor._USE_C_DESCRIPTORS == False: - DESCRIPTOR._options = None + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z3github.com/feast-dev/feast/go/protos/feast/registry' _globals['_LISTENTITIESREQUEST_TAGSENTRY']._options = None _globals['_LISTENTITIESREQUEST_TAGSENTRY']._serialized_options = b'8\001' _globals['_LISTDATASOURCESREQUEST_TAGSENTRY']._options = None diff --git a/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py b/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py index 8e40630cfff..ce4db37a658 100644 --- a/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py +++ b/sdk/python/feast/protos/feast/serving/GrpcServer_pb2.py @@ -15,13 +15,14 @@ from feast.protos.feast.serving import ServingService_pb2 as feast_dot_serving_dot_ServingService__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x66\x65\x61st/serving/GrpcServer.proto\x1a\"feast/serving/ServingService.proto\"\xb3\x01\n\x0bPushRequest\x12,\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32\x1a.PushRequest.FeaturesEntry\x12\x1b\n\x13stream_feature_view\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x12\n\n\x02to\x18\x04 \x01(\t\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x1e\n\x0cPushResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\"\xc1\x01\n\x19WriteToOnlineStoreRequest\x12:\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32(.WriteToOnlineStoreRequest.FeaturesEntry\x12\x19\n\x11\x66\x65\x61ture_view_name\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\",\n\x1aWriteToOnlineStoreResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\x32\xf1\x01\n\x11GrpcFeatureServer\x12%\n\x04Push\x12\x0c.PushRequest\x1a\r.PushResponse\"\x00\x12M\n\x12WriteToOnlineStore\x12\x1a.WriteToOnlineStoreRequest\x1a\x1b.WriteToOnlineStoreResponse\x12\x66\n\x11GetOnlineFeatures\x12\'.feast.serving.GetOnlineFeaturesRequest\x1a(.feast.serving.GetOnlineFeaturesResponseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1e\x66\x65\x61st/serving/GrpcServer.proto\x1a\"feast/serving/ServingService.proto\"\xb3\x01\n\x0bPushRequest\x12,\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32\x1a.PushRequest.FeaturesEntry\x12\x1b\n\x13stream_feature_view\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x12\n\n\x02to\x18\x04 \x01(\t\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x1e\n\x0cPushResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\"\xc1\x01\n\x19WriteToOnlineStoreRequest\x12:\n\x08\x66\x65\x61tures\x18\x01 \x03(\x0b\x32(.WriteToOnlineStoreRequest.FeaturesEntry\x12\x19\n\x11\x66\x65\x61ture_view_name\x18\x02 \x01(\t\x12\x1c\n\x14\x61llow_registry_cache\x18\x03 \x01(\x08\x1a/\n\rFeaturesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\",\n\x1aWriteToOnlineStoreResponse\x12\x0e\n\x06status\x18\x01 \x01(\x08\x32\xf1\x01\n\x11GrpcFeatureServer\x12%\n\x04Push\x12\x0c.PushRequest\x1a\r.PushResponse\"\x00\x12M\n\x12WriteToOnlineStore\x12\x1a.WriteToOnlineStoreRequest\x1a\x1b.WriteToOnlineStoreResponse\x12\x66\n\x11GetOnlineFeatures\x12\'.feast.serving.GetOnlineFeaturesRequest\x1a(.feast.serving.GetOnlineFeaturesResponseB4Z2github.com/feast-dev/feast/go/protos/feast/servingb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'feast.serving.GrpcServer_pb2', _globals) if _descriptor._USE_C_DESCRIPTORS == False: - DESCRIPTOR._options = None + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z2github.com/feast-dev/feast/go/protos/feast/serving' _globals['_PUSHREQUEST_FEATURESENTRY']._options = None _globals['_PUSHREQUEST_FEATURESENTRY']._serialized_options = b'8\001' _globals['_WRITETOONLINESTOREREQUEST_FEATURESENTRY']._options = None diff --git a/sdk/python/feast/registry_server.py b/sdk/python/feast/registry_server.py index 181dc79656e..c9abf62ccd7 100644 --- a/sdk/python/feast/registry_server.py +++ b/sdk/python/feast/registry_server.py @@ -792,9 +792,10 @@ def start_server( reflection.enable_server_reflection(service_names_available_for_reflection, server) if tls_cert_path and tls_key_path: - with open(tls_cert_path, "rb") as cert_file, open( - tls_key_path, "rb" - ) as key_file: + with ( + open(tls_cert_path, "rb") as cert_file, + open(tls_key_path, "rb") as key_file, + ): certificate_chain = cert_file.read() private_key = key_file.read() server_credentials = grpc.ssl_server_credentials( diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 185a8723ada..66b3f201594 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -70,7 +70,7 @@ "dynamodb": "feast.infra.online_stores.dynamodb.DynamoDBOnlineStore", "snowflake.online": "feast.infra.online_stores.snowflake.SnowflakeOnlineStore", "bigtable": "feast.infra.online_stores.bigtable.BigtableOnlineStore", - "postgres": "feast.infra.online_stores.postgres_online_store.PostgreSQLOnlineStore", + "postgres": "feast.infra.online_stores.postgres_online_store.postgres.PostgreSQLOnlineStore", "hbase": "feast.infra.online_stores.hbase_online_store.hbase.HbaseOnlineStore", "cassandra": "feast.infra.online_stores.cassandra_online_store.cassandra_online_store.CassandraOnlineStore", "mysql": "feast.infra.online_stores.mysql_online_store.mysql.MySQLOnlineStore", @@ -80,7 +80,8 @@ "remote": "feast.infra.online_stores.remote.RemoteOnlineStore", "singlestore": "feast.infra.online_stores.singlestore_online_store.singlestore.SingleStoreOnlineStore", "qdrant": "feast.infra.online_stores.cqdrant.QdrantOnlineStore", - "couchbase": "feast.infra.online_stores.couchbase_online_store.couchbase.CouchbaseOnlineStore", + "couchbase.online": "feast.infra.online_stores.couchbase_online_store.couchbase.CouchbaseOnlineStore", + "milvus": "feast.infra.online_stores.milvus_online_store.milvus.MilvusOnlineStore", **LEGACY_ONLINE_STORE_CLASS_FOR_TYPE, } @@ -97,6 +98,7 @@ "mssql": "feast.infra.offline_stores.contrib.mssql_offline_store.mssql.MsSqlServerOfflineStore", "duckdb": "feast.infra.offline_stores.duckdb.DuckDBOfflineStore", "remote": "feast.infra.offline_stores.remote.RemoteOfflineStore", + "couchbase.offline": "feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase.CouchbaseColumnarOfflineStore", } FEATURE_SERVER_CONFIG_CLASS_FOR_TYPE = { @@ -368,14 +370,14 @@ def _validate_auth_config(cls, values: Any) -> Any: ) elif values["auth"]["type"] not in ALLOWED_AUTH_TYPES: raise ValueError( - f'auth configuration has invalid authentication type={values["auth"]["type"]}. Possible ' - f'values={ALLOWED_AUTH_TYPES}' + f"auth configuration has invalid authentication type={values['auth']['type']}. Possible " + f"values={ALLOWED_AUTH_TYPES}" ) elif isinstance(values["auth"], AuthConfig): if values["auth"].type not in ALLOWED_AUTH_TYPES: raise ValueError( - f'auth configuration has invalid authentication type={values["auth"].type}. Possible ' - f'values={ALLOWED_AUTH_TYPES}' + f"auth configuration has invalid authentication type={values['auth'].type}. Possible " + f"values={ALLOWED_AUTH_TYPES}" ) return values diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py index 4db0bbc6fdb..a3bf52fb10e 100644 --- a/sdk/python/feast/repo_operations.py +++ b/sdk/python/feast/repo_operations.py @@ -43,6 +43,7 @@ def py_path_to_module(path: Path) -> str: str(path.relative_to(os.getcwd()))[: -len(".py")] .replace("./", "") .replace("/", ".") + .replace("\\", ".") ) diff --git a/sdk/python/feast/ssl_ca_trust_store_setup.py b/sdk/python/feast/ssl_ca_trust_store_setup.py new file mode 100644 index 00000000000..72e84132187 --- /dev/null +++ b/sdk/python/feast/ssl_ca_trust_store_setup.py @@ -0,0 +1,22 @@ +import logging +import os + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +def configure_ca_trust_store_env_variables(): + """ + configures the environment variable so that other libraries or servers refer to the TLS ca file path. + :param ca_file_path: + :return: + """ + if ( + "FEAST_CA_CERT_FILE_PATH" in os.environ + and os.environ["FEAST_CA_CERT_FILE_PATH"] + ): + logger.info( + f"Feast CA Cert file path found in environment variable FEAST_CA_CERT_FILE_PATH={os.environ['FEAST_CA_CERT_FILE_PATH']}. Going to refer this path." + ) + os.environ["SSL_CERT_FILE"] = os.environ["FEAST_CA_CERT_FILE_PATH"] + os.environ["REQUESTS_CA_BUNDLE"] = os.environ["FEAST_CA_CERT_FILE_PATH"] diff --git a/sdk/python/feast/static/chat/index.html b/sdk/python/feast/static/chat/index.html new file mode 100644 index 00000000000..302c3b55b6a --- /dev/null +++ b/sdk/python/feast/static/chat/index.html @@ -0,0 +1,129 @@ + + + + + + Feast Chat + + + +
+
Hello! How can I help you today?
+
+
+ + +
+ + + diff --git a/sdk/python/feast/stream_feature_view.py b/sdk/python/feast/stream_feature_view.py index 50e1a221456..42802993226 100644 --- a/sdk/python/feast/stream_feature_view.py +++ b/sdk/python/feast/stream_feature_view.py @@ -31,7 +31,8 @@ from feast.protos.feast.core.Transformation_pb2 import ( UserDefinedFunctionV2 as UserDefinedFunctionProtoV2, ) -from feast.transformation.pandas_transformation import PandasTransformation +from feast.transformation.base import Transformation +from feast.transformation.mode import TransformationMode warnings.simplefilter("once", RuntimeWarning) @@ -75,12 +76,12 @@ class StreamFeatureView(FeatureView): tags: Dict[str, str] owner: str aggregations: List[Aggregation] - mode: str + mode: Union[TransformationMode, str] timestamp_field: str materialization_intervals: List[Tuple[datetime, datetime]] udf: Optional[FunctionType] udf_string: Optional[str] - feature_transformation: Optional[PandasTransformation] + feature_transformation: Optional[Transformation] def __init__( self, @@ -95,11 +96,11 @@ def __init__( owner: str = "", schema: Optional[List[Field]] = None, aggregations: Optional[List[Aggregation]] = None, - mode: Optional[str] = "spark", + mode: Union[str, TransformationMode] = TransformationMode.PYTHON, timestamp_field: Optional[str] = "", udf: Optional[FunctionType] = None, udf_string: Optional[str] = "", - feature_transformation: Optional[Union[PandasTransformation]] = None, + feature_transformation: Optional[Transformation] = None, ): if not flags_helper.is_test(): warnings.warn( @@ -123,11 +124,13 @@ def __init__( ) self.aggregations = aggregations or [] - self.mode = mode or "" + self.mode = mode self.timestamp_field = timestamp_field or "" self.udf = udf self.udf_string = udf_string - self.feature_transformation = feature_transformation + self.feature_transformation = ( + feature_transformation or self.get_feature_transformation() + ) super().__init__( name=name, @@ -141,6 +144,23 @@ def __init__( source=source, ) + def get_feature_transformation(self) -> Optional[Transformation]: + if not self.udf: + # TODO: Currently StreamFeatureView allow no transformation, but this should be removed in the future + return None + if self.mode in ( + TransformationMode.PANDAS, + TransformationMode.PYTHON, + TransformationMode.SPARK, + ) or self.mode in ("pandas", "python", "spark"): + return Transformation( + mode=self.mode, udf=self.udf, udf_string=self.udf_string or "" + ) + else: + raise ValueError( + f"Unsupported transformation mode: {self.mode} for StreamFeatureView" + ) + def __eq__(self, other): if not isinstance(other, StreamFeatureView): raise TypeError("Comparisons should only involve StreamFeatureViews") @@ -198,6 +218,10 @@ def to_proto(self): user_defined_function=udf_proto_v2, ) + mode = ( + self.mode.value if isinstance(self.mode, TransformationMode) else self.mode + ) + spec = StreamFeatureViewSpecProto( name=self.name, entities=self.entities, @@ -214,7 +238,7 @@ def to_proto(self): stream_source=stream_source_proto or None, timestamp_field=self.timestamp_field, aggregations=[agg.to_proto() for agg in self.aggregations], - mode=self.mode, + mode=mode, ) return StreamFeatureViewProto(spec=spec, meta=meta) @@ -264,9 +288,6 @@ def from_proto(cls, sfv_proto): mode=sfv_proto.spec.mode, udf=udf, udf_string=udf_string, - feature_transformation=PandasTransformation(udf, udf_string) - if udf - else None, aggregations=[ Aggregation.from_proto(agg_proto) for agg_proto in sfv_proto.spec.aggregations @@ -323,6 +344,7 @@ def __copy__(self): timestamp_field=self.timestamp_field, source=self.stream_source if self.stream_source else self.batch_source, udf=self.udf, + udf_string=self.udf_string, feature_transformation=self.feature_transformation, ) fv.entities = self.entities @@ -373,7 +395,6 @@ def decorator(user_function): schema=schema, udf=user_function, udf_string=udf_string, - feature_transformation=PandasTransformation(user_function, udf_string), description=description, tags=tags, online=online, diff --git a/sdk/python/feast/templates/cassandra/bootstrap.py b/sdk/python/feast/templates/cassandra/bootstrap.py index 33385141145..16c82316258 100644 --- a/sdk/python/feast/templates/cassandra/bootstrap.py +++ b/sdk/python/feast/templates/cassandra/bootstrap.py @@ -57,7 +57,7 @@ def collect_cassandra_store_settings(): # it's regular Cassandra c_secure_bundle_path = None hosts_string = click.prompt( - ("Enter the seed hosts of your cluster " "(comma-separated IP addresses)"), + ("Enter the seed hosts of your cluster (comma-separated IP addresses)"), default="127.0.0.1", ) c_hosts = [ diff --git a/sdk/python/feast/templates/couchbase/__init__.py b/sdk/python/feast/templates/couchbase/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/templates/couchbase/bootstrap.py b/sdk/python/feast/templates/couchbase/bootstrap.py new file mode 100644 index 00000000000..1034e14e0de --- /dev/null +++ b/sdk/python/feast/templates/couchbase/bootstrap.py @@ -0,0 +1,108 @@ +import click +from couchbase_columnar.cluster import Cluster +from couchbase_columnar.common.errors import InvalidCredentialError +from couchbase_columnar.credential import Credential +from couchbase_columnar.options import ClusterOptions, QueryOptions, TimeoutOptions + +from feast.file_utils import replace_str_in_file +from feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase import ( + CouchbaseColumnarOfflineStoreConfig, + df_to_columnar, +) + + +def bootstrap(): + # Bootstrap() will automatically be called from the init_repo() during `feast init` + + import pathlib + from datetime import datetime, timedelta + + from feast.driver_test_data import create_driver_hourly_stats_df + + repo_path = pathlib.Path(__file__).parent.absolute() / "feature_repo" + config_file = repo_path / "feature_store.yaml" + + if click.confirm("Configure Couchbase Online Store?", default=True): + connection_string = click.prompt( + "Couchbase Connection String", default="couchbase://127.0.0.1" + ) + user = click.prompt("Couchbase Username", default="Administrator") + password = click.prompt("Couchbase Password", hide_input=True) + bucket_name = click.prompt("Couchbase Bucket Name", default="feast") + kv_port = click.prompt("Couchbase KV Port", default=11210) + + replace_str_in_file( + config_file, "COUCHBASE_CONNECTION_STRING", connection_string + ) + replace_str_in_file(config_file, "COUCHBASE_USER", user) + replace_str_in_file(config_file, "COUCHBASE_PASSWORD", password) + replace_str_in_file(config_file, "COUCHBASE_BUCKET_NAME", bucket_name) + replace_str_in_file(config_file, "COUCHBASE_KV_PORT", str(kv_port)) + + if click.confirm( + "Configure Couchbase Columnar Offline Store? (Note: requires Couchbase Capella Columnar)", + default=True, + ): + end_date = datetime.now().replace(microsecond=0, second=0, minute=0) + start_date = end_date - timedelta(days=15) + + driver_entities = [1001, 1002, 1003, 1004, 1005] + driver_df = create_driver_hourly_stats_df(driver_entities, start_date, end_date) + + columnar_connection_string = click.prompt("Columnar Connection String") + columnar_user = click.prompt("Columnar Username") + columnar_password = click.prompt("Columnar Password", hide_input=True) + columnar_timeout = click.prompt("Couchbase Columnar Timeout", default=120) + + if click.confirm( + 'Should I upload example data to Couchbase Capella Columnar (overwriting "Default.Default.feast_driver_hourly_stats" table)?', + default=True, + ): + cred = Credential.from_username_and_password( + columnar_user, columnar_password + ) + timeout_opts = TimeoutOptions(dispatch_timeout=timedelta(seconds=120)) + cluster = Cluster.create_instance( + columnar_connection_string, + cred, + ClusterOptions(timeout_options=timeout_opts), + ) + + table_name = "Default.Default.feast_driver_hourly_stats" + try: + cluster.execute_query( + f"DROP COLLECTION {table_name} IF EXISTS", + QueryOptions(timeout=timedelta(seconds=columnar_timeout)), + ) + except InvalidCredentialError: + print("Error: Invalid Cluster Credentials.") + return + + offline_store = CouchbaseColumnarOfflineStoreConfig( + type="couchbase.offline", + connection_string=columnar_connection_string, + user=columnar_user, + password=columnar_password, + timeout=columnar_timeout, + ) + + df_to_columnar( + df=driver_df, table_name=table_name, offline_store=offline_store + ) + + replace_str_in_file( + config_file, + "COUCHBASE_COLUMNAR_CONNECTION_STRING", + columnar_connection_string, + ) + replace_str_in_file(config_file, "COUCHBASE_COLUMNAR_USER", columnar_user) + replace_str_in_file( + config_file, "COUCHBASE_COLUMNAR_PASSWORD", columnar_password + ) + replace_str_in_file( + config_file, "COUCHBASE_COLUMNAR_TIMEOUT", str(columnar_timeout) + ) + + +if __name__ == "__main__": + bootstrap() diff --git a/sdk/python/feast/templates/couchbase/feature_repo/__init__.py b/sdk/python/feast/templates/couchbase/feature_repo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/templates/couchbase/feature_repo/example_repo.py b/sdk/python/feast/templates/couchbase/feature_repo/example_repo.py new file mode 100644 index 00000000000..363ba3c4664 --- /dev/null +++ b/sdk/python/feast/templates/couchbase/feature_repo/example_repo.py @@ -0,0 +1,134 @@ +# This is an example feature definition file + +from datetime import timedelta + +import pandas as pd + +from feast import Entity, FeatureService, FeatureView, Field, PushSource, RequestSource +from feast.infra.offline_stores.contrib.couchbase_offline_store.couchbase_source import ( + CouchbaseColumnarSource, +) +from feast.on_demand_feature_view import on_demand_feature_view +from feast.types import Float32, Float64, Int64 + +# Define an entity for the driver. You can think of an entity as a primary key used to +# fetch features. +driver = Entity(name="driver", join_keys=["driver_id"]) + +driver_stats_source = CouchbaseColumnarSource( + name="driver_hourly_stats_source", + query="SELECT * FROM Default.Default.`feast_driver_hourly_stats`", + database="Default", + scope="Default", + collection="feast_driver_hourly_stats", + timestamp_field="event_timestamp", + created_timestamp_column="created", +) + +# Our parquet files contain sample data that includes a driver_id column, timestamps and +# three feature column. Here we define a Feature View that will allow us to serve this +# data to our model online. +driver_stats_fv = FeatureView( + # The unique name of this feature view. Two feature views in a single + # project cannot have the same name + name="driver_hourly_stats", + entities=[driver], + ttl=timedelta(days=1), + # The list of features defined below act as a schema to both define features + # for both materialization of features into a store, and are used as references + # during retrieval for building a training dataset or serving features + schema=[ + Field(name="conv_rate", dtype=Float32), + Field(name="acc_rate", dtype=Float32), + Field(name="avg_daily_trips", dtype=Int64), + ], + online=True, + source=driver_stats_source, + # Tags are user defined key/value pairs that are attached to each + # feature view + tags={"team": "driver_performance"}, +) + +# Define a request data source which encodes features / information only +# available at request time (e.g. part of the user initiated HTTP request) +input_request = RequestSource( + name="vals_to_add", + schema=[ + Field(name="val_to_add", dtype=Int64), + Field(name="val_to_add_2", dtype=Int64), + ], +) + + +# Define an on demand feature view which can generate new features based on +# existing feature views and RequestSource features +@on_demand_feature_view( + sources=[driver_stats_fv, input_request], + schema=[ + Field(name="conv_rate_plus_val1", dtype=Float64), + Field(name="conv_rate_plus_val2", dtype=Float64), + ], +) +def transformed_conv_rate(inputs: pd.DataFrame) -> pd.DataFrame: + df = pd.DataFrame() + df["conv_rate_plus_val1"] = inputs["conv_rate"] + inputs["val_to_add"] + df["conv_rate_plus_val2"] = inputs["conv_rate"] + inputs["val_to_add_2"] + return df + + +# This groups features into a model version +driver_activity_v1 = FeatureService( + name="driver_activity_v1", + features=[ + driver_stats_fv[["conv_rate"]], # Sub-selects a feature from a feature view + transformed_conv_rate, # Selects all features from the feature view + ], +) +driver_activity_v2 = FeatureService( + name="driver_activity_v2", features=[driver_stats_fv, transformed_conv_rate] +) + +# Defines a way to push data (to be available offline, online or both) into Feast. +driver_stats_push_source = PushSource( + name="driver_stats_push_source", + batch_source=driver_stats_source, +) + +# Defines a slightly modified version of the feature view from above, where the source +# has been changed to the push source. This allows fresh features to be directly pushed +# to the online store for this feature view. +driver_stats_fresh_fv = FeatureView( + name="driver_hourly_stats_fresh", + entities=[driver], + ttl=timedelta(days=1), + schema=[ + Field(name="conv_rate", dtype=Float32), + Field(name="acc_rate", dtype=Float32), + Field(name="avg_daily_trips", dtype=Int64), + ], + online=True, + source=driver_stats_push_source, # Changed from above + tags={"team": "driver_performance"}, +) + + +# Define an on demand feature view which can generate new features based on +# existing feature views and RequestSource features +@on_demand_feature_view( + sources=[driver_stats_fresh_fv, input_request], # relies on fresh version of FV + schema=[ + Field(name="conv_rate_plus_val1", dtype=Float64), + Field(name="conv_rate_plus_val2", dtype=Float64), + ], +) +def transformed_conv_rate_fresh(inputs: pd.DataFrame) -> pd.DataFrame: + df = pd.DataFrame() + df["conv_rate_plus_val1"] = inputs["conv_rate"] + inputs["val_to_add"] + df["conv_rate_plus_val2"] = inputs["conv_rate"] + inputs["val_to_add_2"] + return df + + +driver_activity_v3 = FeatureService( + name="driver_activity_v3", + features=[driver_stats_fresh_fv, transformed_conv_rate_fresh], +) diff --git a/sdk/python/feast/templates/couchbase/feature_repo/feature_store.yaml b/sdk/python/feast/templates/couchbase/feature_repo/feature_store.yaml new file mode 100644 index 00000000000..96f45934eb5 --- /dev/null +++ b/sdk/python/feast/templates/couchbase/feature_repo/feature_store.yaml @@ -0,0 +1,17 @@ +project: my_project +registry: data/registry.db +provider: local +online_store: + type: couchbase.online + connection_string: COUCHBASE_CONNECTION_STRING # Couchbase connection string, copied from 'Connect' page in Couchbase Capella console + user: COUCHBASE_USER # Couchbase username from database access credentials + password: COUCHBASE_PASSWORD # Couchbase password from database access credentials + bucket_name: COUCHBASE_BUCKET_NAME # Couchbase bucket name, defaults to feast + kv_port: COUCHBASE_KV_PORT # Couchbase key-value port, defaults to 11210. Required if custom ports are used. +offline_store: + type: couchbase.offline + connection_string: COUCHBASE_COLUMNAR_CONNECTION_STRING # Copied from Settings > Connection String page in Capella Columnar console, starts with couchbases:// + user: COUCHBASE_COLUMNAR_USER # Couchbase cluster access name from Settings > Access Control page in Capella Columnar console + password: COUCHBASE_COLUMNAR_PASSWORD # Couchbase password from Settings > Access Control page in Capella Columnar console + timeout: COUCHBASE_COLUMNAR_TIMEOUT # Timeout in seconds for Columnar operations, optional +entity_key_serialization_version: 2 diff --git a/sdk/python/feast/templates/couchbase/feature_repo/test_workflow.py b/sdk/python/feast/templates/couchbase/feature_repo/test_workflow.py new file mode 100644 index 00000000000..192d575181c --- /dev/null +++ b/sdk/python/feast/templates/couchbase/feature_repo/test_workflow.py @@ -0,0 +1,112 @@ +import os.path +import subprocess +from datetime import datetime + +import pandas as pd + +from feast import FeatureStore + + +def run_demo(): + store = FeatureStore(repo_path=os.path.dirname(__file__)) + print("\n--- Run feast apply to setup feature store on Couchbase ---") + subprocess.run(["feast", "--chdir", os.path.dirname(__file__), "apply"]) + + print("\n--- Historical features for training ---") + fetch_historical_features_entity_df(store, for_batch_scoring=False) + + print("\n--- Historical features for batch scoring ---") + fetch_historical_features_entity_df(store, for_batch_scoring=True) + + print("\n--- Load features into online store ---") + store.materialize_incremental(end_date=datetime.now()) + + print("\n--- Online features ---") + fetch_online_features(store) + + print("\n--- Online features retrieved (instead) through a feature service---") + fetch_online_features(store, source="feature_service") + + print( + "\n--- Online features retrieved (using feature service v3, which uses a feature view with a push source---" + ) + fetch_online_features(store, source="push") + + print("\n--- Online features again with updated values from a stream push---") + fetch_online_features(store, source="push") + + print("\n--- Run feast teardown ---") + subprocess.run(["feast", "--chdir", os.path.dirname(__file__), "teardown"]) + + +def fetch_historical_features_entity_df(store: FeatureStore, for_batch_scoring: bool): + # Note: see https://docs.feast.dev/getting-started/concepts/feature-retrieval for more details on how to retrieve + # for all entities in the offline store instead + entity_df = pd.DataFrame.from_dict( + { + # entity's join key -> entity values + "driver_id": [1001, 1002, 1003], + # "event_timestamp" (reserved key) -> timestamps + "event_timestamp": [ + datetime(2021, 4, 12, 10, 59, 42), + datetime(2021, 4, 12, 8, 12, 10), + datetime(2021, 4, 12, 16, 40, 26), + ], + # (optional) label name -> label values. Feast does not process these + "label_driver_reported_satisfaction": [1, 5, 3], + # values we're using for an on-demand transformation + "val_to_add": [1, 2, 3], + "val_to_add_2": [10, 20, 30], + } + ) + # For batch scoring, we want the latest timestamps + if for_batch_scoring: + entity_df["event_timestamp"] = pd.to_datetime("now", utc=True) + + training_df = store.get_historical_features( + entity_df=entity_df, + features=[ + "driver_hourly_stats:conv_rate", + "driver_hourly_stats:acc_rate", + "driver_hourly_stats:avg_daily_trips", + "transformed_conv_rate:conv_rate_plus_val1", + "transformed_conv_rate:conv_rate_plus_val2", + ], + ).to_df() + print(training_df.head()) + + +def fetch_online_features(store, source: str = ""): + entity_rows = [ + # {join_key: entity_value} + { + "driver_id": 1001, + "val_to_add": 1000, + "val_to_add_2": 2000, + }, + { + "driver_id": 1002, + "val_to_add": 1001, + "val_to_add_2": 2002, + }, + ] + if source == "feature_service": + features_to_fetch = store.get_feature_service("driver_activity_v1") + elif source == "push": + features_to_fetch = store.get_feature_service("driver_activity_v3") + else: + features_to_fetch = [ + "driver_hourly_stats:acc_rate", + "transformed_conv_rate:conv_rate_plus_val1", + "transformed_conv_rate:conv_rate_plus_val2", + ] + returned_features = store.get_online_features( + features=features_to_fetch, + entity_rows=entity_rows, + ).to_dict() + for key, value in sorted(returned_features.items()): + print(key, " : ", value) + + +if __name__ == "__main__": + run_demo() diff --git a/sdk/python/feast/templates/couchbase/gitignore b/sdk/python/feast/templates/couchbase/gitignore new file mode 100644 index 00000000000..e86277f60f4 --- /dev/null +++ b/sdk/python/feast/templates/couchbase/gitignore @@ -0,0 +1,45 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +*.egg-info/ +dist/ +build/ +.venv + +# Pytest +.cache +*.cover +*.log +.coverage +nosetests.xml +coverage.xml +*.hypothesis/ +*.pytest_cache/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IDEs and Editors +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project + +# OS generated files +.DS_Store +Thumbs.db diff --git a/sdk/python/feast/transformation/base.py b/sdk/python/feast/transformation/base.py new file mode 100644 index 00000000000..74a1575d638 --- /dev/null +++ b/sdk/python/feast/transformation/base.py @@ -0,0 +1,119 @@ +import functools +from abc import ABC +from typing import Any, Callable, Dict, Optional, Union + +import dill + +from feast.protos.feast.core.Transformation_pb2 import ( + SubstraitTransformationV2 as SubstraitTransformationProto, +) +from feast.protos.feast.core.Transformation_pb2 import ( + UserDefinedFunctionV2 as UserDefinedFunctionProto, +) +from feast.transformation.factory import ( + TRANSFORMATION_CLASS_FOR_TYPE, + get_transformation_class_from_type, +) +from feast.transformation.mode import TransformationMode + + +class Transformation(ABC): + udf: Callable[[Any], Any] + udf_string: str + + def __new__( + cls, + mode: Union[TransformationMode, str], + udf: Callable[[Any], Any], + udf_string: str, + name: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + description: str = "", + owner: str = "", + *args, + **kwargs, + ) -> "Transformation": + if cls is Transformation: + if isinstance(mode, TransformationMode): + mode = mode.value + + if mode.lower() in TRANSFORMATION_CLASS_FOR_TYPE: + subclass = get_transformation_class_from_type(mode.lower()) + return super().__new__(subclass) + + raise ValueError( + f"Invalid mode: {mode}. Choose one from TransformationMode." + ) + + return super().__new__(cls) + + def __init__( + self, + mode: Union[TransformationMode, str], + udf: Callable[[Any], Any], + udf_string: str, + name: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + description: str = "", + owner: str = "", + ): + self.mode = mode if isinstance(mode, str) else mode.value + self.udf = udf + self.udf_string = udf_string + self.name = name + self.tags = tags or {} + self.description = description + self.owner = owner + + def to_proto(self) -> Union[UserDefinedFunctionProto, SubstraitTransformationProto]: + return UserDefinedFunctionProto( + name=self.udf.__name__, + body=dill.dumps(self.udf, recurse=True), + body_text=self.udf_string, + ) + + def transform(self, inputs: Any) -> Any: + raise NotImplementedError + + def transform_arrow(self, *args, **kwargs) -> Any: + pass + + def transform_singleton(self, *args, **kwargs) -> Any: + pass + + def infer_features(self, *args, **kwargs) -> Any: + raise NotImplementedError + + def __deepcopy__(self, memo: Optional[Dict[int, Any]] = None) -> "Transformation": + return Transformation(mode=self.mode, udf=self.udf, udf_string=self.udf_string) + + +def transformation( + mode: Union[TransformationMode, str], + name: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + description: Optional[str] = "", + owner: Optional[str] = "", +): + def mainify(obj): + # Needed to allow dill to properly serialize the udf. Otherwise, clients will need to have a file with the same + # name as the original file defining the sfv. + if obj.__module__ != "__main__": + obj.__module__ = "__main__" + + def decorator(user_function): + udf_string = dill.source.getsource(user_function) + mainify(user_function) + transformation_obj = Transformation( + mode=mode, + name=name or user_function.__name__, + tags=tags, + description=description, + owner=owner, + udf=user_function, + udf_string=udf_string, + ) + functools.update_wrapper(wrapper=transformation_obj, wrapped=user_function) + return transformation_obj + + return decorator diff --git a/sdk/python/feast/transformation/factory.py b/sdk/python/feast/transformation/factory.py new file mode 100644 index 00000000000..5097d71353a --- /dev/null +++ b/sdk/python/feast/transformation/factory.py @@ -0,0 +1,22 @@ +from feast.importer import import_class + +TRANSFORMATION_CLASS_FOR_TYPE = { + "python": "feast.transformation.python_transformation.PythonTransformation", + "pandas": "feast.transformation.pandas_transformation.PandasTransformation", + "substrait": "feast.transformation.substrait_transformation.SubstraitTransformation", + "sql": "feast.transformation.sql_transformation.SQLTransformation", + "spark": "feast.transformation.spark_transformation.SparkTransformation", +} + + +def get_transformation_class_from_type(transformation_type: str): + if transformation_type in TRANSFORMATION_CLASS_FOR_TYPE: + transformation_type = TRANSFORMATION_CLASS_FOR_TYPE[transformation_type] + elif not transformation_type.endswith("Transformation"): + raise ValueError( + f"Invalid transformation type: {transformation_type}. Choose from {list(TRANSFORMATION_CLASS_FOR_TYPE.keys())}." + ) + module_name, transformation_class_type = transformation_type.rsplit(".", 1) + return import_class( + module_name, transformation_class_type, transformation_class_type + ) diff --git a/sdk/python/feast/transformation/mode.py b/sdk/python/feast/transformation/mode.py new file mode 100644 index 00000000000..4bd5ddbe7a3 --- /dev/null +++ b/sdk/python/feast/transformation/mode.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class TransformationMode(Enum): + PYTHON = "python" + PANDAS = "pandas" + SPARK = "spark" + SQL = "sql" + SUBSTRAIT = "substrait" diff --git a/sdk/python/feast/transformation/pandas_transformation.py b/sdk/python/feast/transformation/pandas_transformation.py index 35e786aac8f..469ddaa7768 100644 --- a/sdk/python/feast/transformation/pandas_transformation.py +++ b/sdk/python/feast/transformation/pandas_transformation.py @@ -1,4 +1,5 @@ -from typing import Any, Callable +import inspect +from typing import Any, Callable, Optional, cast, get_type_hints import dill import pandas as pd @@ -8,23 +9,61 @@ from feast.protos.feast.core.Transformation_pb2 import ( UserDefinedFunctionV2 as UserDefinedFunctionProto, ) +from feast.transformation.base import Transformation +from feast.transformation.mode import TransformationMode from feast.type_map import ( python_type_to_feast_value_type, ) -class PandasTransformation: - def __init__(self, udf: Callable[[Any], Any], udf_string: str = ""): - """ - Creates an PandasTransformation object. +class PandasTransformation(Transformation): + def __new__( + cls, + udf: Callable[[Any], Any], + udf_string: str, + name: Optional[str] = None, + tags: Optional[dict[str, str]] = None, + description: str = "", + owner: str = "", + ) -> "PandasTransformation": + instance = super(PandasTransformation, cls).__new__( + cls, + mode=TransformationMode.PANDAS, + udf=udf, + name=name, + udf_string=udf_string, + tags=tags, + description=description, + owner=owner, + ) + return cast(PandasTransformation, instance) - Args: - udf: The user defined transformation function, which must take pandas - dataframes as inputs. - udf_string: The source code version of the udf (for diffing and displaying in Web UI) - """ - self.udf = udf - self.udf_string = udf_string + def __init__( + self, + udf: Callable[[Any], Any], + udf_string: str, + name: Optional[str] = None, + tags: Optional[dict[str, str]] = None, + description: str = "", + owner: str = "", + *args, + **kwargs, + ): + return_annotation = get_type_hints(udf).get("return", inspect._empty) + if return_annotation not in (inspect._empty, pd.DataFrame): + raise TypeError( + f"return signature for PandasTransformation should be pd.DataFrame, instead got {return_annotation}" + ) + + super().__init__( + mode=TransformationMode.PANDAS, + udf=udf, + name=name, + udf_string=udf_string, + tags=tags, + description=description, + owner=owner, + ) def transform_arrow( self, pa_table: pyarrow.Table, features: list[Field] @@ -32,15 +71,15 @@ def transform_arrow( output_df_pandas = self.udf(pa_table.to_pandas()) return pyarrow.Table.from_pandas(output_df_pandas) - def transform(self, input_df: pd.DataFrame) -> pd.DataFrame: - return self.udf(input_df) + def transform(self, inputs: pd.DataFrame) -> pd.DataFrame: + return self.udf(inputs) - def transform_singleton(self, input_df: pd.DataFrame) -> pd.DataFrame: - raise ValueError( - "PandasTransformation does not support singleton transformations." - ) - - def infer_features(self, random_input: dict[str, list[Any]]) -> list[Field]: + def infer_features( + self, + random_input: dict[str, list[Any]], + *args, + **kwargs, + ) -> list[Field]: df = pd.DataFrame.from_dict(random_input) output_df: pd.DataFrame = self.transform(df) @@ -80,13 +119,6 @@ def __eq__(self, other): return True - def to_proto(self) -> UserDefinedFunctionProto: - return UserDefinedFunctionProto( - name=self.udf.__name__, - body=dill.dumps(self.udf, recurse=True), - body_text=self.udf_string, - ) - @classmethod def from_proto(cls, user_defined_function_proto: UserDefinedFunctionProto): return PandasTransformation( diff --git a/sdk/python/feast/transformation/python_transformation.py b/sdk/python/feast/transformation/python_transformation.py index ce2aaf2002d..0c2014e6d66 100644 --- a/sdk/python/feast/transformation/python_transformation.py +++ b/sdk/python/feast/transformation/python_transformation.py @@ -1,5 +1,5 @@ from types import FunctionType -from typing import Any +from typing import Any, Dict, Optional, cast import dill import pyarrow @@ -8,22 +8,73 @@ from feast.protos.feast.core.Transformation_pb2 import ( UserDefinedFunctionV2 as UserDefinedFunctionProto, ) +from feast.transformation.base import Transformation +from feast.transformation.mode import TransformationMode from feast.type_map import ( python_type_to_feast_value_type, ) -class PythonTransformation: - def __init__(self, udf: FunctionType, udf_string: str = ""): +class PythonTransformation(Transformation): + udf: FunctionType + + def __new__( + cls, + udf: FunctionType, + udf_string: str, + singleton: bool = False, + name: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + description: str = "", + owner: str = "", + ) -> "PythonTransformation": + instance = super(PythonTransformation, cls).__new__( + cls, + mode=TransformationMode.PYTHON, + singleton=singleton, + udf=udf, + udf_string=udf_string, + name=name, + tags=tags, + description=description, + owner=owner, + ) + return cast(PythonTransformation, instance) + + def __init__( + self, + udf: FunctionType, + udf_string: str, + singleton: bool = False, + name: Optional[str] = None, + tags: Optional[Dict[str, str]] = None, + description: str = "", + owner: str = "", + *args, + **kwargs, + ): """ - Creates an PythonTransformation object. + Creates a PythonTransformation object. + Args: - udf: The user defined transformation function, which must take pandas + udf: The user-defined transformation function, which must take pandas dataframes as inputs. - udf_string: The source code version of the udf (for diffing and displaying in Web UI) + name: The name of the transformation. + udf_string: The source code version of the UDF (for diffing and displaying in Web UI). + tags: Metadata tags for the transformation. + description: A description of the transformation. + owner: The owner of the transformation. """ - self.udf = udf - self.udf_string = udf_string + super().__init__( + mode=TransformationMode.PYTHON, + udf=udf, + name=name, + udf_string=udf_string, + tags=tags, + description=description, + owner=owner, + ) + self.singleton = singleton def transform_arrow( self, @@ -42,10 +93,12 @@ def transform_singleton(self, input_dict: dict) -> dict: # in the case of a singleton element, it takes the value directly # in the case of a list of lists, it takes the first list input_dict = {k: v[0] for k, v in input_dict.items()} - output_dict = self.udf.__call__(input_dict) + output_dict = self.udf(input_dict) return {**input_dict, **output_dict} - def infer_features(self, random_input: dict[str, Any]) -> list[Field]: + def infer_features( + self, random_input: dict[str, Any], singleton: Optional[bool] = False + ) -> list[Field]: output_dict: dict[str, Any] = self.transform(random_input) fields = [] @@ -58,6 +111,10 @@ def infer_features(self, random_input: dict[str, Any]) -> list[Field]: ) inferred_type = type(feature_value[0]) inferred_value = feature_value[0] + if singleton: + inferred_value = feature_value + inferred_type = None # type: ignore + else: inferred_type = type(feature_value) inferred_value = feature_value @@ -69,7 +126,7 @@ def infer_features(self, random_input: dict[str, Any]) -> list[Field]: python_type_to_feast_value_type( feature_name, value=inferred_value, - type_name=inferred_type.__name__, + type_name=inferred_type.__name__ if inferred_type else None, ) ), ) @@ -90,13 +147,6 @@ def __eq__(self, other): return True - def to_proto(self) -> UserDefinedFunctionProto: - return UserDefinedFunctionProto( - name=self.udf.__name__, - body=dill.dumps(self.udf, recurse=True), - body_text=self.udf_string, - ) - @classmethod def from_proto(cls, user_defined_function_proto: UserDefinedFunctionProto): return PythonTransformation( diff --git a/sdk/python/feast/transformation/spark_transformation.py b/sdk/python/feast/transformation/spark_transformation.py new file mode 100644 index 00000000000..d288cf58b08 --- /dev/null +++ b/sdk/python/feast/transformation/spark_transformation.py @@ -0,0 +1,11 @@ +from typing import Any + +from feast.transformation.base import Transformation + + +class SparkTransformation(Transformation): + def transform(self, inputs: Any) -> Any: + pass + + def infer_features(self, *args, **kwargs) -> Any: + pass diff --git a/sdk/python/feast/transformation/sql_transformation.py b/sdk/python/feast/transformation/sql_transformation.py new file mode 100644 index 00000000000..62d6b40de0b --- /dev/null +++ b/sdk/python/feast/transformation/sql_transformation.py @@ -0,0 +1,8 @@ +from typing import Any + +from feast.transformation.base import Transformation + + +class SQLTransformation(Transformation): + def transform(self, inputs: Any) -> str: + return self.udf(inputs) diff --git a/sdk/python/feast/transformation/substrait_transformation.py b/sdk/python/feast/transformation/substrait_transformation.py index 47e2ced9768..476159cd003 100644 --- a/sdk/python/feast/transformation/substrait_transformation.py +++ b/sdk/python/feast/transformation/substrait_transformation.py @@ -1,5 +1,5 @@ -from types import FunctionType -from typing import Any +import inspect +from typing import Any, Callable, Dict, Optional, cast, get_type_hints import dill import pandas as pd @@ -11,23 +11,64 @@ from feast.protos.feast.core.Transformation_pb2 import ( SubstraitTransformationV2 as SubstraitTransformationProto, ) +from feast.transformation.base import Transformation +from feast.transformation.mode import TransformationMode from feast.type_map import ( feast_value_type_to_pandas_type, python_type_to_feast_value_type, ) -class SubstraitTransformation: - def __init__(self, substrait_plan: bytes, ibis_function: FunctionType): +class SubstraitTransformation(Transformation): + def __new__( + cls, + substrait_plan: bytes, + udf: Callable[[Any], Any], + name: Optional[str] = None, + tags: Optional[dict[str, str]] = None, + description: str = "", + owner: str = "", + ) -> "SubstraitTransformation": + instance = super(SubstraitTransformation, cls).__new__( + cls, + mode=TransformationMode.SUBSTRAIT, + udf=udf, + name=name, + udf_string="", + tags=tags, + description=description, + owner=owner, + ) + return cast(SubstraitTransformation, instance) + + def __init__( + self, + substrait_plan: bytes, + udf: Callable[[Any], Any], + name: Optional[str] = None, + tags: Optional[dict[str, str]] = None, + description: str = "", + owner: str = "", + *args, + **kwargs, + ): """ Creates an SubstraitTransformation object. Args: substrait_plan: The user-provided substrait plan. - ibis_function: The user-provided ibis function. + udf: The user-provided ibis function. """ + super().__init__( + mode=TransformationMode.SUBSTRAIT, + udf=udf, + name=name, + udf_string="", + tags=tags, + description=description, + owner=owner, + ) self.substrait_plan = substrait_plan - self.ibis_function = ibis_function def transform(self, df: pd.DataFrame) -> pd.DataFrame: def table_provider(names, schema: pyarrow.Schema): @@ -44,7 +85,7 @@ def transform_singleton(self, input_df: pd.DataFrame) -> pd.DataFrame: ) def transform_ibis(self, table): - return self.ibis_function(table) + return self.udf(table) def transform_arrow( self, pa_table: pyarrow.Table, features: list[Field] = [] @@ -61,7 +102,9 @@ def table_provider(names, schema: pyarrow.Schema): return table - def infer_features(self, random_input: dict[str, list[Any]]) -> list[Field]: + def infer_features( + self, random_input: dict[str, list[Any]], singleton: Optional[bool] + ) -> list[Field]: df = pd.DataFrame.from_dict(random_input) output_df: pd.DataFrame = self.transform(df) @@ -96,14 +139,18 @@ def __eq__(self, other): return ( self.substrait_plan == other.substrait_plan - and self.ibis_function.__code__.co_code - == other.ibis_function.__code__.co_code + and self.udf.__code__.co_code == other.udf.__code__.co_code ) + def __deepcopy__( + self, memo: Optional[Dict[int, Any]] = None + ) -> "SubstraitTransformation": + return SubstraitTransformation(substrait_plan=self.substrait_plan, udf=self.udf) + def to_proto(self) -> SubstraitTransformationProto: return SubstraitTransformationProto( substrait_plan=self.substrait_plan, - ibis_function=dill.dumps(self.ibis_function, recurse=True), + ibis_function=dill.dumps(self.udf, recurse=True), ) @classmethod @@ -113,11 +160,19 @@ def from_proto( ): return SubstraitTransformation( substrait_plan=substrait_transformation_proto.substrait_plan, - ibis_function=dill.loads(substrait_transformation_proto.ibis_function), + udf=dill.loads(substrait_transformation_proto.ibis_function), ) @classmethod def from_ibis(cls, user_function, sources): + from ibis.expr.types.relations import Table + + return_annotation = get_type_hints(user_function).get("return", inspect._empty) + if return_annotation not in (inspect._empty, Table): + raise TypeError( + f"User function must return an ibis Table, got {return_annotation} for SubstraitTransformation" + ) + import ibis import ibis.expr.datatypes as dt from ibis_substrait.compiler.core import SubstraitCompiler @@ -143,7 +198,9 @@ def from_ibis(cls, user_function, sources): expr = user_function(ibis.table(input_fields, "t")) + substrait_plan = compiler.compile(expr).SerializeToString() + return SubstraitTransformation( - substrait_plan=compiler.compile(expr).SerializeToString(), - ibis_function=user_function, + substrait_plan=substrait_plan, + udf=user_function, ) diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 8a88c24ffc1..edc9f0c66d8 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -164,6 +164,7 @@ def python_type_to_feast_value_type( "datetime64[ns]": ValueType.UNIX_TIMESTAMP, "datetime64[ns, tz]": ValueType.UNIX_TIMESTAMP, # special dtype of pandas "datetime64[ns, utc]": ValueType.UNIX_TIMESTAMP, + "date": ValueType.UNIX_TIMESTAMP, "category": ValueType.STRING, } @@ -211,8 +212,7 @@ def python_type_to_feast_value_type( return ValueType[common_item_value_type.name + "_LIST"] raise ValueError( - f"Value with native type {type_name} " - f"cannot be converted into Feast value type" + f"Value with native type {type_name} cannot be converted into Feast value type" ) @@ -458,13 +458,13 @@ def _python_value_to_proto_value( # Numpy convert 0 to int. However, in the feature view definition, the type of column may be a float. # So, if value is 0, type validation must pass if scalar_types are either int or float. allowed_types = {np.int64, int, np.float64, float} - assert ( - type(sample) in allowed_types - ), f"Type `{type(sample)}` not in {allowed_types}" + assert type(sample) in allowed_types, ( + f"Type `{type(sample)}` not in {allowed_types}" + ) else: - assert ( - type(sample) in valid_scalar_types - ), f"Type `{type(sample)}` not in {valid_scalar_types}" + assert type(sample) in valid_scalar_types, ( + f"Type `{type(sample)}` not in {valid_scalar_types}" + ) if feast_value_type == ValueType.BOOL: # ProtoValue does not support conversion of np.bool_ so we need to convert it to support np.bool_. return [ @@ -523,6 +523,28 @@ def python_values_to_proto_values( return proto_values +PROTO_VALUE_TO_VALUE_TYPE_MAP: Dict[str, ValueType] = { + "int32_val": ValueType.INT32, + "int64_val": ValueType.INT64, + "double_val": ValueType.DOUBLE, + "float_val": ValueType.FLOAT, + "string_val": ValueType.STRING, + "bytes_val": ValueType.BYTES, + "bool_val": ValueType.BOOL, + "int32_list_val": ValueType.INT32_LIST, + "int64_list_val": ValueType.INT64_LIST, + "double_list_val": ValueType.DOUBLE_LIST, + "float_list_val": ValueType.FLOAT_LIST, + "string_list_val": ValueType.STRING_LIST, + "bytes_list_val": ValueType.BYTES_LIST, + "bool_list_val": ValueType.BOOL_LIST, +} + +VALUE_TYPE_TO_PROTO_VALUE_MAP: Dict[ValueType, str] = { + v: k for k, v in PROTO_VALUE_TO_VALUE_TYPE_MAP.items() +} + + def _proto_value_to_value_type(proto_value: ProtoValue) -> ValueType: """ Returns Feast ValueType given Feast ValueType string. @@ -534,25 +556,9 @@ def _proto_value_to_value_type(proto_value: ProtoValue) -> ValueType: A variant of ValueType. """ proto_str = proto_value.WhichOneof("val") - type_map = { - "int32_val": ValueType.INT32, - "int64_val": ValueType.INT64, - "double_val": ValueType.DOUBLE, - "float_val": ValueType.FLOAT, - "string_val": ValueType.STRING, - "bytes_val": ValueType.BYTES, - "bool_val": ValueType.BOOL, - "int32_list_val": ValueType.INT32_LIST, - "int64_list_val": ValueType.INT64_LIST, - "double_list_val": ValueType.DOUBLE_LIST, - "float_list_val": ValueType.FLOAT_LIST, - "string_list_val": ValueType.STRING_LIST, - "bytes_list_val": ValueType.BYTES_LIST, - "bool_list_val": ValueType.BOOL_LIST, - None: ValueType.NULL, - } - - return type_map[proto_str] + if proto_str is None: + return ValueType.UNKNOWN + return PROTO_VALUE_TO_VALUE_TYPE_MAP[proto_str] def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType: @@ -574,6 +580,12 @@ def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType: "bool": ValueType.BOOL, "null": ValueType.NULL, "list": ValueType.DOUBLE_LIST, + "list": ValueType.INT64_LIST, + "list": ValueType.INT32_LIST, + "list": ValueType.STRING_LIST, + "list": ValueType.BOOL_LIST, + "list": ValueType.BYTES_LIST, + "list": ValueType.FLOAT_LIST, } value_type = type_map[pa_type_as_str] @@ -813,6 +825,7 @@ def spark_to_feast_value_type(spark_type_as_str: str) -> ValueType: "float": ValueType.FLOAT, "boolean": ValueType.BOOL, "timestamp": ValueType.UNIX_TIMESTAMP, + "date": ValueType.UNIX_TIMESTAMP, "array": ValueType.BYTES_LIST, "array": ValueType.STRING_LIST, "array": ValueType.INT32_LIST, @@ -822,6 +835,7 @@ def spark_to_feast_value_type(spark_type_as_str: str) -> ValueType: "array": ValueType.FLOAT_LIST, "array": ValueType.BOOL_LIST, "array": ValueType.UNIX_TIMESTAMP_LIST, + "array": ValueType.UNIX_TIMESTAMP_LIST, } if spark_type_as_str.startswith("decimal"): spark_type_as_str = "decimal" @@ -1069,3 +1083,33 @@ def pa_to_athena_value_type(pa_type: "pyarrow.DataType") -> str: } return type_map[pa_type_as_str] + + +def cb_columnar_type_to_feast_value_type(type_str: str) -> ValueType: + """ + Convert a Couchbase Columnar type string to a Feast ValueType + """ + type_map: Dict[str, ValueType] = { + # primitive types + "boolean": ValueType.BOOL, + "string": ValueType.STRING, + "bigint": ValueType.INT64, + "double": ValueType.DOUBLE, + # special types + "null": ValueType.NULL, + "missing": ValueType.UNKNOWN, + # composite types + # todo: support for arrays of primitives + "object": ValueType.UNKNOWN, + "array": ValueType.UNKNOWN, + "multiset": ValueType.UNKNOWN, + "uuid": ValueType.STRING, + } + value = ( + type_map[type_str.lower()] + if type_str.lower() in type_map + else ValueType.UNKNOWN + ) + if value == ValueType.UNKNOWN: + print("unknown type:", type_str) + return value diff --git a/sdk/python/feast/types.py b/sdk/python/feast/types.py index 9fb3207e6d6..4f13fbf2652 100644 --- a/sdk/python/feast/types.py +++ b/sdk/python/feast/types.py @@ -14,7 +14,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timezone from enum import Enum -from typing import Dict, Union +from typing import Dict, List, Union import pyarrow @@ -196,6 +196,17 @@ def __str__(self): UnixTimestamp: pyarrow.timestamp("us", tz=_utc_now().tzname()), } +FEAST_VECTOR_TYPES: List[Union[ValueType, PrimitiveFeastType, ComplexFeastType]] = [ + ValueType.BYTES_LIST, + ValueType.INT32_LIST, + ValueType.INT64_LIST, + ValueType.FLOAT_LIST, + ValueType.BOOL_LIST, +] +for k in VALUE_TYPES_TO_FEAST_TYPES: + if k in FEAST_VECTOR_TYPES: + FEAST_VECTOR_TYPES.append(VALUE_TYPES_TO_FEAST_TYPES[k]) + def from_feast_to_pyarrow_type(feast_type: FeastType) -> pyarrow.DataType: """ @@ -207,9 +218,9 @@ def from_feast_to_pyarrow_type(feast_type: FeastType) -> pyarrow.DataType: Raises: ValueError: The conversion could not be performed. """ - assert isinstance( - feast_type, (ComplexFeastType, PrimitiveFeastType) - ), f"Expected FeastType, got {type(feast_type)}" + assert isinstance(feast_type, (ComplexFeastType, PrimitiveFeastType)), ( + f"Expected FeastType, got {type(feast_type)}" + ) if isinstance(feast_type, PrimitiveFeastType): if feast_type in FEAST_TYPES_TO_PYARROW_TYPES: return FEAST_TYPES_TO_PYARROW_TYPES[feast_type] @@ -236,3 +247,26 @@ def from_value_type( return VALUE_TYPES_TO_FEAST_TYPES[value_type] raise ValueError(f"Could not convert value type {value_type} to FeastType.") + + +def from_feast_type( + feast_type: FeastType, +) -> ValueType: + """ + Converts a Feast type to a ValueType enum. + + Args: + feast_type: The Feast type to be converted. + + Returns: + The corresponding ValueType enum. + + Raises: + ValueError: The conversion could not be performed. + """ + if feast_type in VALUE_TYPES_TO_FEAST_TYPES.values(): + return list(VALUE_TYPES_TO_FEAST_TYPES.keys())[ + list(VALUE_TYPES_TO_FEAST_TYPES.values()).index(feast_type) + ] + + raise ValueError(f"Could not convert feast type {feast_type} to ValueType.") diff --git a/sdk/python/feast/ui/package.json b/sdk/python/feast/ui/package.json index 2a6329a166b..de74a03f2af 100644 --- a/sdk/python/feast/ui/package.json +++ b/sdk/python/feast/ui/package.json @@ -4,9 +4,9 @@ "private": true, "dependencies": { "@elastic/datemath": "^5.0.3", - "@elastic/eui": "^55.0.1", + "@elastic/eui": "^72.0.0", "@emotion/react": "^11.9.0", - "@feast-dev/feast-ui": "0.41.0", + "@feast-dev/feast-ui": "0.46.0", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.2.0", "@testing-library/user-event": "^13.5.0", diff --git a/sdk/python/feast/ui/yarn.lock b/sdk/python/feast/ui/yarn.lock index 24de47b1232..896c9877f14 100644 --- a/sdk/python/feast/ui/yarn.lock +++ b/sdk/python/feast/ui/yarn.lock @@ -1272,10 +1272,10 @@ dependencies: tslib "^1.9.3" -"@elastic/eui@^55.0.1": - version "55.1.2" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.2.tgz#dd0b42f5b26c5800d6a9cb2d4c2fe1afce9d3f07" - integrity sha512-wwZz5KxMIMFlqEsoCRiQBJDc4CrluS1d0sCOmQ5lhIzKhYc91MdxnqCk2i6YkhL4sSDf2Y9KAEuMXa+uweOWUA== +"@elastic/eui@^72.0.0": + version "72.2.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-72.2.0.tgz#0d89ec4c6d8a677ba41d086abd509c5a5ea09180" + integrity sha512-3JHKLWqbU1A6qMVkw0n1VZ5PaL07sd3N44tWsRCn+DEaDv9jq68ilEmY1wdYqKXw8VyFwcPbd8ZYZpdzBD2nPA== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" @@ -1296,7 +1296,7 @@ react-beautiful-dnd "^13.1.0" react-dropzone "^11.5.3" react-element-to-jsx-string "^14.3.4" - react-focus-on "^3.5.4" + react-focus-on "^3.7.0" react-input-autosize "^3.0.0" react-is "^17.0.2" react-virtualized-auto-sizer "^1.0.6" @@ -1307,7 +1307,7 @@ rehype-stringify "^8.0.0" remark-breaks "^2.0.2" remark-emoji "^2.1.0" - remark-parse "^8.0.3" + remark-parse-no-trim "^8.0.4" remark-rehype "^8.0.0" tabbable "^5.2.1" text-diff "^1.0.1" @@ -1570,25 +1570,26 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@feast-dev/feast-ui@0.41.0": - version "0.41.0" - resolved "https://registry.yarnpkg.com/@feast-dev/feast-ui/-/feast-ui-0.41.0.tgz#67eca6328131ee524ee6a6f286cfc4386f698053" - integrity sha512-BkVb4zfR+j95IX9FBzeXFyCimG5Za1a3jyLqjmETRO3hpp5OJanpc2N35AaOn8ZPqka00Be/b8NZ8TjbsRWyVg== +"@feast-dev/feast-ui@0.46.0": + version "0.46.0" + resolved "https://registry.yarnpkg.com/@feast-dev/feast-ui/-/feast-ui-0.46.0.tgz#2ab5fa42b43c20829a6cbb44e66df8f4ee2597ae" + integrity sha512-d4EgsfhXH1nlpMGuD8M/D/2Z7OryUQkg4cUWvGadj06bwoUM60+ku0gGUZb2PnbfgdUdMrB5p7VS9di0jFurNA== dependencies: "@elastic/datemath" "^5.0.3" "@elastic/eui" "^95.12.0" "@emotion/css" "^11.13.0" "@emotion/react" "^11.13.3" inter-ui "^3.19.3" + long "^5.2.3" moment "^2.29.1" protobufjs "^7.1.1" query-string "^7.1.1" + react-app-polyfill "^3.0.0" react-code-blocks "^0.1.6" react-query "^3.39.3" - react-router-dom "<6.4.0" - react-scripts "^5.0.1" + react-router-dom "^6.28.0" tslib "^2.3.1" - use-query-params "^1.2.3" + use-query-params "^2.2.1" zod "^3.11.6" "@hello-pangea/dnd@^16.6.0": @@ -2055,6 +2056,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@remix-run/router@1.21.1": + version "1.21.1" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.21.1.tgz#bf15274d3856c395402719fa6b1dc8cc5245aaf7" + integrity sha512-KeBYSwohb8g4/wCcnksvKTYlg69O62sQeLynn2YE+5z7JWEj95if27kclW9QqbrlsQ2DINI8fjbV3zyuKfwjKg== + "@rollup/plugin-babel@^5.2.0": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -3362,13 +3368,6 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-hidden@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.1.3.tgz#bb48de18dc84787a3c6eee113709c473c64ec254" - integrity sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA== - dependencies: - tslib "^1.0.0" - aria-hidden@^1.2.2: version "1.2.4" resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" @@ -5723,13 +5722,6 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3" integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg== -focus-lock@^0.11.2: - version "0.11.2" - resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.11.2.tgz#aeef3caf1cea757797ac8afdebaec8fd9ab243ed" - integrity sha512-pZ2bO++NWLHhiKkgP1bEXHhR1/OjVcSvlCJ98aNJDFeb7H5OOQaO+SKOZle6041O9rv2tmbrO4JzClAvDUHf0g== - dependencies: - tslib "^2.0.3" - focus-lock@^1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-1.3.5.tgz#aa644576e5ec47d227b57eb14e1efb2abf33914c" @@ -7506,6 +7498,11 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.2.0.tgz#2696dadf4b4da2ce3f6f6b89186085d94d52fd61" integrity sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w== +long@^5.2.3: + version "5.2.3" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" + integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -7779,15 +7776,10 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" -nanoid@^3.3.3: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - -nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^3.3.3, nanoid@^3.3.7: + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare@^1.4.0: version "1.4.0" @@ -9097,32 +9089,7 @@ react-focus-lock@^2.11.3: use-callback-ref "^1.3.2" use-sidecar "^1.1.2" -react-focus-lock@^2.9.0: - version "2.9.1" - resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.9.1.tgz#094cfc19b4f334122c73bb0bff65d77a0c92dd16" - integrity sha512-pSWOQrUmiKLkffPO6BpMXN7SNKXMsuOakl652IBuALAu1esk+IcpJyM+ALcYzPTTFz1rD0R54aB9A4HuP5t1Wg== - dependencies: - "@babel/runtime" "^7.0.0" - focus-lock "^0.11.2" - prop-types "^15.6.2" - react-clientside-effect "^1.2.6" - use-callback-ref "^1.3.0" - use-sidecar "^1.1.2" - -react-focus-on@^3.5.4: - version "3.6.0" - resolved "https://registry.yarnpkg.com/react-focus-on/-/react-focus-on-3.6.0.tgz#159e13082dad4ea1f07abe11254f0e981d5a7b79" - integrity sha512-onIRjpd9trAUenXNdDcvjc8KJUSklty4X/Gr7hAm/MzM7ekSF2pg9D8KBKL7ipige22IAPxLRRf/EmJji9KD6Q== - dependencies: - aria-hidden "^1.1.3" - react-focus-lock "^2.9.0" - react-remove-scroll "^2.5.2" - react-style-singleton "^2.2.0" - tslib "^2.3.1" - use-callback-ref "^1.3.0" - use-sidecar "^1.1.2" - -react-focus-on@^3.9.1: +react-focus-on@^3.7.0, react-focus-on@^3.9.1: version "3.9.4" resolved "https://registry.yarnpkg.com/react-focus-on/-/react-focus-on-3.9.4.tgz#0b6c13273d86243c330d1aa53af39290f543da7b" integrity sha512-NFKmeH6++wu8e7LJcbwV8TTd4L5w/U5LMXTMOdUcXhCcZ7F5VOvgeTHd4XN1PD7TNmdvldDu/ENROOykUQ4yQg== @@ -9203,14 +9170,6 @@ react-refresh@^0.11.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== -react-remove-scroll-bar@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.1.tgz#9f13b05b249eaa57c8d646c1ebb83006b3581f5f" - integrity sha512-IvGX3mJclEF7+hga8APZczve1UyGMkMG+tjS0o/U1iLgvZRpjFAQEUBJ4JETfvbNlfNnZnoDyWJCICkA15Mghg== - dependencies: - react-style-singleton "^2.2.0" - tslib "^2.0.0" - react-remove-scroll-bar@^2.3.4, react-remove-scroll-bar@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" @@ -9219,17 +9178,6 @@ react-remove-scroll-bar@^2.3.4, react-remove-scroll-bar@^2.3.6: react-style-singleton "^2.2.1" tslib "^2.0.0" -react-remove-scroll@^2.5.2: - version "2.5.3" - resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.3.tgz#a152196e710e8e5811be39dc352fd8a90b05c961" - integrity sha512-NQ1bXrxKrnK5pFo/GhLkXeo3CrK5steI+5L+jynwwIemvZyfXqaL0L5BzwJd7CSwNCU723DZaccvjuyOdoy3Xw== - dependencies: - react-remove-scroll-bar "^2.3.1" - react-style-singleton "^2.2.0" - tslib "^2.0.0" - use-callback-ref "^1.3.0" - use-sidecar "^1.1.2" - react-remove-scroll@^2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz#fb03a0845d7768a4f1519a99fdb84983b793dc07" @@ -9241,7 +9189,7 @@ react-remove-scroll@^2.6.0: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" -react-router-dom@6, react-router-dom@<6.4.0: +react-router-dom@6: version "6.3.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== @@ -9249,6 +9197,21 @@ react-router-dom@6, react-router-dom@<6.4.0: history "^5.2.0" react-router "6.3.0" +react-router-dom@^6.28.0: + version "6.28.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.28.2.tgz#9bc4f58b0cfe91d39d1a6be4beb0ef051ca9b06e" + integrity sha512-O81EWqNJWqvlN/a7eTudAdQm0TbI7hw+WIi7OwwMcTn5JMyZ0ibTFNGz+t+Lju0df4LcqowCegcrK22lB1q9Kw== + dependencies: + "@remix-run/router" "1.21.1" + react-router "6.28.2" + +react-router@6.28.2: + version "6.28.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.28.2.tgz#1ddea57c2de0d99e12d00af14d1499703f1378a9" + integrity sha512-BgFY7+wEGVjHCiqaj2XiUBQ1kkzfg6UoKYwEe0wv+FF+HNPCxtS/MVPvLAPH++EsuCMReZl9RYVGqcHLk5ms3A== + dependencies: + "@remix-run/router" "1.21.1" + react-router@6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" @@ -9256,7 +9219,7 @@ react-router@6.3.0: dependencies: history "^5.2.0" -react-scripts@^5.0.0, react-scripts@^5.0.1: +react-scripts@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003" integrity sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ== @@ -9311,15 +9274,6 @@ react-scripts@^5.0.0, react-scripts@^5.0.1: optionalDependencies: fsevents "^2.3.2" -react-style-singleton@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.0.tgz#70f45f5fef97fdb9a52eed98d1839fa6b9032b22" - integrity sha512-nK7mN92DMYZEu3cQcAhfwE48NpzO5RpxjG4okbSqRRbfal9Pk+fG2RdQXTMp+f6all1hB9LIJSt+j7dCYrU11g== - dependencies: - get-nonce "^1.0.0" - invariant "^2.2.4" - tslib "^2.0.0" - react-style-singleton@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" @@ -9583,28 +9537,6 @@ remark-parse-no-trim@^8.0.4: vfile-location "^3.0.0" xtend "^4.0.1" -remark-parse@^8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-8.0.3.tgz#9c62aa3b35b79a486454c690472906075f40c7e1" - integrity sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q== - dependencies: - ccount "^1.0.0" - collapse-white-space "^1.0.2" - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - is-word-character "^1.0.0" - markdown-escapes "^1.0.0" - parse-entities "^2.0.0" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - trim "0.0.1" - trim-trailing-lines "^1.0.0" - unherit "^1.0.4" - unist-util-remove-position "^2.0.0" - vfile-location "^3.0.0" - xtend "^4.0.1" - remark-rehype@^8.0.0, remark-rehype@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-8.1.0.tgz#610509a043484c1e697437fa5eb3fd992617c945" @@ -9917,6 +9849,11 @@ serialize-query-params@^1.3.5: resolved "https://registry.yarnpkg.com/serialize-query-params/-/serialize-query-params-1.3.6.tgz#5dd5225db85ce747fe6fbc4897628504faafec6d" integrity sha512-VlH7sfWNyPVZClPkRacopn6sn5uQMXBsjPVz1+pBHX895VpcYVznfJtZ49e6jymcrz+l/vowkepCZn/7xEAEdw== +serialize-query-params@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/serialize-query-params/-/serialize-query-params-2.0.2.tgz#598a3fb9e13f4ea1c1992fbd20231aa16b31db81" + integrity sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q== + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -10637,11 +10574,6 @@ trim-trailing-lines@^1.0.0: resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz#bd4abbec7cc880462f10b2c8b5ce1d8d1ec7c2c0" integrity sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ== -trim@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" - integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= - trough@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" @@ -10667,7 +10599,7 @@ tslib@2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== -tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.3: +tslib@^1.8.1, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -10967,6 +10899,13 @@ use-query-params@^1.2.3: dependencies: serialize-query-params "^1.3.5" +use-query-params@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/use-query-params/-/use-query-params-2.2.1.tgz#c558ab70706f319112fbccabf6867b9f904e947d" + integrity sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q== + dependencies: + serialize-query-params "^2.0.2" + use-sidecar@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2" diff --git a/sdk/python/feast/ui_server.py b/sdk/python/feast/ui_server.py index 1d115920c3a..d852bb279cc 100644 --- a/sdk/python/feast/ui_server.py +++ b/sdk/python/feast/ui_server.py @@ -69,6 +69,8 @@ def shutdown_event(): @app.get("/registry") def read_registry(): + if registry_proto is None: + return Response(status_code=503) # Service Unavailable return Response( content=registry_proto.SerializeToString(), media_type="application/octet-stream", diff --git a/sdk/python/feast/utils.py b/sdk/python/feast/utils.py index 51d4bf4f2cc..4cca1379ed3 100644 --- a/sdk/python/feast/utils.py +++ b/sdk/python/feast/utils.py @@ -343,6 +343,22 @@ def _convert_arrow_odfv_to_proto( for column, value_type in columns if column in table.column_names } + + # Ensure join keys are included in proto_values_by_column, but check if they exist first + for join_key, value_type in join_keys.items(): + if join_key not in proto_values_by_column: + # Check if the join key exists in the table before trying to access it + if join_key in table.column_names: + proto_values_by_column[join_key] = python_values_to_proto_values( + table.column(join_key).to_numpy(zero_copy_only=False), value_type + ) + else: + # Create null/default values if the join key isn't in the table + null_column = [None] * table.num_rows + proto_values_by_column[join_key] = python_values_to_proto_values( + null_column, value_type + ) + # Adding On Demand Features for feature in feature_view.features: if ( @@ -357,7 +373,7 @@ def _convert_arrow_odfv_to_proto( updated_table = pyarrow.RecordBatch.from_arrays( table.columns + [null_column], schema=table.schema.append( - pyarrow.field(feature.name, null_column.type) + pyarrow.field(feature.name, null_column.type) # type: ignore[attr-defined] ), ) proto_values_by_column[feature.name] = python_values_to_proto_values( @@ -368,7 +384,11 @@ def _convert_arrow_odfv_to_proto( entity_keys = [ EntityKeyProto( join_keys=join_keys, - entity_values=[proto_values_by_column[k][idx] for k in join_keys], + entity_values=[ + proto_values_by_column[k][idx] + for k in join_keys + if k in proto_values_by_column + ], ) for idx in range(table.num_rows) ] @@ -378,6 +398,12 @@ def _convert_arrow_odfv_to_proto( feature.name: proto_values_by_column[feature.name] for feature in feature_view.features } + if feature_view.write_to_online_store: + table_columns = [col.name for col in table.schema] + for feature in feature_view.schema: + if feature.name not in feature_dict and feature.name in table_columns: + feature_dict[feature.name] = proto_values_by_column[feature.name] + features = [dict(zip(feature_dict, vars)) for vars in zip(*feature_dict.values())] # We need to artificially add event_timestamps and created_timestamps @@ -441,19 +467,24 @@ def _group_feature_refs( all_feature_views: List["FeatureView"], all_on_demand_feature_views: List["OnDemandFeatureView"], ) -> Tuple[ - List[Tuple["FeatureView", List[str]]], List[Tuple["OnDemandFeatureView", List[str]]] + List[Tuple[Union["FeatureView", "OnDemandFeatureView"], List[str]]], + List[Tuple["OnDemandFeatureView", List[str]]], ]: """Get list of feature views and corresponding feature names based on feature references""" # view name to view proto - view_index = {view.projection.name_to_use(): view for view in all_feature_views} + view_index: Dict[str, Union["FeatureView", "OnDemandFeatureView"]] = { + view.projection.name_to_use(): view for view in all_feature_views + } # on demand view to on demand view proto - on_demand_view_index = { - view.projection.name_to_use(): view - for view in all_on_demand_feature_views - if view.projection - } + on_demand_view_index: Dict[str, "OnDemandFeatureView"] = {} + for view in all_on_demand_feature_views: + if view.projection and not view.write_to_online_store: + on_demand_view_index[view.projection.name_to_use()] = view + elif view.projection and view.write_to_online_store: + # we insert the ODFV view to FVs for ones that are written to the online store + view_index[view.projection.name_to_use()] = view # view name to feature names views_features = defaultdict(set) @@ -464,7 +495,16 @@ def _group_feature_refs( for ref in features: view_name, feat_name = ref.split(":") if view_name in view_index: - view_index[view_name].projection.get_feature(feat_name) # For validation + if hasattr(view_index[view_name], "write_to_online_store"): + tmp_feat_name = [ + f for f in view_index[view_name].schema if f.name == feat_name + ] + if len(tmp_feat_name) > 0: + feat_name = tmp_feat_name[0].name + else: + view_index[view_name].projection.get_feature( + feat_name + ) # For validation views_features[view_name].add(feat_name) elif view_name in on_demand_view_index: on_demand_view_index[view_name].projection.get_feature( @@ -480,7 +520,7 @@ def _group_feature_refs( else: raise FeatureViewNotFoundException(view_name) - fvs_result: List[Tuple["FeatureView", List[str]]] = [] + fvs_result: List[Tuple[Union["FeatureView", "OnDemandFeatureView"], List[str]]] = [] odfvs_result: List[Tuple["OnDemandFeatureView", List[str]]] = [] for view_name, feature_names in views_features.items(): @@ -490,16 +530,28 @@ def _group_feature_refs( return fvs_result, odfvs_result -def apply_list_mapping( - lst: Iterable[Any], mapping_indexes: Iterable[List[int]] -) -> Iterable[Any]: - output_len = sum(len(item) for item in mapping_indexes) - output = [None] * output_len - for elem, destinations in zip(lst, mapping_indexes): - for idx in destinations: - output[idx] = elem +def construct_response_feature_vector( + values_vector: Iterable[Any], + statuses_vector: Iterable[Any], + timestamp_vector: Iterable[Any], + mapping_indexes: Iterable[List[int]], + output_len: int, +) -> GetOnlineFeaturesResponse.FeatureVector: + values_output: Iterable[Any] = [None] * output_len + statuses_output: Iterable[Any] = [None] * output_len + timestamp_output: Iterable[Any] = [None] * output_len - return output + for i, destinations in enumerate(mapping_indexes): + for idx in destinations: + values_output[idx] = values_vector[i] # type: ignore[index] + statuses_output[idx] = statuses_vector[i] # type: ignore[index] + timestamp_output[idx] = timestamp_vector[i] # type: ignore[index] + + return GetOnlineFeaturesResponse.FeatureVector( + values=values_output, + statuses=statuses_output, + event_timestamps=timestamp_output, + ) def _augment_response_with_on_demand_transforms( @@ -545,73 +597,74 @@ def _augment_response_with_on_demand_transforms( odfv_result_names = set() for odfv_name, _feature_refs in odfv_feature_refs.items(): odfv = requested_odfv_map[odfv_name] - if odfv.mode == "python": - if initial_response_dict is None: - initial_response_dict = initial_response.to_dict() - transformed_features_dict: Dict[str, List[Any]] = odfv.transform_dict( - initial_response_dict - ) - elif odfv.mode in {"pandas", "substrait"}: - if initial_response_arrow is None: - initial_response_arrow = initial_response.to_arrow() - transformed_features_arrow = odfv.transform_arrow( - initial_response_arrow, full_feature_names + if not odfv.write_to_online_store: + if odfv.mode == "python": + if initial_response_dict is None: + initial_response_dict = initial_response.to_dict() + transformed_features_dict: Dict[str, List[Any]] = odfv.transform_dict( + initial_response_dict + ) + elif odfv.mode in {"pandas", "substrait"}: + if initial_response_arrow is None: + initial_response_arrow = initial_response.to_arrow() + transformed_features_arrow = odfv.transform_arrow( + initial_response_arrow, full_feature_names + ) + else: + raise Exception( + f"Invalid OnDemandFeatureMode: {odfv.mode}. Expected one of 'pandas', 'python', or 'substrait'." + ) + + transformed_features = ( + transformed_features_dict + if odfv.mode == "python" + else transformed_features_arrow ) - else: - raise Exception( - f"Invalid OnDemandFeatureMode: {odfv.mode}. Expected one of 'pandas', 'python', or 'substrait'." + transformed_columns = ( + transformed_features.column_names + if isinstance(transformed_features, pyarrow.Table) + else transformed_features ) - - transformed_features = ( - transformed_features_dict - if odfv.mode == "python" - else transformed_features_arrow - ) - transformed_columns = ( - transformed_features.column_names - if isinstance(transformed_features, pyarrow.Table) - else transformed_features - ) - selected_subset = [f for f in transformed_columns if f in _feature_refs] - - proto_values = [] - schema_dict = {k.name: k.dtype for k in odfv.schema} - for selected_feature in selected_subset: - feature_vector = transformed_features[selected_feature] - selected_feature_type = schema_dict.get(selected_feature, None) - feature_type: ValueType = ValueType.UNKNOWN - if selected_feature_type is not None: - if isinstance( - selected_feature_type, (ComplexFeastType, PrimitiveFeastType) - ): - feature_type = selected_feature_type.to_value_type() - elif not isinstance(selected_feature_type, ValueType): - raise TypeError( - f"Unexpected type for feature_type: {type(feature_type)}" + selected_subset = [f for f in transformed_columns if f in _feature_refs] + + proto_values = [] + schema_dict = {k.name: k.dtype for k in odfv.schema} + for selected_feature in selected_subset: + feature_vector = transformed_features[selected_feature] + selected_feature_type = schema_dict.get(selected_feature, None) + feature_type: ValueType = ValueType.UNKNOWN + if selected_feature_type is not None: + if isinstance( + selected_feature_type, (ComplexFeastType, PrimitiveFeastType) + ): + feature_type = selected_feature_type.to_value_type() + elif not isinstance(selected_feature_type, ValueType): + raise TypeError( + f"Unexpected type for feature_type: {type(feature_type)}" + ) + + proto_values.append( + python_values_to_proto_values( + feature_vector + if isinstance(feature_vector, list) + else [feature_vector] + if odfv.mode == "python" + else feature_vector.to_numpy(), + feature_type, ) - - proto_values.append( - python_values_to_proto_values( - feature_vector - if isinstance(feature_vector, list) - else [feature_vector] - if odfv.mode == "python" - else feature_vector.to_numpy(), - feature_type, ) - ) - odfv_result_names |= set(selected_subset) + odfv_result_names |= set(selected_subset) - online_features_response.metadata.feature_names.val.extend(selected_subset) - for feature_idx in range(len(selected_subset)): - online_features_response.results.append( - GetOnlineFeaturesResponse.FeatureVector( - values=proto_values[feature_idx], - statuses=[FieldStatus.PRESENT] * len(proto_values[feature_idx]), - event_timestamps=[Timestamp()] * len(proto_values[feature_idx]), + online_features_response.metadata.feature_names.val.extend(selected_subset) + for feature_idx in range(len(selected_subset)): + online_features_response.results.append( + GetOnlineFeaturesResponse.FeatureVector( + values=proto_values[feature_idx], + statuses=[FieldStatus.PRESENT] * len(proto_values[feature_idx]), + event_timestamps=[Timestamp()] * len(proto_values[feature_idx]), + ) ) - ) def _get_entity_maps( @@ -674,7 +727,7 @@ def _get_unique_entities( table: "FeatureView", join_key_values: Dict[str, List[ValueProto]], entity_name_to_join_key_map: Dict[str, str], -) -> Tuple[Tuple[Dict[str, ValueProto], ...], Tuple[List[int], ...]]: +) -> Tuple[Tuple[Dict[str, ValueProto], ...], Tuple[List[int], ...], int]: """Return the set of unique composite Entities for a Feature View and the indexes at which they appear. This method allows us to query the OnlineStore for data we need only once @@ -687,8 +740,60 @@ def _get_unique_entities( entity_name_to_join_key_map, join_key_values, ) + # Validate that all expected join keys exist and have non-empty values. + expected_keys = set(entity_name_to_join_key_map.values()) + expected_keys.discard("__dummy_id") + missing_keys = sorted( + list(set([key for key in expected_keys if key not in table_entity_values])) + ) + empty_keys = sorted( + list(set([key for key in expected_keys if not table_entity_values.get(key)])) + ) + + if missing_keys or empty_keys: + if not any(table_entity_values.values()): + raise KeyError( + f"Missing join key values for keys: {missing_keys}. " + f"No values provided for keys: {empty_keys}. " + f"Provided join_key_values: {list(join_key_values.keys())}" + ) + + # Convert the column-oriented table_entity_values into row-wise data. + keys = list(table_entity_values.keys()) + # Each row is a tuple of ValueProto objects corresponding to the join keys. + rowise = list(enumerate(zip(*table_entity_values.values()))) + + # If there are no rows, return empty tuples. + if not rowise: + return (), (), 0 + + # Sort rowise so that rows with the same join key values are adjacent. + rowise.sort(key=lambda row: tuple(getattr(x, x.WhichOneof("val")) for x in row[1])) - # Convert back to rowise. + # Group rows by their composite join key value. + groups = [ + (dict(zip(keys, key_tuple)), [idx for idx, _ in group]) + for key_tuple, group in itertools.groupby(rowise, key=lambda row: row[1]) + ] + + # If no groups were formed (should not happen for valid input), return empty tuples. + if not groups: + return (), (), 0 + + # Unpack the unique entities and their original row indexes. + unique_entities, indexes = tuple(zip(*groups)) + return unique_entities, indexes, len(rowise) + + +def _get_unique_entities_from_values( + table_entity_values: Dict[str, List[ValueProto]], +) -> Tuple[Tuple[Dict[str, ValueProto], ...], Tuple[List[int], ...], int]: + """Return the set of unique composite Entities for a Feature View and the indexes at which they appear. + + This method allows us to query the OnlineStore for data we need only once + rather than requesting and processing data for the same combination of + Entities multiple times. + """ keys = table_entity_values.keys() # Sort the rowise data to allow for grouping but keep original index. This lambda is # sufficient as Entity types cannot be complex (ie. lists). @@ -706,7 +811,7 @@ def _get_unique_entities( ] ) ) - return unique_entities, indexes + return unique_entities, indexes, len(rowise) def _drop_unneeded_columns( @@ -757,6 +862,7 @@ def get_needed_request_data( needed_request_data: Set[str] = set() for odfv, _ in grouped_odfv_refs: odfv_request_data_schema = odfv.get_request_data_schema() + # if odfv.write_to_online_store, we should not pass in the request data needed_request_data.update(odfv_request_data_schema.keys()) return needed_request_data @@ -783,6 +889,7 @@ def _populate_response_from_feature_data( full_feature_names: bool, requested_features: Iterable[str], table: "FeatureView", + output_len: int, ): """Populate the GetOnlineFeaturesResponse with feature data. @@ -801,33 +908,82 @@ def _populate_response_from_feature_data( requested_features: The names of the features in `feature_data`. This should be ordered in the same way as the data in `feature_data`. table: The FeatureView that `feature_data` was retrieved from. + output_len: The number of result rows in `online_features_response`. """ # Add the feature names to the response. + table_name = table.projection.name_to_use() requested_feature_refs = [ - ( - f"{table.projection.name_to_use()}__{feature_name}" - if full_feature_names - else feature_name - ) + f"{table_name}__{feature_name}" if full_feature_names else feature_name for feature_name in requested_features ] online_features_response.metadata.feature_names.val.extend(requested_feature_refs) + # Process each feature vector in a single pass + for timestamp_vector, statuses_vector, values_vector in feature_data: + response_vector = construct_response_feature_vector( + values_vector, statuses_vector, timestamp_vector, indexes, output_len + ) + online_features_response.results.append(response_vector) + + +def _populate_response_from_feature_data_v2( + feature_data: Iterable[ + Tuple[ + Iterable[Timestamp], Iterable["FieldStatus.ValueType"], Iterable[ValueProto] + ] + ], + indexes: Iterable[List[int]], + online_features_response: GetOnlineFeaturesResponse, + requested_features: Iterable[str], + output_len: int, +): + """Populate the GetOnlineFeaturesResponse with feature data. + + This method assumes that `_read_from_online_store` returns data for each + combination of Entities in `entity_rows` in the same order as they + are provided. + + Args: + feature_data: A list of data in Protobuf form which was retrieved from the OnlineStore. + indexes: A list of indexes which should be the same length as `feature_data`. Each list + of indexes corresponds to a set of result rows in `online_features_response`. + online_features_response: The object to populate. + full_feature_names: A boolean that provides the option to add the feature view prefixes to the feature names, + changing them from the format "feature" to "feature_view__feature" (e.g., "daily_transactions" changes to + "customer_fv__daily_transactions"). + requested_features: The names of the features in `feature_data`. This should be ordered in the same way as the + data in `feature_data`. + output_len: The number of result rows in `online_features_response`. + """ + # Add the feature names to the response. + requested_feature_refs = [(feature_name) for feature_name in requested_features] + online_features_response.metadata.feature_names.val.extend(requested_feature_refs) + timestamps, statuses, values = zip(*feature_data) # Populate the result with data fetched from the OnlineStore # which is guaranteed to be aligned with `requested_features`. - for ( - feature_idx, - (timestamp_vector, statuses_vector, values_vector), - ) in enumerate(zip(zip(*timestamps), zip(*statuses), zip(*values))): - online_features_response.results.append( - GetOnlineFeaturesResponse.FeatureVector( - values=apply_list_mapping(values_vector, indexes), - statuses=apply_list_mapping(statuses_vector, indexes), - event_timestamps=apply_list_mapping(timestamp_vector, indexes), - ) + for timestamp_vector, statuses_vector, values_vector in feature_data: + response_vector = construct_response_feature_vector( + values_vector, statuses_vector, timestamp_vector, indexes, output_len ) + online_features_response.results.append(response_vector) + + +def _convert_entity_key_to_proto_to_dict( + entity_key_vals: List[EntityKeyProto], +) -> Dict[str, List[ValueProto]]: + entity_dict: Dict[str, List[ValueProto]] = {} + for entity_key_val in entity_key_vals: + if entity_key_val is not None: + for join_key, entity_value in zip( + entity_key_val.join_keys, entity_key_val.entity_values + ): + if join_key not in entity_dict: + entity_dict[join_key] = [] + # python_entity_value = _proto_value_to_value_type(entity_value) + entity_dict[join_key].append(entity_value) + return entity_dict def _get_features( @@ -995,7 +1151,7 @@ def _get_online_request_context( entityless_case = DUMMY_ENTITY_NAME in [ entity_name for feature_view in feature_views - for entity_name in feature_view.entities + for entity_name in (feature_view.entities or []) ] return ( @@ -1058,7 +1214,13 @@ def _prepare_entities_to_read_from_online_store( odfv_entities: List[Entity] = [] request_source_keys: List[str] = [] for on_demand_feature_view in requested_on_demand_feature_views: - odfv_entities.append(*getattr(on_demand_feature_view, "entities", [])) + entities_for_odfv = getattr(on_demand_feature_view, "entities", []) + if len(entities_for_odfv) > 0 and isinstance(entities_for_odfv[0], str): + entities_for_odfv = [ + registry.get_entity(entity_name, project, allow_cache=True) + for entity_name in entities_for_odfv + ] + odfv_entities.extend(entities_for_odfv) for source in on_demand_feature_view.source_request_sources: source_schema = on_demand_feature_view.source_request_sources[source].schema for column in source_schema: @@ -1130,33 +1292,32 @@ def _convert_rows_to_protobuf( requested_features: List[str], read_rows: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]], ) -> List[Tuple[List[Timestamp], List["FieldStatus.ValueType"], List[ValueProto]]]: - # Each row is a set of features for a given entity key. - # We only need to convert the data to Protobuf once. + # Pre-calculate the length to avoid repeated calculations + n_rows = len(read_rows) + + # Create single instances of commonly used values null_value = ValueProto() - read_row_protos = [] - for read_row in read_rows: - row_ts_proto = Timestamp() - row_ts, feature_data = read_row - # TODO (Ly): reuse whatever timestamp if row_ts is None? - if row_ts is not None: - row_ts_proto.FromDatetime(row_ts) - event_timestamps = [row_ts_proto] * len(requested_features) - if feature_data is None: - statuses = [FieldStatus.NOT_FOUND] * len(requested_features) - values = [null_value] * len(requested_features) - else: - statuses = [] - values = [] - for feature_name in requested_features: - # Make sure order of data is the same as requested_features. - if feature_name not in feature_data: - statuses.append(FieldStatus.NOT_FOUND) - values.append(null_value) - else: - statuses.append(FieldStatus.PRESENT) - values.append(feature_data[feature_name]) - read_row_protos.append((event_timestamps, statuses, values)) - return read_row_protos + null_status = FieldStatus.NOT_FOUND + null_timestamp = Timestamp() + present_status = FieldStatus.PRESENT + + requested_features_vectors = [] + for feature_name in requested_features: + ts_vector = [null_timestamp] * n_rows + status_vector = [null_status] * n_rows + value_vector = [null_value] * n_rows + for idx, read_row in enumerate(read_rows): + row_ts_proto = Timestamp() + row_ts, feature_data = read_row + # TODO (Ly): reuse whatever timestamp if row_ts is None? + if row_ts is not None: + row_ts_proto.FromDatetime(row_ts) + ts_vector[idx] = row_ts_proto + if (feature_data is not None) and (feature_name in feature_data): + status_vector[idx] = present_status + value_vector[idx] = feature_data[feature_name] + requested_features_vectors.append((ts_vector, status_vector, value_vector)) + return requested_features_vectors def has_all_tags( @@ -1192,6 +1353,10 @@ def _utc_now() -> datetime: return datetime.now(tz=timezone.utc) +def _serialize_vector_to_float_list(vector: List[float]) -> ValueProto: + return ValueProto(float_list_val=FloatListProto(val=vector)) + + def _build_retrieve_online_document_record( entity_key: Union[str, bytes], feature_value: Union[str, bytes], diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 10ad007fa90..8a1c5b70c3b 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -6,7 +6,7 @@ select = ["E","F","W","I"] ignore = ["E203", "E266", "E501", "E721"] [tool.ruff.lint.isort] -known-first-party = ["feast", "feast", "feast_serving_server", "feast_core_server"] +known-first-party = ["feast", "feast_serving_server", "feast_core_server"] default-section = "third-party" [tool.mypy] diff --git a/sdk/python/pytest.ini b/sdk/python/pytest.ini index a0736767601..d79459c0d0e 100644 --- a/sdk/python/pytest.ini +++ b/sdk/python/pytest.ini @@ -4,6 +4,7 @@ asyncio_mode = auto markers = universal_offline_stores: mark a test as using all offline stores. universal_online_stores: mark a test as using all online stores. + rbac_remote_integration_test: mark a integration test related to rbac and remote functionality. env = IS_TEST=True diff --git a/sdk/python/requirements/py3.10-ci-requirements.txt b/sdk/python/requirements/py3.10-ci-requirements.txt index 9ac4937582b..239d6b4de30 100644 --- a/sdk/python/requirements/py3.10-ci-requirements.txt +++ b/sdk/python/requirements/py3.10-ci-requirements.txt @@ -1,14 +1,14 @@ # This file was autogenerated by uv via the following command: # uv pip compile -p 3.10 --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py3.10-ci-requirements.txt -aiobotocore==2.15.2 +aiobotocore==2.20.0 # via feast (setup.py) -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.4.6 # via aiohttp -aiohttp==3.10.10 +aiohttp==3.11.13 # via aiobotocore aioitertools==0.12.0 # via aiobotocore -aiosignal==1.3.1 +aiosignal==1.3.2 # via aiohttp alabaster==0.7.16 # via sphinx @@ -16,7 +16,7 @@ altair==4.2.2 # via great-expectations annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.8.0 # via # httpx # jupyter-server @@ -25,7 +25,9 @@ anyio==4.6.2.post1 appnope==0.1.4 # via ipykernel argon2-cffi==23.1.0 - # via jupyter-server + # via + # jupyter-server + # minio argon2-cffi-bindings==21.2.0 # via argon2-cffi arrow==1.3.0 @@ -34,46 +36,50 @@ asn1crypto==1.5.1 # via snowflake-connector-python assertpy==1.1 # via feast (setup.py) -asttokens==2.4.1 +asttokens==3.0.0 # via stack-data async-lru==2.0.4 # via jupyterlab async-property==0.2.2 # via python-keycloak -async-timeout==4.0.3 +async-timeout==5.0.1 # via # aiohttp # redis -atpublic==5.0 +atpublic==5.1 # via ibis-framework -attrs==24.2.0 +attrs==25.1.0 # via # aiohttp + # jsonlines # jsonschema # referencing -azure-core==1.31.0 +azure-core==1.32.0 # via # azure-identity # azure-storage-blob -azure-identity==1.19.0 +azure-identity==1.20.0 # via feast (setup.py) -azure-storage-blob==12.23.1 +azure-storage-blob==12.24.1 # via feast (setup.py) -babel==2.16.0 +babel==2.17.0 # via # jupyterlab-server # sphinx -beautifulsoup4==4.12.3 - # via nbconvert -bigtree==0.21.3 +beautifulsoup4==4.13.3 + # via + # docling + # nbconvert +bigtree==0.25.0 # via feast (setup.py) -bleach==6.1.0 +bleach[css]==6.2.0 # via nbconvert -boto3==1.35.36 +boto3==1.36.23 # via # feast (setup.py) + # ikvpy # moto -botocore==1.35.36 +botocore==1.36.23 # via # aiobotocore # boto3 @@ -84,12 +90,13 @@ build==1.2.2.post1 # feast (setup.py) # pip-tools # singlestoredb -cachetools==5.5.0 +cachetools==5.5.2 # via google-auth cassandra-driver==3.29.2 # via feast (setup.py) -certifi==2024.8.30 +certifi==2025.1.31 # via + # docling # elastic-transport # httpcore # httpx @@ -99,24 +106,27 @@ certifi==2024.8.30 # snowflake-connector-python cffi==1.17.1 # via + # feast (setup.py) # argon2-cffi-bindings # cryptography + # ikvpy # snowflake-connector-python cfgv==3.4.0 # via pre-commit -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via # requests # snowflake-connector-python -click==8.1.7 +click==8.1.8 # via # feast (setup.py) # dask # geomet # great-expectations # pip-tools + # typer # uvicorn -cloudpickle==3.1.0 +cloudpickle==3.1.1 # via dask colorama==0.4.6 # via @@ -128,9 +138,11 @@ comm==0.2.2 # ipywidgets couchbase==4.3.2 # via feast (setup.py) -coverage[toml]==7.6.4 +couchbase-columnar==1.0.0 + # via feast (setup.py) +coverage[toml]==7.6.12 # via pytest-cov -cryptography==42.0.8 +cryptography==43.0.3 # via # feast (setup.py) # azure-identity @@ -144,42 +156,57 @@ cryptography==42.0.8 # snowflake-connector-python # types-pyopenssl # types-redis -cython==3.0.11 +cython==3.0.12 # via thriftpy2 -dask[dataframe]==2024.10.0 - # via - # feast (setup.py) - # dask-expr -dask-expr==1.1.16 - # via dask -db-dtypes==1.3.0 +dask[dataframe]==2025.2.0 + # via feast (setup.py) +db-dtypes==1.4.1 # via google-cloud-bigquery -debugpy==1.8.7 +debugpy==1.8.12 # via ipykernel -decorator==5.1.1 +decorator==5.2.1 # via ipython defusedxml==0.7.1 # via nbconvert -deltalake==0.20.2 +deltalake==0.25.1 # via feast (setup.py) deprecation==2.1.0 # via python-keycloak dill==0.3.9 - # via feast (setup.py) + # via + # feast (setup.py) + # multiprocess distlib==0.3.9 # via virtualenv docker==7.1.0 # via testcontainers +docling==2.24.0 + # via feast (setup.py) +docling-core[chunking]==2.20.0 + # via + # docling + # docling-ibm-models + # docling-parse +docling-ibm-models==3.4.0 + # via docling +docling-parse==3.4.0 + # via docling docutils==0.19 # via sphinx -duckdb==1.1.2 +duckdb==1.1.3 # via ibis-framework -elastic-transport==8.15.1 +easyocr==1.7.2 + # via docling +elastic-transport==8.17.0 # via elasticsearch -elasticsearch==8.15.1 +elasticsearch==8.17.1 # via feast (setup.py) entrypoints==0.4 # via altair +environs==9.5.0 + # via pymilvus +et-xmlfile==2.0.0 + # via openpyxl exceptiongroup==1.2.2 # via # anyio @@ -187,18 +214,23 @@ exceptiongroup==1.2.2 # pytest execnet==2.1.1 # via pytest-xdist -executing==2.1.0 +executing==2.2.0 # via stack-data -faiss-cpu==1.9.0 +faiss-cpu==1.10.0 # via feast (setup.py) -fastapi==0.115.4 +fastapi==0.115.8 # via feast (setup.py) -fastjsonschema==2.20.0 +fastjsonschema==2.21.1 # via nbformat -filelock==3.16.1 +filelock==3.17.0 # via + # huggingface-hub # snowflake-connector-python + # torch + # transformers # virtualenv +filetype==1.2.0 + # via docling fqdn==1.5.1 # via jsonschema frozenlist==1.5.0 @@ -209,9 +241,11 @@ fsspec==2024.9.0 # via # feast (setup.py) # dask + # huggingface-hub + # torch geomet==0.2.1.post1 # via cassandra-driver -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.24.1 # via # feast (setup.py) # google-cloud-bigquery @@ -220,7 +254,7 @@ google-api-core[grpc]==2.22.0 # google-cloud-core # google-cloud-datastore # google-cloud-storage -google-auth==2.35.0 +google-auth==2.38.0 # via # google-api-core # google-cloud-bigquery @@ -230,21 +264,21 @@ google-auth==2.35.0 # google-cloud-datastore # google-cloud-storage # kubernetes -google-cloud-bigquery[pandas]==3.26.0 +google-cloud-bigquery[pandas]==3.29.0 # via feast (setup.py) -google-cloud-bigquery-storage==2.27.0 +google-cloud-bigquery-storage==2.28.0 # via feast (setup.py) -google-cloud-bigtable==2.26.0 +google-cloud-bigtable==2.28.1 # via feast (setup.py) -google-cloud-core==2.4.1 +google-cloud-core==2.4.2 # via # google-cloud-bigquery # google-cloud-bigtable # google-cloud-datastore # google-cloud-storage -google-cloud-datastore==2.20.1 +google-cloud-datastore==2.20.2 # via feast (setup.py) -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via feast (setup.py) google-crc32c==1.6.0 # via @@ -254,7 +288,7 @@ google-resumable-media==2.7.2 # via # google-cloud-bigquery # google-cloud-storage -googleapis-common-protos[grpc]==1.65.0 +googleapis-common-protos[grpc]==1.68.0 # via # feast (setup.py) # google-api-core @@ -262,9 +296,11 @@ googleapis-common-protos[grpc]==1.65.0 # grpcio-status great-expectations==0.18.22 # via feast (setup.py) -grpc-google-iam-v1==0.13.1 +greenlet==3.1.1 + # via sqlalchemy +grpc-google-iam-v1==0.14.0 # via google-cloud-bigtable -grpcio==1.67.0 +grpcio==1.70.0 # via # feast (setup.py) # google-api-core @@ -275,16 +311,20 @@ grpcio==1.67.0 # grpcio-status # grpcio-testing # grpcio-tools + # ikvpy + # pymilvus # qdrant-client -grpcio-health-checking==1.62.3 +grpcio-health-checking==1.70.0 # via feast (setup.py) -grpcio-reflection==1.62.3 +grpcio-reflection==1.70.0 # via feast (setup.py) -grpcio-status==1.62.3 - # via google-api-core -grpcio-testing==1.62.3 +grpcio-status==1.70.0 + # via + # google-api-core + # ikvpy +grpcio-testing==1.70.0 # via feast (setup.py) -grpcio-tools==1.62.3 +grpcio-tools==1.70.0 # via # feast (setup.py) # qdrant-client @@ -296,7 +336,7 @@ h11==0.14.0 # via # httpcore # uvicorn -h2==4.1.0 +h2==4.2.0 # via httpx happybase==1.2.0 # via feast (setup.py) @@ -304,9 +344,9 @@ hazelcast-python-client==5.5.0 # via feast (setup.py) hiredis==2.4.0 # via feast (setup.py) -hpack==4.0.0 +hpack==4.1.0 # via h2 -httpcore==1.0.6 +httpcore==1.0.7 # via httpx httptools==0.6.4 # via uvicorn @@ -316,15 +356,21 @@ httpx[http2]==0.27.2 # jupyterlab # python-keycloak # qdrant-client -hyperframe==6.0.1 +huggingface-hub==0.29.1 + # via + # docling + # docling-ibm-models + # tokenizers + # transformers +hyperframe==6.1.0 # via h2 -ibis-framework[duckdb]==9.5.0 +ibis-framework[duckdb, mssql]==9.5.0 # via # feast (setup.py) # ibis-substrait ibis-substrait==4.0.1 # via feast (setup.py) -identify==2.6.1 +identify==2.6.8 # via pre-commit idna==3.10 # via @@ -334,9 +380,13 @@ idna==3.10 # requests # snowflake-connector-python # yarl +ikvpy==0.0.36 + # via feast (setup.py) +imageio==2.37.0 + # via scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==8.5.0 +importlib-metadata==8.6.1 # via # build # dask @@ -344,7 +394,7 @@ iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via jupyterlab -ipython==8.29.0 +ipython==8.32.0 # via # great-expectations # ipykernel @@ -355,9 +405,9 @@ isodate==0.7.2 # via azure-storage-blob isoduration==20.11.0 # via jsonschema -jedi==0.19.1 +jedi==0.19.2 # via ipython -jinja2==3.1.4 +jinja2==3.1.5 # via # feast (setup.py) # altair @@ -368,22 +418,29 @@ jinja2==3.1.4 # moto # nbconvert # sphinx + # torch jmespath==1.0.1 # via + # aiobotocore # boto3 # botocore -json5==0.9.25 +json5==0.10.0 # via jupyterlab-server +jsonlines==3.1.0 + # via docling-ibm-models jsonpatch==1.33 # via great-expectations jsonpointer==3.0.0 # via # jsonpatch # jsonschema +jsonref==1.1.0 + # via docling-core jsonschema[format-nongpl]==4.23.0 # via # feast (setup.py) # altair + # docling-core # great-expectations # jupyter-events # jupyterlab-server @@ -404,11 +461,11 @@ jupyter-core==5.7.2 # nbclient # nbconvert # nbformat -jupyter-events==0.10.0 +jupyter-events==0.12.0 # via jupyter-server jupyter-lsp==2.2.5 # via jupyterlab -jupyter-server==2.14.2 +jupyter-server==2.15.0 # via # jupyter-lsp # jupyterlab @@ -417,7 +474,7 @@ jupyter-server==2.14.2 # notebook-shim jupyter-server-terminals==0.5.3 # via jupyter-server -jupyterlab==4.2.5 +jupyterlab==4.3.5 # via notebook jupyterlab-pygments==0.3.0 # via nbconvert @@ -431,38 +488,59 @@ jwcrypto==1.5.6 # via python-keycloak kubernetes==20.13.0 # via feast (setup.py) +latex2mathml==3.77.0 + # via docling-core +lazy-loader==0.4 + # via scikit-image locket==1.0.0 # via partd +lxml==5.3.1 + # via + # docling + # python-docx + # python-pptx +lz4==4.4.3 + # via trino makefun==1.15.6 # via great-expectations markdown-it-py==3.0.0 # via rich +marko==2.1.2 + # via docling markupsafe==3.0.2 # via # jinja2 # nbconvert # werkzeug -marshmallow==3.23.0 - # via great-expectations +marshmallow==3.26.1 + # via + # environs + # great-expectations matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py -minio==7.1.0 +milvus-lite==2.4.11 + # via pymilvus +minio==7.2.11 # via feast (setup.py) -mistune==3.0.2 +mistune==3.1.2 # via # great-expectations # nbconvert -mmh3==5.0.1 +mmh3==5.1.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) moto==4.2.14 # via feast (setup.py) -msal==1.31.0 +mpire[dill]==2.10.2 + # via semchunk +mpmath==1.3.0 + # via sympy +msal==1.31.1 # via # azure-identity # msal-extensions @@ -470,8 +548,11 @@ msal-extensions==1.2.0 # via azure-identity multidict==6.1.0 # via + # aiobotocore # aiohttp # yarl +multiprocess==0.70.17 + # via mpire mypy==1.11.2 # via # feast (setup.py) @@ -480,9 +561,9 @@ mypy-extensions==1.0.0 # via mypy mypy-protobuf==3.3.0 # via feast (setup.py) -nbclient==0.10.0 +nbclient==0.10.2 # via nbconvert -nbconvert==7.16.4 +nbconvert==7.16.6 # via jupyter-server nbformat==5.10.4 # via @@ -492,9 +573,15 @@ nbformat==5.10.4 # nbconvert nest-asyncio==1.6.0 # via ipykernel +networkx==3.4.2 + # via + # scikit-image + # torch +ninja==1.11.1.3 + # via easyocr nodeenv==1.9.1 # via pre-commit -notebook==7.2.2 +notebook==7.3.2 # via great-expectations notebook-shim==0.2.4 # via @@ -506,18 +593,34 @@ numpy==1.26.4 # altair # dask # db-dtypes + # docling-ibm-models + # easyocr # faiss-cpu # great-expectations # ibis-framework + # imageio + # opencv-python-headless # pandas # pyarrow # qdrant-client + # safetensors + # scikit-image # scipy + # shapely + # tifffile + # torchvision + # transformers oauthlib==3.2.2 # via requests-oauthlib +opencv-python-headless==4.11.0.86 + # via + # docling-ibm-models + # easyocr +openpyxl==3.1.5 + # via docling overrides==7.7.0 # via jupyter-server -packaging==24.1 +packaging==24.2 # via # build # dask @@ -527,27 +630,34 @@ packaging==24.1 # google-cloud-bigquery # great-expectations # gunicorn + # huggingface-hub # ibis-framework # ibis-substrait # ipykernel + # jupyter-events # jupyter-server # jupyterlab # jupyterlab-server + # lazy-loader # marshmallow # nbconvert # pytest + # scikit-image # snowflake-connector-python # sphinx + # transformers pandas==2.2.3 # via # feast (setup.py) # altair # dask - # dask-expr # db-dtypes + # docling + # docling-core # google-cloud-bigquery # great-expectations # ibis-framework + # pymilvus # snowflake-connector-python pandocfilters==1.5.1 # via nbconvert @@ -559,11 +669,22 @@ parsy==2.1 # via ibis-framework partd==1.4.2 # via dask -pbr==6.1.0 +pbr==6.1.1 # via mock pexpect==4.9.0 # via ipython -pip==24.3.1 +pillow==11.1.0 + # via + # docling + # docling-core + # docling-ibm-models + # docling-parse + # easyocr + # imageio + # python-pptx + # scikit-image + # torchvision +pip==25.0.1 # via pip-tools pip-tools==7.4.1 # via feast (setup.py) @@ -582,21 +703,23 @@ portalocker==2.10.1 # qdrant-client pre-commit==3.3.1 # via feast (setup.py) -prometheus-client==0.21.0 +prometheus-client==0.21.1 # via # feast (setup.py) # jupyter-server -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.50 # via ipython -propcache==0.2.0 - # via yarl -proto-plus==1.25.0 +propcache==0.3.0 + # via + # aiohttp + # yarl +proto-plus==1.26.0 # via # google-api-core # google-cloud-bigquery-storage # google-cloud-bigtable # google-cloud-datastore -protobuf==4.25.5 +protobuf==5.29.3 # via # feast (setup.py) # google-api-core @@ -610,18 +733,20 @@ protobuf==4.25.5 # grpcio-status # grpcio-testing # grpcio-tools + # ikvpy # mypy-protobuf # proto-plus + # pymilvus # substrait psutil==5.9.0 # via # feast (setup.py) # ipykernel -psycopg[binary, pool]==3.2.3 +psycopg[binary, pool]==3.2.5 # via feast (setup.py) -psycopg-binary==3.2.3 +psycopg-binary==3.2.5 # via psycopg -psycopg-pool==3.2.3 +psycopg-pool==3.2.5 # via psycopg ptyprocess==0.7.0 # via @@ -638,7 +763,7 @@ py4j==0.10.9.7 pyarrow==17.0.0 # via # feast (setup.py) - # dask-expr + # dask # db-dtypes # deltalake # google-cloud-bigquery @@ -654,44 +779,62 @@ pyasn1-modules==0.4.1 # via google-auth pybindgen==0.22.1 # via feast (setup.py) +pyclipper==1.3.0.post6 + # via easyocr pycparser==2.22 # via cffi -pydantic==2.9.2 +pycryptodome==3.21.0 + # via minio +pydantic==2.10.6 # via # feast (setup.py) + # docling + # docling-core + # docling-ibm-models + # docling-parse # fastapi # great-expectations + # pydantic-settings # qdrant-client -pydantic-core==2.23.4 +pydantic-core==2.27.2 # via pydantic -pygments==2.18.0 +pydantic-settings==2.8.0 + # via docling +pygments==2.19.1 # via # feast (setup.py) # ipython + # mpire # nbconvert # rich # sphinx -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # feast (setup.py) # msal # singlestoredb # snowflake-connector-python -pymssql==2.3.1 +pymilvus==2.4.9 + # via feast (setup.py) +pymssql==2.3.2 # via feast (setup.py) pymysql==1.1.1 # via feast (setup.py) pyodbc==5.2.0 - # via feast (setup.py) -pyopenssl==24.2.1 + # via + # feast (setup.py) + # ibis-framework +pyopenssl==24.3.0 # via snowflake-connector-python -pyparsing==3.2.0 +pyparsing==3.2.1 # via great-expectations +pypdfium2==4.30.1 + # via docling pyproject-hooks==1.2.0 # via # build # pip-tools -pyspark==3.5.3 +pyspark==3.5.4 # via feast (setup.py) pytest==7.4.4 # via @@ -709,7 +852,7 @@ pytest-asyncio==0.23.8 # via feast (setup.py) pytest-benchmark==3.4.1 # via feast (setup.py) -pytest-cov==5.0.0 +pytest-cov==6.0.0 # via feast (setup.py) pytest-env==1.1.3 # via feast (setup.py) @@ -723,8 +866,11 @@ pytest-timeout==1.4.2 # via feast (setup.py) pytest-xdist==3.6.1 # via feast (setup.py) +python-bidi==0.6.6 + # via easyocr python-dateutil==2.9.0.post0 # via + # aiobotocore # arrow # botocore # google-cloud-bigquery @@ -735,13 +881,20 @@ python-dateutil==2.9.0.post0 # moto # pandas # trino +python-docx==1.1.2 + # via docling python-dotenv==1.0.1 - # via uvicorn -python-json-logger==2.0.7 + # via + # environs + # pydantic-settings + # uvicorn +python-json-logger==3.2.1 # via jupyter-events python-keycloak==4.2.2 # via feast (setup.py) -pytz==2024.2 +python-pptx==1.0.2 + # via docling +pytz==2025.1 # via # great-expectations # ibis-framework @@ -752,39 +905,46 @@ pyyaml==6.0.2 # via # feast (setup.py) # dask + # docling-core + # easyocr + # huggingface-hub # ibis-substrait # jupyter-events # kubernetes # pre-commit # responses + # transformers # uvicorn -pyzmq==26.2.0 +pyzmq==26.2.1 # via # ipykernel # jupyter-client # jupyter-server -qdrant-client==1.12.0 +qdrant-client==1.13.2 # via feast (setup.py) redis==4.6.0 # via feast (setup.py) -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications # jupyter-events -regex==2024.9.11 +regex==2024.11.6 # via # feast (setup.py) # parsimonious + # transformers requests==2.32.3 # via # feast (setup.py) # azure-core # docker + # docling # google-api-core # google-cloud-bigquery # google-cloud-storage # great-expectations + # huggingface-hub # jupyterlab-server # kubernetes # moto @@ -796,12 +956,13 @@ requests==2.32.3 # singlestoredb # snowflake-connector-python # sphinx + # transformers # trino requests-oauthlib==2.0.0 # via kubernetes requests-toolbelt==1.0.0 # via python-keycloak -responses==0.25.3 +responses==0.25.6 # via moto rfc3339-validator==0.1.4 # via @@ -811,40 +972,60 @@ rfc3986-validator==0.1.1 # via # jsonschema # jupyter-events -rich==13.9.3 - # via ibis-framework -rpds-py==0.20.0 +rich==13.9.4 + # via + # ibis-framework + # typer +rpds-py==0.23.1 # via # jsonschema # referencing rsa==4.9 # via google-auth +rtree==1.3.0 + # via docling ruamel-yaml==0.17.40 # via great-expectations ruamel-yaml-clib==0.2.12 # via ruamel-yaml -ruff==0.7.1 +ruff==0.9.7 # via feast (setup.py) -s3transfer==0.10.3 +s3transfer==0.11.2 # via boto3 -scipy==1.14.1 - # via great-expectations +safetensors[torch]==0.5.2 + # via + # docling-ibm-models + # transformers +scikit-image==0.25.2 + # via easyocr +scipy==1.15.2 + # via + # docling + # easyocr + # great-expectations + # scikit-image +semchunk==2.2.2 + # via docling-core send2trash==1.8.3 # via jupyter-server -setuptools==75.2.0 +setuptools==75.8.0 # via # grpcio-tools # jupyterlab # kubernetes + # pbr # pip-tools + # pymilvus # singlestoredb +shapely==2.0.7 + # via easyocr +shellingham==1.5.4 + # via typer singlestoredb==1.7.2 # via feast (setup.py) -six==1.16.0 +six==1.17.0 # via - # asttokens # azure-core - # bleach # geomet # happybase # kubernetes @@ -858,7 +1039,7 @@ sniffio==1.3.1 # httpx snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python[pandas]==3.12.3 +snowflake-connector-python[pandas]==3.13.2 # via feast (setup.py) sortedcontainers==2.4.0 # via snowflake-connector-python @@ -878,37 +1059,46 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sqlalchemy[mypy]==2.0.36 +sqlalchemy[mypy]==2.0.38 # via feast (setup.py) sqlglot==25.20.2 # via ibis-framework -sqlite-vec==0.1.1 +sqlite-vec==0.1.6 # via feast (setup.py) -sqlparams==6.1.0 +sqlparams==6.2.0 # via singlestoredb stack-data==0.6.3 # via ipython -starlette==0.41.2 +starlette==0.45.3 # via fastapi substrait==0.23.0 # via ibis-substrait +sympy==1.13.3 + # via torch tabulate==0.9.0 - # via feast (setup.py) + # via + # feast (setup.py) + # docling-core + # docling-parse tenacity==8.5.0 # via feast (setup.py) terminado==0.18.1 # via # jupyter-server # jupyter-server-terminals -testcontainers==4.4.0 +testcontainers==4.8.2 # via feast (setup.py) thriftpy2==0.5.2 # via happybase +tifffile==2025.2.18 + # via scikit-image tinycss2==1.4.0 - # via nbconvert + # via bleach +tokenizers==0.19.1 + # via transformers toml==0.10.2 # via feast (setup.py) -tomli==2.0.2 +tomli==2.2.1 # via # build # coverage @@ -926,7 +1116,19 @@ toolz==0.12.1 # dask # ibis-framework # partd -tornado==6.4.1 +torch==2.2.2 + # via + # feast (setup.py) + # docling-ibm-models + # easyocr + # safetensors + # torchvision +torchvision==0.17.2 + # via + # feast (setup.py) + # docling-ibm-models + # easyocr +tornado==6.4.2 # via # ipykernel # jupyter-client @@ -934,10 +1136,17 @@ tornado==6.4.1 # jupyterlab # notebook # terminado -tqdm==4.66.6 +tqdm==4.67.1 # via # feast (setup.py) + # docling + # docling-ibm-models # great-expectations + # huggingface-hub + # milvus-lite + # mpire + # semchunk + # transformers traitlets==5.14.3 # via # comm @@ -953,37 +1162,45 @@ traitlets==5.14.3 # nbclient # nbconvert # nbformat -trino==0.330.0 +transformers==4.42.4 + # via + # docling-core + # docling-ibm-models +trino==0.333.0 # via feast (setup.py) -typeguard==4.4.0 +typeguard==4.4.2 # via feast (setup.py) -types-cffi==1.16.0.20240331 +typer==0.12.5 + # via + # docling + # docling-core +types-cffi==1.16.0.20241221 # via types-pyopenssl types-protobuf==3.19.22 # via # feast (setup.py) # mypy-protobuf -types-pymysql==1.1.0.20240524 +types-pymysql==1.1.0.20241103 # via feast (setup.py) types-pyopenssl==24.1.0.20240722 # via types-redis -types-python-dateutil==2.9.0.20241003 +types-python-dateutil==2.9.0.20241206 # via # feast (setup.py) # arrow -types-pytz==2024.2.0.20241003 +types-pytz==2025.1.0.20250204 # via feast (setup.py) -types-pyyaml==6.0.12.20240917 +types-pyyaml==6.0.12.20241230 # via feast (setup.py) types-redis==4.6.0.20241004 # via feast (setup.py) types-requests==2.30.0.0 # via feast (setup.py) -types-setuptools==75.2.0.20241025 +types-setuptools==75.8.0.20250225 # via # feast (setup.py) # types-cffi -types-tabulate==0.9.0.20240106 +types-tabulate==0.9.0.20241207 # via feast (setup.py) types-urllib3==1.26.25.14 # via types-requests @@ -994,34 +1211,47 @@ typing-extensions==4.12.2 # azure-core # azure-identity # azure-storage-blob + # beautifulsoup4 + # docling-core # fastapi # great-expectations + # huggingface-hub # ibis-framework # ipython # jwcrypto + # minio + # mistune # multidict # mypy # psycopg # psycopg-pool # pydantic # pydantic-core + # python-docx + # python-pptx + # referencing # rich # snowflake-connector-python # sqlalchemy # testcontainers + # torch # typeguard + # typer # uvicorn -tzdata==2024.2 +tzdata==2025.1 # via pandas -tzlocal==5.2 +tzlocal==5.3 # via # great-expectations # trino +ujson==5.10.0 + # via pymilvus uri-template==1.3.0 # via jsonschema -urllib3==2.2.3 +urllib3==2.3.0 # via # feast (setup.py) + # aiobotocore # botocore # docker # elastic-transport @@ -1032,11 +1262,11 @@ urllib3==2.2.3 # requests # responses # testcontainers -uvicorn[standard]==0.32.0 +uvicorn[standard]==0.34.0 # via # feast (setup.py) # uvicorn-worker -uvicorn-worker==0.2.0 +uvicorn-worker==0.3.0 # via feast (setup.py) uvloop==0.21.0 # via uvicorn @@ -1044,11 +1274,11 @@ virtualenv==20.23.0 # via # feast (setup.py) # pre-commit -watchfiles==0.24.0 +watchfiles==1.0.4 # via uvicorn wcwidth==0.2.13 # via prompt-toolkit -webcolors==24.8.0 +webcolors==24.11.1 # via jsonschema webencodings==0.5.1 # via @@ -1058,23 +1288,27 @@ websocket-client==1.8.0 # via # jupyter-server # kubernetes -websockets==13.1 +websockets==15.0 # via uvicorn -werkzeug==3.0.6 +werkzeug==3.1.3 # via moto -wheel==0.44.0 +wheel==0.45.1 # via # pip-tools # singlestoredb widgetsnbextension==4.0.13 # via ipywidgets -wrapt==1.16.0 +wrapt==1.17.2 # via # aiobotocore # testcontainers +xlsxwriter==3.2.2 + # via python-pptx xmltodict==0.14.2 # via moto -yarl==1.16.0 +yarl==1.18.3 # via aiohttp -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata +zstandard==0.23.0 + # via trino diff --git a/sdk/python/requirements/py3.10-requirements.txt b/sdk/python/requirements/py3.10-requirements.txt index dd2ed6951c9..ea4baadec05 100644 --- a/sdk/python/requirements/py3.10-requirements.txt +++ b/sdk/python/requirements/py3.10-requirements.txt @@ -2,43 +2,41 @@ # uv pip compile -p 3.10 --system --no-strip-extras setup.py --output-file sdk/python/requirements/py3.10-requirements.txt annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.8.0 # via # starlette # watchfiles -attrs==24.2.0 +attrs==25.1.0 # via # jsonschema # referencing -bigtree==0.21.3 +bigtree==0.25.0 # via feast (setup.py) -certifi==2024.8.30 +certifi==2025.1.31 # via requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -click==8.1.7 +click==8.1.8 # via # feast (setup.py) # dask # uvicorn -cloudpickle==3.1.0 +cloudpickle==3.1.1 # via dask colorama==0.4.6 # via feast (setup.py) -dask[dataframe]==2024.10.0 - # via - # feast (setup.py) - # dask-expr -dask-expr==1.1.16 - # via dask +dask[dataframe]==2025.2.0 + # via feast (setup.py) dill==0.3.9 # via feast (setup.py) exceptiongroup==1.2.2 # via anyio -fastapi==0.115.4 +fastapi==0.115.8 # via feast (setup.py) -fsspec==2024.10.0 +fsspec==2025.2.0 # via dask +greenlet==3.1.1 + # via sqlalchemy gunicorn==23.0.0 # via # feast (setup.py) @@ -51,9 +49,9 @@ idna==3.10 # via # anyio # requests -importlib-metadata==8.5.0 +importlib-metadata==8.6.1 # via dask -jinja2==3.1.4 +jinja2==3.1.5 # via feast (setup.py) jsonschema==4.23.0 # via feast (setup.py) @@ -63,9 +61,9 @@ locket==1.0.0 # via partd markupsafe==3.0.2 # via jinja2 -mmh3==5.0.1 +mmh3==5.1.0 # via feast (setup.py) -mypy==1.13.0 +mypy==1.15.0 # via sqlalchemy mypy-extensions==1.0.0 # via mypy @@ -74,7 +72,7 @@ numpy==1.26.4 # feast (setup.py) # dask # pandas -packaging==24.1 +packaging==24.2 # via # dask # gunicorn @@ -82,57 +80,56 @@ pandas==2.2.3 # via # feast (setup.py) # dask - # dask-expr partd==1.4.2 # via dask -prometheus-client==0.21.0 +prometheus-client==0.21.1 # via feast (setup.py) -protobuf==4.25.5 +protobuf==5.29.3 # via feast (setup.py) -psutil==6.1.0 +psutil==7.0.0 # via feast (setup.py) pyarrow==18.0.0 # via # feast (setup.py) - # dask-expr -pydantic==2.9.2 + # dask +pydantic==2.10.6 # via # feast (setup.py) # fastapi -pydantic-core==2.23.4 +pydantic-core==2.27.2 # via pydantic -pygments==2.18.0 +pygments==2.19.1 # via feast (setup.py) -pyjwt==2.9.0 +pyjwt==2.10.1 # via feast (setup.py) python-dateutil==2.9.0.post0 # via pandas python-dotenv==1.0.1 # via uvicorn -pytz==2024.2 +pytz==2025.1 # via pandas pyyaml==6.0.2 # via # feast (setup.py) # dask # uvicorn -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via feast (setup.py) -rpds-py==0.20.0 +rpds-py==0.23.1 # via # jsonschema # referencing -six==1.16.0 +six==1.17.0 # via python-dateutil sniffio==1.3.1 # via anyio -sqlalchemy[mypy]==2.0.36 +sqlalchemy[mypy]==2.0.38 # via feast (setup.py) -starlette==0.41.2 +starlette==0.45.3 # via fastapi tabulate==0.9.0 # via feast (setup.py) @@ -140,15 +137,15 @@ tenacity==8.5.0 # via feast (setup.py) toml==0.10.2 # via feast (setup.py) -tomli==2.0.2 +tomli==2.2.1 # via mypy toolz==1.0.0 # via # dask # partd -tqdm==4.66.6 +tqdm==4.67.1 # via feast (setup.py) -typeguard==4.4.0 +typeguard==4.4.2 # via feast (setup.py) typing-extensions==4.12.2 # via @@ -157,24 +154,25 @@ typing-extensions==4.12.2 # mypy # pydantic # pydantic-core + # referencing # sqlalchemy # typeguard # uvicorn -tzdata==2024.2 +tzdata==2025.1 # via pandas -urllib3==2.2.3 +urllib3==2.3.0 # via requests -uvicorn[standard]==0.32.0 +uvicorn[standard]==0.34.0 # via # feast (setup.py) # uvicorn-worker -uvicorn-worker==0.2.0 +uvicorn-worker==0.3.0 # via feast (setup.py) uvloop==0.21.0 # via uvicorn -watchfiles==0.24.0 +watchfiles==1.0.4 # via uvicorn -websockets==13.1 +websockets==15.0 # via uvicorn -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata diff --git a/sdk/python/requirements/py3.11-ci-requirements.txt b/sdk/python/requirements/py3.11-ci-requirements.txt index feaafa36e3f..af6b5b469c9 100644 --- a/sdk/python/requirements/py3.11-ci-requirements.txt +++ b/sdk/python/requirements/py3.11-ci-requirements.txt @@ -1,14 +1,14 @@ # This file was autogenerated by uv via the following command: # uv pip compile -p 3.11 --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py3.11-ci-requirements.txt -aiobotocore==2.15.2 +aiobotocore==2.20.0 # via feast (setup.py) -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.4.6 # via aiohttp -aiohttp==3.10.10 +aiohttp==3.11.13 # via aiobotocore aioitertools==0.12.0 # via aiobotocore -aiosignal==1.3.1 +aiosignal==1.3.2 # via aiohttp alabaster==0.7.16 # via sphinx @@ -16,7 +16,7 @@ altair==4.2.2 # via great-expectations annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.8.0 # via # httpx # jupyter-server @@ -25,7 +25,9 @@ anyio==4.6.2.post1 appnope==0.1.4 # via ipykernel argon2-cffi==23.1.0 - # via jupyter-server + # via + # jupyter-server + # minio argon2-cffi-bindings==21.2.0 # via argon2-cffi arrow==1.3.0 @@ -34,44 +36,48 @@ asn1crypto==1.5.1 # via snowflake-connector-python assertpy==1.1 # via feast (setup.py) -asttokens==2.4.1 +asttokens==3.0.0 # via stack-data async-lru==2.0.4 # via jupyterlab async-property==0.2.2 # via python-keycloak -async-timeout==4.0.3 +async-timeout==5.0.1 # via redis -atpublic==5.0 +atpublic==5.1 # via ibis-framework -attrs==24.2.0 +attrs==25.1.0 # via # aiohttp + # jsonlines # jsonschema # referencing -azure-core==1.31.0 +azure-core==1.32.0 # via # azure-identity # azure-storage-blob -azure-identity==1.19.0 +azure-identity==1.20.0 # via feast (setup.py) -azure-storage-blob==12.23.1 +azure-storage-blob==12.24.1 # via feast (setup.py) -babel==2.16.0 +babel==2.17.0 # via # jupyterlab-server # sphinx -beautifulsoup4==4.12.3 - # via nbconvert -bigtree==0.21.3 +beautifulsoup4==4.13.3 + # via + # docling + # nbconvert +bigtree==0.25.0 # via feast (setup.py) -bleach==6.1.0 +bleach[css]==6.2.0 # via nbconvert -boto3==1.35.36 +boto3==1.36.23 # via # feast (setup.py) + # ikvpy # moto -botocore==1.35.36 +botocore==1.36.23 # via # aiobotocore # boto3 @@ -82,12 +88,13 @@ build==1.2.2.post1 # feast (setup.py) # pip-tools # singlestoredb -cachetools==5.5.0 +cachetools==5.5.2 # via google-auth cassandra-driver==3.29.2 # via feast (setup.py) -certifi==2024.8.30 +certifi==2025.1.31 # via + # docling # elastic-transport # httpcore # httpx @@ -97,24 +104,27 @@ certifi==2024.8.30 # snowflake-connector-python cffi==1.17.1 # via + # feast (setup.py) # argon2-cffi-bindings # cryptography + # ikvpy # snowflake-connector-python cfgv==3.4.0 # via pre-commit -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via # requests # snowflake-connector-python -click==8.1.7 +click==8.1.8 # via # feast (setup.py) # dask # geomet # great-expectations # pip-tools + # typer # uvicorn -cloudpickle==3.1.0 +cloudpickle==3.1.1 # via dask colorama==0.4.6 # via @@ -126,9 +136,11 @@ comm==0.2.2 # ipywidgets couchbase==4.3.2 # via feast (setup.py) -coverage[toml]==7.6.4 +couchbase-columnar==1.0.0 + # via feast (setup.py) +coverage[toml]==7.6.12 # via pytest-cov -cryptography==42.0.8 +cryptography==43.0.3 # via # feast (setup.py) # azure-identity @@ -142,56 +154,76 @@ cryptography==42.0.8 # snowflake-connector-python # types-pyopenssl # types-redis -cython==3.0.11 +cython==3.0.12 # via thriftpy2 -dask[dataframe]==2024.10.0 - # via - # feast (setup.py) - # dask-expr -dask-expr==1.1.16 - # via dask -db-dtypes==1.3.0 +dask[dataframe]==2025.2.0 + # via feast (setup.py) +db-dtypes==1.4.1 # via google-cloud-bigquery -debugpy==1.8.7 +debugpy==1.8.12 # via ipykernel -decorator==5.1.1 +decorator==5.2.1 # via ipython defusedxml==0.7.1 # via nbconvert -deltalake==0.20.2 +deltalake==0.25.1 # via feast (setup.py) deprecation==2.1.0 # via python-keycloak dill==0.3.9 - # via feast (setup.py) + # via + # feast (setup.py) + # multiprocess distlib==0.3.9 # via virtualenv docker==7.1.0 # via testcontainers +docling==2.24.0 + # via feast (setup.py) +docling-core[chunking]==2.20.0 + # via + # docling + # docling-ibm-models + # docling-parse +docling-ibm-models==3.4.0 + # via docling +docling-parse==3.4.0 + # via docling docutils==0.19 # via sphinx -duckdb==1.1.2 +duckdb==1.1.3 # via ibis-framework -elastic-transport==8.15.1 +easyocr==1.7.2 + # via docling +elastic-transport==8.17.0 # via elasticsearch -elasticsearch==8.15.1 +elasticsearch==8.17.1 # via feast (setup.py) entrypoints==0.4 # via altair +environs==9.5.0 + # via pymilvus +et-xmlfile==2.0.0 + # via openpyxl execnet==2.1.1 # via pytest-xdist -executing==2.1.0 +executing==2.2.0 # via stack-data -faiss-cpu==1.9.0 +faiss-cpu==1.10.0 # via feast (setup.py) -fastapi==0.115.4 +fastapi==0.115.8 # via feast (setup.py) -fastjsonschema==2.20.0 +fastjsonschema==2.21.1 # via nbformat -filelock==3.16.1 +filelock==3.17.0 # via + # huggingface-hub # snowflake-connector-python + # torch + # transformers # virtualenv +filetype==1.2.0 + # via docling fqdn==1.5.1 # via jsonschema frozenlist==1.5.0 @@ -202,9 +234,11 @@ fsspec==2024.9.0 # via # feast (setup.py) # dask + # huggingface-hub + # torch geomet==0.2.1.post1 # via cassandra-driver -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.24.1 # via # feast (setup.py) # google-cloud-bigquery @@ -213,7 +247,7 @@ google-api-core[grpc]==2.22.0 # google-cloud-core # google-cloud-datastore # google-cloud-storage -google-auth==2.35.0 +google-auth==2.38.0 # via # google-api-core # google-cloud-bigquery @@ -223,21 +257,21 @@ google-auth==2.35.0 # google-cloud-datastore # google-cloud-storage # kubernetes -google-cloud-bigquery[pandas]==3.26.0 +google-cloud-bigquery[pandas]==3.29.0 # via feast (setup.py) -google-cloud-bigquery-storage==2.27.0 +google-cloud-bigquery-storage==2.28.0 # via feast (setup.py) -google-cloud-bigtable==2.26.0 +google-cloud-bigtable==2.28.1 # via feast (setup.py) -google-cloud-core==2.4.1 +google-cloud-core==2.4.2 # via # google-cloud-bigquery # google-cloud-bigtable # google-cloud-datastore # google-cloud-storage -google-cloud-datastore==2.20.1 +google-cloud-datastore==2.20.2 # via feast (setup.py) -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via feast (setup.py) google-crc32c==1.6.0 # via @@ -247,7 +281,7 @@ google-resumable-media==2.7.2 # via # google-cloud-bigquery # google-cloud-storage -googleapis-common-protos[grpc]==1.65.0 +googleapis-common-protos[grpc]==1.68.0 # via # feast (setup.py) # google-api-core @@ -255,9 +289,11 @@ googleapis-common-protos[grpc]==1.65.0 # grpcio-status great-expectations==0.18.22 # via feast (setup.py) -grpc-google-iam-v1==0.13.1 +greenlet==3.1.1 + # via sqlalchemy +grpc-google-iam-v1==0.14.0 # via google-cloud-bigtable -grpcio==1.67.0 +grpcio==1.70.0 # via # feast (setup.py) # google-api-core @@ -268,16 +304,20 @@ grpcio==1.67.0 # grpcio-status # grpcio-testing # grpcio-tools + # ikvpy + # pymilvus # qdrant-client -grpcio-health-checking==1.62.3 +grpcio-health-checking==1.70.0 # via feast (setup.py) -grpcio-reflection==1.62.3 +grpcio-reflection==1.70.0 # via feast (setup.py) -grpcio-status==1.62.3 - # via google-api-core -grpcio-testing==1.62.3 +grpcio-status==1.70.0 + # via + # google-api-core + # ikvpy +grpcio-testing==1.70.0 # via feast (setup.py) -grpcio-tools==1.62.3 +grpcio-tools==1.70.0 # via # feast (setup.py) # qdrant-client @@ -289,7 +329,7 @@ h11==0.14.0 # via # httpcore # uvicorn -h2==4.1.0 +h2==4.2.0 # via httpx happybase==1.2.0 # via feast (setup.py) @@ -297,9 +337,9 @@ hazelcast-python-client==5.5.0 # via feast (setup.py) hiredis==2.4.0 # via feast (setup.py) -hpack==4.0.0 +hpack==4.1.0 # via h2 -httpcore==1.0.6 +httpcore==1.0.7 # via httpx httptools==0.6.4 # via uvicorn @@ -309,15 +349,21 @@ httpx[http2]==0.27.2 # jupyterlab # python-keycloak # qdrant-client -hyperframe==6.0.1 +huggingface-hub==0.29.1 + # via + # docling + # docling-ibm-models + # tokenizers + # transformers +hyperframe==6.1.0 # via h2 -ibis-framework[duckdb]==9.5.0 +ibis-framework[duckdb, mssql]==9.5.0 # via # feast (setup.py) # ibis-substrait ibis-substrait==4.0.1 # via feast (setup.py) -identify==2.6.1 +identify==2.6.8 # via pre-commit idna==3.10 # via @@ -327,15 +373,19 @@ idna==3.10 # requests # snowflake-connector-python # yarl +ikvpy==0.0.36 + # via feast (setup.py) +imageio==2.37.0 + # via scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==8.5.0 +importlib-metadata==8.6.1 # via dask iniconfig==2.0.0 # via pytest ipykernel==6.29.5 # via jupyterlab -ipython==8.29.0 +ipython==8.32.0 # via # great-expectations # ipykernel @@ -346,9 +396,9 @@ isodate==0.7.2 # via azure-storage-blob isoduration==20.11.0 # via jsonschema -jedi==0.19.1 +jedi==0.19.2 # via ipython -jinja2==3.1.4 +jinja2==3.1.5 # via # feast (setup.py) # altair @@ -359,22 +409,29 @@ jinja2==3.1.4 # moto # nbconvert # sphinx + # torch jmespath==1.0.1 # via + # aiobotocore # boto3 # botocore -json5==0.9.25 +json5==0.10.0 # via jupyterlab-server +jsonlines==3.1.0 + # via docling-ibm-models jsonpatch==1.33 # via great-expectations jsonpointer==3.0.0 # via # jsonpatch # jsonschema +jsonref==1.1.0 + # via docling-core jsonschema[format-nongpl]==4.23.0 # via # feast (setup.py) # altair + # docling-core # great-expectations # jupyter-events # jupyterlab-server @@ -395,11 +452,11 @@ jupyter-core==5.7.2 # nbclient # nbconvert # nbformat -jupyter-events==0.10.0 +jupyter-events==0.12.0 # via jupyter-server jupyter-lsp==2.2.5 # via jupyterlab -jupyter-server==2.14.2 +jupyter-server==2.15.0 # via # jupyter-lsp # jupyterlab @@ -408,7 +465,7 @@ jupyter-server==2.14.2 # notebook-shim jupyter-server-terminals==0.5.3 # via jupyter-server -jupyterlab==4.2.5 +jupyterlab==4.3.5 # via notebook jupyterlab-pygments==0.3.0 # via nbconvert @@ -422,38 +479,59 @@ jwcrypto==1.5.6 # via python-keycloak kubernetes==20.13.0 # via feast (setup.py) +latex2mathml==3.77.0 + # via docling-core +lazy-loader==0.4 + # via scikit-image locket==1.0.0 # via partd +lxml==5.3.1 + # via + # docling + # python-docx + # python-pptx +lz4==4.4.3 + # via trino makefun==1.15.6 # via great-expectations markdown-it-py==3.0.0 # via rich +marko==2.1.2 + # via docling markupsafe==3.0.2 # via # jinja2 # nbconvert # werkzeug -marshmallow==3.23.0 - # via great-expectations +marshmallow==3.26.1 + # via + # environs + # great-expectations matplotlib-inline==0.1.7 # via # ipykernel # ipython mdurl==0.1.2 # via markdown-it-py -minio==7.1.0 +milvus-lite==2.4.11 + # via pymilvus +minio==7.2.11 # via feast (setup.py) -mistune==3.0.2 +mistune==3.1.2 # via # great-expectations # nbconvert -mmh3==5.0.1 +mmh3==5.1.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) moto==4.2.14 # via feast (setup.py) -msal==1.31.0 +mpire[dill]==2.10.2 + # via semchunk +mpmath==1.3.0 + # via sympy +msal==1.31.1 # via # azure-identity # msal-extensions @@ -461,8 +539,11 @@ msal-extensions==1.2.0 # via azure-identity multidict==6.1.0 # via + # aiobotocore # aiohttp # yarl +multiprocess==0.70.17 + # via mpire mypy==1.11.2 # via # feast (setup.py) @@ -471,9 +552,9 @@ mypy-extensions==1.0.0 # via mypy mypy-protobuf==3.3.0 # via feast (setup.py) -nbclient==0.10.0 +nbclient==0.10.2 # via nbconvert -nbconvert==7.16.4 +nbconvert==7.16.6 # via jupyter-server nbformat==5.10.4 # via @@ -483,9 +564,15 @@ nbformat==5.10.4 # nbconvert nest-asyncio==1.6.0 # via ipykernel +networkx==3.4.2 + # via + # scikit-image + # torch +ninja==1.11.1.3 + # via easyocr nodeenv==1.9.1 # via pre-commit -notebook==7.2.2 +notebook==7.3.2 # via great-expectations notebook-shim==0.2.4 # via @@ -497,18 +584,34 @@ numpy==1.26.4 # altair # dask # db-dtypes + # docling-ibm-models + # easyocr # faiss-cpu # great-expectations # ibis-framework + # imageio + # opencv-python-headless # pandas # pyarrow # qdrant-client + # safetensors + # scikit-image # scipy + # shapely + # tifffile + # torchvision + # transformers oauthlib==3.2.2 # via requests-oauthlib +opencv-python-headless==4.11.0.86 + # via + # docling-ibm-models + # easyocr +openpyxl==3.1.5 + # via docling overrides==7.7.0 # via jupyter-server -packaging==24.1 +packaging==24.2 # via # build # dask @@ -518,27 +621,34 @@ packaging==24.1 # google-cloud-bigquery # great-expectations # gunicorn + # huggingface-hub # ibis-framework # ibis-substrait # ipykernel + # jupyter-events # jupyter-server # jupyterlab # jupyterlab-server + # lazy-loader # marshmallow # nbconvert # pytest + # scikit-image # snowflake-connector-python # sphinx + # transformers pandas==2.2.3 # via # feast (setup.py) # altair # dask - # dask-expr # db-dtypes + # docling + # docling-core # google-cloud-bigquery # great-expectations # ibis-framework + # pymilvus # snowflake-connector-python pandocfilters==1.5.1 # via nbconvert @@ -550,11 +660,22 @@ parsy==2.1 # via ibis-framework partd==1.4.2 # via dask -pbr==6.1.0 +pbr==6.1.1 # via mock pexpect==4.9.0 # via ipython -pip==24.3.1 +pillow==11.1.0 + # via + # docling + # docling-core + # docling-ibm-models + # docling-parse + # easyocr + # imageio + # python-pptx + # scikit-image + # torchvision +pip==25.0.1 # via pip-tools pip-tools==7.4.1 # via feast (setup.py) @@ -573,21 +694,23 @@ portalocker==2.10.1 # qdrant-client pre-commit==3.3.1 # via feast (setup.py) -prometheus-client==0.21.0 +prometheus-client==0.21.1 # via # feast (setup.py) # jupyter-server -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.50 # via ipython -propcache==0.2.0 - # via yarl -proto-plus==1.25.0 +propcache==0.3.0 + # via + # aiohttp + # yarl +proto-plus==1.26.0 # via # google-api-core # google-cloud-bigquery-storage # google-cloud-bigtable # google-cloud-datastore -protobuf==4.25.5 +protobuf==5.29.3 # via # feast (setup.py) # google-api-core @@ -601,18 +724,20 @@ protobuf==4.25.5 # grpcio-status # grpcio-testing # grpcio-tools + # ikvpy # mypy-protobuf # proto-plus + # pymilvus # substrait psutil==5.9.0 # via # feast (setup.py) # ipykernel -psycopg[binary, pool]==3.2.3 +psycopg[binary, pool]==3.2.5 # via feast (setup.py) -psycopg-binary==3.2.3 +psycopg-binary==3.2.5 # via psycopg -psycopg-pool==3.2.3 +psycopg-pool==3.2.5 # via psycopg ptyprocess==0.7.0 # via @@ -629,7 +754,7 @@ py4j==0.10.9.7 pyarrow==17.0.0 # via # feast (setup.py) - # dask-expr + # dask # db-dtypes # deltalake # google-cloud-bigquery @@ -645,44 +770,62 @@ pyasn1-modules==0.4.1 # via google-auth pybindgen==0.22.1 # via feast (setup.py) +pyclipper==1.3.0.post6 + # via easyocr pycparser==2.22 # via cffi -pydantic==2.9.2 +pycryptodome==3.21.0 + # via minio +pydantic==2.10.6 # via # feast (setup.py) + # docling + # docling-core + # docling-ibm-models + # docling-parse # fastapi # great-expectations + # pydantic-settings # qdrant-client -pydantic-core==2.23.4 +pydantic-core==2.27.2 # via pydantic -pygments==2.18.0 +pydantic-settings==2.8.0 + # via docling +pygments==2.19.1 # via # feast (setup.py) # ipython + # mpire # nbconvert # rich # sphinx -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # feast (setup.py) # msal # singlestoredb # snowflake-connector-python -pymssql==2.3.1 +pymilvus==2.4.9 + # via feast (setup.py) +pymssql==2.3.2 # via feast (setup.py) pymysql==1.1.1 # via feast (setup.py) pyodbc==5.2.0 - # via feast (setup.py) -pyopenssl==24.2.1 + # via + # feast (setup.py) + # ibis-framework +pyopenssl==24.3.0 # via snowflake-connector-python -pyparsing==3.2.0 +pyparsing==3.2.1 # via great-expectations +pypdfium2==4.30.1 + # via docling pyproject-hooks==1.2.0 # via # build # pip-tools -pyspark==3.5.3 +pyspark==3.5.4 # via feast (setup.py) pytest==7.4.4 # via @@ -700,7 +843,7 @@ pytest-asyncio==0.23.8 # via feast (setup.py) pytest-benchmark==3.4.1 # via feast (setup.py) -pytest-cov==5.0.0 +pytest-cov==6.0.0 # via feast (setup.py) pytest-env==1.1.3 # via feast (setup.py) @@ -714,8 +857,11 @@ pytest-timeout==1.4.2 # via feast (setup.py) pytest-xdist==3.6.1 # via feast (setup.py) +python-bidi==0.6.6 + # via easyocr python-dateutil==2.9.0.post0 # via + # aiobotocore # arrow # botocore # google-cloud-bigquery @@ -726,13 +872,20 @@ python-dateutil==2.9.0.post0 # moto # pandas # trino +python-docx==1.1.2 + # via docling python-dotenv==1.0.1 - # via uvicorn -python-json-logger==2.0.7 + # via + # environs + # pydantic-settings + # uvicorn +python-json-logger==3.2.1 # via jupyter-events python-keycloak==4.2.2 # via feast (setup.py) -pytz==2024.2 +python-pptx==1.0.2 + # via docling +pytz==2025.1 # via # great-expectations # ibis-framework @@ -743,39 +896,46 @@ pyyaml==6.0.2 # via # feast (setup.py) # dask + # docling-core + # easyocr + # huggingface-hub # ibis-substrait # jupyter-events # kubernetes # pre-commit # responses + # transformers # uvicorn -pyzmq==26.2.0 +pyzmq==26.2.1 # via # ipykernel # jupyter-client # jupyter-server -qdrant-client==1.12.0 +qdrant-client==1.13.2 # via feast (setup.py) redis==4.6.0 # via feast (setup.py) -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications # jupyter-events -regex==2024.9.11 +regex==2024.11.6 # via # feast (setup.py) # parsimonious + # transformers requests==2.32.3 # via # feast (setup.py) # azure-core # docker + # docling # google-api-core # google-cloud-bigquery # google-cloud-storage # great-expectations + # huggingface-hub # jupyterlab-server # kubernetes # moto @@ -787,12 +947,13 @@ requests==2.32.3 # singlestoredb # snowflake-connector-python # sphinx + # transformers # trino requests-oauthlib==2.0.0 # via kubernetes requests-toolbelt==1.0.0 # via python-keycloak -responses==0.25.3 +responses==0.25.6 # via moto rfc3339-validator==0.1.4 # via @@ -802,40 +963,60 @@ rfc3986-validator==0.1.1 # via # jsonschema # jupyter-events -rich==13.9.3 - # via ibis-framework -rpds-py==0.20.0 +rich==13.9.4 + # via + # ibis-framework + # typer +rpds-py==0.23.1 # via # jsonschema # referencing rsa==4.9 # via google-auth +rtree==1.3.0 + # via docling ruamel-yaml==0.17.40 # via great-expectations ruamel-yaml-clib==0.2.12 # via ruamel-yaml -ruff==0.7.1 +ruff==0.9.7 # via feast (setup.py) -s3transfer==0.10.3 +s3transfer==0.11.2 # via boto3 -scipy==1.14.1 - # via great-expectations +safetensors[torch]==0.5.2 + # via + # docling-ibm-models + # transformers +scikit-image==0.25.2 + # via easyocr +scipy==1.15.2 + # via + # docling + # easyocr + # great-expectations + # scikit-image +semchunk==2.2.2 + # via docling-core send2trash==1.8.3 # via jupyter-server -setuptools==75.2.0 +setuptools==75.8.0 # via # grpcio-tools # jupyterlab # kubernetes + # pbr # pip-tools + # pymilvus # singlestoredb +shapely==2.0.7 + # via easyocr +shellingham==1.5.4 + # via typer singlestoredb==1.7.2 # via feast (setup.py) -six==1.16.0 +six==1.17.0 # via - # asttokens # azure-core - # bleach # geomet # happybase # kubernetes @@ -849,7 +1030,7 @@ sniffio==1.3.1 # httpx snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python[pandas]==3.12.3 +snowflake-connector-python[pandas]==3.13.2 # via feast (setup.py) sortedcontainers==2.4.0 # via snowflake-connector-python @@ -869,36 +1050,47 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sqlalchemy[mypy]==2.0.36 +sqlalchemy[mypy]==2.0.38 # via feast (setup.py) sqlglot==25.20.2 # via ibis-framework -sqlite-vec==0.1.1 +sqlite-vec==0.1.6 # via feast (setup.py) -sqlparams==6.1.0 +sqlparams==6.2.0 # via singlestoredb stack-data==0.6.3 # via ipython -starlette==0.41.2 +starlette==0.45.3 # via fastapi substrait==0.23.0 # via ibis-substrait +sympy==1.13.3 + # via torch tabulate==0.9.0 - # via feast (setup.py) + # via + # feast (setup.py) + # docling-core + # docling-parse tenacity==8.5.0 # via feast (setup.py) terminado==0.18.1 # via # jupyter-server # jupyter-server-terminals -testcontainers==4.4.0 +testcontainers==4.8.2 # via feast (setup.py) thriftpy2==0.5.2 # via happybase +tifffile==2025.2.18 + # via scikit-image tinycss2==1.4.0 - # via nbconvert + # via bleach +tokenizers==0.19.1 + # via transformers toml==0.10.2 # via feast (setup.py) +tomli==2.2.1 + # via coverage tomlkit==0.13.2 # via snowflake-connector-python toolz==0.12.1 @@ -907,7 +1099,19 @@ toolz==0.12.1 # dask # ibis-framework # partd -tornado==6.4.1 +torch==2.2.2 + # via + # feast (setup.py) + # docling-ibm-models + # easyocr + # safetensors + # torchvision +torchvision==0.17.2 + # via + # feast (setup.py) + # docling-ibm-models + # easyocr +tornado==6.4.2 # via # ipykernel # jupyter-client @@ -915,10 +1119,17 @@ tornado==6.4.1 # jupyterlab # notebook # terminado -tqdm==4.66.6 +tqdm==4.67.1 # via # feast (setup.py) + # docling + # docling-ibm-models # great-expectations + # huggingface-hub + # milvus-lite + # mpire + # semchunk + # transformers traitlets==5.14.3 # via # comm @@ -934,70 +1145,91 @@ traitlets==5.14.3 # nbclient # nbconvert # nbformat -trino==0.330.0 +transformers==4.42.4 + # via + # docling-core + # docling-ibm-models +trino==0.333.0 # via feast (setup.py) -typeguard==4.4.0 +typeguard==4.4.2 # via feast (setup.py) -types-cffi==1.16.0.20240331 +typer==0.12.5 + # via + # docling + # docling-core +types-cffi==1.16.0.20241221 # via types-pyopenssl types-protobuf==3.19.22 # via # feast (setup.py) # mypy-protobuf -types-pymysql==1.1.0.20240524 +types-pymysql==1.1.0.20241103 # via feast (setup.py) types-pyopenssl==24.1.0.20240722 # via types-redis -types-python-dateutil==2.9.0.20241003 +types-python-dateutil==2.9.0.20241206 # via # feast (setup.py) # arrow -types-pytz==2024.2.0.20241003 +types-pytz==2025.1.0.20250204 # via feast (setup.py) -types-pyyaml==6.0.12.20240917 +types-pyyaml==6.0.12.20241230 # via feast (setup.py) types-redis==4.6.0.20241004 # via feast (setup.py) types-requests==2.30.0.0 # via feast (setup.py) -types-setuptools==75.2.0.20241025 +types-setuptools==75.8.0.20250225 # via # feast (setup.py) # types-cffi -types-tabulate==0.9.0.20240106 +types-tabulate==0.9.0.20241207 # via feast (setup.py) types-urllib3==1.26.25.14 # via types-requests typing-extensions==4.12.2 # via + # anyio # azure-core # azure-identity # azure-storage-blob + # beautifulsoup4 + # docling-core # fastapi # great-expectations + # huggingface-hub # ibis-framework # ipython # jwcrypto + # minio # mypy # psycopg # psycopg-pool # pydantic # pydantic-core + # python-docx + # python-pptx + # referencing # snowflake-connector-python # sqlalchemy # testcontainers + # torch # typeguard -tzdata==2024.2 + # typer +tzdata==2025.1 # via pandas -tzlocal==5.2 +tzlocal==5.3 # via # great-expectations # trino +ujson==5.10.0 + # via pymilvus uri-template==1.3.0 # via jsonschema -urllib3==2.2.3 +urllib3==2.3.0 # via # feast (setup.py) + # aiobotocore # botocore # docker # elastic-transport @@ -1008,11 +1240,11 @@ urllib3==2.2.3 # requests # responses # testcontainers -uvicorn[standard]==0.32.0 +uvicorn[standard]==0.34.0 # via # feast (setup.py) # uvicorn-worker -uvicorn-worker==0.2.0 +uvicorn-worker==0.3.0 # via feast (setup.py) uvloop==0.21.0 # via uvicorn @@ -1020,11 +1252,11 @@ virtualenv==20.23.0 # via # feast (setup.py) # pre-commit -watchfiles==0.24.0 +watchfiles==1.0.4 # via uvicorn wcwidth==0.2.13 # via prompt-toolkit -webcolors==24.8.0 +webcolors==24.11.1 # via jsonschema webencodings==0.5.1 # via @@ -1034,23 +1266,27 @@ websocket-client==1.8.0 # via # jupyter-server # kubernetes -websockets==13.1 +websockets==15.0 # via uvicorn -werkzeug==3.0.6 +werkzeug==3.1.3 # via moto -wheel==0.44.0 +wheel==0.45.1 # via # pip-tools # singlestoredb widgetsnbextension==4.0.13 # via ipywidgets -wrapt==1.16.0 +wrapt==1.17.2 # via # aiobotocore # testcontainers +xlsxwriter==3.2.2 + # via python-pptx xmltodict==0.14.2 # via moto -yarl==1.16.0 +yarl==1.18.3 # via aiohttp -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata +zstandard==0.23.0 + # via trino diff --git a/sdk/python/requirements/py3.11-requirements.txt b/sdk/python/requirements/py3.11-requirements.txt index c9833ca07b0..d33da6d75c2 100644 --- a/sdk/python/requirements/py3.11-requirements.txt +++ b/sdk/python/requirements/py3.11-requirements.txt @@ -2,41 +2,39 @@ # uv pip compile -p 3.11 --system --no-strip-extras setup.py --output-file sdk/python/requirements/py3.11-requirements.txt annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.8.0 # via # starlette # watchfiles -attrs==24.2.0 +attrs==25.1.0 # via # jsonschema # referencing -bigtree==0.21.3 +bigtree==0.25.0 # via feast (setup.py) -certifi==2024.8.30 +certifi==2025.1.31 # via requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -click==8.1.7 +click==8.1.8 # via # feast (setup.py) # dask # uvicorn -cloudpickle==3.1.0 +cloudpickle==3.1.1 # via dask colorama==0.4.6 # via feast (setup.py) -dask[dataframe]==2024.10.0 - # via - # feast (setup.py) - # dask-expr -dask-expr==1.1.16 - # via dask +dask[dataframe]==2025.2.0 + # via feast (setup.py) dill==0.3.9 # via feast (setup.py) -fastapi==0.115.4 +fastapi==0.115.8 # via feast (setup.py) -fsspec==2024.10.0 +fsspec==2025.2.0 # via dask +greenlet==3.1.1 + # via sqlalchemy gunicorn==23.0.0 # via # feast (setup.py) @@ -49,9 +47,9 @@ idna==3.10 # via # anyio # requests -importlib-metadata==8.5.0 +importlib-metadata==8.6.1 # via dask -jinja2==3.1.4 +jinja2==3.1.5 # via feast (setup.py) jsonschema==4.23.0 # via feast (setup.py) @@ -61,9 +59,9 @@ locket==1.0.0 # via partd markupsafe==3.0.2 # via jinja2 -mmh3==5.0.1 +mmh3==5.1.0 # via feast (setup.py) -mypy==1.13.0 +mypy==1.15.0 # via sqlalchemy mypy-extensions==1.0.0 # via mypy @@ -72,7 +70,7 @@ numpy==1.26.4 # feast (setup.py) # dask # pandas -packaging==24.1 +packaging==24.2 # via # dask # gunicorn @@ -80,57 +78,56 @@ pandas==2.2.3 # via # feast (setup.py) # dask - # dask-expr partd==1.4.2 # via dask -prometheus-client==0.21.0 +prometheus-client==0.21.1 # via feast (setup.py) -protobuf==4.25.5 +protobuf==5.29.3 # via feast (setup.py) -psutil==6.1.0 +psutil==7.0.0 # via feast (setup.py) pyarrow==18.0.0 # via # feast (setup.py) - # dask-expr -pydantic==2.9.2 + # dask +pydantic==2.10.6 # via # feast (setup.py) # fastapi -pydantic-core==2.23.4 +pydantic-core==2.27.2 # via pydantic -pygments==2.18.0 +pygments==2.19.1 # via feast (setup.py) -pyjwt==2.9.0 +pyjwt==2.10.1 # via feast (setup.py) python-dateutil==2.9.0.post0 # via pandas python-dotenv==1.0.1 # via uvicorn -pytz==2024.2 +pytz==2025.1 # via pandas pyyaml==6.0.2 # via # feast (setup.py) # dask # uvicorn -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via feast (setup.py) -rpds-py==0.20.0 +rpds-py==0.23.1 # via # jsonschema # referencing -six==1.16.0 +six==1.17.0 # via python-dateutil sniffio==1.3.1 # via anyio -sqlalchemy[mypy]==2.0.36 +sqlalchemy[mypy]==2.0.38 # via feast (setup.py) -starlette==0.41.2 +starlette==0.45.3 # via fastapi tabulate==0.9.0 # via feast (setup.py) @@ -142,33 +139,35 @@ toolz==1.0.0 # via # dask # partd -tqdm==4.66.6 +tqdm==4.67.1 # via feast (setup.py) -typeguard==4.4.0 +typeguard==4.4.2 # via feast (setup.py) typing-extensions==4.12.2 # via + # anyio # fastapi # mypy # pydantic # pydantic-core + # referencing # sqlalchemy # typeguard -tzdata==2024.2 +tzdata==2025.1 # via pandas -urllib3==2.2.3 +urllib3==2.3.0 # via requests -uvicorn[standard]==0.32.0 +uvicorn[standard]==0.34.0 # via # feast (setup.py) # uvicorn-worker -uvicorn-worker==0.2.0 +uvicorn-worker==0.3.0 # via feast (setup.py) uvloop==0.21.0 # via uvicorn -watchfiles==0.24.0 +watchfiles==1.0.4 # via uvicorn -websockets==13.1 +websockets==15.0 # via uvicorn -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata diff --git a/sdk/python/requirements/py3.9-ci-requirements.txt b/sdk/python/requirements/py3.9-ci-requirements.txt index 30eab84822e..4473d933258 100644 --- a/sdk/python/requirements/py3.9-ci-requirements.txt +++ b/sdk/python/requirements/py3.9-ci-requirements.txt @@ -1,14 +1,14 @@ # This file was autogenerated by uv via the following command: # uv pip compile -p 3.9 --system --no-strip-extras setup.py --extra ci --output-file sdk/python/requirements/py3.9-ci-requirements.txt -aiobotocore==2.15.2 +aiobotocore==2.20.0 # via feast (setup.py) -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.4.6 # via aiohttp -aiohttp==3.10.10 +aiohttp==3.11.13 # via aiobotocore aioitertools==0.12.0 # via aiobotocore -aiosignal==1.3.1 +aiosignal==1.3.2 # via aiohttp alabaster==0.7.16 # via sphinx @@ -16,7 +16,7 @@ altair==4.2.2 # via great-expectations annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.8.0 # via # httpx # jupyter-server @@ -25,7 +25,9 @@ anyio==4.6.2.post1 appnope==0.1.4 # via ipykernel argon2-cffi==23.1.0 - # via jupyter-server + # via + # jupyter-server + # minio argon2-cffi-bindings==21.2.0 # via argon2-cffi arrow==1.3.0 @@ -34,48 +36,52 @@ asn1crypto==1.5.1 # via snowflake-connector-python assertpy==1.1 # via feast (setup.py) -asttokens==2.4.1 +asttokens==3.0.0 # via stack-data async-lru==2.0.4 # via jupyterlab async-property==0.2.2 # via python-keycloak -async-timeout==4.0.3 +async-timeout==5.0.1 # via # aiohttp # redis atpublic==4.1.0 # via ibis-framework -attrs==24.2.0 +attrs==25.1.0 # via # aiohttp + # jsonlines # jsonschema # referencing -azure-core==1.31.0 +azure-core==1.32.0 # via # azure-identity # azure-storage-blob -azure-identity==1.19.0 +azure-identity==1.20.0 # via feast (setup.py) -azure-storage-blob==12.23.1 +azure-storage-blob==12.24.1 # via feast (setup.py) -babel==2.16.0 +babel==2.17.0 # via # jupyterlab-server # sphinx -beautifulsoup4==4.12.3 - # via nbconvert +beautifulsoup4==4.13.3 + # via + # docling + # nbconvert bidict==0.23.1 # via ibis-framework -bigtree==0.21.3 +bigtree==0.25.0 # via feast (setup.py) -bleach==6.1.0 +bleach[css]==6.2.0 # via nbconvert -boto3==1.35.36 +boto3==1.36.23 # via # feast (setup.py) + # ikvpy # moto -botocore==1.35.36 +botocore==1.36.23 # via # aiobotocore # boto3 @@ -86,12 +92,13 @@ build==1.2.2.post1 # feast (setup.py) # pip-tools # singlestoredb -cachetools==5.5.0 +cachetools==5.5.2 # via google-auth cassandra-driver==3.29.2 # via feast (setup.py) -certifi==2024.8.30 +certifi==2025.1.31 # via + # docling # elastic-transport # httpcore # httpx @@ -101,24 +108,27 @@ certifi==2024.8.30 # snowflake-connector-python cffi==1.17.1 # via + # feast (setup.py) # argon2-cffi-bindings # cryptography + # ikvpy # snowflake-connector-python cfgv==3.4.0 # via pre-commit -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via # requests # snowflake-connector-python -click==8.1.7 +click==8.1.8 # via # feast (setup.py) # dask # geomet # great-expectations # pip-tools + # typer # uvicorn -cloudpickle==3.1.0 +cloudpickle==3.1.1 # via dask colorama==0.4.6 # via @@ -130,9 +140,11 @@ comm==0.2.2 # ipywidgets couchbase==4.3.2 # via feast (setup.py) -coverage[toml]==7.6.4 +couchbase-columnar==1.0.0 + # via feast (setup.py) +coverage[toml]==7.6.12 # via pytest-cov -cryptography==42.0.8 +cryptography==43.0.3 # via # feast (setup.py) # azure-identity @@ -146,7 +158,7 @@ cryptography==42.0.8 # snowflake-connector-python # types-pyopenssl # types-redis -cython==3.0.11 +cython==3.0.12 # via thriftpy2 dask[dataframe]==2024.8.0 # via @@ -154,34 +166,51 @@ dask[dataframe]==2024.8.0 # dask-expr dask-expr==1.1.10 # via dask -db-dtypes==1.3.0 +db-dtypes==1.4.1 # via google-cloud-bigquery -debugpy==1.8.7 +debugpy==1.8.12 # via ipykernel -decorator==5.1.1 +decorator==5.2.1 # via ipython defusedxml==0.7.1 # via nbconvert -deltalake==0.20.2 +deltalake==0.25.1 # via feast (setup.py) deprecation==2.1.0 # via python-keycloak dill==0.3.9 - # via feast (setup.py) + # via + # feast (setup.py) + # multiprocess distlib==0.3.9 # via virtualenv docker==7.1.0 # via testcontainers +docling==2.24.0 + # via feast (setup.py) +docling-core[chunking]==2.20.0 + # via + # docling + # docling-ibm-models + # docling-parse +docling-ibm-models==3.4.0 + # via docling +docling-parse==3.4.0 + # via docling docutils==0.19 # via sphinx duckdb==0.10.3 # via ibis-framework -elastic-transport==8.15.1 +easyocr==1.7.2 + # via docling +elastic-transport==8.17.0 # via elasticsearch -elasticsearch==8.15.1 +elasticsearch==8.17.1 # via feast (setup.py) entrypoints==0.4 # via altair +et-xmlfile==2.0.0 + # via openpyxl exceptiongroup==1.2.2 # via # anyio @@ -189,18 +218,23 @@ exceptiongroup==1.2.2 # pytest execnet==2.1.1 # via pytest-xdist -executing==2.1.0 +executing==2.2.0 # via stack-data -faiss-cpu==1.9.0 +faiss-cpu==1.10.0 # via feast (setup.py) -fastapi==0.115.4 +fastapi==0.115.8 # via feast (setup.py) -fastjsonschema==2.20.0 +fastjsonschema==2.21.1 # via nbformat -filelock==3.16.1 +filelock==3.17.0 # via + # huggingface-hub # snowflake-connector-python + # torch + # transformers # virtualenv +filetype==1.2.0 + # via docling fqdn==1.5.1 # via jsonschema frozenlist==1.5.0 @@ -211,9 +245,11 @@ fsspec==2024.9.0 # via # feast (setup.py) # dask + # huggingface-hub + # torch geomet==0.2.1.post1 # via cassandra-driver -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.24.1 # via # feast (setup.py) # google-cloud-bigquery @@ -222,7 +258,7 @@ google-api-core[grpc]==2.22.0 # google-cloud-core # google-cloud-datastore # google-cloud-storage -google-auth==2.35.0 +google-auth==2.38.0 # via # google-api-core # google-cloud-bigquery @@ -232,21 +268,21 @@ google-auth==2.35.0 # google-cloud-datastore # google-cloud-storage # kubernetes -google-cloud-bigquery[pandas]==3.26.0 +google-cloud-bigquery[pandas]==3.29.0 # via feast (setup.py) -google-cloud-bigquery-storage==2.27.0 +google-cloud-bigquery-storage==2.28.0 # via feast (setup.py) -google-cloud-bigtable==2.26.0 +google-cloud-bigtable==2.28.1 # via feast (setup.py) -google-cloud-core==2.4.1 +google-cloud-core==2.4.2 # via # google-cloud-bigquery # google-cloud-bigtable # google-cloud-datastore # google-cloud-storage -google-cloud-datastore==2.20.1 +google-cloud-datastore==2.20.2 # via feast (setup.py) -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via feast (setup.py) google-crc32c==1.6.0 # via @@ -256,7 +292,7 @@ google-resumable-media==2.7.2 # via # google-cloud-bigquery # google-cloud-storage -googleapis-common-protos[grpc]==1.65.0 +googleapis-common-protos[grpc]==1.68.0 # via # feast (setup.py) # google-api-core @@ -264,9 +300,11 @@ googleapis-common-protos[grpc]==1.65.0 # grpcio-status great-expectations==0.18.22 # via feast (setup.py) -grpc-google-iam-v1==0.13.1 +greenlet==3.1.1 + # via sqlalchemy +grpc-google-iam-v1==0.14.0 # via google-cloud-bigtable -grpcio==1.67.0 +grpcio==1.67.1 # via # feast (setup.py) # google-api-core @@ -277,16 +315,20 @@ grpcio==1.67.0 # grpcio-status # grpcio-testing # grpcio-tools + # ikvpy + # pymilvus # qdrant-client -grpcio-health-checking==1.62.3 +grpcio-health-checking==1.67.1 # via feast (setup.py) -grpcio-reflection==1.62.3 +grpcio-reflection==1.67.1 # via feast (setup.py) -grpcio-status==1.62.3 - # via google-api-core -grpcio-testing==1.62.3 +grpcio-status==1.67.1 + # via + # google-api-core + # ikvpy +grpcio-testing==1.67.1 # via feast (setup.py) -grpcio-tools==1.62.3 +grpcio-tools==1.67.1 # via # feast (setup.py) # qdrant-client @@ -298,7 +340,7 @@ h11==0.14.0 # via # httpcore # uvicorn -h2==4.1.0 +h2==4.2.0 # via httpx happybase==1.2.0 # via feast (setup.py) @@ -306,9 +348,9 @@ hazelcast-python-client==5.5.0 # via feast (setup.py) hiredis==2.4.0 # via feast (setup.py) -hpack==4.0.0 +hpack==4.1.0 # via h2 -httpcore==1.0.6 +httpcore==1.0.7 # via httpx httptools==0.6.4 # via uvicorn @@ -318,15 +360,21 @@ httpx[http2]==0.27.2 # jupyterlab # python-keycloak # qdrant-client -hyperframe==6.0.1 +huggingface-hub==0.29.1 + # via + # docling + # docling-ibm-models + # tokenizers + # transformers +hyperframe==6.1.0 # via h2 -ibis-framework[duckdb]==9.0.0 +ibis-framework[duckdb, mssql]==9.0.0 # via # feast (setup.py) # ibis-substrait ibis-substrait==4.0.1 # via feast (setup.py) -identify==2.6.1 +identify==2.6.8 # via pre-commit idna==3.10 # via @@ -336,9 +384,13 @@ idna==3.10 # requests # snowflake-connector-python # yarl +ikvpy==0.0.36 + # via feast (setup.py) +imageio==2.37.0 + # via scikit-image imagesize==1.4.1 # via sphinx -importlib-metadata==8.5.0 +importlib-metadata==8.6.1 # via # build # dask @@ -364,9 +416,9 @@ isodate==0.7.2 # via azure-storage-blob isoduration==20.11.0 # via jsonschema -jedi==0.19.1 +jedi==0.19.2 # via ipython -jinja2==3.1.4 +jinja2==3.1.5 # via # feast (setup.py) # altair @@ -377,22 +429,29 @@ jinja2==3.1.4 # moto # nbconvert # sphinx + # torch jmespath==1.0.1 # via + # aiobotocore # boto3 # botocore -json5==0.9.25 +json5==0.10.0 # via jupyterlab-server +jsonlines==3.1.0 + # via docling-ibm-models jsonpatch==1.33 # via great-expectations jsonpointer==3.0.0 # via # jsonpatch # jsonschema +jsonref==1.1.0 + # via docling-core jsonschema[format-nongpl]==4.23.0 # via # feast (setup.py) # altair + # docling-core # great-expectations # jupyter-events # jupyterlab-server @@ -413,11 +472,11 @@ jupyter-core==5.7.2 # nbclient # nbconvert # nbformat -jupyter-events==0.10.0 +jupyter-events==0.12.0 # via jupyter-server jupyter-lsp==2.2.5 # via jupyterlab -jupyter-server==2.14.2 +jupyter-server==2.15.0 # via # jupyter-lsp # jupyterlab @@ -426,7 +485,7 @@ jupyter-server==2.14.2 # notebook-shim jupyter-server-terminals==0.5.3 # via jupyter-server -jupyterlab==4.2.5 +jupyterlab==4.3.5 # via notebook jupyterlab-pygments==0.3.0 # via nbconvert @@ -440,18 +499,31 @@ jwcrypto==1.5.6 # via python-keycloak kubernetes==20.13.0 # via feast (setup.py) +latex2mathml==3.77.0 + # via docling-core +lazy-loader==0.4 + # via scikit-image locket==1.0.0 # via partd +lxml==5.3.1 + # via + # docling + # python-docx + # python-pptx +lz4==4.4.3 + # via trino makefun==1.15.6 # via great-expectations markdown-it-py==3.0.0 # via rich +marko==2.1.2 + # via docling markupsafe==3.0.2 # via # jinja2 # nbconvert # werkzeug -marshmallow==3.23.0 +marshmallow==3.26.1 # via great-expectations matplotlib-inline==0.1.7 # via @@ -459,19 +531,25 @@ matplotlib-inline==0.1.7 # ipython mdurl==0.1.2 # via markdown-it-py -minio==7.1.0 +milvus-lite==2.4.11 + # via pymilvus +minio==7.2.11 # via feast (setup.py) -mistune==3.0.2 +mistune==3.1.2 # via # great-expectations # nbconvert -mmh3==5.0.1 +mmh3==5.1.0 # via feast (setup.py) mock==2.0.0 # via feast (setup.py) moto==4.2.14 # via feast (setup.py) -msal==1.31.0 +mpire[dill]==2.10.2 + # via semchunk +mpmath==1.3.0 + # via sympy +msal==1.31.1 # via # azure-identity # msal-extensions @@ -479,8 +557,11 @@ msal-extensions==1.2.0 # via azure-identity multidict==6.1.0 # via + # aiobotocore # aiohttp # yarl +multiprocess==0.70.17 + # via mpire mypy==1.11.2 # via # feast (setup.py) @@ -489,9 +570,9 @@ mypy-extensions==1.0.0 # via mypy mypy-protobuf==3.3.0 # via feast (setup.py) -nbclient==0.10.0 +nbclient==0.10.2 # via nbconvert -nbconvert==7.16.4 +nbconvert==7.16.6 # via jupyter-server nbformat==5.10.4 # via @@ -501,9 +582,15 @@ nbformat==5.10.4 # nbconvert nest-asyncio==1.6.0 # via ipykernel +networkx==3.2.1 + # via + # scikit-image + # torch +ninja==1.11.1.3 + # via easyocr nodeenv==1.9.1 # via pre-commit -notebook==7.2.2 +notebook==7.3.2 # via great-expectations notebook-shim==0.2.4 # via @@ -515,18 +602,34 @@ numpy==1.26.4 # altair # dask # db-dtypes + # docling-ibm-models + # easyocr # faiss-cpu # great-expectations # ibis-framework + # imageio + # opencv-python-headless # pandas # pyarrow # qdrant-client + # safetensors + # scikit-image # scipy + # shapely + # tifffile + # torchvision + # transformers oauthlib==3.2.2 # via requests-oauthlib +opencv-python-headless==4.11.0.86 + # via + # docling-ibm-models + # easyocr +openpyxl==3.1.5 + # via docling overrides==7.7.0 # via jupyter-server -packaging==24.1 +packaging==24.2 # via # build # dask @@ -536,16 +639,21 @@ packaging==24.1 # google-cloud-bigquery # great-expectations # gunicorn + # huggingface-hub # ibis-substrait # ipykernel + # jupyter-events # jupyter-server # jupyterlab # jupyterlab-server + # lazy-loader # marshmallow # nbconvert # pytest + # scikit-image # snowflake-connector-python # sphinx + # transformers pandas==2.2.3 # via # feast (setup.py) @@ -553,9 +661,12 @@ pandas==2.2.3 # dask # dask-expr # db-dtypes + # docling + # docling-core # google-cloud-bigquery # great-expectations # ibis-framework + # pymilvus # snowflake-connector-python pandocfilters==1.5.1 # via nbconvert @@ -567,11 +678,22 @@ parsy==2.1 # via ibis-framework partd==1.4.2 # via dask -pbr==6.1.0 +pbr==6.1.1 # via mock pexpect==4.9.0 # via ipython -pip==24.3.1 +pillow==11.1.0 + # via + # docling + # docling-core + # docling-ibm-models + # docling-parse + # easyocr + # imageio + # python-pptx + # scikit-image + # torchvision +pip==25.0.1 # via pip-tools pip-tools==7.4.1 # via feast (setup.py) @@ -590,21 +712,23 @@ portalocker==2.10.1 # qdrant-client pre-commit==3.3.1 # via feast (setup.py) -prometheus-client==0.21.0 +prometheus-client==0.21.1 # via # feast (setup.py) # jupyter-server -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.50 # via ipython -propcache==0.2.0 - # via yarl -proto-plus==1.25.0 +propcache==0.3.0 + # via + # aiohttp + # yarl +proto-plus==1.26.0 # via # google-api-core # google-cloud-bigquery-storage # google-cloud-bigtable # google-cloud-datastore -protobuf==4.25.5 +protobuf==5.29.3 # via # feast (setup.py) # google-api-core @@ -618,18 +742,20 @@ protobuf==4.25.5 # grpcio-status # grpcio-testing # grpcio-tools + # ikvpy # mypy-protobuf # proto-plus + # pymilvus # substrait psutil==5.9.0 # via # feast (setup.py) # ipykernel -psycopg[binary, pool]==3.1.18 +psycopg[binary, pool]==3.2.5 # via feast (setup.py) -psycopg-binary==3.1.18 +psycopg-binary==3.2.5 # via psycopg -psycopg-pool==3.2.3 +psycopg-pool==3.2.5 # via psycopg ptyprocess==0.7.0 # via @@ -662,44 +788,62 @@ pyasn1-modules==0.4.1 # via google-auth pybindgen==0.22.1 # via feast (setup.py) +pyclipper==1.3.0.post6 + # via easyocr pycparser==2.22 # via cffi -pydantic==2.9.2 +pycryptodome==3.21.0 + # via minio +pydantic==2.10.6 # via # feast (setup.py) + # docling + # docling-core + # docling-ibm-models + # docling-parse # fastapi # great-expectations + # pydantic-settings # qdrant-client -pydantic-core==2.23.4 +pydantic-core==2.27.2 # via pydantic -pygments==2.18.0 +pydantic-settings==2.8.0 + # via docling +pygments==2.19.1 # via # feast (setup.py) # ipython + # mpire # nbconvert # rich # sphinx -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # feast (setup.py) # msal # singlestoredb # snowflake-connector-python -pymssql==2.3.1 +pymilvus==2.5.4 + # via feast (setup.py) +pymssql==2.3.2 # via feast (setup.py) pymysql==1.1.1 # via feast (setup.py) pyodbc==5.2.0 - # via feast (setup.py) -pyopenssl==24.2.1 + # via + # feast (setup.py) + # ibis-framework +pyopenssl==24.3.0 # via snowflake-connector-python -pyparsing==3.2.0 +pyparsing==3.2.1 # via great-expectations +pypdfium2==4.30.1 + # via docling pyproject-hooks==1.2.0 # via # build # pip-tools -pyspark==3.5.3 +pyspark==3.5.4 # via feast (setup.py) pytest==7.4.4 # via @@ -717,7 +861,7 @@ pytest-asyncio==0.23.8 # via feast (setup.py) pytest-benchmark==3.4.1 # via feast (setup.py) -pytest-cov==5.0.0 +pytest-cov==6.0.0 # via feast (setup.py) pytest-env==1.1.3 # via feast (setup.py) @@ -731,8 +875,11 @@ pytest-timeout==1.4.2 # via feast (setup.py) pytest-xdist==3.6.1 # via feast (setup.py) +python-bidi==0.6.6 + # via easyocr python-dateutil==2.9.0.post0 # via + # aiobotocore # arrow # botocore # google-cloud-bigquery @@ -743,13 +890,20 @@ python-dateutil==2.9.0.post0 # moto # pandas # trino +python-docx==1.1.2 + # via docling python-dotenv==1.0.1 - # via uvicorn -python-json-logger==2.0.7 + # via + # pydantic-settings + # pymilvus + # uvicorn +python-json-logger==3.2.1 # via jupyter-events python-keycloak==4.2.2 # via feast (setup.py) -pytz==2024.2 +python-pptx==1.0.2 + # via docling +pytz==2025.1 # via # great-expectations # ibis-framework @@ -760,39 +914,46 @@ pyyaml==6.0.2 # via # feast (setup.py) # dask + # docling-core + # easyocr + # huggingface-hub # ibis-substrait # jupyter-events # kubernetes # pre-commit # responses + # transformers # uvicorn -pyzmq==26.2.0 +pyzmq==26.2.1 # via # ipykernel # jupyter-client # jupyter-server -qdrant-client==1.12.0 +qdrant-client==1.13.2 # via feast (setup.py) redis==4.6.0 # via feast (setup.py) -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications # jupyter-events -regex==2024.9.11 +regex==2024.11.6 # via # feast (setup.py) # parsimonious + # transformers requests==2.32.3 # via # feast (setup.py) # azure-core # docker + # docling # google-api-core # google-cloud-bigquery # google-cloud-storage # great-expectations + # huggingface-hub # jupyterlab-server # kubernetes # moto @@ -804,12 +965,13 @@ requests==2.32.3 # singlestoredb # snowflake-connector-python # sphinx + # transformers # trino requests-oauthlib==2.0.0 # via kubernetes requests-toolbelt==1.0.0 # via python-keycloak -responses==0.25.3 +responses==0.25.6 # via moto rfc3339-validator==0.1.4 # via @@ -819,40 +981,60 @@ rfc3986-validator==0.1.1 # via # jsonschema # jupyter-events -rich==13.9.3 - # via ibis-framework -rpds-py==0.20.0 +rich==13.9.4 + # via + # ibis-framework + # typer +rpds-py==0.23.1 # via # jsonschema # referencing rsa==4.9 # via google-auth +rtree==1.3.0 + # via docling ruamel-yaml==0.17.40 # via great-expectations ruamel-yaml-clib==0.2.12 # via ruamel-yaml -ruff==0.7.1 +ruff==0.9.7 # via feast (setup.py) -s3transfer==0.10.3 +s3transfer==0.11.2 # via boto3 +safetensors[torch]==0.5.2 + # via + # docling-ibm-models + # transformers +scikit-image==0.24.0 + # via easyocr scipy==1.13.1 - # via great-expectations + # via + # docling + # easyocr + # great-expectations + # scikit-image +semchunk==2.2.2 + # via docling-core send2trash==1.8.3 # via jupyter-server -setuptools==75.2.0 +setuptools==75.8.0 # via # grpcio-tools # jupyterlab # kubernetes + # pbr # pip-tools + # pymilvus # singlestoredb +shapely==2.0.7 + # via easyocr +shellingham==1.5.4 + # via typer singlestoredb==1.7.2 # via feast (setup.py) -six==1.16.0 +six==1.17.0 # via - # asttokens # azure-core - # bleach # geomet # happybase # kubernetes @@ -866,7 +1048,7 @@ sniffio==1.3.1 # httpx snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python[pandas]==3.12.3 +snowflake-connector-python[pandas]==3.13.2 # via feast (setup.py) sortedcontainers==2.4.0 # via snowflake-connector-python @@ -886,37 +1068,46 @@ sphinxcontrib-qthelp==2.0.0 # via sphinx sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sqlalchemy[mypy]==2.0.36 +sqlalchemy[mypy]==2.0.38 # via feast (setup.py) sqlglot==23.12.2 # via ibis-framework -sqlite-vec==0.1.1 +sqlite-vec==0.1.6 # via feast (setup.py) -sqlparams==6.1.0 +sqlparams==6.2.0 # via singlestoredb stack-data==0.6.3 # via ipython -starlette==0.41.2 +starlette==0.45.3 # via fastapi substrait==0.23.0 # via ibis-substrait +sympy==1.13.3 + # via torch tabulate==0.9.0 - # via feast (setup.py) + # via + # feast (setup.py) + # docling-core + # docling-parse tenacity==8.5.0 # via feast (setup.py) terminado==0.18.1 # via # jupyter-server # jupyter-server-terminals -testcontainers==4.4.0 +testcontainers==4.8.2 # via feast (setup.py) thriftpy2==0.5.2 # via happybase +tifffile==2024.8.30 + # via scikit-image tinycss2==1.4.0 - # via nbconvert + # via bleach +tokenizers==0.19.1 + # via transformers toml==0.10.2 # via feast (setup.py) -tomli==2.0.2 +tomli==2.2.1 # via # build # coverage @@ -934,7 +1125,19 @@ toolz==0.12.1 # dask # ibis-framework # partd -tornado==6.4.1 +torch==2.2.2 + # via + # feast (setup.py) + # docling-ibm-models + # easyocr + # safetensors + # torchvision +torchvision==0.17.2 + # via + # feast (setup.py) + # docling-ibm-models + # easyocr +tornado==6.4.2 # via # ipykernel # jupyter-client @@ -942,10 +1145,17 @@ tornado==6.4.1 # jupyterlab # notebook # terminado -tqdm==4.66.6 +tqdm==4.67.1 # via # feast (setup.py) + # docling + # docling-ibm-models # great-expectations + # huggingface-hub + # milvus-lite + # mpire + # semchunk + # transformers traitlets==5.14.3 # via # comm @@ -961,37 +1171,45 @@ traitlets==5.14.3 # nbclient # nbconvert # nbformat -trino==0.330.0 +transformers==4.42.4 + # via + # docling-core + # docling-ibm-models +trino==0.333.0 # via feast (setup.py) -typeguard==4.4.0 +typeguard==4.4.2 # via feast (setup.py) -types-cffi==1.16.0.20240331 +typer==0.12.5 + # via + # docling + # docling-core +types-cffi==1.16.0.20241221 # via types-pyopenssl types-protobuf==3.19.22 # via # feast (setup.py) # mypy-protobuf -types-pymysql==1.1.0.20240524 +types-pymysql==1.1.0.20241103 # via feast (setup.py) types-pyopenssl==24.1.0.20240722 # via types-redis -types-python-dateutil==2.9.0.20241003 +types-python-dateutil==2.9.0.20241206 # via # feast (setup.py) # arrow -types-pytz==2024.2.0.20241003 +types-pytz==2025.1.0.20250204 # via feast (setup.py) -types-pyyaml==6.0.12.20240917 +types-pyyaml==6.0.12.20241230 # via feast (setup.py) types-redis==4.6.0.20241004 # via feast (setup.py) types-requests==2.30.0.0 # via feast (setup.py) -types-setuptools==75.2.0.20241025 +types-setuptools==75.8.0.20250225 # via # feast (setup.py) # types-cffi -types-tabulate==0.9.0.20240106 +types-tabulate==0.9.0.20241207 # via feast (setup.py) types-urllib3==1.26.25.14 # via types-requests @@ -1003,35 +1221,49 @@ typing-extensions==4.12.2 # azure-core # azure-identity # azure-storage-blob + # beautifulsoup4 + # docling-core # fastapi # great-expectations + # huggingface-hub # ibis-framework # ipython # jwcrypto + # minio + # mistune # multidict # mypy # psycopg # psycopg-pool # pydantic # pydantic-core + # python-docx + # python-json-logger + # python-pptx + # referencing # rich # snowflake-connector-python # sqlalchemy # starlette # testcontainers + # torch # typeguard + # typer # uvicorn -tzdata==2024.2 +tzdata==2025.1 # via pandas -tzlocal==5.2 +tzlocal==5.3 # via # great-expectations # trino +ujson==5.10.0 + # via pymilvus uri-template==1.3.0 # via jsonschema urllib3==1.26.20 # via # feast (setup.py) + # aiobotocore # botocore # docker # elastic-transport @@ -1043,11 +1275,11 @@ urllib3==1.26.20 # responses # snowflake-connector-python # testcontainers -uvicorn[standard]==0.32.0 +uvicorn[standard]==0.34.0 # via # feast (setup.py) # uvicorn-worker -uvicorn-worker==0.2.0 +uvicorn-worker==0.3.0 # via feast (setup.py) uvloop==0.21.0 # via uvicorn @@ -1055,11 +1287,11 @@ virtualenv==20.23.0 # via # feast (setup.py) # pre-commit -watchfiles==0.24.0 +watchfiles==1.0.4 # via uvicorn wcwidth==0.2.13 # via prompt-toolkit -webcolors==24.8.0 +webcolors==24.11.1 # via jsonschema webencodings==0.5.1 # via @@ -1069,23 +1301,27 @@ websocket-client==1.8.0 # via # jupyter-server # kubernetes -websockets==13.1 +websockets==15.0 # via uvicorn -werkzeug==3.0.6 +werkzeug==3.1.3 # via moto -wheel==0.44.0 +wheel==0.45.1 # via # pip-tools # singlestoredb widgetsnbextension==4.0.13 # via ipywidgets -wrapt==1.16.0 +wrapt==1.17.2 # via # aiobotocore # testcontainers +xlsxwriter==3.2.2 + # via python-pptx xmltodict==0.14.2 # via moto -yarl==1.16.0 +yarl==1.18.3 # via aiohttp -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata +zstandard==0.23.0 + # via trino diff --git a/sdk/python/requirements/py3.9-requirements.txt b/sdk/python/requirements/py3.9-requirements.txt index ec46a195c12..e7aa5a42409 100644 --- a/sdk/python/requirements/py3.9-requirements.txt +++ b/sdk/python/requirements/py3.9-requirements.txt @@ -2,26 +2,26 @@ # uv pip compile -p 3.9 --system --no-strip-extras setup.py --output-file sdk/python/requirements/py3.9-requirements.txt annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.8.0 # via # starlette # watchfiles -attrs==24.2.0 +attrs==25.1.0 # via # jsonschema # referencing -bigtree==0.21.3 +bigtree==0.25.0 # via feast (setup.py) -certifi==2024.8.30 +certifi==2025.1.31 # via requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -click==8.1.7 +click==8.1.8 # via # feast (setup.py) # dask # uvicorn -cloudpickle==3.1.0 +cloudpickle==3.1.1 # via dask colorama==0.4.6 # via feast (setup.py) @@ -35,10 +35,12 @@ dill==0.3.9 # via feast (setup.py) exceptiongroup==1.2.2 # via anyio -fastapi==0.115.4 +fastapi==0.115.8 # via feast (setup.py) -fsspec==2024.10.0 +fsspec==2025.2.0 # via dask +greenlet==3.1.1 + # via sqlalchemy gunicorn==23.0.0 # via # feast (setup.py) @@ -51,11 +53,11 @@ idna==3.10 # via # anyio # requests -importlib-metadata==8.5.0 +importlib-metadata==8.6.1 # via # dask # typeguard -jinja2==3.1.4 +jinja2==3.1.5 # via feast (setup.py) jsonschema==4.23.0 # via feast (setup.py) @@ -65,9 +67,9 @@ locket==1.0.0 # via partd markupsafe==3.0.2 # via jinja2 -mmh3==5.0.1 +mmh3==5.1.0 # via feast (setup.py) -mypy==1.13.0 +mypy==1.15.0 # via sqlalchemy mypy-extensions==1.0.0 # via mypy @@ -76,7 +78,7 @@ numpy==1.26.4 # feast (setup.py) # dask # pandas -packaging==24.1 +packaging==24.2 # via # dask # gunicorn @@ -87,54 +89,54 @@ pandas==2.2.3 # dask-expr partd==1.4.2 # via dask -prometheus-client==0.21.0 +prometheus-client==0.21.1 # via feast (setup.py) -protobuf==4.25.5 +protobuf==5.29.3 # via feast (setup.py) -psutil==6.1.0 +psutil==7.0.0 # via feast (setup.py) pyarrow==18.0.0 # via # feast (setup.py) # dask-expr -pydantic==2.9.2 +pydantic==2.10.6 # via # feast (setup.py) # fastapi -pydantic-core==2.23.4 +pydantic-core==2.27.2 # via pydantic -pygments==2.18.0 +pygments==2.19.1 # via feast (setup.py) -pyjwt==2.9.0 +pyjwt==2.10.1 # via feast (setup.py) python-dateutil==2.9.0.post0 # via pandas python-dotenv==1.0.1 # via uvicorn -pytz==2024.2 +pytz==2025.1 # via pandas pyyaml==6.0.2 # via # feast (setup.py) # dask # uvicorn -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications requests==2.32.3 # via feast (setup.py) -rpds-py==0.20.0 +rpds-py==0.23.1 # via # jsonschema # referencing -six==1.16.0 +six==1.17.0 # via python-dateutil sniffio==1.3.1 # via anyio -sqlalchemy[mypy]==2.0.36 +sqlalchemy[mypy]==2.0.38 # via feast (setup.py) -starlette==0.41.2 +starlette==0.45.3 # via fastapi tabulate==0.9.0 # via feast (setup.py) @@ -142,15 +144,15 @@ tenacity==8.5.0 # via feast (setup.py) toml==0.10.2 # via feast (setup.py) -tomli==2.0.2 +tomli==2.2.1 # via mypy toolz==1.0.0 # via # dask # partd -tqdm==4.66.6 +tqdm==4.67.1 # via feast (setup.py) -typeguard==4.4.0 +typeguard==4.4.2 # via feast (setup.py) typing-extensions==4.12.2 # via @@ -159,25 +161,26 @@ typing-extensions==4.12.2 # mypy # pydantic # pydantic-core + # referencing # sqlalchemy # starlette # typeguard # uvicorn -tzdata==2024.2 +tzdata==2025.1 # via pandas -urllib3==2.2.3 +urllib3==2.3.0 # via requests -uvicorn[standard]==0.32.0 +uvicorn[standard]==0.34.0 # via # feast (setup.py) # uvicorn-worker -uvicorn-worker==0.2.0 +uvicorn-worker==0.3.0 # via feast (setup.py) uvloop==0.21.0 # via uvicorn -watchfiles==0.24.0 +watchfiles==1.0.4 # via uvicorn -websockets==13.1 +websockets==15.0 # via uvicorn -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index 24c8f40f742..c46aff681a3 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -57,8 +57,12 @@ location, ) from tests.utils.auth_permissions_util import default_store -from tests.utils.generate_self_signed_certifcate_util import generate_self_signed_cert from tests.utils.http_server import check_port_open, free_port # noqa: E402 +from tests.utils.ssl_certifcates_util import ( + combine_trust_stores, + create_ca_trust_store, + generate_self_signed_cert, +) logger = logging.getLogger(__name__) @@ -81,7 +85,7 @@ def pytest_configure(config): if platform in ["darwin", "windows"]: - multiprocessing.set_start_method("spawn") + multiprocessing.set_start_method("spawn", force=True) else: multiprocessing.set_start_method("fork") config.addinivalue_line( @@ -306,6 +310,10 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): pytest.mark.xdist_group(name=m) for m in c.offline_store_creator.xdist_groups() ] + # Check if there are any test markers associated with the creator and add them. + if c.offline_store_creator.test_markers(): + marks.extend(c.offline_store_creator.test_markers()) + _config_cache[c] = pytest.param(c, marks=marks) configs.append(_config_cache[c]) @@ -514,17 +522,36 @@ def auth_config(request, is_integration_test): return auth_configuration -@pytest.fixture(params=[True, False], scope="module") +@pytest.fixture(scope="module") def tls_mode(request): - is_tls_mode = request.param + is_tls_mode = request.param[0] + output_combined_truststore_path = "" if is_tls_mode: certificates_path = tempfile.mkdtemp() tls_key_path = os.path.join(certificates_path, "key.pem") tls_cert_path = os.path.join(certificates_path, "cert.pem") + generate_self_signed_cert(cert_path=tls_cert_path, key_path=tls_key_path) + is_ca_trust_store_set = request.param[1] + if is_ca_trust_store_set: + # Paths + feast_ca_trust_store_path = os.path.join( + certificates_path, "feast_trust_store.pem" + ) + create_ca_trust_store( + public_key_path=tls_cert_path, + private_key_path=tls_key_path, + output_trust_store_path=feast_ca_trust_store_path, + ) + + # Combine trust stores + output_combined_path = os.path.join( + certificates_path, "combined_trust_store.pem" + ) + combine_trust_stores(feast_ca_trust_store_path, output_combined_path) else: tls_key_path = "" tls_cert_path = "" - return is_tls_mode, tls_key_path, tls_cert_path + return is_tls_mode, tls_key_path, tls_cert_path, output_combined_truststore_path diff --git a/sdk/python/tests/data/data_creator.py b/sdk/python/tests/data/data_creator.py index 5d6cffeb9df..dfe94913e97 100644 --- a/sdk/python/tests/data/data_creator.py +++ b/sdk/python/tests/data/data_creator.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional +from zoneinfo import ZoneInfo import pandas as pd -from zoneinfo import ZoneInfo from feast.types import FeastType, Float32, Int32, Int64, String from feast.utils import _utc_now @@ -84,6 +84,8 @@ def get_feature_values_for_dtype( def create_document_dataset() -> pd.DataFrame: data = { "item_id": [1, 2, 3], + "string_feature": ["a", "b", "c"], + "float_feature": [1.0, 2.0, 3.0], "embedding_float": [[4.0, 5.0], [1.0, 2.0], [3.0, 4.0]], "embedding_double": [[4.0, 5.0], [1.0, 2.0], [3.0, 4.0]], "ts": [ diff --git a/sdk/python/tests/doctest/test_all.py b/sdk/python/tests/doctest/test_all.py index d1b2161252f..de032264e6d 100644 --- a/sdk/python/tests/doctest/test_all.py +++ b/sdk/python/tests/doctest/test_all.py @@ -77,9 +77,11 @@ def test_docstrings(): full_name = package.__name__ + "." + name try: - temp_module = importlib.import_module(full_name) - if is_pkg: - next_packages.append(temp_module) + # https://github.com/feast-dev/feast/issues/5088 + if "ikv" not in full_name and "milvus" not in full_name: + temp_module = importlib.import_module(full_name) + if is_pkg: + next_packages.append(temp_module) except ModuleNotFoundError: pass diff --git a/sdk/python/tests/example_repos/example_feature_repo_1.py b/sdk/python/tests/example_repos/example_feature_repo_1.py index daf7b7e7e6f..1671bd0ae3a 100644 --- a/sdk/python/tests/example_repos/example_feature_repo_1.py +++ b/sdk/python/tests/example_repos/example_feature_repo_1.py @@ -118,8 +118,15 @@ name="document_embeddings", entities=[item], schema=[ - Field(name="Embeddings", dtype=Array(Float32)), + Field( + name="Embeddings", + dtype=Array(Float32), + vector_index=True, + vector_search_metric="L2", + ), Field(name="item_id", dtype=String), + Field(name="content", dtype=String), + Field(name="title", dtype=String), ], source=rag_documents_source, ttl=timedelta(hours=24), diff --git a/sdk/python/tests/example_repos/example_feature_repo_with_bfvs.py b/sdk/python/tests/example_repos/example_feature_repo_with_bfvs.py index e0f75c0c6ff..9ee05b47fe4 100644 --- a/sdk/python/tests/example_repos/example_feature_repo_with_bfvs.py +++ b/sdk/python/tests/example_repos/example_feature_repo_with_bfvs.py @@ -18,6 +18,8 @@ driver_hourly_stats_view = BatchFeatureView( name="driver_hourly_stats", entities=[driver], + mode="python", + udf=lambda x: x, ttl=timedelta(days=1), schema=[ Field(name="conv_rate", dtype=Float32), @@ -41,6 +43,8 @@ global_stats_feature_view = BatchFeatureView( name="global_daily_stats", entities=None, + mode="python", + udf=lambda x: x, ttl=timedelta(days=1), schema=[ Field(name="num_rides", dtype=Int32), diff --git a/sdk/python/tests/example_repos/example_rag_feature_repo.py b/sdk/python/tests/example_repos/example_rag_feature_repo.py new file mode 100644 index 00000000000..d87a2a34df1 --- /dev/null +++ b/sdk/python/tests/example_repos/example_rag_feature_repo.py @@ -0,0 +1,47 @@ +from datetime import timedelta + +from feast import Entity, FeatureView, Field, FileSource +from feast.types import Array, Float32, Int64, String, UnixTimestamp, ValueType + +# This is for Milvus +# Note that file source paths are not validated, so there doesn't actually need to be any data +# at the paths for these file sources. Since these paths are effectively fake, this example +# feature repo should not be used for historical retrieval. + +rag_documents_source = FileSource( + path="data/embedded_documents.parquet", + timestamp_field="event_timestamp", + created_timestamp_column="created_timestamp", +) + +item = Entity( + name="item_id", # The name is derived from this argument, not object name. + join_keys=["item_id"], + value_type=ValueType.INT64, +) + +author = Entity( + name="author_id", + join_keys=["author_id"], + value_type=ValueType.STRING, +) + +document_embeddings = FeatureView( + name="embedded_documents", + entities=[item, author], + schema=[ + Field( + name="vector", + dtype=Array(Float32), + vector_index=True, + vector_search_metric="COSINE", + ), + Field(name="item_id", dtype=Int64), + Field(name="author_id", dtype=String), + Field(name="created_timestamp", dtype=UnixTimestamp), + Field(name="sentence_chunks", dtype=String), + Field(name="event_timestamp", dtype=UnixTimestamp), + ], + source=rag_documents_source, + ttl=timedelta(hours=24), +) diff --git a/sdk/python/tests/foo_provider.py b/sdk/python/tests/foo_provider.py index 570a6d4f8d5..2aa674c0aa5 100644 --- a/sdk/python/tests/foo_provider.py +++ b/sdk/python/tests/foo_provider.py @@ -150,6 +150,7 @@ def retrieve_online_documents( config: RepoConfig, table: FeatureView, requested_feature: str, + requested_features: Optional[List[str]], query: List[float], top_k: int, distance_metric: Optional[str] = None, @@ -163,6 +164,24 @@ def retrieve_online_documents( ]: return [] + def retrieve_online_documents_v2( + self, + config: RepoConfig, + table: FeatureView, + requested_features: List[str], + query: Optional[List[float]], + top_k: int, + distance_metric: Optional[str] = None, + query_string: Optional[str] = None, + ) -> List[ + Tuple[ + Optional[datetime], + Optional[EntityKeyProto], + Optional[Dict[str, ValueProto]], + ] + ]: + return [] + def validate_data_source( self, config: RepoConfig, diff --git a/sdk/python/tests/integration/conftest.py b/sdk/python/tests/integration/conftest.py index 82f80b89927..21c9051d0d7 100644 --- a/sdk/python/tests/integration/conftest.py +++ b/sdk/python/tests/integration/conftest.py @@ -1,4 +1,7 @@ import logging +import random +import time +from multiprocessing import Manager import pytest from testcontainers.keycloak import KeycloakContainer @@ -9,14 +12,30 @@ from tests.utils.auth_permissions_util import setup_permissions_on_keycloak logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +shared_state = Manager().dict() @pytest.fixture(scope="session") def start_keycloak_server(): + # Add random sleep between 0 and 2 before checking the state to avoid concurrency issues. + random_sleep_time = random.uniform(0, 2) + time.sleep(random_sleep_time) + + # If the Keycloak instance is already started (in any worker), reuse it + if shared_state.get("keycloak_started", False): + return shared_state["keycloak_url"] logger.info("Starting keycloak instance") with KeycloakContainer("quay.io/keycloak/keycloak:24.0.1") as keycloak_container: setup_permissions_on_keycloak(keycloak_container.get_client()) - yield keycloak_container.get_url() + shared_state["keycloak_started"] = True + shared_state["keycloak_url"] = keycloak_container.get_url() + yield shared_state["keycloak_url"] + + # After the fixture is done, cleanup the shared state + del shared_state["keycloak_started"] + del shared_state["keycloak_url"] @pytest.fixture(scope="session") diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index c688a848362..54129f23c6e 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -49,6 +49,7 @@ FileDataSourceCreator, RemoteOfflineOidcAuthStoreDataSourceCreator, RemoteOfflineStoreDataSourceCreator, + RemoteOfflineTlsStoreDataSourceCreator, ) from tests.integration.feature_repos.universal.data_sources.redshift import ( RedshiftDataSourceCreator, @@ -85,6 +86,7 @@ ) DYNAMO_CONFIG = {"type": "dynamodb", "region": "us-west-2"} +MILVUS_CONFIG = {"type": "milvus"} REDIS_CONFIG = {"type": "redis", "connection_string": "localhost:6379,db=0"} REDIS_CLUSTER_CONFIG = { "type": "redis", @@ -131,6 +133,7 @@ ("local", DuckDBDeltaDataSourceCreator), ("local", RemoteOfflineStoreDataSourceCreator), ("local", RemoteOfflineOidcAuthStoreDataSourceCreator), + ("local", RemoteOfflineTlsStoreDataSourceCreator), ] if os.getenv("FEAST_IS_LOCAL_TEST", "False") == "True": @@ -160,6 +163,7 @@ AVAILABLE_ONLINE_STORES["datastore"] = ("datastore", None) AVAILABLE_ONLINE_STORES["snowflake"] = (SNOWFLAKE_CONFIG, None) AVAILABLE_ONLINE_STORES["bigtable"] = (BIGTABLE_CONFIG, None) + # AVAILABLE_ONLINE_STORES["milvus"] = (MILVUS_CONFIG, None) # Uncomment to test using private IKV account. Currently not enabled as # there is no dedicated IKV instance for CI testing and there is no @@ -557,6 +561,9 @@ def construct_test_environment( cache_ttl_seconds=1, ) + if test_repo_config.online_store in ["milvus", "pgvector", "qdrant"]: + entity_key_serialization_version = 3 + environment_params = { "name": project, "provider": test_repo_config.provider, diff --git a/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py b/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py index 513a94ee210..467db4dddce 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py @@ -2,6 +2,7 @@ from typing import Dict, Optional import pandas as pd +from _pytest.mark import MarkDecorator from feast.data_source import DataSource from feast.feature_logging import LoggingDestination @@ -64,3 +65,11 @@ def teardown(self): @staticmethod def xdist_groups() -> list[str]: return [] + + @staticmethod + def test_markers() -> list[MarkDecorator]: + """ + return the array of test markers to add dynamically to the tests created by this creator method. override this method in your implementations. By default, it will not add any markers. + :return: + """ + return [] diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py index 35325c2737e..6f6e5d68133 100644 --- a/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py +++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py @@ -11,7 +11,9 @@ import pandas as pd import pyarrow as pa import pyarrow.parquet as pq +import pytest import yaml +from _pytest.mark import MarkDecorator from minio import Minio from testcontainers.core.generic import DockerContainer from testcontainers.core.waiting_utils import wait_for_logs @@ -35,6 +37,7 @@ ) from tests.utils.auth_permissions_util import include_auth_config from tests.utils.http_server import check_port_open, free_port # noqa: E402 +from tests.utils.ssl_certifcates_util import generate_self_signed_cert logger = logging.getLogger(__name__) @@ -371,6 +374,10 @@ def __init__(self, project_name: str, *args, **kwargs): self.server_port: int = 0 self.proc: Optional[Popen[bytes]] = None + @staticmethod + def test_markers() -> list[MarkDecorator]: + return [pytest.mark.rbac_remote_integration_test] + def setup(self, registry: RegistryConfig): parent_offline_config = super().create_offline_store_config() config = RepoConfig( @@ -410,11 +417,74 @@ def setup(self, registry: RegistryConfig): ) return "grpc+tcp://{}:{}".format(host, self.server_port) + +class RemoteOfflineTlsStoreDataSourceCreator(FileDataSourceCreator): + def __init__(self, project_name: str, *args, **kwargs): + super().__init__(project_name) + self.server_port: int = 0 + self.proc: Optional[Popen[bytes]] = None + + @staticmethod + def test_markers() -> list[MarkDecorator]: + return [pytest.mark.rbac_remote_integration_test] + + def setup(self, registry: RegistryConfig): + parent_offline_config = super().create_offline_store_config() + config = RepoConfig( + project=self.project_name, + provider="local", + offline_store=parent_offline_config, + registry=registry.path, + entity_key_serialization_version=2, + ) + + certificates_path = tempfile.mkdtemp() + tls_key_path = os.path.join(certificates_path, "key.pem") + self.tls_cert_path = os.path.join(certificates_path, "cert.pem") + generate_self_signed_cert(cert_path=self.tls_cert_path, key_path=tls_key_path) + + repo_path = Path(tempfile.mkdtemp()) + with open(repo_path / "feature_store.yaml", "w") as outfile: + yaml.dump(config.model_dump(by_alias=True), outfile) + repo_path = repo_path.resolve() + + self.server_port = free_port() + host = "0.0.0.0" + cmd = [ + "feast", + "-c" + str(repo_path), + "serve_offline", + "--host", + host, + "--port", + str(self.server_port), + "--key", + str(tls_key_path), + "--cert", + str(self.tls_cert_path), + ] + self.proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL + ) + + _time_out_sec: int = 60 + # Wait for server to start + wait_retry_backoff( + lambda: (None, check_port_open(host, self.server_port)), + timeout_secs=_time_out_sec, + timeout_msg=f"Unable to start the feast remote offline server in {_time_out_sec} seconds at port={self.server_port}", + ) + return "grpc+tls://{}:{}".format(host, self.server_port) + def create_offline_store_config(self) -> FeastConfigBaseModel: - self.remote_offline_store_config = RemoteOfflineStoreConfig( - type="remote", host="0.0.0.0", port=self.server_port + remote_offline_store_config = RemoteOfflineStoreConfig( + type="remote", + host="0.0.0.0", + port=self.server_port, + scheme="https", + cert=self.tls_cert_path, ) - return self.remote_offline_store_config + return remote_offline_store_config def teardown(self): super().teardown() @@ -455,6 +525,10 @@ def __init__(self, project_name: str, *args, **kwargs): def xdist_groups() -> list[str]: return ["keycloak"] + @staticmethod + def test_markers() -> list[MarkDecorator]: + return [pytest.mark.rbac_remote_integration_test] + def setup(self, registry: RegistryConfig): parent_offline_config = super().create_offline_store_config() config = RepoConfig( @@ -499,10 +573,10 @@ def setup(self, registry: RegistryConfig): return "grpc+tcp://{}:{}".format(host, self.server_port) def create_offline_store_config(self) -> FeastConfigBaseModel: - self.remote_offline_store_config = RemoteOfflineStoreConfig( + remote_offline_store_config = RemoteOfflineStoreConfig( type="remote", host="0.0.0.0", port=self.server_port ) - return self.remote_offline_store_config + return remote_offline_store_config def get_keycloak_url(self): return self.keycloak_url diff --git a/sdk/python/tests/integration/feature_repos/universal/feature_views.py b/sdk/python/tests/integration/feature_repos/universal/feature_views.py index 11ddcb0ecc6..7fc5149d240 100644 --- a/sdk/python/tests/integration/feature_repos/universal/feature_views.py +++ b/sdk/python/tests/integration/feature_repos/universal/feature_views.py @@ -17,7 +17,7 @@ from feast.data_source import DataSource, RequestSource from feast.feature_view_projection import FeatureViewProjection from feast.on_demand_feature_view import PandasTransformation, SubstraitTransformation -from feast.types import Array, FeastType, Float32, Float64, Int32, Int64 +from feast.types import Array, FeastType, Float32, Float64, Int32, Int64, String from tests.integration.feature_repos.universal.entities import ( customer, driver, @@ -160,8 +160,20 @@ def create_item_embeddings_feature_view(source, infer_features: bool = False): schema=None if infer_features else [ - Field(name="embedding_double", dtype=Array(Float64)), - Field(name="embedding_float", dtype=Array(Float32)), + Field( + name="embedding_double", + dtype=Array(Float64), + vector_index=True, + vector_search_metric="L2", + ), + Field( + name="embedding_float", + dtype=Array(Float32), + vector_index=True, + vector_search_metric="L2", + ), + Field(name="string_feature", dtype=String), + Field(name="float_feature", dtype=Float32), ], source=source, ttl=timedelta(hours=2), @@ -183,6 +195,7 @@ def create_item_embeddings_batch_feature_view( ], source=source, ttl=timedelta(hours=2), + udf=lambda x: x, ) return item_embeddings_feature_view @@ -225,6 +238,7 @@ def create_driver_hourly_stats_batch_feature_view( source=source, ttl=timedelta(hours=2), tags=TAGS, + udf=lambda x: x, ) return driver_stats_feature_view diff --git a/sdk/python/tests/integration/feature_repos/universal/online_store/couchbase.py b/sdk/python/tests/integration/feature_repos/universal/online_store/couchbase.py index f2ba12da8da..2723ff13a30 100644 --- a/sdk/python/tests/integration/feature_repos/universal/online_store/couchbase.py +++ b/sdk/python/tests/integration/feature_repos/universal/online_store/couchbase.py @@ -66,7 +66,7 @@ def create_online_store(self) -> Dict[str, object]: # Return the configuration for Feast return { - "type": "couchbase", + "type": "couchbase.online", "connection_string": "couchbase://127.0.0.1", "user": self.username, "password": self.password, diff --git a/sdk/python/tests/integration/feature_repos/universal/online_store/milvus.py b/sdk/python/tests/integration/feature_repos/universal/online_store/milvus.py new file mode 100644 index 00000000000..c02bd144016 --- /dev/null +++ b/sdk/python/tests/integration/feature_repos/universal/online_store/milvus.py @@ -0,0 +1,43 @@ +from typing import Any, Dict + +import docker +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + +from tests.integration.feature_repos.universal.online_store_creator import ( + OnlineStoreCreator, +) + + +class MilvusOnlineStoreCreator(OnlineStoreCreator): + def __init__(self, project_name: str, **kwargs): + super().__init__(project_name) + self.fixed_port = 19530 + self.container = DockerContainer("milvusdb/milvus:v2.4.4").with_exposed_ports( + self.fixed_port + ) + self.client = docker.from_env() + + def create_online_store(self) -> Dict[str, Any]: + self.container.start() + # Wait for Milvus server to be ready + # log_string_to_wait_for = "Ready to accept connections" + log_string_to_wait_for = "" + wait_for_logs( + container=self.container, predicate=log_string_to_wait_for, timeout=30 + ) + host = "localhost" + port = self.container.get_exposed_port(self.fixed_port) + return { + "type": "milvus", + "host": host, + "port": int(port), + "index_type": "IVF_FLAT", + "metric_type": "L2", + "embedding_dim": 2, + "vector_enabled": True, + "nlist": 1, + } + + def teardown(self): + self.container.stop() diff --git a/sdk/python/tests/integration/materialization/test_snowflake.py b/sdk/python/tests/integration/materialization/test_snowflake.py index 5f01641c3b5..a783eac0380 100644 --- a/sdk/python/tests/integration/materialization/test_snowflake.py +++ b/sdk/python/tests/integration/materialization/test_snowflake.py @@ -178,9 +178,9 @@ def test_snowflake_materialization_consistency_internal_with_lists( assert actual_value is not None, f"Response: {response_dict}" if feature_dtype == "float": for actual_num, expected_num in zip(actual_value, expected_value): - assert ( - abs(actual_num - expected_num) < 1e-6 - ), f"Response: {response_dict}, Expected: {expected_value}" + assert abs(actual_num - expected_num) < 1e-6, ( + f"Response: {response_dict}, Expected: {expected_value}" + ) else: assert actual_value == expected_value diff --git a/sdk/python/tests/integration/offline_store/test_validation.py b/sdk/python/tests/integration/offline_store/test_dqm_validation.py similarity index 100% rename from sdk/python/tests/integration/offline_store/test_validation.py rename to sdk/python/tests/integration/offline_store/test_dqm_validation.py diff --git a/sdk/python/tests/integration/offline_store/test_feature_logging.py b/sdk/python/tests/integration/offline_store/test_feature_logging.py index 32f506f90b2..53147d242ef 100644 --- a/sdk/python/tests/integration/offline_store/test_feature_logging.py +++ b/sdk/python/tests/integration/offline_store/test_feature_logging.py @@ -106,7 +106,15 @@ def retrieve(): ) persisted_logs = persisted_logs[expected_columns] + logs_df = logs_df[expected_columns] + + # Convert timezone-aware datetime values to naive datetime values + logs_df[LOG_TIMESTAMP_FIELD] = logs_df[LOG_TIMESTAMP_FIELD].dt.tz_localize(None) + persisted_logs[LOG_TIMESTAMP_FIELD] = persisted_logs[ + LOG_TIMESTAMP_FIELD + ].dt.tz_localize(None) + pd.testing.assert_frame_equal( logs_df.sort_values(REQUEST_ID_FIELD).reset_index(drop=True), persisted_logs.sort_values(REQUEST_ID_FIELD).reset_index(drop=True), diff --git a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py index 97ad54251fe..37df649386c 100644 --- a/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py +++ b/sdk/python/tests/integration/offline_store/test_universal_historical_retrieval.py @@ -14,7 +14,7 @@ from feast.infra.offline_stores.offline_utils import ( DEFAULT_ENTITY_DF_EVENT_TIMESTAMP_COL, ) -from feast.types import Float32, Int32 +from feast.types import Float32, Int32, String from feast.utils import _utc_now from tests.integration.feature_repos.repo_configuration import ( construct_universal_feature_views, @@ -23,6 +23,7 @@ from tests.integration.feature_repos.universal.data_sources.file import ( RemoteOfflineOidcAuthStoreDataSourceCreator, RemoteOfflineStoreDataSourceCreator, + RemoteOfflineTlsStoreDataSourceCreator, ) from tests.integration.feature_repos.universal.data_sources.snowflake import ( SnowflakeDataSourceCreator, @@ -166,6 +167,7 @@ def test_historical_features_main( environment.data_source_creator, ( RemoteOfflineStoreDataSourceCreator, + RemoteOfflineTlsStoreDataSourceCreator, RemoteOfflineOidcAuthStoreDataSourceCreator, ), ): @@ -637,3 +639,100 @@ def test_historical_features_containing_backfills(environment): actual_df, sort_by=["driver_id"], ) + + +@pytest.mark.integration +@pytest.mark.universal_offline_stores +@pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: str(v)) +def test_historical_features_field_mapping( + environment, universal_data_sources, full_feature_names +): + store = environment.feature_store + + # (entities, datasets, data_sources) = universal_data_sources + # feature_views = construct_universal_feature_views(data_sources) + + now = datetime.now().replace(microsecond=0, second=0, minute=0) + tomorrow = now + timedelta(days=1) + day_after_tomorrow = now + timedelta(days=2) + + entity_df = pd.DataFrame( + data=[ + {"driver_id": 1001, "event_timestamp": day_after_tomorrow}, + {"driver_id": 1002, "event_timestamp": day_after_tomorrow}, + ] + ) + + driver_stats_df = pd.DataFrame( + data=[ + { + "id": 1001, + "avg_daily_trips": 20, + "event_timestamp": now, + "created": tomorrow, + }, + { + "id": 1002, + "avg_daily_trips": 40, + "event_timestamp": tomorrow, + "created": now, + }, + ] + ) + + expected_df = pd.DataFrame( + data=[ + { + "driver_id": 1001, + "event_timestamp": day_after_tomorrow, + "avg_daily_trips": 20, + }, + { + "driver_id": 1002, + "event_timestamp": day_after_tomorrow, + "avg_daily_trips": 40, + }, + ] + ) + + driver_stats_data_source = environment.data_source_creator.create_data_source( + df=driver_stats_df, + destination_name=f"test_driver_stats_{int(time.time_ns())}_{random.randint(1000, 9999)}", + timestamp_field="event_timestamp", + created_timestamp_column="created", + # Map original "id" column to "driver_id" join key + field_mapping={"id": "driver_id"}, + ) + + driver = Entity(name="driver", join_keys=["driver_id"]) + driver_fv = FeatureView( + name="driver_stats", + entities=[driver], + schema=[ + Field(name="driver_id", dtype=String), + Field(name="avg_daily_trips", dtype=Int32), + ], + source=driver_stats_data_source, + ) + + store.apply([driver, driver_fv]) + + offline_job = store.get_historical_features( + entity_df=entity_df, + features=["driver_stats:avg_daily_trips"], + full_feature_names=False, + ) + + start_time = _utc_now() + actual_df = offline_job.to_df() + + print(f"actual_df shape: {actual_df.shape}") + end_time = _utc_now() + print(str(f"Time to execute job_from_df.to_df() = '{(end_time - start_time)}'\n")) + + assert sorted(expected_df.columns) == sorted(actual_df.columns) + validate_dataframes( + expected_df, + actual_df, + sort_by=["driver_id"], + ) diff --git a/sdk/python/tests/integration/online_store/test_remote_online_store.py b/sdk/python/tests/integration/online_store/test_remote_online_store.py index 2519d3d9bef..eb03fd0c3c5 100644 --- a/sdk/python/tests/integration/online_store/test_remote_online_store.py +++ b/sdk/python/tests/integration/online_store/test_remote_online_store.py @@ -22,8 +22,15 @@ @pytest.mark.integration +@pytest.mark.rbac_remote_integration_test +@pytest.mark.parametrize( + "tls_mode", [("True", "True"), ("True", "False"), ("False", "")], indirect=True +) def test_remote_online_store_read(auth_config, tls_mode): - with tempfile.TemporaryDirectory() as remote_server_tmp_dir, tempfile.TemporaryDirectory() as remote_client_tmp_dir: + with ( + tempfile.TemporaryDirectory() as remote_server_tmp_dir, + tempfile.TemporaryDirectory() as remote_client_tmp_dir, + ): permissions_list = [ Permission( name="online_list_fv_perm", @@ -53,13 +60,13 @@ def test_remote_online_store_read(auth_config, tls_mode): ) ) assert None not in (server_store, server_url, registry_path) - _, _, tls_cert_path = tls_mode + client_store = _create_remote_client_feature_store( temp_dir=remote_client_tmp_dir, server_registry_path=str(registry_path), feature_server_url=server_url, auth_config=auth_config, - tls_cert_path=tls_cert_path, + tls_mode=tls_mode, ) assert client_store is not None _assert_non_existing_entity_feature_views_entity( @@ -169,7 +176,7 @@ def _create_server_store_spin_feature_server( ): store = default_store(str(temp_dir), auth_config, permissions_list) feast_server_port = free_port() - is_tls_mode, tls_key_path, tls_cert_path = tls_mode + is_tls_mode, tls_key_path, tls_cert_path, ca_trust_store_path = tls_mode server_url = next( start_feature_server( @@ -177,6 +184,7 @@ def _create_server_store_spin_feature_server( server_port=feast_server_port, tls_key_path=tls_key_path, tls_cert_path=tls_cert_path, + ca_trust_store_path=ca_trust_store_path, ) ) if is_tls_mode: @@ -200,20 +208,33 @@ def _create_remote_client_feature_store( server_registry_path: str, feature_server_url: str, auth_config: str, - tls_cert_path: str = "", + tls_mode, ) -> FeatureStore: project_name = "REMOTE_ONLINE_CLIENT_PROJECT" runner = CliRunner() result = runner.run(["init", project_name], cwd=temp_dir) assert result.returncode == 0 repo_path = os.path.join(temp_dir, project_name, "feature_repo") - _overwrite_remote_client_feature_store_yaml( - repo_path=str(repo_path), - registry_path=server_registry_path, - feature_server_url=feature_server_url, - auth_config=auth_config, - tls_cert_path=tls_cert_path, - ) + is_tls_mode, _, tls_cert_path, ca_trust_store_path = tls_mode + if is_tls_mode and not ca_trust_store_path: + _overwrite_remote_client_feature_store_yaml( + repo_path=str(repo_path), + registry_path=server_registry_path, + feature_server_url=feature_server_url, + auth_config=auth_config, + tls_cert_path=tls_cert_path, + ) + else: + _overwrite_remote_client_feature_store_yaml( + repo_path=str(repo_path), + registry_path=server_registry_path, + feature_server_url=feature_server_url, + auth_config=auth_config, + ) + + if is_tls_mode and ca_trust_store_path: + # configure trust store path only when is_tls_mode and ca_trust_store_path exists. + os.environ["FEAST_CA_CERT_FILE_PATH"] = ca_trust_store_path return FeatureStore(repo_path=repo_path) diff --git a/sdk/python/tests/integration/online_store/test_universal_online.py b/sdk/python/tests/integration/online_store/test_universal_online.py index 4074dcb194e..64122d2c861 100644 --- a/sdk/python/tests/integration/online_store/test_universal_online.py +++ b/sdk/python/tests/integration/online_store/test_universal_online.py @@ -614,6 +614,10 @@ def eventually_apply() -> Tuple[None, bool]: online_features = fs.get_online_features( features=features, entity_rows=entity_rows ).to_dict() + + # Debugging print statement + print("Online features values:", online_features["value"]) + assert all(v is None for v in online_features["value"]) @@ -858,8 +862,8 @@ def assert_feature_service_entity_mapping_correctness( @pytest.mark.integration @pytest.mark.universal_online_stores(only=["pgvector", "elasticsearch", "qdrant"]) -def test_retrieve_online_documents(vectordb_environment, fake_document_data): - fs = vectordb_environment.feature_store +def test_retrieve_online_documents(environment, fake_document_data): + fs = environment.feature_store df, data_source = fake_document_data item_embeddings_feature_view = create_item_embeddings_feature_view(data_source) fs.apply([item_embeddings_feature_view, item()]) @@ -891,3 +895,28 @@ def test_retrieve_online_documents(vectordb_environment, fake_document_data): top_k=2, distance_metric="wrong", ).to_dict() + + +@pytest.mark.integration +@pytest.mark.universal_online_stores(only=["milvus"]) +def test_retrieve_online_milvus_documents(environment, fake_document_data): + fs = environment.feature_store + df, data_source = fake_document_data + item_embeddings_feature_view = create_item_embeddings_feature_view(data_source) + fs.apply([item_embeddings_feature_view, item()]) + fs.write_to_online_store("item_embeddings", df) + documents = fs.retrieve_online_documents( + feature=None, + features=[ + "item_embeddings:embedding_float", + "item_embeddings:item_id", + "item_embeddings:string_feature", + ], + query=[1.0, 2.0], + top_k=2, + distance_metric="L2", + ).to_dict() + assert len(documents["embedding_float"]) == 2 + + assert len(documents["item_id"]) == 2 + assert documents["item_id"] == [2, 3] diff --git a/sdk/python/tests/integration/registration/test_universal_registry.py b/sdk/python/tests/integration/registration/test_universal_registry.py index 5e06247ebbb..3819d168d78 100644 --- a/sdk/python/tests/integration/registration/test_universal_registry.py +++ b/sdk/python/tests/integration/registration/test_universal_registry.py @@ -344,7 +344,10 @@ def mock_remote_registry(): marks=pytest.mark.xdist_group(name="mysql_registry"), ), lazy_fixture("sqlite_registry"), - lazy_fixture("mock_remote_registry"), + pytest.param( + lazy_fixture("mock_remote_registry"), + marks=pytest.mark.rbac_remote_integration_test, + ), ] sql_fixtures = [ diff --git a/sdk/python/tests/integration/registration/test_universal_types.py b/sdk/python/tests/integration/registration/test_universal_types.py index 928d05ad31e..9d0b620c083 100644 --- a/sdk/python/tests/integration/registration/test_universal_types.py +++ b/sdk/python/tests/integration/registration/test_universal_types.py @@ -171,9 +171,9 @@ def test_feature_get_online_features_types_match( if config.feature_is_list: for feature in online_features["value"]: assert isinstance(feature, list), "Feature value should be a list" - assert ( - config.has_empty_list or len(feature) > 0 - ), "List of values should not be empty" + assert config.has_empty_list or len(feature) > 0, ( + "List of values should not be empty" + ) for element in feature: assert isinstance(element, expected_dtype) else: @@ -224,7 +224,9 @@ def assert_expected_historical_feature_types( dtype_checkers = feature_dtype_to_expected_historical_feature_dtype[feature_dtype] assert any( check(historical_features_df.dtypes["value"]) for check in dtype_checkers - ), f"Failed to match feature type {historical_features_df.dtypes['value']} with checkers {dtype_checkers}" + ), ( + f"Failed to match feature type {historical_features_df.dtypes['value']} with checkers {dtype_checkers}" + ) def assert_feature_list_types( diff --git a/sdk/python/tests/unit/cli/test_cli.py b/sdk/python/tests/unit/cli/test_cli.py index a286c847dd2..b09eabebb80 100644 --- a/sdk/python/tests/unit/cli/test_cli.py +++ b/sdk/python/tests/unit/cli/test_cli.py @@ -170,3 +170,23 @@ def setup_third_party_registry_store_repo( ) yield repo_path + + +def test_cli_configuration(): + """ + Unit test for the 'feast configuration' command + """ + runner = CliRunner() + + with setup_third_party_provider_repo("local") as repo_path: + # Run the 'feast configuration' command + return_code, output = runner.run_with_output(["configuration"], cwd=repo_path) + + # Assertions + assertpy.assert_that(return_code).is_equal_to(0) + assertpy.assert_that(output).contains(b"project: foo") + assertpy.assert_that(output).contains(b"provider: local") + assertpy.assert_that(output).contains(b"type: sqlite") + assertpy.assert_that(output).contains(b"path: data/online_store.db") + assertpy.assert_that(output).contains(b"type: file") + assertpy.assert_that(output).contains(b"entity_key_serialization_version: 2") diff --git a/sdk/python/tests/unit/cli/test_cli_apply_duplicates.py b/sdk/python/tests/unit/cli/test_cli_apply_duplicates.py index e331a1cc2de..b3e350fe73c 100644 --- a/sdk/python/tests/unit/cli/test_cli_apply_duplicates.py +++ b/sdk/python/tests/unit/cli/test_cli_apply_duplicates.py @@ -20,7 +20,10 @@ def test_cli_apply_duplicate_data_source_names() -> None: def run_simple_apply_test(example_repo_file_name: str, expected_error: bytes): - with tempfile.TemporaryDirectory() as repo_dir_name, tempfile.TemporaryDirectory() as data_dir_name: + with ( + tempfile.TemporaryDirectory() as repo_dir_name, + tempfile.TemporaryDirectory() as data_dir_name, + ): runner = CliRunner() # Construct an example repo in a temporary dir repo_path = Path(repo_dir_name) @@ -51,7 +54,10 @@ def test_cli_apply_imported_featureview() -> None: """ Tests that applying a feature view imported from a separate Python file is successful. """ - with tempfile.TemporaryDirectory() as repo_dir_name, tempfile.TemporaryDirectory() as data_dir_name: + with ( + tempfile.TemporaryDirectory() as repo_dir_name, + tempfile.TemporaryDirectory() as data_dir_name, + ): runner = CliRunner() # Construct an example repo in a temporary dir repo_path = Path(repo_dir_name) @@ -97,7 +103,10 @@ def test_cli_apply_imported_featureview_with_duplication() -> None: Tests that applying feature views with duplicated names is not possible, even if one of the duplicated feature views is imported from another file. """ - with tempfile.TemporaryDirectory() as repo_dir_name, tempfile.TemporaryDirectory() as data_dir_name: + with ( + tempfile.TemporaryDirectory() as repo_dir_name, + tempfile.TemporaryDirectory() as data_dir_name, + ): runner = CliRunner() # Construct an example repo in a temporary dir repo_path = Path(repo_dir_name) @@ -152,7 +161,10 @@ def test_cli_apply_duplicated_featureview_names_multiple_py_files() -> None: """ Test apply feature views with duplicated names from multiple py files in a feature repo using CLI """ - with tempfile.TemporaryDirectory() as repo_dir_name, tempfile.TemporaryDirectory() as data_dir_name: + with ( + tempfile.TemporaryDirectory() as repo_dir_name, + tempfile.TemporaryDirectory() as data_dir_name, + ): runner = CliRunner() # Construct an example repo in a temporary dir repo_path = Path(repo_dir_name) diff --git a/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark.py b/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark.py index b8f8cc42474..307ba4058c1 100644 --- a/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark.py +++ b/sdk/python/tests/unit/infra/offline_stores/contrib/spark_offline_store/test_spark.py @@ -71,6 +71,68 @@ def test_pull_latest_from_table_with_nested_timestamp_or_query(mock_get_spark_se assert retrieval_job.query.strip() == expected_query.strip() +@patch( + "feast.infra.offline_stores.contrib.spark_offline_store.spark.get_spark_session_or_start_new_with_repoconfig" +) +def test_pull_latest_from_table_with_nested_timestamp_or_query_and_date_partition_column_set( + mock_get_spark_session, +): + mock_spark_session = MagicMock() + mock_get_spark_session.return_value = mock_spark_session + + test_repo_config = RepoConfig( + project="test_project", + registry="test_registry", + provider="local", + offline_store=SparkOfflineStoreConfig(type="spark"), + ) + + test_data_source = SparkSource( + name="test_nested_batch_source", + description="test_nested_batch_source", + table="offline_store_database_name.offline_store_table_name", + timestamp_field="nested_timestamp", + field_mapping={ + "event_header.event_published_datetime_utc": "nested_timestamp", + }, + date_partition_column="effective_date", + ) + + # Define the parameters for the method + join_key_columns = ["key1", "key2"] + feature_name_columns = ["feature1", "feature2"] + timestamp_field = "event_header.event_published_datetime_utc" + created_timestamp_column = "created_timestamp" + start_date = datetime(2021, 1, 1) + end_date = datetime(2021, 1, 2) + + # Call the method + retrieval_job = SparkOfflineStore.pull_latest_from_table_or_query( + config=test_repo_config, + data_source=test_data_source, + join_key_columns=join_key_columns, + feature_name_columns=feature_name_columns, + timestamp_field=timestamp_field, + created_timestamp_column=created_timestamp_column, + start_date=start_date, + end_date=end_date, + ) + + expected_query = """SELECT + key1, key2, feature1, feature2, nested_timestamp, created_timestamp + + FROM ( + SELECT key1, key2, feature1, feature2, event_header.event_published_datetime_utc AS nested_timestamp, created_timestamp, + ROW_NUMBER() OVER(PARTITION BY key1, key2 ORDER BY event_header.event_published_datetime_utc DESC, created_timestamp DESC) AS feast_row_ + FROM `offline_store_database_name`.`offline_store_table_name` t1 + WHERE event_header.event_published_datetime_utc BETWEEN TIMESTAMP('2021-01-01 00:00:00.000000') AND TIMESTAMP('2021-01-02 00:00:00.000000') AND effective_date >= '2021-01-01' AND effective_date <= '2021-01-02' + ) t2 + WHERE feast_row_ = 1""" # noqa: W293, W291 + + assert isinstance(retrieval_job, RetrievalJob) + assert retrieval_job.query.strip() == expected_query.strip() + + @patch( "feast.infra.offline_stores.contrib.spark_offline_store.spark.get_spark_session_or_start_new_with_repoconfig" ) @@ -127,3 +189,62 @@ def test_pull_latest_from_table_without_nested_timestamp_or_query( assert isinstance(retrieval_job, RetrievalJob) assert retrieval_job.query.strip() == expected_query.strip() + + +@patch( + "feast.infra.offline_stores.contrib.spark_offline_store.spark.get_spark_session_or_start_new_with_repoconfig" +) +def test_pull_latest_from_table_without_nested_timestamp_or_query_and_date_partition_column_set( + mock_get_spark_session, +): + mock_spark_session = MagicMock() + mock_get_spark_session.return_value = mock_spark_session + + test_repo_config = RepoConfig( + project="test_project", + registry="test_registry", + provider="local", + offline_store=SparkOfflineStoreConfig(type="spark"), + ) + + test_data_source = SparkSource( + name="test_batch_source", + description="test_nested_batch_source", + table="offline_store_database_name.offline_store_table_name", + timestamp_field="event_published_datetime_utc", + date_partition_column="effective_date", + ) + + # Define the parameters for the method + join_key_columns = ["key1", "key2"] + feature_name_columns = ["feature1", "feature2"] + timestamp_field = "event_published_datetime_utc" + created_timestamp_column = "created_timestamp" + start_date = datetime(2021, 1, 1) + end_date = datetime(2021, 1, 2) + + # Call the method + retrieval_job = SparkOfflineStore.pull_latest_from_table_or_query( + config=test_repo_config, + data_source=test_data_source, + join_key_columns=join_key_columns, + feature_name_columns=feature_name_columns, + timestamp_field=timestamp_field, + created_timestamp_column=created_timestamp_column, + start_date=start_date, + end_date=end_date, + ) + + expected_query = """SELECT + key1, key2, feature1, feature2, event_published_datetime_utc, created_timestamp + + FROM ( + SELECT key1, key2, feature1, feature2, event_published_datetime_utc, created_timestamp, + ROW_NUMBER() OVER(PARTITION BY key1, key2 ORDER BY event_published_datetime_utc DESC, created_timestamp DESC) AS feast_row_ + FROM `offline_store_database_name`.`offline_store_table_name` t1 + WHERE event_published_datetime_utc BETWEEN TIMESTAMP('2021-01-01 00:00:00.000000') AND TIMESTAMP('2021-01-02 00:00:00.000000') AND effective_date >= '2021-01-01' AND effective_date <= '2021-01-02' + ) t2 + WHERE feast_row_ = 1""" # noqa: W293, W291 + + assert isinstance(retrieval_job, RetrievalJob) + assert retrieval_job.query.strip() == expected_query.strip() diff --git a/sdk/python/tests/unit/infra/offline_stores/test_offline_store.py b/sdk/python/tests/unit/infra/offline_stores/test_offline_store.py index afc0e4e5c8f..fe2c437617a 100644 --- a/sdk/python/tests/unit/infra/offline_stores/test_offline_store.py +++ b/sdk/python/tests/unit/infra/offline_stores/test_offline_store.py @@ -21,6 +21,7 @@ TrinoRetrievalJob, ) from feast.infra.offline_stores.dask import DaskRetrievalJob +from feast.infra.offline_stores.file_source import FileSource from feast.infra.offline_stores.offline_store import RetrievalJob, RetrievalMetadata from feast.infra.offline_stores.redshift import ( RedshiftOfflineStoreConfig, @@ -246,3 +247,28 @@ def test_to_arrow_timeout(retrieval_job, timeout: Optional[int]): with patch.object(retrieval_job, "_to_arrow_internal") as mock_to_arrow_internal: retrieval_job.to_arrow(timeout=timeout) mock_to_arrow_internal.assert_called_once_with(timeout=timeout) + + +@pytest.mark.parametrize( + "repo_path, uri, expected", + [ + # Remote URI - Should return as-is + ( + "/some/repo", + "s3://bucket-name/file.parquet", + "s3://bucket-name/file.parquet", + ), + # Absolute Path - Should return as-is + ("/some/repo", "/abs/path/file.parquet", "/abs/path/file.parquet"), + # Relative Path with repo_path - Should combine + ("/some/repo", "data/output.parquet", "/some/repo/data/output.parquet"), + # Relative Path without repo_path - Should return absolute path + (None, "C:/path/to/file.parquet", "C:/path/to/file.parquet"), + ], + ids=["s3_uri", "absolute_path", "relative_path", "windows_path"], +) +def test_get_uri_for_file_path( + repo_path: Optional[str], uri: str, expected: str +) -> None: + result = FileSource.get_uri_for_file_path(repo_path=repo_path, uri=uri) + assert result == expected, f"Expected {expected}, but got {result}" diff --git a/sdk/python/tests/unit/infra/offline_stores/test_snowflake.py b/sdk/python/tests/unit/infra/offline_stores/test_snowflake.py index 6e27cba341b..d692d0f957a 100644 --- a/sdk/python/tests/unit/infra/offline_stores/test_snowflake.py +++ b/sdk/python/tests/unit/infra/offline_stores/test_snowflake.py @@ -48,14 +48,17 @@ def retrieval_job(request): def test_to_remote_storage(retrieval_job): stored_files = ["just a path", "maybe another"] - with patch.object( - retrieval_job, "to_snowflake", return_value=None - ) as mock_to_snowflake, patch.object( - retrieval_job, "_get_file_names_from_copy_into", return_value=stored_files - ) as mock_get_file_names_from_copy: - assert ( - retrieval_job.to_remote_storage() == stored_files - ), "should return the list of files" + with ( + patch.object( + retrieval_job, "to_snowflake", return_value=None + ) as mock_to_snowflake, + patch.object( + retrieval_job, "_get_file_names_from_copy_into", return_value=stored_files + ) as mock_get_file_names_from_copy, + ): + assert retrieval_job.to_remote_storage() == stored_files, ( + "should return the list of files" + ) mock_to_snowflake.assert_called_once() mock_get_file_names_from_copy.assert_called_once_with(ANY, ANY) native_path = mock_get_file_names_from_copy.call_args[0][1] diff --git a/sdk/python/tests/unit/infra/registry/__init__.py b/sdk/python/tests/unit/infra/registry/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/tests/unit/infra/registry/test_registry.py b/sdk/python/tests/unit/infra/registry/test_registry.py new file mode 100644 index 00000000000..65dea2ff680 --- /dev/null +++ b/sdk/python/tests/unit/infra/registry/test_registry.py @@ -0,0 +1,197 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +from feast.infra.registry.caching_registry import CachingRegistry + + +class TestCachingRegistry(CachingRegistry): + """Test subclass that implements abstract methods as no-ops""" + + def _get_any_feature_view(self, *args, **kwargs): + pass + + def _get_data_source(self, *args, **kwargs): + pass + + def _get_entity(self, *args, **kwargs): + pass + + def _get_feature_service(self, *args, **kwargs): + pass + + def _get_feature_view(self, *args, **kwargs): + pass + + def _get_infra(self, *args, **kwargs): + pass + + def _get_on_demand_feature_view(self, *args, **kwargs): + pass + + def _get_permission(self, *args, **kwargs): + pass + + def _get_project(self, *args, **kwargs): + pass + + def _get_saved_dataset(self, *args, **kwargs): + pass + + def _get_stream_feature_view(self, *args, **kwargs): + pass + + def _get_validation_reference(self, *args, **kwargs): + pass + + def _list_all_feature_views(self, *args, **kwargs): + pass + + def _list_data_sources(self, *args, **kwargs): + pass + + def _list_entities(self, *args, **kwargs): + pass + + def _list_feature_services(self, *args, **kwargs): + pass + + def _list_feature_views(self, *args, **kwargs): + pass + + def _list_on_demand_feature_views(self, *args, **kwargs): + pass + + def _list_permissions(self, *args, **kwargs): + pass + + def _list_project_metadata(self, *args, **kwargs): + pass + + def _list_projects(self, *args, **kwargs): + pass + + def _list_saved_datasets(self, *args, **kwargs): + pass + + def _list_stream_feature_views(self, *args, **kwargs): + pass + + def _list_validation_references(self, *args, **kwargs): + pass + + def apply_data_source(self, *args, **kwargs): + pass + + def apply_entity(self, *args, **kwargs): + pass + + def apply_feature_service(self, *args, **kwargs): + pass + + def apply_feature_view(self, *args, **kwargs): + pass + + def apply_materialization(self, *args, **kwargs): + pass + + def apply_permission(self, *args, **kwargs): + pass + + def apply_project(self, *args, **kwargs): + pass + + def apply_saved_dataset(self, *args, **kwargs): + pass + + def apply_user_metadata(self, *args, **kwargs): + pass + + def apply_validation_reference(self, *args, **kwargs): + pass + + def commit(self, *args, **kwargs): + pass + + def delete_data_source(self, *args, **kwargs): + pass + + def delete_entity(self, *args, **kwargs): + pass + + def delete_feature_service(self, *args, **kwargs): + pass + + def delete_feature_view(self, *args, **kwargs): + pass + + def delete_permission(self, *args, **kwargs): + pass + + def delete_project(self, *args, **kwargs): + pass + + def delete_validation_reference(self, *args, **kwargs): + pass + + def get_user_metadata(self, *args, **kwargs): + pass + + def proto(self, *args, **kwargs): + pass + + def update_infra(self, *args, **kwargs): + pass + + +@pytest.fixture +def registry(): + """Fixture to create a real instance of CachingRegistry""" + return TestCachingRegistry( + project="test_example", cache_ttl_seconds=2, cache_mode="sync" + ) + + +def test_cache_expiry_triggers_refresh(registry): + """Test that an expired cache triggers a refresh""" + # Set cache creation time to a value that is expired + registry.cached_registry_proto = "some_cached_data" + registry.cached_registry_proto_created = datetime.now(timezone.utc) - timedelta( + seconds=5 + ) + + # Mock _refresh_cached_registry_if_necessary to check if it is called + with patch.object( + CachingRegistry, + "_refresh_cached_registry_if_necessary", + wraps=registry._refresh_cached_registry_if_necessary, + ) as mock_refresh_check: + registry._refresh_cached_registry_if_necessary() + mock_refresh_check.assert_called_once() + + # Now check if the refresh was actually triggered + with patch.object( + CachingRegistry, "refresh", wraps=registry.refresh + ) as mock_refresh: + registry._refresh_cached_registry_if_necessary() + mock_refresh.assert_called_once() + + +def test_skip_refresh_if_lock_held(registry): + """Test that refresh is skipped if the lock is already held by another thread""" + registry.cached_registry_proto = "some_cached_data" + registry.cached_registry_proto_created = datetime.now(timezone.utc) - timedelta( + seconds=5 + ) + + # Acquire the lock manually to simulate another thread holding it + registry._refresh_lock.acquire() + with patch.object( + CachingRegistry, "refresh", wraps=registry.refresh + ) as mock_refresh: + registry._refresh_cached_registry_if_necessary() + + # Since the lock was already held, refresh should NOT be called + mock_refresh.assert_not_called() + registry._refresh_lock.release() diff --git a/sdk/python/tests/unit/infra/test_inference_unit_tests.py b/sdk/python/tests/unit/infra/test_inference_unit_tests.py index 54488d43212..951f7033d23 100644 --- a/sdk/python/tests/unit/infra/test_inference_unit_tests.py +++ b/sdk/python/tests/unit/infra/test_inference_unit_tests.py @@ -154,23 +154,6 @@ def python_native_test_invalid_pandas_view( } return output_dict - with pytest.raises(TypeError): - - @on_demand_feature_view( - sources=[date_request], - schema=[ - Field(name="output", dtype=UnixTimestamp), - Field(name="object_output", dtype=String), - ], - mode="python", - ) - def python_native_test_invalid_dict_view( - features_df: pd.DataFrame, - ) -> pd.DataFrame: - data = pd.DataFrame() - data["output"] = features_df["some_date"] - return data - def test_datasource_inference(): # Create Feature Views diff --git a/sdk/python/tests/unit/infra/test_key_encoding_utils.py b/sdk/python/tests/unit/infra/test_key_encoding_utils.py index 658c04c358b..cad5e27f367 100644 --- a/sdk/python/tests/unit/infra/test_key_encoding_utils.py +++ b/sdk/python/tests/unit/infra/test_key_encoding_utils.py @@ -52,6 +52,24 @@ def test_deserialize_entity_key(): ) +def test_deserialize_multiple_entity_keys(): + entity_key_proto = EntityKeyProto( + join_keys=["customer", "user"], + entity_values=[ValueProto(string_val="test"), ValueProto(int64_val=int(2**15))], + ) + + serialized_entity_key = serialize_entity_key( + entity_key_proto, + entity_key_serialization_version=3, + ) + + deserialized_entity_key = deserialize_entity_key( + serialized_entity_key, + entity_key_serialization_version=3, + ) + assert deserialized_entity_key == entity_key_proto + + def test_serialize_value(): v, t = _serialize_val("string_val", ValueProto(string_val="test")) assert t == ValueType.STRING diff --git a/sdk/python/tests/unit/infra/utils/snowflake/test_snowflake_utils.py b/sdk/python/tests/unit/infra/utils/snowflake/test_snowflake_utils.py new file mode 100644 index 00000000000..8ae6ec63ba5 --- /dev/null +++ b/sdk/python/tests/unit/infra/utils/snowflake/test_snowflake_utils.py @@ -0,0 +1,71 @@ +import tempfile +from typing import Optional + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from feast.infra.utils.snowflake.snowflake_utils import parse_private_key_path + +PRIVATE_KEY_PASSPHRASE = "test" + + +def _pem_private_key(passphrase: Optional[str]): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=( + serialization.BestAvailableEncryption(passphrase.encode()) + if passphrase + else serialization.NoEncryption() + ), + ) + + +@pytest.fixture +def unencrypted_private_key(): + return _pem_private_key(None) + + +@pytest.fixture +def encrypted_private_key(): + return _pem_private_key(PRIVATE_KEY_PASSPHRASE) + + +def test_parse_private_key_path_key_content_unencrypted(unencrypted_private_key): + parse_private_key_path( + None, + None, + unencrypted_private_key, + ) + + +def test_parse_private_key_path_key_content_encrypted(encrypted_private_key): + parse_private_key_path( + PRIVATE_KEY_PASSPHRASE, + None, + encrypted_private_key, + ) + + +def test_parse_private_key_path_key_path_unencrypted(unencrypted_private_key): + with tempfile.NamedTemporaryFile(mode="wb") as f: + f.write(unencrypted_private_key) + f.flush() + parse_private_key_path( + None, + f.name, + None, + ) + + +def test_parse_private_key_path_key_path_encrypted(encrypted_private_key): + with tempfile.NamedTemporaryFile(mode="wb") as f: + f.write(encrypted_private_key) + f.flush() + parse_private_key_path( + PRIVATE_KEY_PASSPHRASE, + f.name, + None, + ) diff --git a/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py b/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py index 1b9b48d8d0a..931acfb3919 100644 --- a/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py +++ b/sdk/python/tests/unit/local_feast_tests/test_local_feature_store.py @@ -93,6 +93,7 @@ def test_apply_feature_view(test_feature_store): Field(name="fs1_my_feature_4", dtype=Array(Bytes)), Field(name="entity_id", dtype=Int64), ], + udf=lambda df: df, entities=[entity], tags={"team": "matchmaking", "tag": "two"}, source=batch_source, @@ -654,7 +655,7 @@ def pandas_view(pandas_df): import pandas as pd assert type(pandas_df) == pd.DataFrame - df = pandas_df.transform(lambda x: x + 10, axis=1) + df = pandas_df.transform(lambda x: x + 10) df.insert(2, "C", [20.2, 230.0, 34.0], True) return df diff --git a/sdk/python/tests/unit/online_store/test_online_retrieval.py b/sdk/python/tests/unit/online_store/test_online_retrieval.py index 83184643f35..ea76ed6f544 100644 --- a/sdk/python/tests/unit/online_store/test_online_retrieval.py +++ b/sdk/python/tests/unit/online_store/test_online_retrieval.py @@ -1,5 +1,6 @@ import os import platform +import random import sqlite3 import sys import time @@ -16,6 +17,7 @@ from feast.protos.feast.types.Value_pb2 import FloatList as FloatListProto from feast.protos.feast.types.Value_pb2 import Value as ValueProto from feast.repo_config import RegistryConfig +from feast.types import ValueType from feast.utils import _utc_now from tests.integration.feature_repos.universal.feature_views import TAGS from tests.utils.cli_repo_creator import CliRunner, get_example_repo @@ -27,7 +29,8 @@ def test_get_online_features() -> None: """ runner = CliRunner() with runner.local_repo( - get_example_repo("example_feature_repo_1.py"), "file" + get_example_repo("example_feature_repo_1.py"), + "file", ) as store: # Write some data to two tables driver_locations_fv = store.get_feature_view(name="driver_locations") @@ -134,6 +137,21 @@ def test_get_online_features() -> None: assert "trips" in result + with pytest.raises(KeyError) as excinfo: + _ = store.get_online_features( + features=["driver_locations:lon"], + entity_rows=[{"customer_id": 0}], + full_feature_names=False, + ).to_dict() + + error_message = str(excinfo.value) + assert "Missing join key values for keys:" in error_message + assert ( + "Missing join key values for keys: ['customer_id', 'driver_id', 'item_id']." + in error_message + ) + assert "Provided join_key_values: ['customer_id']" in error_message + result = store.get_online_features( features=["customer_profile_pandas_odfv:on_demand_age"], entity_rows=[{"driver_id": 1, "customer_id": "5"}], @@ -272,6 +290,178 @@ def test_get_online_features() -> None: os.rename(store.config.registry.path + "_fake", store.config.registry.path) +def test_get_online_features_milvus() -> None: + """ + Test reading from the online store in local mode. + """ + runner = CliRunner() + with runner.local_repo( + get_example_repo("example_feature_repo_1.py"), + offline_store="file", + online_store="milvus", + apply=False, + teardown=False, + ) as store: + from tests.example_repos.example_feature_repo_1 import ( + all_drivers_feature_service, + customer, + customer_driver_combined, + customer_driver_combined_source, + customer_profile, + customer_profile_pandas_odfv, + customer_profile_source, + driver, + driver_locations, + driver_locations_source, + item, + pushed_driver_locations, + rag_documents_source, + ) + + store.apply( + [ + driver_locations_source, + customer_profile_source, + customer_driver_combined_source, + rag_documents_source, + driver, + customer, + item, + driver_locations, + pushed_driver_locations, + customer_profile, + customer_driver_combined, + # document_embeddings, + customer_profile_pandas_odfv, + all_drivers_feature_service, + ] + ) + + # Write some data to two tables + driver_locations_fv = store.get_feature_view(name="driver_locations") + customer_profile_fv = store.get_feature_view(name="customer_profile") + customer_driver_combined_fv = store.get_feature_view( + name="customer_driver_combined" + ) + + provider = store._get_provider() + + driver_key = EntityKeyProto( + join_keys=["driver_id"], entity_values=[ValueProto(int64_val=1)] + ) + provider.online_write_batch( + config=store.config, + table=driver_locations_fv, + data=[ + ( + driver_key, + { + "lat": ValueProto(double_val=0.1), + "lon": ValueProto(string_val="1.0"), + }, + _utc_now(), + _utc_now(), + ) + ], + progress=None, + ) + + customer_key = EntityKeyProto( + join_keys=["customer_id"], entity_values=[ValueProto(string_val="5")] + ) + provider.online_write_batch( + config=store.config, + table=customer_profile_fv, + data=[ + ( + customer_key, + { + "avg_orders_day": ValueProto(float_val=1.0), + "name": ValueProto(string_val="John"), + "age": ValueProto(int64_val=3), + }, + _utc_now(), + _utc_now(), + ) + ], + progress=None, + ) + + customer_key = EntityKeyProto( + join_keys=["customer_id", "driver_id"], + entity_values=[ValueProto(string_val="5"), ValueProto(int64_val=1)], + ) + provider.online_write_batch( + config=store.config, + table=customer_driver_combined_fv, + data=[ + ( + customer_key, + {"trips": ValueProto(int64_val=7)}, + _utc_now(), + _utc_now(), + ) + ], + progress=None, + ) + + assert len(store.list_entities()) == 3 + assert len(store.list_entities(tags=TAGS)) == 2 + + # Retrieve two features using two keys, one valid one non-existing + result = store.get_online_features( + features=[ + "driver_locations:lon", + "customer_profile:avg_orders_day", + "customer_profile:name", + "customer_driver_combined:trips", + ], + entity_rows=[ + {"driver_id": 1, "customer_id": "5"}, + {"driver_id": 1, "customer_id": 5}, + ], + full_feature_names=False, + ).to_dict() + + assert "lon" in result + assert "avg_orders_day" in result + assert "name" in result + assert result["driver_id"] == [1, 1] + assert result["customer_id"] == ["5", "5"] + assert result["lon"] == ["1.0", "1.0"] + assert result["avg_orders_day"] == [1.0, 1.0] + assert result["name"] == ["John", "John"] + assert result["trips"] == [7, 7] + + # Ensure features are still in result when keys not found + result = store.get_online_features( + features=["customer_driver_combined:trips"], + entity_rows=[{"driver_id": 0, "customer_id": 0}], + full_feature_names=False, + ).to_dict() + + assert "trips" in result + + result = store.get_online_features( + features=["customer_profile_pandas_odfv:on_demand_age"], + entity_rows=[{"driver_id": 1, "customer_id": "5"}], + full_feature_names=False, + ).to_dict() + + assert "on_demand_age" in result + assert result["driver_id"] == [1] + assert result["customer_id"] == ["5"] + assert result["on_demand_age"] == [4] + + # invalid table reference + with pytest.raises(FeatureViewNotFoundException): + store.get_online_features( + features=["driver_locations_bad:lon"], + entity_rows=[{"driver_id": 1}], + full_feature_names=False, + ) + + def test_online_to_df(): """ Test dataframe conversion. Make sure the response columns and rows are @@ -450,12 +640,12 @@ def test_sqlite_get_online_documents() -> None: item_keys = [ EntityKeyProto( - join_keys=["item_id"], entity_values=[ValueProto(int64_val=i)] + join_keys=["item_id"], entity_values=[ValueProto(string_val=str(i))] ) for i in range(n) ] data = [] - for item_key in item_keys: + for i, item_key in enumerate(item_keys): data.append( ( item_key, @@ -466,19 +656,17 @@ def test_sqlite_get_online_documents() -> None: vector_length, ) ) - ) + ), + "content": ValueProto( + string_val=f"the {i}th sentence with some text" + ), + "title": ValueProto(string_val=f"Title {i}"), }, _utc_now(), _utc_now(), ) ) - provider.online_write_batch( - config=store.config, - table=document_embeddings_fv, - data=data, - progress=None, - ) documents_df = pd.DataFrame( { "item_id": [str(i) for i in range(n)], @@ -488,26 +676,42 @@ def test_sqlite_get_online_documents() -> None: ) for i in range(n) ], + "content": [f"the {i}th sentence with some text" for i in range(n)], + "title": [f"Title {i}" for i in range(n)], "event_timestamp": [_utc_now() for _ in range(n)], } ) - store.write_to_online_store( - feature_view_name="document_embeddings", - df=documents_df, + print(len(data), documents_df.shape[0]) + provider.online_write_batch( + config=store.config, + table=document_embeddings_fv, + data=data, + progress=None, ) - document_table = store._provider._online_store._conn.execute( "SELECT name FROM sqlite_master WHERE type='table' and name like '%_document_embeddings';" ).fetchall() + assert len(document_table) == 1 document_table_name = document_table[0][0] + record_count = len( store._provider._online_store._conn.execute( f"select * from {document_table_name}" ).fetchall() ) - assert record_count == len(data) + documents_df.shape[0] + assert record_count == len(data) * len(document_embeddings_fv.features) + store.write_to_online_store( + feature_view_name="document_embeddings", + df=documents_df, + ) + record_count = len( + store._provider._online_store._conn.execute( + f"select * from {document_table_name}" + ).fetchall() + ) + assert record_count == len(data) * len(document_embeddings_fv.features) query_embedding = np.random.random( vector_length, @@ -561,3 +765,526 @@ def test_sqlite_vec_import() -> None: """).fetchall() result = [(rowid, round(distance, 2)) for rowid, distance in result] assert result == [(2, 2.39), (1, 2.39)] + + +def test_sqlite_hybrid_search() -> None: + imdb_sample_data = { + "Rank": {0: 1, 1: 2, 2: 3, 3: 4, 4: 5}, + "Title": { + 0: "Guardians of the Galaxy", + 1: "Prometheus", + 2: "Split", + 3: "Sing", + 4: "Suicide Squad", + }, + "Genre": { + 0: "Action,Adventure,Sci-Fi", + 1: "Adventure,Mystery,Sci-Fi", + 2: "Horror,Thriller", + 3: "Animation,Comedy,Family", + 4: "Action,Adventure,Fantasy", + }, + "Description": { + 0: "A group of intergalactic criminals are forced to work together to stop a fanatical warrior from taking control of the universe.", + 1: "Following clues to the origin of mankind, a team finds a structure on a distant moon, but they soon realize they are not alone.", + 2: "Three girls are kidnapped by a man with a diagnosed 23 distinct personalities. They must try to escape before the apparent emergence of a frightful new 24th.", + 3: "In a city of humanoid animals, a hustling theater impresario's attempt to save his theater with a singing competition becomes grander than he anticipates even as its finalists' find that their lives will never be the same.", + 4: "A secret government agency recruits some of the most dangerous incarcerated super-villains to form a defensive task force. Their first mission: save the world from the apocalypse.", + }, + "Director": { + 0: "James Gunn", + 1: "Ridley Scott", + 2: "M. Night Shyamalan", + 3: "Christophe Lourdelet", + 4: "David Ayer", + }, + "Actors": { + 0: "Chris Pratt, Vin Diesel, Bradley Cooper, Zoe Saldana", + 1: "Noomi Rapace, Logan Marshall-Green, Michael Fassbender, Charlize Theron", + 2: "James McAvoy, Anya Taylor-Joy, Haley Lu Richardson, Jessica Sula", + 3: "Matthew McConaughey,Reese Witherspoon, Seth MacFarlane, Scarlett Johansson", + 4: "Will Smith, Jared Leto, Margot Robbie, Viola Davis", + }, + "Year": {0: 2014, 1: 2012, 2: 2016, 3: 2016, 4: 2016}, + "Runtime (Minutes)": {0: 121, 1: 124, 2: 117, 3: 108, 4: 123}, + "Rating": {0: 8.1, 1: 7.0, 2: 7.3, 3: 7.2, 4: 6.2}, + "Votes": {0: 757074, 1: 485820, 2: 157606, 3: 60545, 4: 393727}, + "Revenue (Millions)": {0: 333.13, 1: 126.46, 2: 138.12, 3: 270.32, 4: 325.02}, + "Metascore": {0: 76.0, 1: 65.0, 2: 62.0, 3: 59.0, 4: 40.0}, + } + df = pd.DataFrame(imdb_sample_data) + db = sqlite3.connect(":memory:") + + cur = db.cursor() + + cur.execute( + 'create virtual table imdb using fts5(title, description, genre, rating, tokenize="porter unicode61");' + ) + cur.executemany( + "insert into imdb (title, description, genre, rating) values (?,?,?,?);", + df[["Title", "Description", "Genre", "Rating"]].to_records(index=False), + ) + db.commit() + + query = "Prom" + res = cur.execute(f"""select title, description, genre, rating, rank + from imdb + where title MATCH "{query}*" + ORDER BY rank + limit 5""").fetchall() + assert len(res) == 1 + assert res[0][0] == "Prometheus" + + q = "(title : the OR of) AND (genre: Action OR Comedy)" + res_df = pd.read_sql_query( + f""" + select + rowid, + title, + description, + bm25(imdb, 10.0, 5.0) + from imdb + where imdb MATCH "{q}" + ORDER BY bm25(imdb, 10.0, 5.0) + limit 5 + """, + db, + ) + res_df["rowid"].tolist() == [1, 4, 5] + res_df["title"].tolist() == ["Guardians of the Galaxy", "Sing", "Suicide Squad"] + + +@pytest.mark.skipif( + sys.version_info[0:2] != (3, 10), + reason="Only works on Python 3.10", +) +def test_sqlite_get_online_documents_v2() -> None: + """Test retrieving documents using v2 method with vector similarity search.""" + n = 10 + vector_length = 8 + runner = CliRunner() + with runner.local_repo( + get_example_repo("example_feature_repo_1.py"), "file" + ) as store: + store.config.online_store.vector_enabled = True + store.config.online_store.vector_len = vector_length + store.config.entity_key_serialization_version = 3 + document_embeddings_fv = store.get_feature_view(name="document_embeddings") + + provider = store._get_provider() + + # Create test data + item_keys = [ + EntityKeyProto( + join_keys=["item_id"], entity_values=[ValueProto(int64_val=i)] + ) + for i in range(n) + ] + data = [] + for i, item_key in enumerate(item_keys): + data.append( + ( + item_key, + { + "Embeddings": ValueProto( + float_list_val=FloatListProto( + val=[float(x) for x in np.random.random(vector_length)] + ) + ), + "content": ValueProto( + string_val=f"the {i}th sentence with some text" + ), + "title": ValueProto(string_val=f"Title {i}"), + }, + _utc_now(), + _utc_now(), + ) + ) + + provider.online_write_batch( + config=store.config, + table=document_embeddings_fv, + data=data, + progress=None, + ) + + # Test vector similarity search + query_embedding = [float(x) for x in np.random.random(vector_length)] + result = store.retrieve_online_documents_v2( + features=[ + "document_embeddings:Embeddings", + "document_embeddings:content", + "document_embeddings:title", + ], + query=query_embedding, + top_k=3, + ).to_dict() + + assert "Embeddings" in result + assert "content" in result + assert "title" in result + assert "distance" in result + assert ["1th sentence with some text" in r for r in result["content"]] + assert ["Title " in r for r in result["title"]] + assert len(result["distance"]) == 3 + + +def test_sqlite_get_online_documents_v2_search() -> None: + """Test retrieving documents using v2 method with key word search""" + n = 10 + vector_length = 8 + runner = CliRunner() + with runner.local_repo( + get_example_repo("example_feature_repo_1.py"), "file" + ) as store: + store.config.online_store.text_search_enabled = True + store.config.entity_key_serialization_version = 3 + document_embeddings_fv = store.get_feature_view(name="document_embeddings") + + provider = store._get_provider() + + # Create test data + item_keys = [ + EntityKeyProto( + join_keys=["item_id"], entity_values=[ValueProto(int64_val=i)] + ) + for i in range(n) + ] + data = [] + for i, item_key in enumerate(item_keys): + data.append( + ( + item_key, + { + "Embeddings": ValueProto( + float_list_val=FloatListProto( + val=[float(x) for x in np.random.random(vector_length)] + ) + ), + "content": ValueProto( + string_val=f"the {i}th sentence with some text" + ), + "title": ValueProto(string_val=f"Title {i}"), + }, + _utc_now(), + _utc_now(), + ) + ) + + provider.online_write_batch( + config=store.config, + table=document_embeddings_fv, + data=data, + progress=None, + ) + + # Test vector similarity search + # query_embedding = [float(x) for x in np.random.random(vector_length)] + result = store.retrieve_online_documents_v2( + features=[ + "document_embeddings:Embeddings", + "document_embeddings:content", + "document_embeddings:title", + ], + query_string="(content: 5) OR (title: 1) OR (title: 3)", + top_k=3, + ).to_dict() + + assert "Embeddings" in result + assert "content" in result + assert "title" in result + assert "distance" in result + assert ["1th sentence with some text" in r for r in result["content"]] + assert ["Title " in r for r in result["title"]] + assert len(result["distance"]) == 2 + assert result["distance"] == [-1.8458267450332642, -1.8458267450332642] + + +@pytest.mark.skip(reason="Skipping this test as CI struggles with it") +def test_local_milvus() -> None: + import random + + from pymilvus import MilvusClient + + random.seed(42) + VECTOR_LENGTH: int = 768 + COLLECTION_NAME: str = "test_demo_collection" + + client = MilvusClient("./milvus_demo.db") + + for collection in client.list_collections(): + client.drop_collection(collection_name=collection) + client.create_collection( + collection_name=COLLECTION_NAME, + dimension=VECTOR_LENGTH, + ) + assert client.list_collections() == [COLLECTION_NAME] + + docs = [ + "Artificial intelligence was founded as an academic discipline in 1956.", + "Alan Turing was the first person to conduct substantial research in AI.", + "Born in Maida Vale, London, Turing was raised in southern England.", + ] + # Use fake representation with random vectors (vector_length dimension). + vectors = [[random.uniform(-1, 1) for _ in range(VECTOR_LENGTH)] for _ in docs] + data = [ + {"id": i, "vector": vectors[i], "text": docs[i], "subject": "history"} + for i in range(len(vectors)) + ] + + print("Data has", len(data), "entities, each with fields: ", data[0].keys()) + print("Vector dim:", len(data[0]["vector"])) + + insert_res = client.insert(collection_name=COLLECTION_NAME, data=data) + assert insert_res == {"insert_count": 3, "ids": [0, 1, 2], "cost": 0} + + query_vectors = [[random.uniform(-1, 1) for _ in range(VECTOR_LENGTH)]] + + search_res = client.search( + collection_name=COLLECTION_NAME, # target collection + data=query_vectors, # query vectors + limit=2, # number of returned entities + output_fields=["text", "subject"], # specifies fields to be returned + ) + assert [j["id"] for j in search_res[0]] == [0, 1] + query_result = client.query( + collection_name=COLLECTION_NAME, + filter="id == 0", + ) + assert list(query_result[0].keys()) == ["id", "text", "subject", "vector"] + + client.drop_collection(collection_name=COLLECTION_NAME) + + +def test_milvus_lite_get_online_documents_v2() -> None: + """ + Test retrieving documents from the online store in local mode. + """ + + random.seed(42) + n = 10 # number of samples - note: we'll actually double it + vector_length = 10 + runner = CliRunner() + with runner.local_repo( + example_repo_py=get_example_repo("example_rag_feature_repo.py"), + offline_store="file", + online_store="milvus", + apply=False, + teardown=False, + ) as store: + from datetime import timedelta + + from feast import Entity, FeatureView, Field, FileSource + from feast.types import Array, Float32, Int64, String, UnixTimestamp + + # This is for Milvus + # Note that file source paths are not validated, so there doesn't actually need to be any data + # at the paths for these file sources. Since these paths are effectively fake, this example + # feature repo should not be used for historical retrieval. + + rag_documents_source = FileSource( + path="data/embedded_documents.parquet", + timestamp_field="event_timestamp", + created_timestamp_column="created_timestamp", + ) + + item = Entity( + name="item_id", # The name is derived from this argument, not object name. + join_keys=["item_id"], + value_type=ValueType.INT64, + ) + author = Entity( + name="author_id", + join_keys=["author_id"], + value_type=ValueType.STRING, + ) + + document_embeddings = FeatureView( + name="embedded_documents", + entities=[item, author], + schema=[ + Field( + name="vector", + dtype=Array(Float32), + vector_index=True, + vector_search_metric="COSINE", + ), + Field(name="item_id", dtype=Int64), + Field(name="author_id", dtype=String), + Field(name="created_timestamp", dtype=UnixTimestamp), + Field(name="sentence_chunks", dtype=String), + Field(name="event_timestamp", dtype=UnixTimestamp), + ], + source=rag_documents_source, + ttl=timedelta(hours=24), + ) + + store.apply([rag_documents_source, item, document_embeddings]) + + # Write some data to two tables + document_embeddings_fv = store.get_feature_view(name="embedded_documents") + + provider = store._get_provider() + + item_keys = [ + EntityKeyProto( + join_keys=["item_id", "author_id"], + entity_values=[ + ValueProto(int64_val=i), + ValueProto(string_val=f"author_{i}"), + ], + ) + for i in range(n) + ] + data = [] + for i, item_key in enumerate(item_keys): + data.append( + ( + item_key, + { + "vector": ValueProto( + float_list_val=FloatListProto( + val=np.random.random( + vector_length, + ) + + i + ) + ), + "sentence_chunks": ValueProto(string_val=f"sentence chunk {i}"), + }, + _utc_now(), + _utc_now(), + ) + ) + + provider.online_write_batch( + config=store.config, + table=document_embeddings_fv, + data=data, + progress=None, + ) + documents_df = pd.DataFrame( + { + "item_id": [str(i) for i in range(n)], + "author_id": [f"author_{i}" for i in range(n)], + "vector": [ + np.random.random( + vector_length, + ) + + i + for i in range(n) + ], + "sentence_chunks": [f"sentence chunk {i}" for i in range(n)], + "event_timestamp": [_utc_now() for _ in range(n)], + "created_timestamp": [_utc_now() for _ in range(n)], + } + ) + + store.write_to_online_store( + feature_view_name="embedded_documents", + df=documents_df, + ) + + query_embedding = np.random.random( + vector_length, + ) + + client = store._provider._online_store.client + collection_name = client.list_collections()[0] + search_params = { + "metric_type": "COSINE", + "params": {"nprobe": 10}, + } + + results = client.search( + collection_name=collection_name, + data=[query_embedding], + anns_field="vector", + search_params=search_params, + limit=3, + output_fields=[ + "item_id", + "author_id", + "sentence_chunks", + "created_ts", + "event_ts", + ], + ) + result = store.retrieve_online_documents_v2( + features=[ + "embedded_documents:vector", + "embedded_documents:item_id", + "embedded_documents:author_id", + "embedded_documents:sentence_chunks", + ], + query=query_embedding, + top_k=3, + ).to_dict() + + for k in ["vector", "item_id", "author_id", "sentence_chunks", "distance"]: + assert k in result, f"Missing {k} in retrieve_online_documents response" + assert len(result["distance"]) == len(results[0]) + + +def test_milvus_native_from_feast_data() -> None: + import random + from datetime import datetime + + import numpy as np + from pymilvus import MilvusClient + + random.seed(42) + VECTOR_LENGTH = 10 # Matches vector_length from the Feast example + COLLECTION_NAME = "embedded_documents" + + # Initialize Milvus client with local setup + client = MilvusClient("./milvus_demo.db") + + # Clear and recreate collection + for collection in client.list_collections(): + client.drop_collection(collection_name=collection) + client.create_collection( + collection_name=COLLECTION_NAME, + dimension=VECTOR_LENGTH, + metric_type="COSINE", # Matches Feast's vector_search_metric + ) + assert client.list_collections() == [COLLECTION_NAME] + + # Prepare data for insertion, similar to the Feast example + n = 10 # Number of items + data = [] + for i in range(n): + vector = (np.random.random(VECTOR_LENGTH) + i).tolist() + data.append( + { + "id": i, + "vector": vector, + "item_id": i, + "author_id": f"author_{i}", + "sentence_chunks": f"sentence chunk {i}", + "event_timestamp": datetime.utcnow().isoformat(), + "created_timestamp": datetime.utcnow().isoformat(), + } + ) + + print("Data has", len(data), "entities, each with fields:", data[0].keys()) + + # Insert data into Milvus + insert_res = client.insert(collection_name=COLLECTION_NAME, data=data) + assert insert_res == {"insert_count": n, "ids": list(range(n)), "cost": 0} + + # Perform a vector search using a random query embedding + query_embedding = (np.random.random(VECTOR_LENGTH)).tolist() + search_res = client.search( + collection_name=COLLECTION_NAME, + data=[query_embedding], + limit=5, # Top 3 results + output_fields=["item_id", "author_id", "sentence_chunks"], + ) + + # Validate the search results + assert len(search_res[0]) == 5 + print("Search Results:", search_res[0]) + + # Clean up the collection + client.drop_collection(collection_name=COLLECTION_NAME) diff --git a/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py b/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py index 25c5fe3eb8c..0395f995410 100644 --- a/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py +++ b/sdk/python/tests/unit/permissions/auth/server/test_auth_registry_server.py @@ -44,7 +44,7 @@ def start_registry_server( assertpy.assert_that(server_port).is_not_equal_to(0) - is_tls_mode, tls_key_path, tls_cert_path = tls_mode + is_tls_mode, tls_key_path, tls_cert_path, tls_ca_file_path = tls_mode if is_tls_mode: print(f"Starting Registry in TLS mode at {server_port}") server = start_server( @@ -74,6 +74,9 @@ def start_registry_server( server.stop(grace=None) # Teardown server +@pytest.mark.parametrize( + "tls_mode", [("True", "True"), ("True", "False"), ("False", "")], indirect=True +) def test_registry_apis( auth_config, tls_mode, diff --git a/sdk/python/tests/unit/permissions/test_oidc_auth_client.py b/sdk/python/tests/unit/permissions/test_oidc_auth_client.py index 68aec70fc79..3d74eb2a55f 100644 --- a/sdk/python/tests/unit/permissions/test_oidc_auth_client.py +++ b/sdk/python/tests/unit/permissions/test_oidc_auth_client.py @@ -58,6 +58,6 @@ def _assert_auth_requests_session( "Authorization header is missing in object of class: " "AuthenticatedRequestsSession " ) - assert ( - auth_req_session.headers["Authorization"] == f"Bearer {expected_token}" - ), "Authorization token is incorrect" + assert auth_req_session.headers["Authorization"] == f"Bearer {expected_token}", ( + "Authorization token is incorrect" + ) diff --git a/sdk/python/tests/unit/test_entity.py b/sdk/python/tests/unit/test_entity.py index 78f71231049..b36f363a6ff 100644 --- a/sdk/python/tests/unit/test_entity.py +++ b/sdk/python/tests/unit/test_entity.py @@ -11,6 +11,8 @@ # 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. +import warnings + import assertpy import pytest @@ -73,3 +75,16 @@ def test_hash(): s4 = {entity1, entity2, entity3, entity4} assert len(s4) == 3 + + +def test_entity_without_value_type_warns(): + with pytest.warns(DeprecationWarning, match="Entity value_type will be mandatory"): + entity = Entity(name="my-entity") + assert entity.value_type == ValueType.UNKNOWN + + +def test_entity_with_value_type_no_warning(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + entity = Entity(name="my-entity", value_type=ValueType.STRING) + assert entity.value_type == ValueType.STRING diff --git a/sdk/python/tests/unit/test_feature_views.py b/sdk/python/tests/unit/test_feature_views.py index ce789c706c5..911c94ff34c 100644 --- a/sdk/python/tests/unit/test_feature_views.py +++ b/sdk/python/tests/unit/test_feature_views.py @@ -36,6 +36,8 @@ def test_create_batch_feature_view(): entities=[], ttl=timedelta(days=30), source=batch_source, + mode="python", + udf=lambda x: x, ) with pytest.raises(TypeError): @@ -54,6 +56,8 @@ def test_create_batch_feature_view(): with pytest.raises(ValueError): BatchFeatureView( name="test batch feature view", + mode="python", + udf=lambda x: x, entities=[], ttl=timedelta(days=30), source=stream_source, diff --git a/sdk/python/tests/unit/test_on_demand_feature_view.py b/sdk/python/tests/unit/test_on_demand_feature_view.py index 4b30bd6be99..505146aa612 100644 --- a/sdk/python/tests/unit/test_on_demand_feature_view.py +++ b/sdk/python/tests/unit/test_on_demand_feature_view.py @@ -24,6 +24,7 @@ OnDemandFeatureView, PandasTransformation, PythonTransformation, + on_demand_feature_view, ) from feast.types import Float32 @@ -148,7 +149,7 @@ def test_hash(): assert len(s4) == 3 assert on_demand_feature_view_5.feature_transformation == PandasTransformation( - udf2, "udf2 source code" + udf2, udf_string="udf2 source code" ) @@ -179,26 +180,25 @@ def test_python_native_transformation_mode(): mode="python", ) - on_demand_feature_view_python_native_err = OnDemandFeatureView( - name="my-on-demand-feature-view", - sources=sources, - schema=[ - Field(name="output1", dtype=Float32), - Field(name="output2", dtype=Float32), - ], - feature_transformation=PandasTransformation( - udf=python_native_udf, udf_string="python native udf source code" - ), - description="test", - mode="python", - ) - assert ( on_demand_feature_view_python_native.feature_transformation == PythonTransformation(python_native_udf, "python native udf source code") ) with pytest.raises(TypeError): + on_demand_feature_view_python_native_err = OnDemandFeatureView( + name="my-on-demand-feature-view", + sources=sources, + schema=[ + Field(name="output1", dtype=Float32), + Field(name="output2", dtype=Float32), + ], + feature_transformation=PandasTransformation( + udf=python_native_udf, udf_string="python native udf source code" + ), + description="test", + mode="python", + ) assert ( on_demand_feature_view_python_native_err.feature_transformation == PythonTransformation(python_native_udf, "python native udf source code") @@ -356,3 +356,65 @@ def test_on_demand_feature_view_stored_writes(): assert transformed_output["output3"] is not None and isinstance( transformed_output["output3"], datetime.datetime ) + + +def test_function_call_syntax(): + CUSTOM_FUNCTION_NAME = "custom-function-name" + file_source = FileSource(name="my-file-source", path="test.parquet") + feature_view = FeatureView( + name="my-feature-view", + entities=[], + schema=[ + Field(name="feature1", dtype=Float32), + Field(name="feature2", dtype=Float32), + ], + source=file_source, + ) + sources = [feature_view] + + def transform_features(features_df: pd.DataFrame) -> pd.DataFrame: + df = pd.DataFrame() + df["output1"] = features_df["feature1"] + df["output2"] = features_df["feature2"] + return df + + odfv = on_demand_feature_view( + sources=sources, + schema=[ + Field(name="output1", dtype=Float32), + Field(name="output2", dtype=Float32), + ], + )(transform_features) + + assert odfv.name == transform_features.__name__ + assert isinstance(odfv, OnDemandFeatureView) + + proto = odfv.to_proto() + assert proto.spec.name == transform_features.__name__ + + deserialized = OnDemandFeatureView.from_proto(proto) + assert deserialized.name == transform_features.__name__ + + def another_transform(features_df: pd.DataFrame) -> pd.DataFrame: + df = pd.DataFrame() + df["output1"] = features_df["feature1"] + df["output2"] = features_df["feature2"] + return df + + odfv_custom = on_demand_feature_view( + name=CUSTOM_FUNCTION_NAME, + sources=sources, + schema=[ + Field(name="output1", dtype=Float32), + Field(name="output2", dtype=Float32), + ], + )(another_transform) + + assert odfv_custom.name == CUSTOM_FUNCTION_NAME + assert isinstance(odfv_custom, OnDemandFeatureView) + + proto = odfv_custom.to_proto() + assert proto.spec.name == CUSTOM_FUNCTION_NAME + + deserialized = OnDemandFeatureView.from_proto(proto) + assert deserialized.name == CUSTOM_FUNCTION_NAME diff --git a/sdk/python/tests/unit/test_on_demand_python_transformation.py b/sdk/python/tests/unit/test_on_demand_python_transformation.py index a0c33fadfda..7ae9f1c70e6 100644 --- a/sdk/python/tests/unit/test_on_demand_python_transformation.py +++ b/sdk/python/tests/unit/test_on_demand_python_transformation.py @@ -1,5 +1,7 @@ import os import re +import sqlite3 +import sys import tempfile import unittest from datetime import datetime, timedelta @@ -20,10 +22,12 @@ from feast.feature_view import DUMMY_ENTITY_FIELD from feast.field import Field from feast.infra.online_stores.sqlite import SqliteOnlineStoreConfig +from feast.nlp_test_data import create_document_chunks_df from feast.on_demand_feature_view import on_demand_feature_view from feast.types import ( Array, Bool, + Bytes, Float32, Float64, Int64, @@ -161,7 +165,11 @@ def python_demo_view(inputs: dict[str, Any]) -> dict[str, Any]: @on_demand_feature_view( sources=[driver_stats_fv[["conv_rate", "acc_rate"]]], schema=[ - Field(name="conv_rate_plus_acc_python_singleton", dtype=Float64) + Field(name="conv_rate_plus_acc_python_singleton", dtype=Float64), + Field( + name="conv_rate_plus_acc_python_singleton_array", + dtype=Array(Float64), + ), ], mode="python", singleton=True, @@ -171,6 +179,7 @@ def python_singleton_view(inputs: dict[str, Any]) -> dict[str, Any]: output["conv_rate_plus_acc_python_singleton"] = ( inputs["conv_rate"] + inputs["acc_rate"] ) + output["conv_rate_plus_acc_python_singleton_array"] = [0.1, 0.2, 0.3] return output @on_demand_feature_view( @@ -852,6 +861,9 @@ def test_stored_writes(self): assert driver_stats_fv.entities == [driver.name] assert driver_stats_fv.entity_columns == [] + ODFV_STRING_CONSTANT = "guaranteed constant" + ODFV_OTHER_STRING_CONSTANT = "somethign else" + @on_demand_feature_view( entities=[driver], sources=[ @@ -863,6 +875,7 @@ def test_stored_writes(self): Field(name="current_datetime", dtype=UnixTimestamp), Field(name="counter", dtype=Int64), Field(name="input_datetime", dtype=UnixTimestamp), + Field(name="string_constant", dtype=String), ], mode="python", write_to_online_store=True, @@ -880,6 +893,7 @@ def python_stored_writes_feature_view( "current_datetime": [datetime.now() for _ in inputs["conv_rate"]], "counter": [c + 1 for c in inputs["counter"]], "input_datetime": [d for d in inputs["input_datetime"]], + "string_constant": [ODFV_STRING_CONSTANT], } return output @@ -933,30 +947,13 @@ def python_stored_writes_feature_view( "created": current_datetime, } ] - odfv_entity_rows_to_write = [ - { - "driver_id": 1001, - "counter": 0, - "input_datetime": current_datetime, - } - ] fv_entity_rows_to_read = [ { "driver_id": 1001, } ] - # Note that here we shouldn't have to pass the request source features for reading - # because they should have already been written to the online store - odfv_entity_rows_to_read = [ - { - "driver_id": 1001, - "conv_rate": 0.25, - "acc_rate": 0.50, - "counter": 0, - "input_datetime": current_datetime, - } - ] - print("storing fv features") + print("") + print("storing FV features") self.store.write_to_online_store( feature_view_name="driver_hourly_stats", df=fv_entity_rows_to_write, @@ -978,11 +975,58 @@ def python_stored_writes_feature_view( "acc_rate": [0.25], } - print("storing odfv features") + # Note that here we shouldn't have to pass the request source features for reading + # because they should have already been written to the online store + odfv_entity_rows_to_write = [ + { + "driver_id": 1002, + "counter": 0, + "conv_rate": 0.25, + "acc_rate": 0.50, + "input_datetime": current_datetime, + "string_constant": ODFV_OTHER_STRING_CONSTANT, + } + ] + odfv_entity_rows_to_read = [ + { + "driver_id": 1002, + "conv_rate_plus_acc": 7, # note how this is not the correct value and would be calculate on demand + "conv_rate": 0.25, + "acc_rate": 0.50, + "counter": 0, + "input_datetime": current_datetime, + "string_constant": ODFV_STRING_CONSTANT, + } + ] + print("storing ODFV features") self.store.write_to_online_store( feature_view_name="python_stored_writes_feature_view", df=odfv_entity_rows_to_write, ) + _conn = sqlite3.connect(self.store.config.online_store.path) + _table_name = ( + self.store.project + + "_" + + self.store.get_on_demand_feature_view( + "python_stored_writes_feature_view" + ).name + ) + sample = pd.read_sql( + f""" + select + feature_name, + value + from {_table_name} + """, + _conn, + ) + assert ( + sample[sample["feature_name"] == "string_constant"]["value"] + .astype(str) + .str.contains("guaranteed constant") + .values[0] + ) + print("reading odfv features") online_odfv_python_response = self.store.get_online_features( entity_rows=odfv_entity_rows_to_read, @@ -991,6 +1035,7 @@ def python_stored_writes_feature_view( "python_stored_writes_feature_view:current_datetime", "python_stored_writes_feature_view:counter", "python_stored_writes_feature_view:input_datetime", + "python_stored_writes_feature_view:string_constant", ], ).to_dict() print(online_odfv_python_response) @@ -1001,5 +1046,248 @@ def python_stored_writes_feature_view( "counter", "current_datetime", "input_datetime", + "string_constant", + ] + ) + # This should be 1 because we write the value of 0 and during the write, the counter is incremented + assert online_odfv_python_response["counter"] == [1] + assert online_odfv_python_response["string_constant"] == [ + ODFV_STRING_CONSTANT + ] + assert online_odfv_python_response["string_constant"] != [ + ODFV_OTHER_STRING_CONSTANT + ] + + def test_stored_writes_with_explode(self): + with tempfile.TemporaryDirectory() as data_dir: + self.store = FeatureStore( + config=RepoConfig( + project="test_on_demand_python_transformation_explode", + registry=os.path.join(data_dir, "registry.db"), + provider="local", + entity_key_serialization_version=3, + online_store=SqliteOnlineStoreConfig( + path=os.path.join(data_dir, "online.db"), + vector_enabled=True, + vector_len=5, + ), + ) + ) + + documents = { + "doc_1": "Hello world. How are you?", + "doc_2": "This is a test. Document chunking example.", + } + start_date = datetime.now() - timedelta(days=15) + end_date = datetime.now() + + documents_df = create_document_chunks_df( + documents, + start_date, + end_date, + embedding_size=60, + ) + corpus_path = os.path.join(data_dir, "documents.parquet") + documents_df.to_parquet(path=corpus_path, allow_truncated_timestamps=True) + + chunk = Entity( + name="chunk", join_keys=["chunk_id"], value_type=ValueType.STRING + ) + document = Entity( + name="document", join_keys=["document_id"], value_type=ValueType.STRING + ) + + input_explode_request_source = RequestSource( + name="counter_source", + schema=[ + Field(name="document_id", dtype=String), + Field(name="document_text", dtype=String), + Field(name="document_bytes", dtype=Bytes), + ], + ) + + @on_demand_feature_view( + entities=[chunk, document], + sources=[ + input_explode_request_source, + ], + schema=[ + Field(name="document_id", dtype=String), + Field(name="chunk_id", dtype=String), + Field(name="chunk_text", dtype=String), + Field( + name="vector", + dtype=Array(Float32), + vector_index=True, + vector_search_metric="L2", + ), + ], + mode="python", + write_to_online_store=True, + ) + def python_stored_writes_feature_view_explode_singleton( + inputs: dict[str, Any], + ): + output: dict[str, Any] = { + "document_id": ["doc_1", "doc_1", "doc_2", "doc_2"], + "chunk_id": ["chunk-1", "chunk-2", "chunk-1", "chunk-2"], + "chunk_text": [ + "hello friends", + "how are you?", + "This is a test.", + "Document chunking example.", + ], + "vector": [ + [0.1] * 5, + [0.2] * 5, + [0.3] * 5, + [0.4] * 5, + ], + } + return output + + assert python_stored_writes_feature_view_explode_singleton.entities == [ + chunk.name, + document.name, + ] + assert ( + python_stored_writes_feature_view_explode_singleton.entity_columns[ + 0 + ].name + == document.join_key + ) + assert ( + python_stored_writes_feature_view_explode_singleton.entity_columns[ + 1 + ].name + == chunk.join_key + ) + + self.store.apply( + [ + chunk, + document, + input_explode_request_source, + python_stored_writes_feature_view_explode_singleton, + ] + ) + odfv_applied = self.store.get_on_demand_feature_view( + "python_stored_writes_feature_view_explode_singleton" + ) + + assert odfv_applied.features[1].vector_index + + assert odfv_applied.entities == [chunk.name, document.name] + + # Note here that after apply() is called, the entity_columns are populated with the join_key + assert odfv_applied.entity_columns[1].name == chunk.join_key + assert odfv_applied.entity_columns[0].name == document.join_key + + assert len(self.store.list_all_feature_views()) == 1 + assert len(self.store.list_feature_views()) == 0 + assert len(self.store.list_on_demand_feature_views()) == 1 + assert len(self.store.list_stream_feature_views()) == 0 + assert ( + python_stored_writes_feature_view_explode_singleton.entity_columns + == self.store.get_on_demand_feature_view( + "python_stored_writes_feature_view_explode_singleton" + ).entity_columns + ) + + odfv_entity_rows_to_write = [ + { + "document_id": "document_1", + "document_text": "Hello world. How are you?", + }, + { + "document_id": "document_2", + "document_text": "This is a test. Document chunking example.", + }, + ] + fv_entity_rows_to_read = [ + { + "document_id": "doc_1", + "chunk_id": "chunk-2", + }, + { + "document_id": "doc_2", + "chunk_id": "chunk-1", + }, + ] + + self.store.write_to_online_store( + feature_view_name="python_stored_writes_feature_view_explode_singleton", + df=odfv_entity_rows_to_write, + ) + _table_name = ( + self.store.project + + "_" + + self.store.get_on_demand_feature_view( + "python_stored_writes_feature_view_explode_singleton" + ).name + ) + _conn = sqlite3.connect(self.store.config.online_store.path) + sample = pd.read_sql( + f""" + select + entity_key, + feature_name, + value + from {_table_name} + """, + _conn, + ) + print(f"\nsample from {_table_name}:\n{sample}") + + # verifying we retrieve doc_1 chunk-2 + filt = (sample["feature_name"] == "chunk_text") & ( + sample["value"] + .apply(lambda x: x.decode("latin1")) + .str.contains("how are") + ) + assert ( + sample[filt]["entity_key"].astype(str).str.contains("doc_1") + & sample[filt]["entity_key"].astype(str).str.contains("chunk-2") + ).values[0] + + print("reading fv features") + online_python_response = self.store.get_online_features( + entity_rows=fv_entity_rows_to_read, + features=[ + "python_stored_writes_feature_view_explode_singleton:document_id", + "python_stored_writes_feature_view_explode_singleton:chunk_id", + "python_stored_writes_feature_view_explode_singleton:chunk_text", + ], + ).to_dict() + assert sorted(list(online_python_response.keys())) == sorted( + [ + "chunk_id", + "chunk_text", + "document_id", ] ) + assert online_python_response == { + "document_id": ["doc_1", "doc_2"], + "chunk_id": ["chunk-2", "chunk-1"], + "chunk_text": ["how are you?", "This is a test."], + } + + if sys.version_info[0:2] == (3, 10): + query_embedding = [0.05] * 5 + online_python_vec_response = self.store.retrieve_online_documents_v2( + features=[ + "python_stored_writes_feature_view_explode_singleton:document_id", + "python_stored_writes_feature_view_explode_singleton:chunk_id", + "python_stored_writes_feature_view_explode_singleton:chunk_text", + ], + query=query_embedding, + top_k=2, + ).to_dict() + + assert online_python_vec_response is not None + assert online_python_vec_response == { + "document_id": ["doc_1", "doc_1"], + "chunk_id": ["chunk-1", "chunk-2"], + "chunk_text": ["hello friends", "how are you?"], + "distance": [0.11180340498685837, 0.3354102075099945], + } diff --git a/sdk/python/tests/unit/test_repo_operations_validate_feast_project_name.py b/sdk/python/tests/unit/test_repo_operations_validate_feast_project_name.py index 0dc4b2651b0..33d1d5307d6 100644 --- a/sdk/python/tests/unit/test_repo_operations_validate_feast_project_name.py +++ b/sdk/python/tests/unit/test_repo_operations_validate_feast_project_name.py @@ -21,6 +21,6 @@ def test_is_valid_name(): ] for name, expected in test_cases: - assert ( - is_valid_name(name) == expected - ), f"Failed for project invalid name: {name}" + assert is_valid_name(name) == expected, ( + f"Failed for project invalid name: {name}" + ) diff --git a/sdk/python/tests/unit/test_stream_feature_view.py b/sdk/python/tests/unit/test_stream_feature_view.py index 4f93691028e..96e62d9d9e2 100644 --- a/sdk/python/tests/unit/test_stream_feature_view.py +++ b/sdk/python/tests/unit/test_stream_feature_view.py @@ -4,7 +4,6 @@ import pytest from feast.aggregation import Aggregation -from feast.batch_feature_view import BatchFeatureView from feast.data_format import AvroFormat from feast.data_source import KafkaSource, PushSource from feast.entity import Entity @@ -18,37 +17,6 @@ from feast.utils import _utc_now, make_tzaware -def test_create_batch_feature_view(): - batch_source = FileSource(path="some path") - BatchFeatureView( - name="test batch feature view", - entities=[], - ttl=timedelta(days=30), - source=batch_source, - ) - - with pytest.raises(TypeError): - BatchFeatureView( - name="test batch feature view", entities=[], ttl=timedelta(days=30) - ) - - stream_source = KafkaSource( - name="kafka", - timestamp_field="event_timestamp", - kafka_bootstrap_servers="", - message_format=AvroFormat(""), - topic="topic", - batch_source=FileSource(path="some path"), - ) - with pytest.raises(ValueError): - BatchFeatureView( - name="test batch feature view", - entities=[], - ttl=timedelta(days=30), - source=stream_source, - ) - - def test_create_stream_feature_view(): stream_source = KafkaSource( name="kafka", @@ -64,6 +32,7 @@ def test_create_stream_feature_view(): ttl=timedelta(days=30), source=stream_source, aggregations=[], + udf=lambda x: x, ) push_source = PushSource( @@ -75,6 +44,7 @@ def test_create_stream_feature_view(): ttl=timedelta(days=30), source=push_source, aggregations=[], + udf=lambda x: x, ) with pytest.raises(TypeError): @@ -92,6 +62,7 @@ def test_create_stream_feature_view(): ttl=timedelta(days=30), source=FileSource(path="some path"), aggregations=[], + udf=lambda x: x, ) @@ -173,7 +144,7 @@ def pandas_udf(pandas_df): import pandas as pd assert type(pandas_df) == pd.DataFrame - df = pandas_df.transform(lambda x: x + 10, axis=1) + df = pandas_df.transform(lambda x: x + 10) return df import pandas as pd @@ -230,6 +201,7 @@ def test_stream_feature_view_proto_type(): ttl=timedelta(days=30), source=stream_source, aggregations=[], + udf=lambda x: x, ) assert sfv.proto_class is StreamFeatureViewProto @@ -249,6 +221,7 @@ def test_stream_feature_view_copy(): ttl=timedelta(days=30), source=stream_source, aggregations=[], + udf=lambda x: x, ) assert sfv == copy.copy(sfv) diff --git a/sdk/python/tests/unit/test_unit_feature_store.py b/sdk/python/tests/unit/test_unit_feature_store.py index 19a133564f2..3bad0ec6c59 100644 --- a/sdk/python/tests/unit/test_unit_feature_store.py +++ b/sdk/python/tests/unit/test_unit_feature_store.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Dict, List +import pytest + from feast import utils from feast.protos.feast.types.Value_pb2 import Value @@ -17,7 +19,7 @@ class MockFeatureView: projection: MockFeatureViewProjection -def test_get_unique_entities(): +def test_get_unique_entities_success(): entity_values = { "entity_1": [Value(int64_val=1), Value(int64_val=2), Value(int64_val=1)], "entity_2": [ @@ -36,14 +38,94 @@ def test_get_unique_entities(): projection=MockFeatureViewProjection(join_key_map={}), ) - unique_entities, indexes = utils._get_unique_entities( + unique_entities, indexes, output_len = utils._get_unique_entities( table=fv, join_key_values=entity_values, entity_name_to_join_key_map=entity_name_to_join_key_map, ) - - assert unique_entities == ( + expected_entities = ( {"entity_1": Value(int64_val=1), "entity_2": Value(string_val="1")}, {"entity_1": Value(int64_val=2), "entity_2": Value(string_val="2")}, ) - assert indexes == ([0, 2], [1]) + expected_indexes = ([0, 2], [1]) + + assert unique_entities == expected_entities + assert indexes == expected_indexes + assert output_len == 3 + + +def test_get_unique_entities_missing_join_key_success(): + """ + Tests that _get_unique_entities raises a KeyError when a required join key is missing. + """ + # Here, we omit the required key for "entity_1" + entity_values = { + "entity_2": [ + Value(string_val="1"), + Value(string_val="2"), + Value(string_val="1"), + ], + } + + entity_name_to_join_key_map = {"entity_1": "entity_1", "entity_2": "entity_2"} + + fv = MockFeatureView( + name="fv_1", + entities=["entity_1", "entity_2"], + projection=MockFeatureViewProjection(join_key_map={}), + ) + + unique_entities, indexes, output_len = utils._get_unique_entities( + table=fv, + join_key_values=entity_values, + entity_name_to_join_key_map=entity_name_to_join_key_map, + ) + expected_entities = ( + {"entity_2": Value(string_val="1")}, + {"entity_2": Value(string_val="2")}, + ) + expected_indexes = ([0, 2], [1]) + + assert unique_entities == expected_entities + assert indexes == expected_indexes + assert output_len == 3 + # We're not say anything about the entity_1 missing from the unique_entities list + assert "entity_1" not in [entity.keys() for entity in unique_entities] + + +def test_get_unique_entities_missing_all_join_keys_error(): + """ + Tests that _get_unique_entities raises a KeyError when all required join keys are missing. + """ + entity_values_not_in_feature_view = { + "entity_3": [Value(string_val="3")], + } + entity_name_to_join_key_map = { + "entity_1": "entity_1", + "entity_2": "entity_2", + "entity_3": "entity_3", + } + + fv = MockFeatureView( + name="fv_1", + entities=["entity_1", "entity_2"], + projection=MockFeatureViewProjection(join_key_map={}), + ) + + with pytest.raises(KeyError) as excinfo: + utils._get_unique_entities( + table=fv, + join_key_values=entity_values_not_in_feature_view, + entity_name_to_join_key_map=entity_name_to_join_key_map, + ) + + error_message = str(excinfo.value) + assert ( + "Missing join key values for keys: ['entity_1', 'entity_2', 'entity_3']" + in error_message + ) + assert ( + "No values provided for keys: ['entity_1', 'entity_2', 'entity_3']" + in error_message + ) + assert "Provided join_key_values: ['entity_3']" in error_message diff --git a/sdk/python/tests/utils/auth_permissions_util.py b/sdk/python/tests/utils/auth_permissions_util.py index 6f0a3c8eeac..dcc456e1d82 100644 --- a/sdk/python/tests/utils/auth_permissions_util.py +++ b/sdk/python/tests/utils/auth_permissions_util.py @@ -60,6 +60,7 @@ def start_feature_server( metrics: bool = False, tls_key_path: str = "", tls_cert_path: str = "", + ca_trust_store_path: str = "", ): host = "0.0.0.0" cmd = [ @@ -100,9 +101,9 @@ def start_feature_server( timeout_msg="Unable to start the Prometheus server in 60 seconds.", ) else: - assert not check_port_open( - "localhost", 8000 - ), "Prometheus server is running when it should be disabled." + assert not check_port_open("localhost", 8000), ( + "Prometheus server is running when it should be disabled." + ) online_server_url = ( f"https://localhost:{server_port}" @@ -127,18 +128,30 @@ def start_feature_server( def get_remote_registry_store(server_port, feature_store, tls_mode): - is_tls_mode, _, tls_cert_path = tls_mode + is_tls_mode, _, tls_cert_path, ca_trust_store_path = tls_mode if is_tls_mode: - registry_config = RemoteRegistryConfig( - registry_type="remote", - path=f"localhost:{server_port}", - cert=tls_cert_path, - ) + if ca_trust_store_path: + registry_config = RemoteRegistryConfig( + registry_type="remote", + path=f"localhost:{server_port}", + is_tls=True, + ) + else: + registry_config = RemoteRegistryConfig( + registry_type="remote", + path=f"localhost:{server_port}", + is_tls=True, + cert=tls_cert_path, + ) else: registry_config = RemoteRegistryConfig( registry_type="remote", path=f"localhost:{server_port}" ) + if is_tls_mode and ca_trust_store_path: + # configure trust store path only when is_tls_mode and ca_trust_store_path exists. + os.environ["FEAST_CA_CERT_FILE_PATH"] = ca_trust_store_path + store = FeatureStore( config=RepoConfig( project=PROJECT_NAME, diff --git a/sdk/python/tests/utils/cli_repo_creator.py b/sdk/python/tests/utils/cli_repo_creator.py index 92b6dd992aa..46df563eafb 100644 --- a/sdk/python/tests/utils/cli_repo_creator.py +++ b/sdk/python/tests/utils/cli_repo_creator.py @@ -51,7 +51,14 @@ def run_with_output(self, args: List[str], cwd: Path) -> Tuple[int, bytes]: return e.returncode, e.output @contextmanager - def local_repo(self, example_repo_py: str, offline_store: str): + def local_repo( + self, + example_repo_py: str, + offline_store: str, + online_store: str = "sqlite", + apply=True, + teardown=True, + ): """ Convenience method to set up all the boilerplate for a local feature repo. """ @@ -59,46 +66,69 @@ def local_repo(self, example_repo_py: str, offline_store: str): random.choice(string.ascii_lowercase + string.digits) for _ in range(10) ) - with tempfile.TemporaryDirectory() as repo_dir_name, tempfile.TemporaryDirectory() as data_dir_name: + with ( + tempfile.TemporaryDirectory() as repo_dir_name, + tempfile.TemporaryDirectory() as data_dir_name, + ): repo_path = Path(repo_dir_name) data_path = Path(data_dir_name) repo_config = repo_path / "feature_store.yaml" - - repo_config.write_text( - dedent( + if online_store == "sqlite": + yaml_config = dedent( f""" - project: {project_id} - registry: {data_path / "registry.db"} - provider: local - online_store: - path: {data_path / "online_store.db"} - offline_store: - type: {offline_store} - entity_key_serialization_version: 2 - """ + project: {project_id} + registry: {data_path / "registry.db"} + provider: local + online_store: + path: {data_path / "online_store.db"} + offline_store: + type: {offline_store} + entity_key_serialization_version: 2 + """ ) - ) + elif online_store == "milvus": + yaml_config = dedent( + f""" + project: {project_id} + registry: {data_path / "registry.db"} + provider: local + online_store: + path: {data_path / "online_store.db"} + type: milvus + vector_enabled: true + embedding_dim: 10 + offline_store: + type: {offline_store} + entity_key_serialization_version: 3 + """ + ) + else: + pass + + repo_config.write_text(yaml_config) repo_example = repo_path / "example.py" repo_example.write_text(example_repo_py) - result = self.run(["apply"], cwd=repo_path) - stdout = result.stdout.decode("utf-8") - stderr = result.stderr.decode("utf-8") - print(f"Apply stdout:\n{stdout}") - print(f"Apply stderr:\n{stderr}") - assert ( - result.returncode == 0 - ), f"stdout: {result.stdout}\nstderr: {result.stderr}" + if apply: + result = self.run(["apply"], cwd=repo_path) + stdout = result.stdout.decode("utf-8") + stderr = result.stderr.decode("utf-8") + print(f"Apply stdout:\n{stdout}") + print(f"Apply stderr:\n{stderr}") + assert result.returncode == 0, ( + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) yield FeatureStore(repo_path=str(repo_path), config=None) - result = self.run(["teardown"], cwd=repo_path) - stdout = result.stdout.decode("utf-8") - stderr = result.stderr.decode("utf-8") - print(f"Apply stdout:\n{stdout}") - print(f"Apply stderr:\n{stderr}") - assert ( - result.returncode == 0 - ), f"stdout: {result.stdout}\nstderr: {result.stderr}" + if teardown: + result = self.run(["teardown"], cwd=repo_path) + stdout = result.stdout.decode("utf-8") + stderr = result.stderr.decode("utf-8") + print(f"Apply stdout:\n{stdout}") + print(f"Apply stderr:\n{stderr}") + assert result.returncode == 0, ( + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) diff --git a/sdk/python/tests/utils/e2e_test_validation.py b/sdk/python/tests/utils/e2e_test_validation.py index a08e8fef429..ed66aead87d 100644 --- a/sdk/python/tests/utils/e2e_test_validation.py +++ b/sdk/python/tests/utils/e2e_test_validation.py @@ -131,17 +131,17 @@ def _check_offline_and_online_features( if full_feature_names: if expected_value: assert response_dict[f"{fv.name}__value"][0], f"Response: {response_dict}" - assert ( - abs(response_dict[f"{fv.name}__value"][0] - expected_value) < 1e-6 - ), f"Response: {response_dict}, Expected: {expected_value}" + assert abs(response_dict[f"{fv.name}__value"][0] - expected_value) < 1e-6, ( + f"Response: {response_dict}, Expected: {expected_value}" + ) else: assert response_dict[f"{fv.name}__value"][0] is None else: if expected_value: assert response_dict["value"][0], f"Response: {response_dict}" - assert ( - abs(response_dict["value"][0] - expected_value) < 1e-6 - ), f"Response: {response_dict}, Expected: {expected_value}" + assert abs(response_dict["value"][0] - expected_value) < 1e-6, ( + f"Response: {response_dict}, Expected: {expected_value}" + ) else: assert response_dict["value"][0] is None diff --git a/sdk/python/tests/utils/generate_self_signed_certifcate_util.py b/sdk/python/tests/utils/generate_self_signed_certifcate_util.py deleted file mode 100644 index 1b0b212818c..00000000000 --- a/sdk/python/tests/utils/generate_self_signed_certifcate_util.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging -from datetime import datetime, timedelta - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID - -logger = logging.getLogger(__name__) - - -def generate_self_signed_cert( - cert_path="cert.pem", key_path="key.pem", common_name="localhost" -): - """ - Generate a self-signed certificate and save it to the specified paths. - - :param cert_path: Path to save the certificate (PEM format) - :param key_path: Path to save the private key (PEM format) - :param common_name: Common name (CN) for the certificate, defaults to 'localhost' - """ - # Generate private key - key = rsa.generate_private_key( - public_exponent=65537, key_size=2048, backend=default_backend() - ) - - # Create a self-signed certificate - subject = issuer = x509.Name( - [ - x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), - x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), - x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Feast"), - x509.NameAttribute(NameOID.COMMON_NAME, common_name), - ] - ) - - certificate = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.utcnow()) - .not_valid_after( - # Certificate valid for 1 year - datetime.utcnow() + timedelta(days=365) - ) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName(common_name)]), - critical=False, - ) - .sign(key, hashes.SHA256(), default_backend()) - ) - - # Write the private key to a file - with open(key_path, "wb") as f: - f.write( - key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - - # Write the certificate to a file - with open(cert_path, "wb") as f: - f.write(certificate.public_bytes(serialization.Encoding.PEM)) - - logger.info( - f"Self-signed certificate and private key have been generated at {cert_path} and {key_path}." - ) diff --git a/sdk/python/tests/utils/ssl_certifcates_util.py b/sdk/python/tests/utils/ssl_certifcates_util.py new file mode 100644 index 00000000000..53a56e04f3d --- /dev/null +++ b/sdk/python/tests/utils/ssl_certifcates_util.py @@ -0,0 +1,174 @@ +import ipaddress +import logging +import os +import shutil +from datetime import datetime, timedelta + +import certifi +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509 import load_pem_x509_certificate +from cryptography.x509.oid import NameOID + +logger = logging.getLogger(__name__) + + +def generate_self_signed_cert( + cert_path="cert.pem", key_path="key.pem", common_name="localhost" +): + """ + Generate a self-signed certificate and save it to the specified paths. + + :param cert_path: Path to save the certificate (PEM format) + :param key_path: Path to save the private key (PEM format) + :param common_name: Common name (CN) for the certificate, defaults to 'localhost' + """ + # Generate private key + key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + + # Create a self-signed certificate + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), + x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Feast"), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ] + ) + + # Define the certificate's Subject Alternative Names (SANs) + alt_names = [ + x509.DNSName("localhost"), # Hostname + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), # Localhost IP + x509.IPAddress(ipaddress.IPv4Address("0.0.0.0")), # Bind-all IP (optional) + ] + san = x509.SubjectAlternativeName(alt_names) + + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after( + # Certificate valid for 1 year + datetime.utcnow() + timedelta(days=365) + ) + .add_extension(san, critical=False) + .sign(key, hashes.SHA256(), default_backend()) + ) + + # Write the private key to a file + with open(key_path, "wb") as f: + f.write( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + # Write the certificate to a file + with open(cert_path, "wb") as f: + f.write(certificate.public_bytes(serialization.Encoding.PEM)) + + logger.info( + f"Self-signed certificate and private key have been generated at {cert_path} and {key_path}." + ) + + +def create_ca_trust_store( + public_key_path: str, private_key_path: str, output_trust_store_path: str +): + """ + Create a new CA trust store as a copy of the existing one (if available), + and add the provided public certificate to it. + + :param public_key_path: Path to the public certificate (e.g., PEM file). + :param private_key_path: Path to the private key (optional, to verify signing authority). + :param output_trust_store_path: Path to save the new trust store. + """ + try: + # Step 1: Identify the existing trust store (if available via environment variables) + existing_trust_store = os.environ.get("SSL_CERT_FILE") or os.environ.get( + "REQUESTS_CA_BUNDLE" + ) + + # Step 2: Copy the existing trust store to the new location (if it exists) + if existing_trust_store and os.path.exists(existing_trust_store): + shutil.copy(existing_trust_store, output_trust_store_path) + logger.info( + f"Copied existing trust store from {existing_trust_store} to {output_trust_store_path}" + ) + else: + # Log the creation of a new trust store (without opening a file unnecessarily) + logger.info( + f"No existing trust store found. Creating a new trust store at {output_trust_store_path}" + ) + + # Step 3: Load and validate the public certificate + with open(public_key_path, "rb") as pub_file: + public_cert_data = pub_file.read() + public_cert = load_pem_x509_certificate( + public_cert_data, backend=default_backend() + ) + + # Verify the private key matches (optional, adds validation) + if private_key_path: + with open(private_key_path, "rb") as priv_file: + private_key_data = priv_file.read() + private_key = serialization.load_pem_private_key( + private_key_data, password=None, backend=default_backend() + ) + # Check the public/private key match + if ( + private_key.public_key().public_numbers() + != public_cert.public_key().public_numbers() + ): + raise ValueError( + "Public certificate does not match the private key." + ) + + # Step 4: Add the public certificate to the new trust store + with open(output_trust_store_path, "ab") as trust_store_file: + trust_store_file.write(public_cert.public_bytes(serialization.Encoding.PEM)) + + logger.info( + f"Trust store created/updated successfully at: {output_trust_store_path}" + ) + + except Exception as e: + logger.error(f"Error creating CA trust store: {e}") + + +def combine_trust_stores(custom_cert_path: str, output_combined_path: str): + """ + Combine the default certifi CA bundle with a custom certificate file. + + :param custom_cert_path: Path to the custom certificate PEM file. + :param output_combined_path: Path where the combined CA bundle will be saved. + """ + try: + # Get the default certifi CA bundle + certifi_ca_bundle = certifi.where() + + with open(output_combined_path, "wb") as combined_file: + # Write the default CA bundle + with open(certifi_ca_bundle, "rb") as default_file: + combined_file.write(default_file.read()) + + # Append the custom certificates + with open(custom_cert_path, "rb") as custom_file: + combined_file.write(custom_file.read()) + + logger.info(f"Combined trust store created at: {output_combined_path}") + + except Exception as e: + logger.error(f"Error combining trust stores: {e}") + raise e diff --git a/setup.py b/setup.py index 5a6581cc853..91af19d6a0f 100644 --- a/setup.py +++ b/setup.py @@ -11,12 +11,17 @@ # 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. +import glob import os import pathlib import re import shutil +import subprocess +from subprocess import CalledProcessError +import sys +from pathlib import Path -from setuptools import find_packages, setup +from setuptools import find_packages, setup, Command NAME = "feast" DESCRIPTION = "Python SDK for Feast" @@ -28,13 +33,13 @@ "click>=7.0.0,<9.0.0", "colorama>=0.3.9,<1", "dill~=0.3.0", - "protobuf>=4.24.0,<5.0.0", + "protobuf>=4.24.0", "Jinja2>=2,<4", "jsonschema", "mmh3", "numpy>=1.22,<2", "pandas>=1.4.3,<3", - "pyarrow>=9.0.0", + "pyarrow<18.1.0", "pydantic>=2.0.0", "pygments>=2.12.0,<3", "PyYAML>=5.4.0,<7", @@ -85,7 +90,7 @@ ] SQLITE_VEC_REQUIRED = [ - "sqlite-vec==v0.1.1", + "sqlite-vec==v0.1.6", ] TRINO_REQUIRED = ["trino>=0.305.0,<0.400.0", "regex"] @@ -105,7 +110,7 @@ "cassandra-driver>=3.24.0,<4", ] -GE_REQUIRED = ["great_expectations>=0.15.41"] +GE_REQUIRED = ["great_expectations>=0.15.41,<1"] AZURE_REQUIRED = [ "azure-storage-blob>=0.37.0", @@ -138,30 +143,43 @@ DELTA_REQUIRED = ["deltalake"] +DOCLING_REQUIRED = ["docling>=2.23.0"] + ELASTICSEARCH_REQUIRED = ["elasticsearch>=8.13.0"] -SINGLESTORE_REQUIRED = ["singlestoredb"] +SINGLESTORE_REQUIRED = ["singlestoredb<1.8.0"] -COUCHBASE_REQUIRED = ["couchbase==4.3.2"] +COUCHBASE_REQUIRED = [ + "couchbase==4.3.2", + "couchbase-columnar==1.0.0" +] MSSQL_REQUIRED = ["ibis-framework[mssql]>=9.0.0,<10"] FAISS_REQUIRED = ["faiss-cpu>=1.7.0,<2"] - QDRANT_REQUIRED = ["qdrant-client>=1.12.0"] +GO_REQUIRED = ["cffi>=1.15.0"] + +MILVUS_REQUIRED = ["pymilvus"] + +TORCH_REQUIRED = [ + "torch>=2.2.2", + "torchvision>=0.17.2", +] + CI_REQUIRED = ( [ "build", "virtualenv==20.23.0", - "cryptography>=35.0,<43", - "ruff>=0.3.3", + "cryptography>=43.0,<44", + "ruff>=0.8.0", "mypy-protobuf>=3.1", "grpcio-tools>=1.56.2,<2", "grpcio-testing>=1.56.2,<2", # FastAPI does not correctly pull starlette dependency on httpx see thread(https://github.com/tiangolo/fastapi/issues/5656). - "httpx>=0.23.3", - "minio==7.1.0", + "httpx==0.27.2", + "minio==7.2.11", "mock==2.0.0", "moto<5", "mypy>=1.4.1,<1.11.3", @@ -179,7 +197,7 @@ "pytest-mock==1.10.4", "pytest-env", "Sphinx>4.0.0,<7", - "testcontainers==4.4.0", + "testcontainers==4.8.2", "python-keycloak==4.2.2", "pre-commit<3.3.2", "assertpy==1.1", @@ -220,8 +238,15 @@ + OPENTELEMETRY + FAISS_REQUIRED + QDRANT_REQUIRED + + MILVUS_REQUIRED + + DOCLING_REQUIRED + + TORCH_REQUIRED +) +NLP_REQUIRED = ( + DOCLING_REQUIRED + + MILVUS_REQUIRED + + TORCH_REQUIRED ) - DOCS_REQUIRED = CI_REQUIRED DEV_REQUIRED = CI_REQUIRED @@ -248,6 +273,7 @@ PYTHON_CODE_PREFIX = "sdk/python" + setup( name=NAME, author=AUTHOR, @@ -291,7 +317,12 @@ "couchbase": COUCHBASE_REQUIRED, "opentelemetry": OPENTELEMETRY, "faiss": FAISS_REQUIRED, - "qdrant": QDRANT_REQUIRED + "qdrant": QDRANT_REQUIRED, + "go": GO_REQUIRED, + "milvus": MILVUS_REQUIRED, + "docling": DOCLING_REQUIRED, + "pytorch": TORCH_REQUIRED, + "nlp": NLP_REQUIRED, }, include_package_data=True, license="Apache", diff --git a/ui/.nvmrc b/ui/.nvmrc index 67e145bf0f9..cc7ce7f49fe 100644 --- a/ui/.nvmrc +++ b/ui/.nvmrc @@ -1 +1 @@ -v20.18.0 +v22.13.1 diff --git a/ui/README.md b/ui/README.md index a2326e1a9ef..bf9ccd367d9 100644 --- a/ui/README.md +++ b/ui/README.md @@ -77,7 +77,7 @@ The advantage of importing Feast UI as a module is in the ease of customization. ##### Fetching the Project List -You can use `projectListPromise` to provide a promise that overrides where the Feast UI fetches the project list from. +By default, the Feast UI fetches the project list from the app root path. You can use `projectListPromise` to provide a promise that overrides where it's fetched from. ```jsx /src"], collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"], @@ -7,7 +9,13 @@ module.exports = { "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}", ], - testEnvironment: "jsdom", + // Couldn't get tests working with msw 2 in jsdom or jest-fixed-jsdom, + // happy-dom finally worked with added globals + testEnvironment: "@happy-dom/jest-environment", + // https://mswjs.io/docs/migrations/1.x-to-2.x#cannot-find-module-mswnode-jsdom + testEnvironmentOptions: { + customExportConditions: [''], + }, transform: { "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "/config/jest/babelTransform.js", "^.+\\.css$": "/config/jest/cssTransform.js", @@ -15,7 +23,7 @@ module.exports = { "/config/jest/fileTransform.js", }, transformIgnorePatterns: [ - "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$", + `[/\\\\]node_modules[/\\\\](?!(${transformNodeModules.map(name => name.replaceAll('/', '[/\\\\]')).join('|')})[/\\\\])`, "^.+\\.module\\.(css|sass|scss)$", ], modulePaths: [], diff --git a/ui/package.json b/ui/package.json index 7f583e29153..f2ac73ef595 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "@feast-dev/feast-ui", - "version": "0.41.0", + "version": "0.46.0", "private": false, "files": [ "dist" @@ -28,15 +28,16 @@ "@emotion/css": "^11.13.0", "@emotion/react": "^11.13.3", "inter-ui": "^3.19.3", + "long": "^5.2.3", "moment": "^2.29.1", "protobufjs": "^7.1.1", "query-string": "^7.1.1", "react-app-polyfill": "^3.0.0", "react-code-blocks": "^0.1.6", "react-query": "^3.39.3", - "react-router-dom": "<6.4.0", + "react-router-dom": "^6.28.0", "tslib": "^2.3.1", - "use-query-params": "^1.2.3", + "use-query-params": "^2.2.1", "zod": "^3.11.6" }, "scripts": { @@ -55,6 +56,7 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/preset-env": "^7.25.8", "@babel/preset-react": "^7.25.7", + "@happy-dom/jest-environment": "^16.7.3", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^21.0.2", @@ -67,7 +69,7 @@ "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/jest": "^27.0.1", - "@types/node": "^20.16.13", + "@types/node": "^22.12.0", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "babel-jest": "^27.4.2", @@ -89,11 +91,12 @@ "fs-extra": "^10.0.0", "html-webpack-plugin": "^5.5.0", "identity-obj-proxy": "^3.0.0", - "jest": "^27.4.3", - "jest-resolve": "^27.4.2", - "jest-watch-typeahead": "^1.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-watch-typeahead": "^2.2.2", "mini-css-extract-plugin": "^2.4.5", - "msw": "^0.36.8", + "msw": "^2.7.0", "postcss": "^8.4.4", "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^6.2.1", @@ -119,12 +122,15 @@ "source-map-loader": "^3.0.0", "style-loader": "^3.3.1", "terser-webpack-plugin": "^5.2.5", - "typescript": "^4.9.5", + "typescript": "~5.7.2", "webpack": "^5.64.4", "webpack-dev-server": "^4.6.0", "webpack-manifest-plugin": "^4.0.2", "workbox-webpack-plugin": "^6.4.1" }, + "resolutions": { + "nwsapi": "2.2.13" + }, "description": "Web UI for the [Feast Feature Store](https://feast.dev/)", "repository": { "type": "git", diff --git a/ui/public/registry.db b/ui/public/registry.db index 617771999c7e43ef6bdb1e6acf9eed18b06b1dae..ae9a05a4a97c6553d43cacf28eade1ec8da791b8 100644 GIT binary patch literal 6812 zcmeHLZ)hCV7T?+3W;Zu!vKjkFSH*c-(IjLy$=0@ZF;bIKc;=B0ZFzzv40mVlX2;Gy z=iW_RX+=bfgjKst5=XdV8=l;+i(FjV(vRX5YdJZ)oWKF$OR2-seOs0zEXy$^9m#BCh z>Q8@u^xS`YG9Nts!b=IUO+ts!JWAcK*@~r-Z0{XX^D!6brZ=N?){;paaX`hUS>IJ5 zn6d)ariW0H5p0OPRjeG8mIyg`f#tvW{>9Mpo`g;d=m`O(RI4mgYG!ZSjTw=wid5Dd zR+X@+N;)hjRjisLsoH|ZDF2mOiiUNG5~b#7tS*@~qfDG!FwOL)z~=SLx+s&+{p~*? zlRYUbOy+K!+&-~`6embtRkrTT-?9zgn%_ybmGeZ|RxINklRLJP%4iVik6%A=CQ)2Y zqAwF@pb1b_qs(&ZVgz8>z>VBhXs|h4!Hx=WlT0#~LH&m1um!w8nwkwLLs?BFCX;mw zn=S1kscMQ&!rB3Zs(dcHrt1`D+k`|zqm+OK*A1A0%EfxLesT@{cEh)-jLm~i&1Q;5 zVCK=y2&n+3j8&DWVsCMxSj(apnj|m6uU@o=YpqHF_=TBlp?F z(L9^P{+5uxqu7&v@Z9I$#Y@XVTF4HPMeaF+k(6P>9vy<1!NO`wb@%n+wO|V#Zx4RK z#?HMp!j^?eKdCjd;^2Z+spy(XWDXFag^C6dq>z6KDeQsgect0KVHJ|QgmT|X9%tgA zUH9$TS3^1ENyXO^=wt$Anww3ALC_{s=84x#nBrC_F6 zrp)R#S8YT?4Bsa^1m-lU%pFhxB7oku0C#UJ$b+blSq$s)5~R|35ZlomV%ypgyD5y= zSV))-3o{(cBd4DhN)L65<#l4#!!n2+!)2XZ9F#$=^D%5roJoc-Jo(H=Aq+jK_(TGI z6Gxi@I}FPt^)8WI1EDL31e@!^h z+M?f9mCMedEzH3t#fsP3EkJJ%7a@7oeL%dqt6`bcnpLxX6JMxkzWL}kp?vhD(o0G7 zct<`?M&{%HI#1mzXKc7BzueE*Oi=PuaQ|2UhT+1;?6(h{2_3Usn*OQ}z17!M;znjs z*E#=Lyrao`FP?q7QyZCto&8|sMOy@I=S7=0PvMJpRNKfe+EIGmPDbhZMOy@`R$a8a z>%uNku$AowlR}Vrv~CZKPD6I$Ov=yx`*9H zT9!>PM3%ir18XKc(rWMAoIe1D49YUFe?S-T(}=>)hat!VjUk)2gtWk~S_L3az?*9S zQSiqnAKdB z`9tVd?n!%(C(%CyG}cD3nPG;F-m7bj6!v-l_H#NXcL?3}i|qi9Ewa273u;;XL(}UJ zMILljO;Q%U_;byD;L@49CBod`ej3n*=wCnA;D?1vXYt5+EN&;s%EvAP((ms=#09@B zr&G(q4Svq@1PU17y?%M|d8+Qtx#$I+>(=#OfxC{ha9OwoZ9oyR32`WYedoxDR&(b` FKLHvVUj6_8 delta 1330 zcma)*Ur19?9LGENZgU>j+HPChEG^brHoH1++uT-}6+Ht2%>V=?FZkk8Z7T_cJw07WYkcXP&7^7M=xo< zsf1791KqX7>#bO)P}0oh$4Ttasy0dwZFA4nO6LK?uJHDr9xEU z_K0B`ZV-Y&J`$!OJ`fNn9}b2nABluxLQstOg>+p01ouF!-WRlRzb*i^ZQ9n8Gf2$rizoz6919LR)>6UH|mS zc58qBRpX;l^a_i=F~FLvHAf-J3HnMy2Q!!ea;y
}R?{vFzkHJEkNkSyKMLaCUYyIQYmt)TLPGXF`>9b^DLRaB3c(Y+7FDO0UA@lzclCK9XUdNj*t4n6 zyuW^Hro2kg`wYIy0GPdKGgMo>a6HeEz2;?eM}@-{e^zf|>W)TFrETXi{+ORx&!nZw z`37NgOrm0wQWtNMm85i(8$*R7DD5-?9o$CN_tub}&Ep=h?YfKo@SUrItxHo@p*)^! zrsW5zlsVF?1-s2-GS-~jZd{}{GUi_f*kN-cBgAd1N*8xpTQXac%VJ51mNiJEnfAmB zpr_LyQKeN%^u&u>MwE+jhf7dlj6pe_qY4z`0*@@XNK-%=M_WtDnVZ zfjIn2_u}BJP)D3z*z8!1I@O7N^GtkjtU(vliTc?S14o=6(WYZj%w_LMPM2s(G}e(e lne&LDSY;$6622l-FY { const queryClient = reactQueryClient || defaultQueryClient; + const basename = process.env.PUBLIC_URL ?? ''; return ( - + // Disable v7_relativeSplatPath: custom tab routes don't currently work with it + - - + + diff --git a/ui/src/FeastUISansProviders.test.tsx b/ui/src/FeastUISansProviders.test.tsx index 4af9490e10b..94bd2dfbe35 100644 --- a/ui/src/FeastUISansProviders.test.tsx +++ b/ui/src/FeastUISansProviders.test.tsx @@ -55,7 +55,7 @@ test("full app rendering", async () => { expect(screen.getByText(/Explore this Project/i)).toBeInTheDocument(); const projectNameRegExp = new RegExp( - parsedRegistry.projectMetadata[0].project!, + parsedRegistry.projects[0].spec?.name!, "i" ); @@ -89,7 +89,7 @@ test("routes are reachable", async () => { const routeRegExp = new RegExp(routeName, "i"); - await user.click(screen.getByRole("button", { name: routeRegExp })); + await user.click(screen.getByRole("link", { name: routeRegExp })); // Should land on a page with the heading screen.getByRole("heading", { @@ -112,7 +112,7 @@ test("features are reachable", async () => { await screen.findByText(/Explore this Project/i); const routeRegExp = new RegExp("Feature Views", "i"); - await user.click(screen.getByRole("button", { name: routeRegExp })); + await user.click(screen.getByRole("link", { name: routeRegExp })); screen.getByRole("heading", { name: "Feature Views", diff --git a/ui/src/FeastUISansProviders.tsx b/ui/src/FeastUISansProviders.tsx index 8a12abdc39f..52676c5d0b5 100644 --- a/ui/src/FeastUISansProviders.tsx +++ b/ui/src/FeastUISansProviders.tsx @@ -40,8 +40,8 @@ interface FeastUIConfigs { projectListPromise?: Promise; } -const defaultProjectListPromise = () => { - return fetch("/projects-list.json", { +const defaultProjectListPromise = (basename: string) => { + return fetch(`${basename}/projects-list.json`, { headers: { "Content-Type": "application/json", }, @@ -51,8 +51,10 @@ const defaultProjectListPromise = () => { }; const FeastUISansProviders = ({ + basename = "", feastUIConfigs, }: { + basename?: string; feastUIConfigs?: FeastUIConfigs; }) => { const projectListContext: ProjectsListContextInterface = @@ -61,9 +63,7 @@ const FeastUISansProviders = ({ projectsListPromise: feastUIConfigs?.projectListPromise, isCustom: true, } - : { projectsListPromise: defaultProjectListPromise(), isCustom: false }; - - const BASE_URL = process.env.PUBLIC_URL || "" + : { projectsListPromise: defaultProjectListPromise(basename), isCustom: false }; return ( @@ -76,9 +76,9 @@ const FeastUISansProviders = ({ > - }> + }> } /> - }> + }> } /> } /> - !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); - -const isLeftClickEvent = (event) => event.button === 0; - -const isTargetBlank = (event) => { - const target = event.target.getAttribute("target"); - return target && target !== "_self"; -}; - -export default function EuiCustomLink({ to, ...rest }) { - // This is the key! - const navigate = useNavigate(); - - function onClick(event) { - if (event.defaultPrevented) { - return; - } - - // Let the browser handle links that open new tabs/windows - if ( - isModifiedEvent(event) || - !isLeftClickEvent(event) || - isTargetBlank(event) - ) { - return; - } - - // Prevent regular link behavior, which causes a browser refresh. - event.preventDefault(); - - // Push the route to the history. - navigate(to); - } - - // Generate the correct link href (with basename accounted for) - const href = useHref({ pathname: to }); - - const props = { ...rest, href, onClick }; - return ; -} diff --git a/ui/src/components/EuiCustomLink.tsx b/ui/src/components/EuiCustomLink.tsx new file mode 100644 index 00000000000..bf180baaa97 --- /dev/null +++ b/ui/src/components/EuiCustomLink.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { EuiLink, type EuiLinkAnchorProps } from "@elastic/eui"; +import { useNavigate, useHref, type To } from "react-router-dom"; + +interface EuiCustomLinkProps extends Omit { + to: To; +} + +const isModifiedEvent = (event: React.MouseEvent) => + !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +const isLeftClickEvent = (event: React.MouseEvent) => event.button === 0; + +const isTargetBlank = (event: React.MouseEvent) => { + const target = (event.target as Element).getAttribute("target"); + return target && target !== "_self"; +}; + +export default function EuiCustomLink({ to, ...rest }: EuiCustomLinkProps) { + // This is the key! + const navigate = useNavigate(); + + const onClick: React.MouseEventHandler = (event) => { + if (event.defaultPrevented) { + return; + } + + // Let the browser handle links that open new tabs/windows + if ( + isModifiedEvent(event) || + !isLeftClickEvent(event) || + isTargetBlank(event) + ) { + return; + } + + // Prevent regular link behavior, which causes a browser refresh. + event.preventDefault(); + + // Push the route to the history. + navigate(to); + } + + // Generate the correct link href (with basename accounted for) + const href = useHref(to); + + return ; +} diff --git a/ui/src/components/FeaturesInServiceDisplay.tsx b/ui/src/components/FeaturesInServiceDisplay.tsx index bec2550a5d3..63dd447c6f3 100644 --- a/ui/src/components/FeaturesInServiceDisplay.tsx +++ b/ui/src/components/FeaturesInServiceDisplay.tsx @@ -28,10 +28,7 @@ const FeaturesInServiceList = ({ featureViews }: FeatureViewsListInterace) => { field: "featureViewName", render: (name: string) => { return ( - + {name} ); diff --git a/ui/src/components/FeaturesListDisplay.tsx b/ui/src/components/FeaturesListDisplay.tsx index 2a0628b0f56..61f57b478d6 100644 --- a/ui/src/components/FeaturesListDisplay.tsx +++ b/ui/src/components/FeaturesListDisplay.tsx @@ -21,8 +21,7 @@ const FeaturesList = ({ field: "name", render: (item: string) => ( {item} diff --git a/ui/src/components/ObjectsCountStats.tsx b/ui/src/components/ObjectsCountStats.tsx index eff3f8a2ca7..bf1dd2dc9dd 100644 --- a/ui/src/components/ObjectsCountStats.tsx +++ b/ui/src/components/ObjectsCountStats.tsx @@ -55,7 +55,7 @@ const ObjectsCountStats = () => { navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/feature-service`)} + onClick={() => navigate(`/p/${projectName}/feature-service`)} description="Feature Services→" title={data.featureServices} reverse @@ -65,7 +65,7 @@ const ObjectsCountStats = () => { navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/feature-view`)} + onClick={() => navigate(`/p/${projectName}/feature-view`)} title={data.featureViews} reverse /> @@ -74,7 +74,7 @@ const ObjectsCountStats = () => { navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/entity`)} + onClick={() => navigate(`/p/${projectName}/entity`)} title={data.entities} reverse /> @@ -83,7 +83,7 @@ const ObjectsCountStats = () => { navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/data-source`)} + onClick={() => navigate(`/p/${projectName}/data-source`)} title={data.dataSources} reverse /> diff --git a/ui/src/components/ProjectSelector.test.tsx b/ui/src/components/ProjectSelector.test.tsx index fc5b3c68400..dfaaab7f626 100644 --- a/ui/src/components/ProjectSelector.test.tsx +++ b/ui/src/components/ProjectSelector.test.tsx @@ -40,7 +40,7 @@ test("in a full App render, it shows the right initial project", async () => { name: "Top Level", }); - within(topLevelNavigation).getByDisplayValue("Credit Score Project"); + await within(topLevelNavigation).findByDisplayValue("Credit Score Project"); expect(options.length).toBe(1); diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index edbcf9d98fe..1bb7ebf85a7 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -22,7 +22,7 @@ const ProjectSelector = () => { const basicSelectId = useGeneratedHtmlId({ prefix: "basicSelect" }); const onChange = (e: React.ChangeEvent) => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${e.target.value}`); + navigate(`/p/${e.target.value}`); }; return ( diff --git a/ui/src/hacks/RouteAdapter.ts b/ui/src/hacks/RouteAdapter.ts deleted file mode 100644 index e7743c9d90b..00000000000 --- a/ui/src/hacks/RouteAdapter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import { Location } from "history"; -import { - useLocation, - useNavigate, - Location as RouterLocation, -} from "react-router-dom"; - -// via: https://github.com/pbeshai/use-query-params/issues/196#issuecomment-996893750 -interface RouteAdapterProps { - children: React.FunctionComponent<{ - history: { - replace(location: Location): void; - push(location: Location): void; - }; - location: RouterLocation; - }>; -} - -// Via: https://github.com/pbeshai/use-query-params/blob/cd44e7fb3394620f757bfb09ff57b7f296d9a5e6/examples/react-router-6/src/index.js#L36 -const RouteAdapter = ({ children }: RouteAdapterProps) => { - const navigate = useNavigate(); - const location = useLocation(); - - const adaptedHistory = React.useMemo( - () => ({ - replace(location: Location) { - navigate(location, { replace: true, state: location.state }); - }, - push(location: Location) { - navigate(location, { replace: false, state: location.state }); - }, - }), - [navigate] - ); - return children && children({ history: adaptedHistory, location }); -}; - -export default RouteAdapter; diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 04eda8a1ba4..9cca508fcae 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -96,16 +96,7 @@ root.render( { - return res.json(); - }) - }} + feastUIConfigs={{ tabsRegistry }} /> ); diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index 39f30b62a6d..dd25b6cd3fc 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -1,35 +1,25 @@ -import { rest } from "msw"; -import {readFileSync} from 'fs'; +import { http, HttpResponse } from "msw"; +import { readFileSync } from 'fs'; import path from "path"; const registry = readFileSync(path.resolve(__dirname, "../../public/registry.db")); -const projectsListWithDefaultProject = rest.get( - "/projects-list.json", - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - default: "credit_score_project", - projects: [ - { - name: "Credit Score Project", - description: - "Project for credit scoring team and associated models.", - id: "credit_score_project", - registryPath: "/registry.pb", - }, - ], - }) - ); - } +const projectsListWithDefaultProject = http.get("/projects-list.json", () => + HttpResponse.json({ + default: "credit_score_project", + projects: [ + { + name: "Credit Score Project", + description: "Project for credit scoring team and associated models.", + id: "credit_score_project", + registryPath: "/registry.pb", + }, + ], + }) ); -const creditHistoryRegistry = rest.get("/registry.pb", (req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/octet-stream'), - ctx.body(registry)); -}); +const creditHistoryRegistry = http.get("/registry.pb", () => + HttpResponse.arrayBuffer(registry.buffer) +); export { projectsListWithDefaultProject, creditHistoryRegistry }; diff --git a/ui/src/pages/RootProjectSelectionPage.tsx b/ui/src/pages/RootProjectSelectionPage.tsx index 5e19b6606b8..fb488e714bc 100644 --- a/ui/src/pages/RootProjectSelectionPage.tsx +++ b/ui/src/pages/RootProjectSelectionPage.tsx @@ -21,12 +21,12 @@ const RootProjectSelectionPage = () => { useEffect(() => { if (data && data.default) { // If a default is set, redirect there. - navigate(`${process.env.PUBLIC_URL || ""}/p/${data.default}`); + navigate(`/p/${data.default}`); } if (data && data.projects.length === 1) { // If there is only one project, redirect there. - navigate(`${process.env.PUBLIC_URL || ""}/p/${data.projects[0].id}`); + navigate(`/p/${data.projects[0].id}`); } }, [data, navigate]); @@ -38,7 +38,7 @@ const RootProjectSelectionPage = () => { title={`${item.name}`} description={item?.description || ""} onClick={() => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${item.id}`); + navigate(`/p/${item.id}`); }} /> diff --git a/ui/src/pages/Sidebar.tsx b/ui/src/pages/Sidebar.tsx index dac02709ba6..44cde07e79d 100644 --- a/ui/src/pages/Sidebar.tsx +++ b/ui/src/pages/Sidebar.tsx @@ -1,7 +1,7 @@ import React, { useContext, useState } from "react"; import { EuiIcon, EuiSideNav, htmlIdGenerator } from "@elastic/eui"; -import { useNavigate, useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { useMatchSubpath } from "../hooks/useMatchSubpath"; import useLoadRegistry from "../queries/useLoadRegistry"; import RegistryPathContext from "../contexts/RegistryPathContext"; @@ -19,8 +19,6 @@ const SideNav = () => { const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); - const navigate = useNavigate(); - const toggleOpenOnMobile = () => { setisSideNavOpenOnMobile(!isSideNavOpenOnMobile); }; @@ -55,58 +53,48 @@ const SideNav = () => { : "" }`; - const sideNav = [ + const baseUrl = `/p/${projectName}`; + + const sideNav: React.ComponentProps['items'] = [ { name: "Home", id: htmlIdGenerator("basicExample")(), - onClick: () => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/`); - }, + renderItem: props => , items: [ { name: dataSourcesLabel, id: htmlIdGenerator("dataSources")(), icon: , - onClick: () => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/data-source`); - }, - isSelected: useMatchSubpath("data-source"), + renderItem: props => , + isSelected: useMatchSubpath(`${baseUrl}/data-source`), }, { name: entitiesLabel, id: htmlIdGenerator("entities")(), icon: , - onClick: () => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/entity`); - }, - isSelected: useMatchSubpath("entity"), + renderItem: props => , + isSelected: useMatchSubpath(`${baseUrl}/entity`), }, { name: featureViewsLabel, id: htmlIdGenerator("featureView")(), icon: , - onClick: () => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/feature-view`); - }, - isSelected: useMatchSubpath("feature-view"), + renderItem: props => , + isSelected: useMatchSubpath(`${baseUrl}/feature-view`), }, { name: featureServicesLabel, id: htmlIdGenerator("featureService")(), icon: , - onClick: () => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/feature-service`); - }, - isSelected: useMatchSubpath("feature-service"), + renderItem: props => , + isSelected: useMatchSubpath(`${baseUrl}/feature-service`), }, { name: savedDatasetsLabel, id: htmlIdGenerator("savedDatasets")(), icon: , - onClick: () => { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/data-set`); - }, - isSelected: useMatchSubpath("data-set"), + renderItem: props => , + isSelected: useMatchSubpath(`${baseUrl}/data-set`), }, ], }, diff --git a/ui/src/pages/data-sources/DataSourcesListingTable.tsx b/ui/src/pages/data-sources/DataSourcesListingTable.tsx index e4f06d6bd0a..fd1ff73deb7 100644 --- a/ui/src/pages/data-sources/DataSourcesListingTable.tsx +++ b/ui/src/pages/data-sources/DataSourcesListingTable.tsx @@ -20,10 +20,7 @@ const DatasourcesListingTable = ({ sortable: true, render: (name: string) => { return ( - + {name} ); diff --git a/ui/src/pages/entities/EntitiesListingTable.tsx b/ui/src/pages/entities/EntitiesListingTable.tsx index baf4ddb8e47..06190409b04 100644 --- a/ui/src/pages/entities/EntitiesListingTable.tsx +++ b/ui/src/pages/entities/EntitiesListingTable.tsx @@ -20,10 +20,7 @@ const EntitiesListingTable = ({ entities }: EntitiesListingTableProps) => { sortable: true, render: (name: string) => { return ( - + {name} ); diff --git a/ui/src/pages/entities/FeatureViewEdgesList.tsx b/ui/src/pages/entities/FeatureViewEdgesList.tsx index 8a0b6164b49..3419bfcb4b7 100644 --- a/ui/src/pages/entities/FeatureViewEdgesList.tsx +++ b/ui/src/pages/entities/FeatureViewEdgesList.tsx @@ -53,10 +53,7 @@ const FeatureViewEdgesList = ({ fvNames }: FeatureViewEdgesListInterace) => { field: "", render: ({ name }: { name: string }) => { return ( - + {name} ); diff --git a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx index 13ffa764092..69d4d1f969d 100644 --- a/ui/src/pages/feature-services/FeatureServiceListingTable.tsx +++ b/ui/src/pages/feature-services/FeatureServiceListingTable.tsx @@ -30,10 +30,7 @@ const FeatureServiceListingTable = ({ field: "spec.name", render: (name: string) => { return ( - + {name} ); diff --git a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx index 4d3d350f084..fcb1dc018b3 100644 --- a/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx +++ b/ui/src/pages/feature-services/FeatureServiceOverviewTab.tsx @@ -109,7 +109,7 @@ const FeatureServiceOverviewTab = () => { tags={data.spec.tags} createLink={(key, value) => { return ( - `${process.env.PUBLIC_URL || ""}/p/${projectName}/feature-service?` + + `/p/${projectName}/feature-service?` + encodeSearchQueryString(`${key}:${value}`) ); }} @@ -133,7 +133,7 @@ const FeatureServiceOverviewTab = () => { color="primary" onClick={() => { navigate( - `${process.env.PUBLIC_URL || ""}/p/${projectName}/entity/${entity.name}` + `/p/${projectName}/entity/${entity.name}` ); }} onClickAriaLabel={entity.name} diff --git a/ui/src/pages/feature-views/ConsumingFeatureServicesList.tsx b/ui/src/pages/feature-views/ConsumingFeatureServicesList.tsx index bb9961c19ca..603a4d96ba4 100644 --- a/ui/src/pages/feature-views/ConsumingFeatureServicesList.tsx +++ b/ui/src/pages/feature-views/ConsumingFeatureServicesList.tsx @@ -18,10 +18,7 @@ const ConsumingFeatureServicesList = ({ field: "", render: ({ name }: { name: string }) => { return ( - + {name} ); diff --git a/ui/src/pages/feature-views/FeatureViewListingTable.tsx b/ui/src/pages/feature-views/FeatureViewListingTable.tsx index ff1a31c4162..02756492c91 100644 --- a/ui/src/pages/feature-views/FeatureViewListingTable.tsx +++ b/ui/src/pages/feature-views/FeatureViewListingTable.tsx @@ -31,10 +31,7 @@ const FeatureViewListingTable = ({ sortable: true, render: (name: string, item: genericFVType) => { return ( - + {name} {(item.type === "ondemand" && ondemand) || (item.type === "stream" && stream)} ); diff --git a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx index cde4f46d4ed..3bbb906e05b 100644 --- a/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/RegularFeatureViewOverviewTab.tsx @@ -96,7 +96,7 @@ const RegularFeatureViewOverviewTab = ({ { - navigate(`${process.env.PUBLIC_URL || ""}/p/${projectName}/entity/${entity}`); + navigate(`/p/${projectName}/entity/${entity}`); }} onClickAriaLabel={entity} data-test-sub="testExample1" @@ -134,7 +134,7 @@ const RegularFeatureViewOverviewTab = ({ tags={data.spec.tags} createLink={(key, value) => { return ( - `${process.env.PUBLIC_URL || ""}/p/${projectName}/feature-view?` + + `/p/${projectName}/feature-view?` + encodeSearchQueryString(`${key}:${value}`) ); }} diff --git a/ui/src/pages/feature-views/StreamFeatureViewOverviewTab.tsx b/ui/src/pages/feature-views/StreamFeatureViewOverviewTab.tsx index b4514a5edd5..9aff3d59f3f 100644 --- a/ui/src/pages/feature-views/StreamFeatureViewOverviewTab.tsx +++ b/ui/src/pages/feature-views/StreamFeatureViewOverviewTab.tsx @@ -96,8 +96,7 @@ const StreamFeatureViewOverviewTab = ({ {inputGroup?.name} diff --git a/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx b/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx index 2a68cc49b51..104ef0f93be 100644 --- a/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx +++ b/ui/src/pages/feature-views/components/FeatureViewProjectionDisplayPanel.tsx @@ -31,8 +31,7 @@ const FeatureViewProjectionDisplayPanel = (featureViewProjection: RequestDataDis {featureViewProjection?.featureViewName} diff --git a/ui/src/pages/feature-views/components/RequestDataDisplayPanel.tsx b/ui/src/pages/feature-views/components/RequestDataDisplayPanel.tsx index 8ec973c3dad..6893dfd6a32 100644 --- a/ui/src/pages/feature-views/components/RequestDataDisplayPanel.tsx +++ b/ui/src/pages/feature-views/components/RequestDataDisplayPanel.tsx @@ -39,8 +39,7 @@ const RequestDataDisplayPanel = ({ {requestDataSource?.name} diff --git a/ui/src/pages/features/FeatureOverviewTab.tsx b/ui/src/pages/features/FeatureOverviewTab.tsx index cc7879b0383..eb101fe3955 100644 --- a/ui/src/pages/features/FeatureOverviewTab.tsx +++ b/ui/src/pages/features/FeatureOverviewTab.tsx @@ -63,8 +63,8 @@ const FeatureOverviewTab = () => { FeatureView + to={`/p/${projectName}/feature-view/${FeatureViewName}`} + > {FeatureViewName} diff --git a/ui/src/pages/saved-data-sets/DatasetsListingTable.tsx b/ui/src/pages/saved-data-sets/DatasetsListingTable.tsx index af794a35f98..7b73e9cd6dc 100644 --- a/ui/src/pages/saved-data-sets/DatasetsListingTable.tsx +++ b/ui/src/pages/saved-data-sets/DatasetsListingTable.tsx @@ -19,10 +19,7 @@ const DatasetsListingTable = ({ datasets }: DatasetsListingTableProps) => { sortable: true, render: (name: string) => { return ( - + {name} ); diff --git a/ui/src/queries/useLoadRegistry.ts b/ui/src/queries/useLoadRegistry.ts index be8ab65a8cd..88274b47131 100644 --- a/ui/src/queries/useLoadRegistry.ts +++ b/ui/src/queries/useLoadRegistry.ts @@ -52,7 +52,7 @@ const useLoadRegistry = (url: string) => { // }); return { - project: objects.projectMetadata[0].project!, + project: objects.projects[0].spec?.name!, objects, mergedFVMap, mergedFVList, diff --git a/ui/src/setupTests.ts b/ui/src/setupTests.ts index 8f2609b7b3e..f30351b9164 100644 --- a/ui/src/setupTests.ts +++ b/ui/src/setupTests.ts @@ -3,3 +3,7 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; +import { BroadcastChannel } from 'worker_threads'; + +// BroadcastChannel is missing from @happy-dom/jest-environment globals +Object.assign(global, { BroadcastChannel }); diff --git a/ui/src/test-utils.tsx b/ui/src/test-utils.tsx index c180b01872c..0130686252d 100644 --- a/ui/src/test-utils.tsx +++ b/ui/src/test-utils.tsx @@ -2,8 +2,8 @@ import React from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "react-query"; import { QueryParamProvider } from "use-query-params"; +import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; import { MemoryRouter as Router } from "react-router-dom"; -import RouteAdapter from "./hacks/RouteAdapter"; interface ProvidersProps { children: React.ReactNode; @@ -14,10 +14,12 @@ const queryClient = new QueryClient(); const AllTheProviders = ({ children }: ProvidersProps) => { return ( - - + + {children} diff --git a/ui/src/utils/timestamp.ts b/ui/src/utils/timestamp.ts index 869d24870f0..4432545457c 100644 --- a/ui/src/utils/timestamp.ts +++ b/ui/src/utils/timestamp.ts @@ -1,9 +1,9 @@ -import long from 'long'; +import Long from 'long'; import { google } from '../protos'; export function toDate(ts: google.protobuf.ITimestamp) { var seconds: number; - if (ts.seconds instanceof long) { + if (ts.seconds instanceof Long) { seconds = ts.seconds.low } else { seconds = ts.seconds!; diff --git a/ui/yarn.lock b/ui/yarn.lock index 90ba33269eb..32dcfe3996b 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -40,6 +40,15 @@ "@babel/highlight" "^7.25.9" picocolors "^1.0.0" +"@babel/code-frame@^7.26.2": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.7", "@babel/compat-data@^7.25.8": version "7.25.8" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.8.tgz#0376e83df5ab0eb0da18885c0140041f0747a402" @@ -50,7 +59,12 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.9.tgz#24b01c5db6a3ebf85661b4fb4a946a9bccc72ac8" integrity sha512-yD+hEuJ/+wAJ4Ox2/rpNv5HIuPG82x3ZlQvYVn8iYCprdxzE7P1udpGF1jyjQVBU4dgznN+k2h103vxZ7NdPyw== -"@babel/core@^7.1.0", "@babel/core@^7.11.1", "@babel/core@^7.12.3", "@babel/core@^7.16.0", "@babel/core@^7.7.2", "@babel/core@^7.8.0": +"@babel/compat-data@^7.26.5": + version "7.26.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.5.tgz#df93ac37f4417854130e21d72c66ff3d4b897fc7" + integrity sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg== + +"@babel/core@^7.1.0", "@babel/core@^7.11.1", "@babel/core@^7.12.3", "@babel/core@^7.16.0": version "7.25.8" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.25.8.tgz#a57137d2a51bbcffcfaeba43cb4dd33ae3e0e1c6" integrity sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg== @@ -71,6 +85,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.11.6", "@babel/core@^7.23.9": + version "7.26.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.7.tgz#0439347a183b97534d52811144d763a17f9d2b24" + integrity sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.5" + "@babel/helper-compilation-targets" "^7.26.5" + "@babel/helper-module-transforms" "^7.26.0" + "@babel/helpers" "^7.26.7" + "@babel/parser" "^7.26.7" + "@babel/template" "^7.25.9" + "@babel/traverse" "^7.26.7" + "@babel/types" "^7.26.7" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/core@^7.21.3", "@babel/core@^7.25.8": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.25.9.tgz#855a4cddcec4158f3f7afadacdab2a7de8af7434" @@ -121,6 +156,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" +"@babel/generator@^7.26.5": + version "7.26.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.5.tgz#e44d4ab3176bbcaf78a5725da5f1dc28802a9458" + integrity sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw== + dependencies: + "@babel/parser" "^7.26.5" + "@babel/types" "^7.26.5" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz#63f02dbfa1f7cb75a9bdb832f300582f30bb8972" @@ -173,6 +219,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.26.5": + version "7.26.5" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8" + integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA== + dependencies: + "@babel/compat-data" "^7.26.5" + "@babel/helper-validator-option" "^7.25.9" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.7.tgz#5d65074c76cae75607421c00d6bd517fe1892d6b" @@ -280,6 +337,15 @@ "@babel/helper-validator-identifier" "^7.25.9" "@babel/traverse" "^7.25.9" +"@babel/helper-module-transforms@^7.26.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" + integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== + dependencies: + "@babel/helper-module-imports" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + "@babel/traverse" "^7.25.9" + "@babel/helper-optimise-call-expression@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.7.tgz#1de1b99688e987af723eed44fa7fc0ee7b97d77a" @@ -436,6 +502,14 @@ "@babel/template" "^7.25.9" "@babel/types" "^7.25.9" +"@babel/helpers@^7.26.7": + version "7.26.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.7.tgz#fd1d2a7c431b6e39290277aacfd8367857c576a4" + integrity sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A== + dependencies: + "@babel/template" "^7.25.9" + "@babel/types" "^7.26.7" + "@babel/highlight@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.25.7.tgz#20383b5f442aa606e7b5e3043b0b1aafe9f37de5" @@ -463,6 +537,13 @@ dependencies: "@babel/types" "^7.25.8" +"@babel/parser@^7.23.9", "@babel/parser@^7.26.5", "@babel/parser@^7.26.7": + version "7.26.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.7.tgz#e114cd099e5f7d17b05368678da0fb9f69b3385c" + integrity sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w== + dependencies: + "@babel/types" "^7.26.7" + "@babel/parser@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.9.tgz#8fcaa079ac7458facfddc5cd705cc8005e4d3817" @@ -704,7 +785,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.7" -"@babel/plugin-syntax-jsx@^7.25.9": +"@babel/plugin-syntax-jsx@^7.25.9", "@babel/plugin-syntax-jsx@^7.7.2": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz#a34313a178ea56f1951599b929c1ceacee719290" integrity sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA== @@ -1869,7 +1950,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.25.9" "@babel/plugin-transform-typescript" "^7.25.9" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.7.6", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.9.2": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== @@ -1915,7 +1996,7 @@ "@babel/parser" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/traverse@^7.25.7", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.7.tgz#83e367619be1cab8e4f2892ef30ba04c26a40fa8" integrity sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg== @@ -1941,6 +2022,19 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.26.7": + version "7.26.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.7.tgz#99a0a136f6a75e7fb8b0a1ace421e0b25994b8bb" + integrity sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.5" + "@babel/parser" "^7.26.7" + "@babel/template" "^7.25.9" + "@babel/types" "^7.26.7" + debug "^4.3.1" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.7", "@babel/types@^7.25.8", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.25.8" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.8.tgz#5cf6037258e8a9bcad533f4979025140cb9993e1" @@ -1958,6 +2052,14 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" +"@babel/types@^7.26.5", "@babel/types@^7.26.7": + version "7.26.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.7.tgz#5e2b89c0768e874d4d061961f3a5a153d71dc17a" + integrity sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -1968,6 +2070,28 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@bundled-es-modules/cookie@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz#b41376af6a06b3e32a15241d927b840a9b4de507" + integrity sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw== + dependencies: + cookie "^0.7.2" + +"@bundled-es-modules/statuses@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz#761d10f44e51a94902c4da48675b71a76cc98872" + integrity sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg== + dependencies: + statuses "^2.0.1" + +"@bundled-es-modules/tough-cookie@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz#fa9cd3cedfeecd6783e8b0d378b4a99e52bde5d3" + integrity sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw== + dependencies: + "@types/tough-cookie" "^4.0.5" + tough-cookie "^4.1.4" + "@csstools/normalize.css@*": version "12.1.1" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.1.1.tgz#f0ad221b7280f3fc814689786fd9ee092776ef8f" @@ -2273,6 +2397,18 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@happy-dom/jest-environment@^16.7.3": + version "16.7.3" + resolved "https://registry.yarnpkg.com/@happy-dom/jest-environment/-/jest-environment-16.7.3.tgz#e32b5622dc838ea4c178a7f999fd46439d607862" + integrity sha512-82cNPOd+jJVVE3dSdlJLy7LXm41wc5rP4sMotMD96jLfL5KKA5s1fSPh0xo2QRHilk6du5seqm1xDXZwyuH++A== + dependencies: + "@jest/environment" "^29.4.0" + "@jest/fake-timers" "^29.4.0" + "@jest/types" "^29.4.0" + happy-dom "^16.7.3" + jest-mock "^29.4.0" + jest-util "^29.4.0" + "@hello-pangea/dnd@^16.6.0": version "16.6.0" resolved "https://registry.yarnpkg.com/@hello-pangea/dnd/-/dnd-16.6.0.tgz#7509639c7bd13f55e537b65a9dcfcd54e7c99ac7" @@ -2305,6 +2441,38 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@inquirer/confirm@^5.0.0": + version "5.1.4" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.4.tgz#3e2c9bfdf80331676196d8dbb2261103a67d0e9d" + integrity sha512-EsiT7K4beM5fN5Mz6j866EFA9+v9d5o9VUra3hrg8zY4GHmCS8b616FErbdo5eyKoVotBQkHzMIeeKYsKDStDw== + dependencies: + "@inquirer/core" "^10.1.5" + "@inquirer/type" "^3.0.3" + +"@inquirer/core@^10.1.5": + version "10.1.5" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-10.1.5.tgz#7271c177340f77c2e231704227704d8cdf497747" + integrity sha512-/vyCWhET0ktav/mUeBqJRYTwmjFPIKPRYb3COAw7qORULgipGSUO2vL32lQKki3UxDKJ8BvuEbokaoyCA6YlWw== + dependencies: + "@inquirer/figures" "^1.0.10" + "@inquirer/type" "^3.0.3" + ansi-escapes "^4.3.2" + cli-width "^4.1.0" + mute-stream "^2.0.0" + signal-exit "^4.1.0" + wrap-ansi "^6.2.0" + yoctocolors-cjs "^2.1.2" + +"@inquirer/figures@^1.0.10": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.10.tgz#e3676a51c9c51aaabcd6ba18a28e82b98417db37" + integrity sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw== + +"@inquirer/type@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-3.0.3.tgz#aa9cb38568f23f772b417c972f6a2d906647a6af" + integrity sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2316,176 +2484,169 @@ js-yaml "^3.13.1" resolve-from "^5.0.0" -"@istanbuljs/schema@^0.1.2": +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-27.5.1.tgz#260fe7239602fe5130a94f1aa386eff54b014bba" - integrity sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg== - dependencies: - "@jest/types" "^27.5.1" - "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^27.5.1" - jest-util "^27.5.1" - slash "^3.0.0" - -"@jest/console@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-28.1.3.tgz#2030606ec03a18c31803b8a36382762e447655df" - integrity sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw== +"@jest/console@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" + integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== dependencies: - "@jest/types" "^28.1.3" + "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^28.1.3" - jest-util "^28.1.3" + jest-message-util "^29.7.0" + jest-util "^29.7.0" slash "^3.0.0" -"@jest/core@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-27.5.1.tgz#267ac5f704e09dc52de2922cbf3af9edcd64b626" - integrity sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ== +"@jest/core@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" + integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== dependencies: - "@jest/console" "^27.5.1" - "@jest/reporters" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" + "@jest/console" "^29.7.0" + "@jest/reporters" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - emittery "^0.8.1" + ci-info "^3.2.0" exit "^0.1.2" graceful-fs "^4.2.9" - jest-changed-files "^27.5.1" - jest-config "^27.5.1" - jest-haste-map "^27.5.1" - jest-message-util "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-resolve-dependencies "^27.5.1" - jest-runner "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - jest-watcher "^27.5.1" + jest-changed-files "^29.7.0" + jest-config "^29.7.0" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-resolve-dependencies "^29.7.0" + jest-runner "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + jest-watcher "^29.7.0" micromatch "^4.0.4" - rimraf "^3.0.0" + pretty-format "^29.7.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-27.5.1.tgz#d7425820511fe7158abbecc010140c3fd3be9c74" - integrity sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA== +"@jest/environment@^29.4.0", "@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== dependencies: - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" "@types/node" "*" - jest-mock "^27.5.1" + jest-mock "^29.7.0" -"@jest/fake-timers@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" - integrity sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ== +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== dependencies: - "@jest/types" "^27.5.1" - "@sinonjs/fake-timers" "^8.0.1" + jest-get-type "^29.6.3" + +"@jest/expect@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" + integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== + dependencies: + expect "^29.7.0" + jest-snapshot "^29.7.0" + +"@jest/fake-timers@^29.4.0", "@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== + dependencies: + "@jest/types" "^29.6.3" + "@sinonjs/fake-timers" "^10.0.2" "@types/node" "*" - jest-message-util "^27.5.1" - jest-mock "^27.5.1" - jest-util "^27.5.1" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" -"@jest/globals@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-27.5.1.tgz#7ac06ce57ab966566c7963431cef458434601b2b" - integrity sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q== +"@jest/globals@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" + integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== dependencies: - "@jest/environment" "^27.5.1" - "@jest/types" "^27.5.1" - expect "^27.5.1" + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/types" "^29.6.3" + jest-mock "^29.7.0" -"@jest/reporters@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-27.5.1.tgz#ceda7be96170b03c923c37987b64015812ffec04" - integrity sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw== +"@jest/reporters@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" + integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" + "@jest/console" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" "@types/node" "*" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" - glob "^7.1.2" + glob "^7.1.3" graceful-fs "^4.2.9" istanbul-lib-coverage "^3.0.0" - istanbul-lib-instrument "^5.1.0" + istanbul-lib-instrument "^6.0.0" istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.1.3" - jest-haste-map "^27.5.1" - jest-resolve "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + jest-worker "^29.7.0" slash "^3.0.0" - source-map "^0.6.0" string-length "^4.0.1" - terminal-link "^2.0.0" - v8-to-istanbul "^8.1.0" + strip-ansi "^6.0.0" + v8-to-istanbul "^9.0.1" -"@jest/schemas@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-28.1.3.tgz#ad8b86a66f11f33619e3d7e1dcddd7f2d40ff905" - integrity sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg== +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== dependencies: - "@sinclair/typebox" "^0.24.1" + "@sinclair/typebox" "^0.27.8" -"@jest/source-map@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" - integrity sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg== +"@jest/source-map@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" + integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== dependencies: + "@jridgewell/trace-mapping" "^0.3.18" callsites "^3.0.0" graceful-fs "^4.2.9" - source-map "^0.6.0" - -"@jest/test-result@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-27.5.1.tgz#56a6585fa80f7cdab72b8c5fc2e871d03832f5bb" - integrity sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag== - dependencies: - "@jest/console" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" -"@jest/test-result@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-28.1.3.tgz#5eae945fd9f4b8fcfce74d239e6f725b6bf076c5" - integrity sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg== +"@jest/test-result@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" + integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== dependencies: - "@jest/console" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/console" "^29.7.0" + "@jest/types" "^29.6.3" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^27.5.1": - version "27.5.1" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz#4057e0e9cea4439e544c6353c6affe58d095745b" - integrity sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ== +"@jest/test-sequencer@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" + integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== dependencies: - "@jest/test-result" "^27.5.1" + "@jest/test-result" "^29.7.0" graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-runtime "^27.5.1" + jest-haste-map "^29.7.0" + slash "^3.0.0" "@jest/transform@^27.5.1": version "27.5.1" @@ -2508,6 +2669,27 @@ source-map "^0.6.1" write-file-atomic "^3.0.0" +"@jest/transform@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" + integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== + dependencies: + "@babel/core" "^7.11.6" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + babel-plugin-istanbul "^6.1.1" + chalk "^4.0.0" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + micromatch "^4.0.4" + pirates "^4.0.4" + slash "^3.0.0" + write-file-atomic "^4.0.2" + "@jest/types@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.5.1.tgz#3c79ec4a8ba61c170bf937bcf9e98a9df175ec80" @@ -2519,12 +2701,12 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" -"@jest/types@^28.1.3": - version "28.1.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.3.tgz#b05de80996ff12512bc5ceb1d208285a7d11748b" - integrity sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ== +"@jest/types@^29.4.0", "@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== dependencies: - "@jest/schemas" "^28.1.3" + "@jest/schemas" "^29.6.3" "@types/istanbul-lib-coverage" "^2.0.0" "@types/istanbul-reports" "^3.0.0" "@types/node" "*" @@ -2563,7 +2745,7 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -2590,25 +2772,17 @@ dependencies: unist-util-visit "^1.4.1" -"@mswjs/cookies@^0.1.7": - version "0.1.7" - resolved "https://registry.yarnpkg.com/@mswjs/cookies/-/cookies-0.1.7.tgz#d334081b2c51057a61c1dd7b76ca3cac02251651" - integrity sha512-bDg1ReMBx+PYDB4Pk7y1Q07Zz1iKIEUWQpkEXiA2lEWg9gvOZ8UBmGXilCEUvyYoRFlmr/9iXTRR69TrgSwX/Q== - dependencies: - "@types/set-cookie-parser" "^2.4.0" - set-cookie-parser "^2.4.6" - -"@mswjs/interceptors@^0.12.7": - version "0.12.7" - resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.12.7.tgz#0d1cd4cd31a0f663e0455993951201faa09d0909" - integrity sha512-eGjZ3JRAt0Fzi5FgXiV/P3bJGj0NqsN7vBS0J0FO2AQRQ0jCKQS4lEFm4wvlSgKQNfeuc/Vz6d81VtU3Gkx/zg== +"@mswjs/interceptors@^0.37.0": + version "0.37.5" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.37.5.tgz#9ce40c56be02b43fcbdb51b63f47e69fc4aaabe6" + integrity sha512-AAwRb5vXFcY4L+FvZ7LZusDuZ0vEe0Zm8ohn1FM6/X7A3bj4mqmkAcGRWuvC2JwSygNwHAAmMnAI73vPHeqsHA== dependencies: - "@open-draft/until" "^1.0.3" - "@xmldom/xmldom" "^0.7.2" - debug "^4.3.2" - headers-utils "^3.0.2" - outvariant "^1.2.0" - strict-event-emitter "^0.2.0" + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" @@ -2638,10 +2812,23 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@open-draft/until@^1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" - integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0", "@open-draft/until@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": version "0.5.15" @@ -2709,6 +2896,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@remix-run/router@1.21.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.21.0.tgz#c65ae4262bdcfe415dbd4f64ec87676e4a56e2b5" + integrity sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA== + "@rollup/plugin-babel@^5.2.0", "@rollup/plugin-babel@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -2804,24 +2996,24 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1" integrity sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA== -"@sinclair/typebox@^0.24.1": - version "0.24.51" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" - integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA== +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sinonjs/commons@^1.7.0": - version "1.8.6" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9" - integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ== +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== dependencies: type-detect "4.0.8" -"@sinonjs/fake-timers@^8.0.1": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz#3fdc2b6cb58935b21bfb8d1625eb1300484316e7" - integrity sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg== +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== dependencies: - "@sinonjs/commons" "^1.7.0" + "@sinonjs/commons" "^3.0.0" "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" @@ -2978,10 +3170,10 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== -"@tootallnate/once@1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" - integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== "@trysound/sax@0.2.0": version "0.2.0" @@ -3019,7 +3211,7 @@ "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": version "7.20.6" resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.6.tgz#8dc9f0ae0f202c08d8d4dab648912c8d6038e3f7" integrity sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg== @@ -3056,10 +3248,10 @@ dependencies: "@types/node" "*" -"@types/cookie@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" - integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookie@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" + integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== "@types/eslint@^7.29.0 || ^8.4.1": version "8.56.12" @@ -3134,7 +3326,7 @@ "@types/minimatch" "*" "@types/node" "*" -"@types/graceful-fs@^4.1.2": +"@types/graceful-fs@^4.1.2", "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== @@ -3173,14 +3365,6 @@ dependencies: "@types/node" "*" -"@types/inquirer@^8.1.3": - version "8.2.10" - resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-8.2.10.tgz#9444dce2d764c35bc5bb4d742598aaa4acb6561b" - integrity sha512-IdD5NmHyVjWM8SHWo/kPBgtzXatwPkfwzyP3fN1jF2g9BWt5WO+8hL2F4o2GKIYsU40PpqeevuUWvkS/roXJkA== - dependencies: - "@types/through" "*" - rxjs "^7.2.0" - "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -3208,10 +3392,14 @@ jest-diff "^27.0.0" pretty-format "^27.0.0" -"@types/js-levenshtein@^1.1.0": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.3.tgz#a6fd0bdc8255b274e5438e0bfb25f154492d1106" - integrity sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ== +"@types/jsdom@^20.0.0": + version "20.0.1" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808" + integrity sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" "@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" @@ -3282,12 +3470,12 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154" integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg== -"@types/node@^20.16.13": - version "20.16.13" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.16.13.tgz#148c152d757dc73f8d65f0f6f078f39050b85b0c" - integrity sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg== +"@types/node@^22.12.0": + version "22.12.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.12.0.tgz#bf8af3b2af0837b5a62a368756ff2b705ae0048c" + integrity sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA== dependencies: - undici-types "~6.19.2" + undici-types "~6.20.0" "@types/numeral@^2.0.5": version "2.0.5" @@ -3304,11 +3492,6 @@ resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== -"@types/prettier@^2.1.5": - version "2.7.3" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" - integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA== - "@types/prismjs@*": version "1.26.0" resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.0.tgz#a1c3809b0ad61c62cac6d4e0c56d610c910b7654" @@ -3413,13 +3596,6 @@ "@types/node" "*" "@types/send" "*" -"@types/set-cookie-parser@^2.4.0": - version "2.4.10" - resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz#ad3a807d6d921db9720621ea3374c5d92020bcbc" - integrity sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw== - dependencies: - "@types/node" "*" - "@types/sockjs@^0.3.33": version "0.3.36" resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.36.tgz#ce322cf07bcc119d4cbf7f88954f3a3bd0f67535" @@ -3432,17 +3608,20 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/statuses@^2.0.4": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/statuses/-/statuses-2.0.5.tgz#f61ab46d5352fd73c863a1ea4e1cef3b0b51ae63" + integrity sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A== + "@types/stylis@4.2.5": version "4.2.5" resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.5.tgz#1daa6456f40959d06157698a653a9ab0a70281df" integrity sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw== -"@types/through@*": - version "0.0.33" - resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.33.tgz#14ebf599320e1c7851e7d598149af183c6b9ea56" - integrity sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ== - dependencies: - "@types/node" "*" +"@types/tough-cookie@*", "@types/tough-cookie@^4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" + integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== "@types/trusted-types@^2.0.2": version "2.0.7" @@ -3702,11 +3881,6 @@ "@webassemblyjs/ast" "1.12.1" "@xtuc/long" "4.2.2" -"@xmldom/xmldom@^0.7.2": - version "0.7.13" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3" - integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g== - "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -3717,7 +3891,7 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -abab@^2.0.3, abab@^2.0.5: +abab@^2.0.5, abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== @@ -3730,13 +3904,13 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -acorn-globals@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" - integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== +acorn-globals@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" + integrity sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q== dependencies: - acorn "^7.1.1" - acorn-walk "^7.1.1" + acorn "^8.1.0" + acorn-walk "^8.0.2" acorn-import-attributes@^1.9.5: version "1.9.5" @@ -3748,17 +3922,19 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^7.1.1: - version "7.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" - integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.0.2: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" -acorn@^7.1.1: - version "7.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" - integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.1.0, acorn@^8.11.0, acorn@^8.8.1: + version "8.14.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" + integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== -acorn@^8.2.4, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: version "8.13.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.13.0.tgz#2a30d670818ad16ddd6a35d3842dacec9e5d7ca3" integrity sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w== @@ -3822,13 +3998,18 @@ ajv@^8.0.0, ajv@^8.6.0, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" -ansi-escapes@^4.2.1, ansi-escapes@^4.3.1: +ansi-escapes@^4.2.1, ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== dependencies: type-fest "^0.21.3" +ansi-escapes@^6.0.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-6.2.1.tgz#76c54ce9b081dad39acec4b5d53377913825fb0f" + integrity sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig== + ansi-html-community@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" @@ -4072,7 +4253,7 @@ axobject-query@^4.1.0: resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== -babel-jest@^27.4.2, babel-jest@^27.5.1: +babel-jest@^27.4.2: version "27.5.1" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.5.1.tgz#a1bf8d61928edfefd21da27eb86a695bfd691444" integrity sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg== @@ -4086,6 +4267,19 @@ babel-jest@^27.4.2, babel-jest@^27.5.1: graceful-fs "^4.2.9" slash "^3.0.0" +babel-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" + integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== + dependencies: + "@jest/transform" "^29.7.0" + "@types/babel__core" "^7.1.14" + babel-plugin-istanbul "^6.1.1" + babel-preset-jest "^29.6.3" + chalk "^4.0.0" + graceful-fs "^4.2.9" + slash "^3.0.0" + babel-loader@^8.2.3: version "8.4.1" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.4.1.tgz#6ccb75c66e62c3b144e1c5f2eaec5b8f6c08c675" @@ -4117,6 +4311,16 @@ babel-plugin-jest-hoist@^27.5.1: "@types/babel__core" "^7.0.0" "@types/babel__traverse" "^7.0.6" +babel-plugin-jest-hoist@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" + integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.1.14" + "@types/babel__traverse" "^7.0.6" + babel-plugin-macros@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1" @@ -4189,6 +4393,14 @@ babel-preset-jest@^27.5.1: babel-plugin-jest-hoist "^27.5.1" babel-preset-current-node-syntax "^1.0.0" +babel-preset-jest@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" + integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== + dependencies: + babel-plugin-jest-hoist "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + babel-preset-react-app@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz#ed6005a20a24f2c88521809fa9aea99903751584" @@ -4221,11 +4433,6 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -4257,15 +4464,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -bl@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -4338,11 +4536,6 @@ broadcast-channel@^3.4.1: rimraf "3.0.2" unload "2.2.0" -browser-process-hrtime@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" - integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== - browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.21.10, browserslist@^4.21.4, browserslist@^4.23.3, browserslist@^4.24.0: version "4.24.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.0.tgz#a1325fe4bc80b64fda169629fc01b3d6cecd38d4" @@ -4365,14 +4558,6 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - builtin-modules@^3.1.0: version "3.3.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" @@ -4459,14 +4644,6 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== -chalk@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" - integrity sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -4484,7 +4661,7 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -4492,6 +4669,11 @@ chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.2.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" + integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -4522,11 +4704,6 @@ character-reference-invalid@^1.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== - check-types@^11.2.3: version "11.2.3" resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.2.3.tgz#1ffdf68faae4e941fce252840b1787b8edc93b71" @@ -4579,31 +4756,10 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - -cli-spinners@^2.5.0: - version "2.9.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" - integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== - -cli-width@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" - integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" +cli-width@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" + integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== cliui@^8.0.1: version "8.0.1" @@ -4614,11 +4770,6 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" -clone@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" - integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== - co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -4757,7 +4908,7 @@ content-type@~1.0.4, content-type@~1.0.5: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== @@ -4777,10 +4928,10 @@ cookie@0.7.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== -cookie@^0.4.1: - version "0.4.2" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" - integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +cookie@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== core-js-compat@^3.38.0, core-js-compat@^3.38.1: version "3.38.1" @@ -4836,6 +4987,19 @@ cosmiconfig@^8.1.3: parse-json "^5.2.0" path-type "^4.0.0" +create-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" + integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-config "^29.7.0" + jest-util "^29.7.0" + prompts "^2.0.1" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5050,10 +5214,10 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" -cssom@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" - integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== cssom@~0.3.6: version "0.3.8" @@ -5082,14 +5246,14 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== -data-urls@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" - integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== +data-urls@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== dependencies: - abab "^2.0.3" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" data-view-buffer@^1.0.1: version "1.0.1" @@ -5125,10 +5289,10 @@ debug@2.6.9, debug@^2.6.0: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== +debug@4: + version "4.4.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== dependencies: ms "^2.1.3" @@ -5139,20 +5303,27 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -decimal.js@^10.2.1: - version "10.4.3" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" - integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +decimal.js@^10.4.2: + version "10.5.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22" + integrity sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw== decode-uri-component@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== -dedent@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" - integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== +dedent@^1.0.0: + version "1.5.3" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" + integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== deep-equal@^2.0.5: version "2.2.3" @@ -5195,13 +5366,6 @@ default-gateway@^6.0.3: dependencies: execa "^5.0.0" -defaults@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" - integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== - dependencies: - clone "^1.0.2" - define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -5273,11 +5437,16 @@ detect-port-alt@^1.1.6: address "^1.0.1" debug "^2.6.0" -diff-sequences@^27.4.0, diff-sequences@^27.5.1: +diff-sequences@^27.4.0: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -5346,12 +5515,12 @@ domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domexception@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" - integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== dependencies: - webidl-conversions "^5.0.0" + webidl-conversions "^7.0.0" domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: version "4.3.1" @@ -5425,15 +5594,10 @@ electron-to-chromium@^1.5.28: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.41.tgz#eae1ba6c49a1a61d84cf8263351d3513b2bcc534" integrity sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ== -emittery@^0.10.2: - version "0.10.2" - resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.10.2.tgz#902eec8aedb8c41938c46e9385e9db7e03182933" - integrity sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw== - -emittery@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860" - integrity sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg== +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== emoji-regex@^8.0.0: version "8.0.0" @@ -5478,7 +5642,7 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== -entities@^4.2.0, entities@^4.4.0: +entities@^4.2.0, entities@^4.4.0, entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -5969,7 +6133,7 @@ eventemitter3@^4.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -events@^3.2.0, events@^3.3.0: +events@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -5994,15 +6158,16 @@ exit@^0.1.2: resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== -expect@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74" - integrity sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw== +expect@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== dependencies: - "@jest/types" "^27.5.1" - jest-get-type "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" express@^4.17.3: version "4.21.1" @@ -6046,15 +6211,6 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -external-editor@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" - integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -6114,13 +6270,6 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -figures@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" - integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== - dependencies: - escape-string-regexp "^1.0.5" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -6269,10 +6418,10 @@ fork-ts-checker-webpack-plugin@^6.5.0: semver "^7.3.2" tapable "^1.0.0" -form-data@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.2.tgz#83ad9ced7c03feaad97e293d6f6091011e1659c8" - integrity sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ== +form-data@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48" + integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" @@ -6430,7 +6579,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -6532,10 +6681,10 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -graphql@^15.5.1: - version "15.9.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.9.0.tgz#4e8ca830cfd30b03d44d3edd9cac2b0690304b53" - integrity sha512-GCOQdvm7XxV1S4U4CGrsdlEN37245eC8P9zaYCMr6K1BG0IPGy5lUwmJsEOGyl1GD6HXjOtl2keCP9asRBwNvA== +graphql@^16.8.1: + version "16.10.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c" + integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ== gzip-size@^6.0.0: version "6.0.0" @@ -6549,6 +6698,14 @@ handle-thing@^2.0.0: resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== +happy-dom@^16.7.3: + version "16.7.3" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-16.7.3.tgz#8f3033265c4e0d31bc7e68f8678676bb92f0e7f7" + integrity sha512-76uiE9jCpC849cOyYZ8YBROpPcstW/hwCKoQYd3aiZaxHeR9zdjpup4z7qYEWbt+lY8Rb3efW2gmrckyoBftKg== + dependencies: + webidl-conversions "^7.0.0" + whatwg-mimetype "^3.0.0" + harmony-reflect@^1.4.6: version "1.6.2" resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710" @@ -6700,10 +6857,10 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-utils@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/headers-utils/-/headers-utils-3.0.2.tgz#dfc65feae4b0e34357308aefbcafa99c895e59ef" - integrity sha512-xAxZkM1dRyGV2Ou5bzMxBPNLoRCjcX+ya7KSWybQD2KwLphxsapUVK6x/02o7f4VU6GPSXch9vNY2+gkU8tYWQ== +headers-polyfill@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-4.0.3.tgz#922a0155de30ecc1f785bcf04be77844ca95ad07" + integrity sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ== highlight.js@^10.4.1, highlight.js@~10.7.0: version "10.7.3" @@ -6715,13 +6872,6 @@ highlightjs-vue@^1.0.0: resolved "https://registry.yarnpkg.com/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz#fdfe97fbea6354e70ee44e3a955875e114db086d" integrity sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA== -history@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/history/-/history-5.2.0.tgz#7cdd31cf9bac3c5d31f09c231c9928fad0007b7c" - integrity sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig== - dependencies: - "@babel/runtime" "^7.7.6" - hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6744,12 +6894,12 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" -html-encoding-sniffer@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" - integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== dependencies: - whatwg-encoding "^1.0.5" + whatwg-encoding "^2.0.0" html-entities@^2.1.0, html-entities@^2.3.2: version "2.5.2" @@ -6831,12 +6981,12 @@ http-parser-js@>=0.5.1: resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== -http-proxy-agent@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" - integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== dependencies: - "@tootallnate/once" "1" + "@tootallnate/once" "2" agent-base "6" debug "4" @@ -6860,7 +7010,7 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" -https-proxy-agent@^5.0.0: +https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -6873,14 +7023,14 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.4.24, iconv-lite@^0.4.24: +iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -6904,11 +7054,6 @@ identity-obj-proxy@^3.0.0: dependencies: harmony-reflect "^1.4.6" -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - ignore@^5.1.1, ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -6953,7 +7098,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6973,27 +7118,6 @@ inline-style-parser@0.1.1: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== -inquirer@^8.2.0: - version "8.2.6" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562" - integrity sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg== - dependencies: - ansi-escapes "^4.2.1" - chalk "^4.1.1" - cli-cursor "^3.1.0" - cli-width "^3.0.0" - external-editor "^3.0.3" - figures "^3.0.0" - lodash "^4.17.21" - mute-stream "0.0.8" - ora "^5.4.1" - run-async "^2.4.0" - rxjs "^7.5.5" - string-width "^4.1.0" - strip-ansi "^6.0.0" - through "^2.3.6" - wrap-ansi "^6.0.1" - inter-ui@^3.19.3: version "3.19.3" resolved "https://registry.yarnpkg.com/inter-ui/-/inter-ui-3.19.3.tgz#cf4b4b6d30de8d5463e2462588654b325206488c" @@ -7170,11 +7294,6 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== -is-interactive@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" - integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== - is-map@^2.0.2, is-map@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" @@ -7190,7 +7309,7 @@ is-negative-zero@^2.0.3: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== -is-node-process@^1.0.1: +is-node-process@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== @@ -7310,11 +7429,6 @@ is-typedarray@^1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - is-weakmap@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" @@ -7372,7 +7486,7 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== -istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: +istanbul-lib-instrument@^5.0.4: version "5.2.1" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== @@ -7383,6 +7497,17 @@ istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: istanbul-lib-coverage "^3.2.0" semver "^6.3.0" +istanbul-lib-instrument@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + istanbul-lib-report@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" @@ -7430,85 +7555,83 @@ jake@^10.8.5: filelist "^1.0.4" minimatch "^3.1.2" -jest-changed-files@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5" - integrity sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw== +jest-changed-files@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" + integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== dependencies: - "@jest/types" "^27.5.1" execa "^5.0.0" - throat "^6.0.1" - -jest-circus@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-27.5.1.tgz#37a5a4459b7bf4406e53d637b49d22c65d125ecc" - integrity sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" + jest-util "^29.7.0" + p-limit "^3.1.0" + +jest-circus@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" + integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" - dedent "^0.7.0" - expect "^27.5.1" + dedent "^1.0.0" is-generator-fn "^2.0.0" - jest-each "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" + jest-each "^29.7.0" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + pretty-format "^29.7.0" + pure-rand "^6.0.0" slash "^3.0.0" stack-utils "^2.0.3" - throat "^6.0.1" -jest-cli@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-27.5.1.tgz#278794a6e6458ea8029547e6c6cbf673bd30b145" - integrity sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw== +jest-cli@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" + integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== dependencies: - "@jest/core" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" + "@jest/core" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" chalk "^4.0.0" + create-jest "^29.7.0" exit "^0.1.2" - graceful-fs "^4.2.9" import-local "^3.0.2" - jest-config "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" - prompts "^2.0.1" - yargs "^16.2.0" - -jest-config@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-27.5.1.tgz#5c387de33dca3f99ad6357ddeccd91bf3a0e4a41" - integrity sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA== - dependencies: - "@babel/core" "^7.8.0" - "@jest/test-sequencer" "^27.5.1" - "@jest/types" "^27.5.1" - babel-jest "^27.5.1" + jest-config "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + yargs "^17.3.1" + +jest-config@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" + integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== + dependencies: + "@babel/core" "^7.11.6" + "@jest/test-sequencer" "^29.7.0" + "@jest/types" "^29.6.3" + babel-jest "^29.7.0" chalk "^4.0.0" ci-info "^3.2.0" deepmerge "^4.2.2" - glob "^7.1.1" + glob "^7.1.3" graceful-fs "^4.2.9" - jest-circus "^27.5.1" - jest-environment-jsdom "^27.5.1" - jest-environment-node "^27.5.1" - jest-get-type "^27.5.1" - jest-jasmine2 "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-runner "^27.5.1" - jest-util "^27.5.1" - jest-validate "^27.5.1" + jest-circus "^29.7.0" + jest-environment-node "^29.7.0" + jest-get-type "^29.6.3" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-runner "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" micromatch "^4.0.4" parse-json "^5.2.0" - pretty-format "^27.5.1" + pretty-format "^29.7.0" slash "^3.0.0" strip-json-comments "^3.1.1" @@ -7522,64 +7645,70 @@ jest-diff@^27.0.0: jest-get-type "^27.4.0" pretty-format "^27.4.6" -jest-diff@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" - integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== dependencies: chalk "^4.0.0" - diff-sequences "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" -jest-docblock@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" - integrity sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ== +jest-docblock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" + integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== dependencies: detect-newline "^3.0.0" -jest-each@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-27.5.1.tgz#5bc87016f45ed9507fed6e4702a5b468a5b2c44e" - integrity sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ== +jest-each@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" + integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== dependencies: - "@jest/types" "^27.5.1" + "@jest/types" "^29.6.3" chalk "^4.0.0" - jest-get-type "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - -jest-environment-jsdom@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz#ea9ccd1fc610209655a77898f86b2b559516a546" - integrity sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" + jest-get-type "^29.6.3" + jest-util "^29.7.0" + pretty-format "^29.7.0" + +jest-environment-jsdom@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz#d206fa3551933c3fd519e5dfdb58a0f5139a837f" + integrity sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/jsdom" "^20.0.0" "@types/node" "*" - jest-mock "^27.5.1" - jest-util "^27.5.1" - jsdom "^16.6.0" - -jest-environment-node@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-27.5.1.tgz#dedc2cfe52fab6b8f5714b4808aefa85357a365e" - integrity sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/types" "^27.5.1" + jest-mock "^29.7.0" + jest-util "^29.7.0" + jsdom "^20.0.0" + +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" "@types/node" "*" - jest-mock "^27.5.1" - jest-util "^27.5.1" + jest-mock "^29.7.0" + jest-util "^29.7.0" -jest-get-type@^27.4.0, jest-get-type@^27.5.1: +jest-get-type@^27.4.0: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + jest-haste-map@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.5.1.tgz#9fd8bd7e7b4fa502d9c6164c5640512b4e811e7f" @@ -7600,84 +7729,66 @@ jest-haste-map@^27.5.1: optionalDependencies: fsevents "^2.3.2" -jest-jasmine2@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz#a037b0034ef49a9f3d71c4375a796f3b230d1ac4" - integrity sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ== +jest-haste-map@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" + integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== dependencies: - "@jest/environment" "^27.5.1" - "@jest/source-map" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" + "@jest/types" "^29.6.3" + "@types/graceful-fs" "^4.1.3" "@types/node" "*" - chalk "^4.0.0" - co "^4.6.0" - expect "^27.5.1" - is-generator-fn "^2.0.0" - jest-each "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-runtime "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" - pretty-format "^27.5.1" - throat "^6.0.1" - -jest-leak-detector@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz#6ec9d54c3579dd6e3e66d70e3498adf80fde3fb8" - integrity sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ== - dependencies: - jest-get-type "^27.5.1" - pretty-format "^27.5.1" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.9" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + jest-worker "^29.7.0" + micromatch "^4.0.4" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.2" -jest-matcher-utils@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" - integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== +jest-leak-detector@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" + integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== dependencies: - chalk "^4.0.0" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" -jest-message-util@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" - integrity sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g== +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^27.5.1" - "@types/stack-utils" "^2.0.0" chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^27.5.1" - slash "^3.0.0" - stack-utils "^2.0.3" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" -jest-message-util@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-28.1.3.tgz#232def7f2e333f1eecc90649b5b94b0055e7c43d" - integrity sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g== +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== dependencies: "@babel/code-frame" "^7.12.13" - "@jest/types" "^28.1.3" + "@jest/types" "^29.6.3" "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.9" micromatch "^4.0.4" - pretty-format "^28.1.3" + pretty-format "^29.7.0" slash "^3.0.0" stack-utils "^2.0.3" -jest-mock@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" - integrity sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og== +jest-mock@^29.4.0, jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== dependencies: - "@jest/types" "^27.5.1" + "@jest/types" "^29.6.3" "@types/node" "*" + jest-util "^29.7.0" jest-pnp-resolver@^1.2.2: version "1.2.3" @@ -7689,88 +7800,86 @@ jest-regex-util@^27.5.1: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-27.5.1.tgz#4da143f7e9fd1e542d4aa69617b38e4a78365b95" integrity sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg== -jest-regex-util@^28.0.0: - version "28.0.2" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-28.0.2.tgz#afdc377a3b25fb6e80825adcf76c854e5bf47ead" - integrity sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw== +jest-regex-util@^29.0.0, jest-regex-util@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" + integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== -jest-resolve-dependencies@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz#d811ecc8305e731cc86dd79741ee98fed06f1da8" - integrity sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg== +jest-resolve-dependencies@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" + integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== dependencies: - "@jest/types" "^27.5.1" - jest-regex-util "^27.5.1" - jest-snapshot "^27.5.1" + jest-regex-util "^29.6.3" + jest-snapshot "^29.7.0" -jest-resolve@^27.4.2, jest-resolve@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-27.5.1.tgz#a2f1c5a0796ec18fe9eb1536ac3814c23617b384" - integrity sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw== +jest-resolve@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" + integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== dependencies: - "@jest/types" "^27.5.1" chalk "^4.0.0" graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" + jest-haste-map "^29.7.0" jest-pnp-resolver "^1.2.2" - jest-util "^27.5.1" - jest-validate "^27.5.1" + jest-util "^29.7.0" + jest-validate "^29.7.0" resolve "^1.20.0" - resolve.exports "^1.1.0" + resolve.exports "^2.0.0" slash "^3.0.0" -jest-runner@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-27.5.1.tgz#071b27c1fa30d90540805c5645a0ec167c7b62e5" - integrity sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ== +jest-runner@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" + integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== dependencies: - "@jest/console" "^27.5.1" - "@jest/environment" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" + "@jest/console" "^29.7.0" + "@jest/environment" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" - emittery "^0.8.1" + emittery "^0.13.1" graceful-fs "^4.2.9" - jest-docblock "^27.5.1" - jest-environment-jsdom "^27.5.1" - jest-environment-node "^27.5.1" - jest-haste-map "^27.5.1" - jest-leak-detector "^27.5.1" - jest-message-util "^27.5.1" - jest-resolve "^27.5.1" - jest-runtime "^27.5.1" - jest-util "^27.5.1" - jest-worker "^27.5.1" - source-map-support "^0.5.6" - throat "^6.0.1" - -jest-runtime@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-27.5.1.tgz#4896003d7a334f7e8e4a53ba93fb9bcd3db0a1af" - integrity sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A== - dependencies: - "@jest/environment" "^27.5.1" - "@jest/fake-timers" "^27.5.1" - "@jest/globals" "^27.5.1" - "@jest/source-map" "^27.5.1" - "@jest/test-result" "^27.5.1" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" + jest-docblock "^29.7.0" + jest-environment-node "^29.7.0" + jest-haste-map "^29.7.0" + jest-leak-detector "^29.7.0" + jest-message-util "^29.7.0" + jest-resolve "^29.7.0" + jest-runtime "^29.7.0" + jest-util "^29.7.0" + jest-watcher "^29.7.0" + jest-worker "^29.7.0" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" + integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/globals" "^29.7.0" + "@jest/source-map" "^29.6.3" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" chalk "^4.0.0" cjs-module-lexer "^1.0.0" collect-v8-coverage "^1.0.0" - execa "^5.0.0" glob "^7.1.3" graceful-fs "^4.2.9" - jest-haste-map "^27.5.1" - jest-message-util "^27.5.1" - jest-mock "^27.5.1" - jest-regex-util "^27.5.1" - jest-resolve "^27.5.1" - jest-snapshot "^27.5.1" - jest-util "^27.5.1" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" slash "^3.0.0" strip-bom "^4.0.0" @@ -7782,33 +7891,31 @@ jest-serializer@^27.5.1: "@types/node" "*" graceful-fs "^4.2.9" -jest-snapshot@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-27.5.1.tgz#b668d50d23d38054a51b42c4039cab59ae6eb6a1" - integrity sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA== +jest-snapshot@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" + integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== dependencies: - "@babel/core" "^7.7.2" + "@babel/core" "^7.11.6" "@babel/generator" "^7.7.2" + "@babel/plugin-syntax-jsx" "^7.7.2" "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/traverse" "^7.7.2" - "@babel/types" "^7.0.0" - "@jest/transform" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/babel__traverse" "^7.0.4" - "@types/prettier" "^2.1.5" + "@babel/types" "^7.3.3" + "@jest/expect-utils" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" babel-preset-current-node-syntax "^1.0.0" chalk "^4.0.0" - expect "^27.5.1" + expect "^29.7.0" graceful-fs "^4.2.9" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - jest-haste-map "^27.5.1" - jest-matcher-utils "^27.5.1" - jest-message-util "^27.5.1" - jest-util "^27.5.1" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" natural-compare "^1.4.0" - pretty-format "^27.5.1" - semver "^7.3.2" + pretty-format "^29.7.0" + semver "^7.5.3" jest-util@^27.5.1: version "27.5.1" @@ -7822,68 +7929,55 @@ jest-util@^27.5.1: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-util@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-28.1.3.tgz#f4f932aa0074f0679943220ff9cbba7e497028b0" - integrity sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ== +jest-util@^29.4.0, jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== dependencies: - "@jest/types" "^28.1.3" + "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" ci-info "^3.2.0" graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-validate@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" - integrity sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ== +jest-validate@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" + integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== dependencies: - "@jest/types" "^27.5.1" + "@jest/types" "^29.6.3" camelcase "^6.2.0" chalk "^4.0.0" - jest-get-type "^27.5.1" + jest-get-type "^29.6.3" leven "^3.1.0" - pretty-format "^27.5.1" + pretty-format "^29.7.0" -jest-watch-typeahead@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz#b4a6826dfb9c9420da2f7bc900de59dad11266a9" - integrity sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw== - dependencies: - ansi-escapes "^4.3.1" - chalk "^4.0.0" - jest-regex-util "^28.0.0" - jest-watcher "^28.0.0" - slash "^4.0.0" +jest-watch-typeahead@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/jest-watch-typeahead/-/jest-watch-typeahead-2.2.2.tgz#5516d3cd006485caa5cfc9bd1de40f1f8b136abf" + integrity sha512-+QgOFW4o5Xlgd6jGS5X37i08tuuXNW8X0CV9WNFi+3n8ExCIP+E1melYhvYLjv5fE6D0yyzk74vsSO8I6GqtvQ== + dependencies: + ansi-escapes "^6.0.0" + chalk "^5.2.0" + jest-regex-util "^29.0.0" + jest-watcher "^29.0.0" + slash "^5.0.0" string-length "^5.0.1" strip-ansi "^7.0.1" -jest-watcher@^27.5.1: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-27.5.1.tgz#71bd85fb9bde3a2c2ec4dc353437971c43c642a2" - integrity sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw== - dependencies: - "@jest/test-result" "^27.5.1" - "@jest/types" "^27.5.1" - "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - jest-util "^27.5.1" - string-length "^4.0.1" - -jest-watcher@^28.0.0: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-28.1.3.tgz#c6023a59ba2255e3b4c57179fc94164b3e73abd4" - integrity sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g== +jest-watcher@^29.0.0, jest-watcher@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" + integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== dependencies: - "@jest/test-result" "^28.1.3" - "@jest/types" "^28.1.3" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - emittery "^0.10.2" - jest-util "^28.1.3" + emittery "^0.13.1" + jest-util "^29.7.0" string-length "^4.0.1" jest-worker@^26.2.1: @@ -7913,19 +8007,25 @@ jest-worker@^28.0.2: merge-stream "^2.0.0" supports-color "^8.0.0" -jest@^27.4.3: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest/-/jest-27.5.1.tgz#dadf33ba70a779be7a6fc33015843b51494f63fc" - integrity sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ== +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== dependencies: - "@jest/core" "^27.5.1" - import-local "^3.0.2" - jest-cli "^27.5.1" + "@types/node" "*" + jest-util "^29.7.0" + merge-stream "^2.0.0" + supports-color "^8.0.0" -js-levenshtein@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" - integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== +jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" + integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== + dependencies: + "@jest/core" "^29.7.0" + "@jest/types" "^29.6.3" + import-local "^3.0.2" + jest-cli "^29.7.0" js-sha3@0.8.0: version "0.8.0" @@ -7980,38 +8080,37 @@ jsdoc@^4.0.0: strip-json-comments "^3.1.0" underscore "~1.13.2" -jsdom@^16.6.0: - version "16.7.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" - integrity sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw== +jsdom@^20.0.0: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db" + integrity sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ== dependencies: - abab "^2.0.5" - acorn "^8.2.4" - acorn-globals "^6.0.0" - cssom "^0.4.4" + abab "^2.0.6" + acorn "^8.8.1" + acorn-globals "^7.0.0" + cssom "^0.5.0" cssstyle "^2.3.0" - data-urls "^2.0.0" - decimal.js "^10.2.1" - domexception "^2.0.1" + data-urls "^3.0.2" + decimal.js "^10.4.2" + domexception "^4.0.0" escodegen "^2.0.0" - form-data "^3.0.0" - html-encoding-sniffer "^2.0.1" - http-proxy-agent "^4.0.1" - https-proxy-agent "^5.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" is-potential-custom-element-name "^1.0.1" - nwsapi "^2.2.0" - parse5 "6.0.1" - saxes "^5.0.1" + nwsapi "^2.2.2" + parse5 "^7.1.1" + saxes "^6.0.0" symbol-tree "^3.2.4" - tough-cookie "^4.0.0" - w3c-hr-time "^1.0.2" - w3c-xmlserializer "^2.0.0" - webidl-conversions "^6.1.0" - whatwg-encoding "^1.0.5" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.5.0" - ws "^7.4.6" - xml-name-validator "^3.0.0" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.11.0" + xml-name-validator "^4.0.0" jsesc@^3.0.2, jsesc@~3.0.2: version "3.0.2" @@ -8253,23 +8352,15 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -log-symbols@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -long@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/long/-/long-5.2.0.tgz#2696dadf4b4da2ce3f6f6b89186085d94d52fd61" - integrity sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w== +long@^5.0.0, long@^5.2.3: + version "5.2.3" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" + integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" @@ -8553,31 +8644,29 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -msw@^0.36.8: - version "0.36.8" - resolved "https://registry.yarnpkg.com/msw/-/msw-0.36.8.tgz#33ff8bfb0299626a95f43d0e4c3dc2c73c17f1ba" - integrity sha512-K7lOQoYqhGhTSChsmHMQbf/SDCsxh/m0uhN6Ipt206lGoe81fpTmaGD0KLh4jUxCONMOUnwCSj0jtX2CM4pEdw== - dependencies: - "@mswjs/cookies" "^0.1.7" - "@mswjs/interceptors" "^0.12.7" - "@open-draft/until" "^1.0.3" - "@types/cookie" "^0.4.1" - "@types/inquirer" "^8.1.3" - "@types/js-levenshtein" "^1.1.0" - chalk "4.1.1" - chokidar "^3.4.2" - cookie "^0.4.1" - graphql "^15.5.1" - headers-utils "^3.0.2" - inquirer "^8.2.0" - is-node-process "^1.0.1" - js-levenshtein "^1.1.6" - node-fetch "^2.6.7" - path-to-regexp "^6.2.0" - statuses "^2.0.0" - strict-event-emitter "^0.2.0" - type-fest "^1.2.2" - yargs "^17.3.0" +msw@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/msw/-/msw-2.7.0.tgz#d13ff87f7e018fc4c359800ff72ba5017033fb56" + integrity sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw== + dependencies: + "@bundled-es-modules/cookie" "^2.0.1" + "@bundled-es-modules/statuses" "^1.0.1" + "@bundled-es-modules/tough-cookie" "^0.1.6" + "@inquirer/confirm" "^5.0.0" + "@mswjs/interceptors" "^0.37.0" + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/until" "^2.1.0" + "@types/cookie" "^0.6.0" + "@types/statuses" "^2.0.4" + graphql "^16.8.1" + headers-polyfill "^4.0.2" + is-node-process "^1.2.0" + outvariant "^1.4.3" + path-to-regexp "^6.3.0" + picocolors "^1.1.1" + strict-event-emitter "^0.5.1" + type-fest "^4.26.1" + yargs "^17.7.2" multicast-dns@^7.2.5: version "7.2.5" @@ -8587,10 +8676,10 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" -mute-stream@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" - integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +mute-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-2.0.0.tgz#a5446fc0c512b71c83c44d908d5c7b7b4c493b2b" + integrity sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA== nano-time@1.0.0: version "1.0.0" @@ -8639,13 +8728,6 @@ node-emoji@^1.10.0: dependencies: lodash "^4.17.21" -node-fetch@^2.6.7: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - node-forge@^1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -8695,7 +8777,7 @@ numeral@^2.0.6: resolved "https://registry.yarnpkg.com/numeral/-/numeral-2.0.6.tgz#4ad080936d443c2561aed9f2197efffe25f4e506" integrity sha1-StCAk21EPCVhrtnyGX7//iX05QY= -nwsapi@^2.2.0: +nwsapi@2.2.13, nwsapi@^2.2.2: version "2.2.13" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.13.tgz#e56b4e98960e7a040e5474536587e599c4ff4655" integrity sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ== @@ -8799,7 +8881,7 @@ once@^1.3.0: dependencies: wrappy "1" -onetime@^5.1.0, onetime@^5.1.2: +onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -8839,27 +8921,7 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" -ora@^5.4.1: - version "5.4.1" - resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" - integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== - dependencies: - bl "^4.1.0" - chalk "^4.1.0" - cli-cursor "^3.1.0" - cli-spinners "^2.5.0" - is-interactive "^1.0.0" - is-unicode-supported "^0.1.0" - log-symbols "^4.1.0" - strip-ansi "^6.0.0" - wcwidth "^1.0.1" - -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== - -outvariant@^1.2.0: +outvariant@^1.4.0, outvariant@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== @@ -8871,7 +8933,7 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^3.0.2: +p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -8949,11 +9011,18 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5@6.0.1, parse5@^6.0.0: +parse5@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parse5@^7.0.0, parse5@^7.1.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.2.1.tgz#8928f55915e6125f430cc44309765bf17556a33a" + integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ== + dependencies: + entities "^4.5.0" + parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -8997,7 +9066,7 @@ path-to-regexp@0.1.10: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== -path-to-regexp@^6.2.0: +path-to-regexp@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== @@ -9012,7 +9081,7 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0: +picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -9600,7 +9669,7 @@ pretty-format@^27.0.0: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^27.0.2, pretty-format@^27.4.6, pretty-format@^27.5.1: +pretty-format@^27.0.2, pretty-format@^27.4.6: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== @@ -9609,13 +9678,12 @@ pretty-format@^27.0.2, pretty-format@^27.4.6, pretty-format@^27.5.1: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^28.1.3: - version "28.1.3" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-28.1.3.tgz#c9fba8cedf99ce50963a11b27d982a9ae90970d5" - integrity sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q== +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== dependencies: - "@jest/schemas" "^28.1.3" - ansi-regex "^5.0.1" + "@jest/schemas" "^29.6.3" ansi-styles "^5.0.0" react-is "^18.0.0" @@ -9708,20 +9776,27 @@ proxy-addr@~2.0.7: ipaddr.js "1.9.1" psl@^1.1.33: - version "1.9.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" - integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + version "1.15.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" punycode.js@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +pure-rand@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" + integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== + qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" @@ -9962,20 +10037,20 @@ react-remove-scroll@^2.6.0: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" -react-router-dom@<6.4.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" - integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== +react-router-dom@^6.28.0: + version "6.28.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.28.0.tgz#f73ebb3490e59ac9f299377062ad1d10a9f579e6" + integrity sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg== dependencies: - history "^5.2.0" - react-router "6.3.0" + "@remix-run/router" "1.21.0" + react-router "6.28.0" -react-router@6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" - integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== +react-router@6.28.0: + version "6.28.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.28.0.tgz#29247c86d7ba901d7e5a13aa79a96723c3e59d0d" + integrity sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg== dependencies: - history "^5.2.0" + "@remix-run/router" "1.21.0" react-style-singleton@^2.2.1: version "2.2.1" @@ -10031,7 +10106,7 @@ readable-stream@^2.0.1: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.4.0: +readable-stream@^3.0.6: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -10301,10 +10376,10 @@ resolve-url-loader@^5.0.0: postcss "^8.2.14" source-map "0.6.1" -resolve.exports@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.1.tgz#05cfd5b3edf641571fd46fa608b610dda9ead999" - integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ== +resolve.exports@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" + integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.4: version "1.22.8" @@ -10333,14 +10408,6 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== - dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" - retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -10351,7 +10418,7 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@3.0.2, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -10415,11 +10482,6 @@ rollup@^2.43.1, rollup@^2.68.0: optionalDependencies: fsevents "~2.3.2" -run-async@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" - integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== - run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -10427,13 +10489,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^7.2.0, rxjs@^7.5.5: - version "7.8.1" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" - integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== - dependencies: - tslib "^2.1.0" - safe-array-concat@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" @@ -10481,10 +10536,10 @@ sass-loader@^12.3.0: klona "^2.0.4" neo-async "^2.6.2" -saxes@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" - integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== dependencies: xmlchars "^2.2.0" @@ -10588,10 +10643,10 @@ serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" -serialize-query-params@^1.3.5: - version "1.3.6" - resolved "https://registry.yarnpkg.com/serialize-query-params/-/serialize-query-params-1.3.6.tgz#5dd5225db85ce747fe6fbc4897628504faafec6d" - integrity sha512-VlH7sfWNyPVZClPkRacopn6sn5uQMXBsjPVz1+pBHX895VpcYVznfJtZ49e6jymcrz+l/vowkepCZn/7xEAEdw== +serialize-query-params@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/serialize-query-params/-/serialize-query-params-2.0.2.tgz#598a3fb9e13f4ea1c1992fbd20231aa16b31db81" + integrity sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q== serve-index@^1.9.1: version "1.9.1" @@ -10616,11 +10671,6 @@ serve-static@1.16.2: parseurl "~1.3.3" send "0.19.0" -set-cookie-parser@^2.4.6: - version "2.7.0" - resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz#ef5552b56dc01baae102acb5fc9fb8cd060c30f9" - integrity sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ== - set-function-length@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -10685,11 +10735,16 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -signal-exit@^3.0.2, signal-exit@^3.0.3: +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -10700,10 +10755,10 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slash@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" - integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +slash@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== snake-case@^3.0.4: version "3.0.4" @@ -10741,7 +10796,15 @@ source-map-loader@^3.0.0: iconv-lite "^0.6.3" source-map-js "^1.0.1" -source-map-support@^0.5.6, source-map-support@~0.5.20: +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -10843,7 +10906,7 @@ static-eval@2.0.2: dependencies: escodegen "^1.8.1" -statuses@2.0.1, statuses@^2.0.0: +statuses@2.0.1, statuses@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== @@ -10860,12 +10923,10 @@ stop-iteration-iterator@^1.0.0: dependencies: internal-slot "^1.0.4" -strict-event-emitter@^0.2.0: - version "0.2.8" - resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz#b4e768927c67273c14c13d20e19d5e6c934b47ca" - integrity sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A== - dependencies: - events "^3.3.0" +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== strict-uri-encode@^2.0.0: version "2.0.0" @@ -11109,14 +11170,6 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" -supports-hyperlinks@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz#3943544347c1ff90b15effb03fc14ae45ec10624" - integrity sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA== - dependencies: - has-flag "^4.0.0" - supports-color "^7.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -11188,14 +11241,6 @@ tempy@^0.6.0: type-fest "^0.16.0" unique-string "^2.0.0" -terminal-link@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" - integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== - dependencies: - ansi-escapes "^4.2.1" - supports-hyperlinks "^2.0.0" - terser-webpack-plugin@^5.2.5, terser-webpack-plugin@^5.3.10: version "5.3.10" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" @@ -11236,16 +11281,6 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -throat@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.2.tgz#51a3fbb5e11ae72e2cf74861ed5c8020f89f29fe" - integrity sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ== - -through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - thunky@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" @@ -11256,13 +11291,6 @@ tiny-invariant@^1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - tmp@^0.2.1: version "0.2.3" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" @@ -11290,7 +11318,7 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -tough-cookie@^4.0.0: +tough-cookie@^4.1.2, tough-cookie@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== @@ -11307,18 +11335,13 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -tr46@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" - integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== dependencies: punycode "^2.1.1" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - trim-trailing-lines@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz#bd4abbec7cc880462f10b2c8b5ce1d8d1ec7c2c0" @@ -11410,10 +11433,10 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -type-fest@^1.2.2: - version "1.4.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" - integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== +type-fest@^4.26.1: + version "4.33.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.33.0.tgz#2da0c135b9afa76cf8b18ecfd4f260ecd414a432" + integrity sha512-s6zVrxuyKbbAsSAD5ZPTB77q4YIdRctkTbJ2/Dqlinwz+8ooH2gd+YA7VA6Pa93KML9GockVvoxjZ2vHP+mu8g== type-is@~1.6.18: version "1.6.18" @@ -11474,10 +11497,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@~5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" + integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0" @@ -11514,6 +11537,11 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + unherit@^1.0.4: version "1.1.3" resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" @@ -11709,12 +11737,12 @@ use-memo-one@^1.1.3: resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== -use-query-params@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/use-query-params/-/use-query-params-1.2.3.tgz#306c31a0cbc714e8a3b4bd7e91a6a9aaccaa5e22" - integrity sha512-cdG0tgbzK+FzsV6DAt2CN8Saa3WpRnze7uC4Rdh7l15epSFq7egmcB/zuREvPNwO5Yk80nUpDZpiyHsoq50d8w== +use-query-params@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/use-query-params/-/use-query-params-2.2.1.tgz#c558ab70706f319112fbccabf6867b9f904e947d" + integrity sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q== dependencies: - serialize-query-params "^1.3.5" + serialize-query-params "^2.0.2" use-sidecar@^1.1.2: version "1.1.2" @@ -11749,14 +11777,14 @@ uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -v8-to-istanbul@^8.1.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed" - integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w== +v8-to-istanbul@^9.0.1: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== dependencies: + "@jridgewell/trace-mapping" "^0.3.12" "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^1.6.0" - source-map "^0.7.3" + convert-source-map "^2.0.0" vary@~1.1.2: version "1.1.2" @@ -11786,21 +11814,14 @@ vfile@^4.0.0, vfile@^4.2.1: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" -w3c-hr-time@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" - integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== - dependencies: - browser-process-hrtime "^1.0.0" - -w3c-xmlserializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" - integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== +w3c-xmlserializer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" + integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw== dependencies: - xml-name-validator "^3.0.0" + xml-name-validator "^4.0.0" -walker@^1.0.7: +walker@^1.0.7, walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== @@ -11822,37 +11843,20 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" -wcwidth@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" - integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== - dependencies: - defaults "^1.0.3" - web-namespaces@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== -webidl-conversions@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" - integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== - -webidl-conversions@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" - integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== webpack-dev-middleware@^5.3.4: version "5.3.4" @@ -11973,30 +11977,30 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== -whatwg-encoding@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" - integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== dependencies: - iconv-lite "0.4.24" + iconv-lite "0.6.3" whatwg-fetch@^3.6.2: version "3.6.20" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg== -whatwg-mimetype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" - integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" + tr46 "^3.0.0" + webidl-conversions "^7.0.0" whatwg-url@^7.0.0: version "7.1.0" @@ -12007,15 +12011,6 @@ whatwg-url@^7.0.0: tr46 "^1.0.1" webidl-conversions "^4.0.2" -whatwg-url@^8.0.0, whatwg-url@^8.5.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" - integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== - dependencies: - lodash "^4.7.0" - tr46 "^2.1.0" - webidl-conversions "^6.1.0" - which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -12254,7 +12249,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -wrap-ansi@^6.0.1: +wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== @@ -12287,20 +12282,23 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -ws@^7.4.6: - version "7.5.10" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" - integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== +write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^3.0.7" -ws@^8.13.0: +ws@^8.11.0, ws@^8.13.0: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== -xml-name-validator@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" - integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== xmlchars@^2.2.0: version "2.2.0" @@ -12332,30 +12330,12 @@ yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - -yargs@^17.3.0: +yargs@^17.3.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== @@ -12373,6 +12353,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yoctocolors-cjs@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242" + integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== + zod@^3.11.6: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060"

eKw*P9#$^nJNI40Q&H4F*lq3(tx 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⁢-VLB@~hQ)bX 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 *w4@qbk8M5vnO8*vs z!F@ha8IypMpR{mEdkKA*r;V5Wn{ke~5h-5M$a?Gf(b`8Y&{Jkhe)=$AbaEYB7~z=2 zLay9;xQI?$e4DPHc#$2S9EAbKDrltjk&ZLzbc#X(-D}KoCOEE5Pj4}KZG8r7f9KMe zX9c+F!X+ws?=xQT3dzAIVZ!0am=OpRzl$YFq&PddkW_ioCNc9?rdaDJa61e45i!`;Lyq$Nal*d z_5JeXVSf&7uy_HP8>;C7F0*w%;SjT5R11c`?}q?|t+?gJF7$tuj$75Mp>*mzk~FCb z7n$|Zss0P0R(&q!xUU7dSBV@?QGpIGXa$c6RdC{BE0JO@aBHl6@M4H#zAfSIwX=D! z#7GC%MmF^0c;i?2b1)0X4&OcSh{aFjhJwPaK9Rgi!*TB%~gCwAI_(i zVamiT2_kEZ_p_$XYVcrZ0bzW;^CT|yB2Tvgbes=S4~eNTrKOct$DU_(I`xU#c?me? zXawG^ZSdvXLfHN%n6BPF5hD*3k)+!i=#!hnv{ikinR@Ra{IWXsduW5J&@l66Sq#~D zK?cs~bH1(AB1kdfdh;_%;eBodIW|3kuG0!-R>)6;jUGX8Qq2_3Zs{cww=U9krL*yy zno8uWyQi?xG8k7)NyhzKztX;KU-a|sjuZ0%U2?x;9yaJJpqhavsw@)&AHAg{F0tT#5$d2fe$G%eMYwIDXu9NmK~*gBK$S zbkLv6SozC>+LjHtWMv;IQPfAJUq6ZArc$uB@J7e}1hjv=3oY$-();R>#A$&CG@9IF z#zw4guFF}DFD%3hdRByoMyAlFdm><7@Q6M<$gz+9{fpmTOR>Qh!;}7lZ*Um5goS`r z^G5hQiBtB?$cEM#(zrDDC;cnXO_nciCQ%c=pq$bq4Ahq5FyxoPTrGrNO)4Qu$GXV3 zmP%%Z^D21QcaYUr9w)1;t4WnHryBe`f#20+hsE40seV--^U`}6>^e8d4oLnMPn{!4 z%#OQ2=^f;b&Who!K6hJN4YIpeO5lyx*&OaInuL{~#!i6@aGbfH|NE{DlcTKynxja6 zJX*q780HdHzq#Oc+6Jp;U!e|%wOA2}ZZ>q8I67r*!oBhw4h3y-lZ}Xhy6{8#NVXV7 z79Aj710@)Bnfn)Z6_6i+xA`5Tjv#jLH)NEJv-h}T+XV8Op8ght>O5=mY{(OW)g!T+ z%dLKiy@3u(t4MguYRp}q%MELN$ab+(>Y(I@GuAX<&-R_9?a?Q8b(|@5yxs~0hKKOg zy*Q#We}vU_RK_uv3zWTeoSM|+;5qfvG|ejnv{eD@+O5gq;sBzqyBWE}8%%Ip4>KYR zamvI6boGKTT5vp@i-w;e-;0WX*K7|>O6tJATE~%oV%XOc2VvisC}}F0g$7G50PnXN z1pJaDtJjEP(NG+nrWDS-_Wuxf(IZ6Vq%attm7wB=+OWU#){Sc}dbA_51@ynQVhil% z5cD}PI7a}*O_dp*;#54Kwu(;F&IT{ZNOC{El9o(pB@#SMdN`^9ZYO)f#hgR9BD5M# zO$@;F(P-SKeToiVNny=B;^F6!Uv#gMDsfn;0FRAt;GxN;^hI_M)2kDK(n~qTo7+}! z5?YU4=PPKyMp+Q6*#cT=niWTH2av~yP1zuQHQ1B=A1*D^!`DMz+132=I69=Vhtl; zXH4#M=#sU2^GU0`CG4&>0C%^~U^5(t6ElS2q+dQ3#2-S7smt-R{1RC8YbDSbmx+#K z0(3oHNKV>+2FGY^qU3dm+VpUB$GBL)%sZ%6J50wVn!sSg4_5BlGxi}KB_ALBUh>KyJjKXZt?~na?2uhxB1jHQkGr~NF?qPpTUKg`38*|l=i+L@XMf+ z>@-e+W!eHT=W#WC#Ua;TN%*q@qml#{CD9s%1l~cxY}D%gM89}W!B_WPG3H$pbrh%u zBdJooj8`Rjd64tMqi1WEEZdlhsJU5aI z*E@o7uIO&KM4o~CdSMv;GMmhM@rTh`J4g~fWsuy5o#a9PIF<1J#Eu=HU~}d+v#Da7 zM!%%QN8f}0p+>~OsKOP*`|lCG)xy}hK@pryzmc7y4{0=~3T#`GO8xS`k(lDcFi$}R zriLZZwb9Pt5bVuAx${Qk+TbOy;A||u=1>(+eQoKx#3Gm@UXNsL6#0!x`1bJ-Ii4iS zRe7W#Vw)Devo^u_xFTF!9sy1fQurx!8i&(Spi6tDP=>3_Y1FMpp~ri0px=PijNpzy zUE-HD4V4fYVSyu#m_}z@@TPylh!JuOQUSWKbn}U@yWw1^?5wDhTb?^8n$W)jMPp_l2UB35miJKj$lfx>th^^dewr;%#(;j#c&Bvy| z309blj?Q5-+6i^}vXAi6){=^fKhSd_n8&d9K!kJq`&mY z2~S3}IG4zcweqJQ_D5SIX)0BjMhBKHz?qq0#4oFof1ImXOew5n(`U?r1)sPWbbUT; z$?&9E{g(Jw-)B;g5|d*_K^T#m7`o@NX1;9Q1|I zX}-A4p_;eID~LR5I)ta!uOKf(>`7piDLcpQBz)Q{Zm{rPJWXC1gteGUeH3T1&Cjpi zFj#aF8fI0%l+;i<{&6uT$;3jCP8h0&r$LR^cV-2*{7@H}23KaU!=}j}=pvHEX@PAa zut$*^rhcH4U59zA!{^e++fq^1i&N3`aa*M3Xz&^P1xf#nGUrl7IIZzq>gP2Nj~N)i zHs&^2(5}j9tp6XOsDmwKNtA}(Cz0mX$e(xsQ}`=kc-s-Oi5G&u#5KuqW*1KP9A}R; zeB)h_O`!3`kNGoaXhE>5EAW;_FzKEr%grDJo%wh! zz>C$)u_E(5uc6$bpTsB1k&KniqT%k6m~=#)JW{MB@xB+47m=(lEgwLGKZ}8!d^oDJ z>+tRIRVXK`$nNm)!rSB?nYe{hNl5=9X!ekcdpKiw(kAI#4Nr9 zxGjxVsei-`(>8LbfUf835-6{-qG{KE-Ux=$jACdazMN32rXnfUGytktg zr+B8~jAjkGIi-bItgWYKqrVd=?E>(~I?pqCa}3LeZZo%+T!b%2TB!4@32-ZKE=+jp zK!5DaAxh8W;mC59cTPqMqVt1^_?(M$ar|?lz33*L{Yi_Q-EkWB24)i}nX@E6_dD}I z<2>fAc|=ZX+~FCXd4iqCq#?Tm>FOh8_*C>kwu3ua}bT<=W`C zPZQE>m!roPRZMD%LxYFzbXa%+3^uKX%!E*AbK3wL!_sK&y&mGVrIPH~G(!JW?SZ4? z#t=RtiWX`17=5>r9{%wNg*z=FM6`_>>g0i4R3PXHr7?S-q|qYrC|dkrGk!~QLv!zW zqS*;Fox8fo(dB)# zW$xtiZMhfVlipLbJ*G{Dc?Gz<>kv~v#e&-Q?Ia^*_tB@Um2ry>Wp`v8M$yA1CcPfHS)-tTI=ElGL+vEn@j^{(q;Dw&kXrY^g=4+~0u}Q|b z-*PrcIb?!IVGa4OFqxeAyhXgs`Vn{(JHo@styJ%W80ov)L9{I=!LSwgXf>`Pse#or zth=9Px4k9ahk|JLSpz=%A`)abj8J1;DcCI913pEWbeTj0v-iSQ^7fr6{F=HfEdx3?6_MwzitWXmT_7H61_s@wSOA`i>%_~VRHE3$Dx8L51@7pL0i!;D5)~&-G)m>znUIu;AQbty%UE`gL6u>Wu1@u?tbrScD zrEXI<;KBRG;C&+%69u>8?Cm1pH@BUrhIpf%(szDm`5^Ji948qTEhNX{GKqDKC3ez& z?39!cyeHm3m!Ijmaiv=t<~=q7%_29VTeKT~{)xa*(JYJ*5CTJv%jv|`IJ;s)pfEiU z{VEsHe|H6F)6Y}%w5kob_xXZrb_7nIu!-AZU+13>7eV9nTz>MvRvJ=m4JQXwVWwLU z+m*Zp8g0a&THOraU2SHS^90Dg?vOiO-j(pkXcK(czZ{gV5?rZPg?Sn8Vah>IY~`u~ z-dE;9>8r&!q8N`Ky!WGD#~hqHDg)KtTj`5!)}V7D3iRtQ63=7NC^RV>-+cH-BiG-- z-#*!jVLVA2^IV>2Tgp;^Y=Z(8hnfjV2sJfv7g267d zQb&aeSoE5C@+<(3uZqKI9c!rchNV#aG86RLit&AY7=HYD7{|_EB(iz3pmC+1)m*p- zE+&Z4yU%M0Z-AkbC5_1Y(oEDk&0!S9Md3%o8TkA|m7Wz^OM}!T4Bp#HSI`^vR58Ph z$Z7b(GI|YmUKVAuXJ%lf$|Uso%K{FDLbFgB9Oj}s16$(psJS6K}j`q`C3eHbrU3>2e|BNlU5E%zW}wCz+`|BXQ z(9!^RqVACxVL43Fo&sT;TIpD$92h;g1ETCd^3a6Cb#>l@)Ne=Wdwv$QYX2g}yU#<7 zr57yPE{Ho=0Tfn<0Xc5(^TW1;gn!my$CCtM=Z6KXi|p*EvFmHu=vmis=^_`J+swsn zr!T|I(fKH%Ax>2DQ|R$6VkF_mQM{qHm1w%&CU34~z@E4ck}DAkgGp|5?uZR)xwJCo zD-MHvdw==Vm>no%=gN&~C9rMm9(nvWABCs#$)@wyXvVjPcz$OhQLOA^eCO!m#p|W8 z^});Xxf)?8e@hA;e?16i#>~mm+tV?fLt}>s`k_;tF}fZYz_R2N=9}0}FgFN8^QzS- zP$P_d(@Lrm_kh`B%#B(#pAgr~Szs-|twmGL5HsV;G&5%w&VBKT1aA^V`D1(WWbGq( zSsO>!oq0%>Otye6;rGed)XgvWV;&mqrcsk^>E66;J5 zoH+w-4hq4TjW(Ko-bI>!IiQRp(EDpo(I-DPF^*Ftp}foitCmL5tdK)^cp#nq)@qFF z>i6MnNm)>o=VHgTDrA#HEX@(jnz&P023{Orf=|a{c>j{(nFp?W==v+J7!YJZFJcMF zFUV&ejQFslBg?Ur|C`1v3qtdRx6Gws6=0(URwfi4!fEl!VDR!TwgmN)CVvi%Hnaqj zHz{GChBx`UO$KJfs^g%!Jz5W5LC5FusB>8hBJ?)GUjK69VQ>;kJSUP%19P!%{UR{U zJxJOLK9fCb?IEDs8S7uF5Npk1=EdL#s$KM&>=C^H+f@9)>S+q`+`O0E(Fw+;hH`49 z_=t^H2%$^aK3utKn6>HJLaVfZoS4hiGrAYEA&>83sdAy3n1y9meC^=y5p{@@KXs){Be4E)tFPM79aG7TfyxN=VsuyUO8oQqXywy4m!)3Ym%^q9f3 zD^{dZAMm$FGHyGOPB&}`!ZSw{Y4-I9eM39BituX`2HKz8k&p<4_x}b+)z3B>=d+XU zT73nVqz2JQWi>cvF%Mpv?ZYbJd$h1(A_~2dK-bx#_&VwUJjk=5%-|E^?Wc!Tp0&K# zGC>26qzIffKaH*!i$kxGy(sN9nFL#3fo3aZycB;IK1`N^$ubf|-e)a@H?KkAlEoM( z?n|-^U(qv*1@VvEebV@wz*R1C35h}YvT`Cw4(mbiJ05=1Z=kgW8Kk0S7O7NFXLxVB zvGmwIQn=O@Z?L%Jtu(@kjviO{FvD9`oSIunn030IDzdZQ^K0NeU3REsvi zVO;_PooAWV&x1+8u59?5Y7Zv`lR&aC2px|N&<6{pNMl4YTNb+*o(M@|#WD?2KCzWm z7~wR2zIVaWQQmlt?((b`+hogR!$^v7MJTl_2Ki}_qUpcC$1;6j4v?j z*T2VIEj6Sr$s8EhQ=q)o9HSzGY086c_-1Af3ih|j8U6pr!aEtny?8Pfh#Zcz*0{pw zJ$V6Rfx#FXF`qk*UyE0%VPDTa-^V^PzoozxFB68XGZk}n$tuZvai$LH((LjFmzda4DHbf3Y< zzu~mCtTOB&o{&01=ut~i{H4$BXZ9X|d3U6549DueFfPiPfX z1s3vZG{aefojn$WB4ut+J)#UQat2s&X+_1wyRkG+VjXx2|0axJA(<4n8=`*2acbps zvWts9D+Kz|2hIyBbnnGNtX2vRBz<7k{V9Z=sg@Qc&EZe3l6$G;?Chs$&}1c2 zaW*ZQ{vP@d8Bwmv@TZcoU-^!XW=PzZL|S&N5pdaV_|b-*?#hYp@- z;*~o*BfM*7tnk~PysQRw2@DZYLnqd;3sC+>kV{9Y&kUN za5|_<*~H~U2Hxnr1fny~QX+SsR+rp@SqnH6*T_1ub=qlcSUiPd*wKs%`%+Pxf{kb7_?6I=> zriWqVVS-BZHqkwr%WAqxVc45%WO>vgTB&^iZujrRS3zaasn`o^H$P)v4N+P*?+x9h zc%JrlZ33Ch2(B9T9ahUbu^Yp)S^b=O@F8J7b~{U8#Ewo}DptfZI(UvXh>3=f&GYb? z#Z(M4o{4|UpONvu^=wtUHO?sTq6JG%Lh+&&R%iAC-XqB1sq19Cxa?Fmk?iDPimF@_QvCza7i+?(#2qlIPyKGx8lSke>3Yia2r|@pZ<0+&$1n&HXk*?&K|KaHRzP z%_*d}r}N>;GzsRi>2<0+I0l!F+o8MQW>RlshGSidShnIhjjVl2`z&wqS6vFn!Wkdo zr{X6n?#(HWGzUP#Ul>At@-XqB8-$-1#diF`(_d3ax=d6-lfMx*ZE+*-Mgtg!uM+_j zy+C?VF}c6w5Y9;8*4^>xu>V2;hzT#j_fAq6BeDhswtOQ$SFB?mrfI>N>$Y&j*OlzM zsQ^cI+R~A$O(Y}ZF`e08L{|?#BBvhvL!n^@hk4x%F|h`)a?B0V-z74UsoUW`>X8qf zsbq2FZu}5cLlk-PsPsVx>OXbCtby%NbF_h|=Gd~2Cj1~83s*y_feaMy`b|0)`l4TX z3#yndfhkvKp=#xNViS|iB)fAp;rp5>bnymmsM)3;E`iwAe--e?bSSHfrS)8l(Lq81 zi>*60Ac)evPo{oYi$lUX@Z=>|s~p`z zwQ>N%dcM=~LPaol%ZBHYyTJYLV)%Yu9`l@UFh0#M=(%e?#EcQGXbPJTd#xXXAs4?s zm0d;;{)&ryy)_xt*DWAYiIX@Kj1h+>i^FuA9IDlOk0{mNXPldJaQaMTPCG9R^m0EP zO=bd6my7=^WC324WiTZAmurrWI@QFEsXjBNjgf{;vloF1XkEbxtIJr6ep4vHHr$R?3li2PoJo&kU9?(sruLmyTQZ++5!rRDn z*%wlQ2a32!IuLf+*OdE+2pR0z;*Pz;XV|l^Ipp-kTv&V2lm_SK;)|WLXhc*#sPOxt zariHJE3*llKO4h+CmB#z4+i}jS&+Iri+}LyN!nl;3J)wh$>Q_&yv%>D$mZ`tpOVSY z?lBuOCd?*|ulFH;UppsWW4f2 zGou+`U>C+N2vfkgn-Q3|tB{n>mj|WBR(4WEH|}`$f-U^gLO+>4gWtN%WTx|BXmdOQ z3H7JQao>Il&27Bx#uc#O_9bjKyF_hF4q=yE8a{mdmHIhy)r+^$xXqH{h+i0osu6`Q zUKM@%wh4|C)=pi;Sa2GUg-0{*>Z4p#a@%_2@y z^fLe?d#~be`(O;ss3O^$e{h!7X!24?7{{C^vGQAt+3&~iarK;Apd@q%jr1nM&C6xP z^z95P_kA5Y>IFhT2REy{U_C%<{IAgy+Zsg=+|7su)NpQ{TM%brdT z3fv&mq}#}=hJWO0oF;WvNFw1=AJVGc6zsjZhE!ahMN<}TK>5kbsjA#86xjHXriq@z zm1(}maqzEK+j(xVLg6f0WXr+a1HT#Vqq^|YN)2BXEM*hRlAv)<4h#?H!?Ot;OnzY%lfK%H zN^p3U2XU{d*i1npQDFxf_d;mRe+SUyc@UhJnF-*y4QzK9z;nm-tj`HUR{X#yz2_&6 z^YymC+<|*^e@+yppZ*Vp-SuFd+F@?qS4!-u0Zl5j<<+;H9IQNIE85G0VUA z&QIknC_gKl94`{UQmav-->!tZ#UsQ_BZO$w6`=gCIPfyex3pqX~(;1 zMTjl8hEoUcYof3(#RlX>6_|iP72?%81W&y<)ooTENwKM7Apa&gu?s#w>ron+b~9cpyuAO>-J$y>Z4a#i9z4T+vct?oHcv#Zh-Nu5HFlQcj) z_&+iDg(;QENhX&c%mu$2sl3nwZ4(_HA)Wnh21EpRvJ)Kc!l@Bw!|0bF9g7ndoFy>FiYcX}6san^HYg3uLGo%5IM(CtEi7%UmH2gKC_GydNSCifxGU^HrJBv)MqM%dblME6Of(LbN^6!gT-5-PWguyX57y6i8Et^HuL%)(m zt}8&m>LRmp*dHGCMB#ClD*Er^O-_xM1+_jdbf?Qs~3AX$wqu%$LsPj*f`%c@QzKEu(h5`QUP zzu!U*wfob8NdZ)+xD4i~d?wFbJ+SkI4_V&ifv0X2Q=fuv@^otnJs|lSKc4j`yUiTv zI>&>=@`xXX?vCH8f1D>brcB4M7b*uYhq&vi#2M@+zN zddfFf-qusvR)xQVY0R&PYVvkP=zDrI6ZkgmhrxBBNooo*UeX-p--dk`jV zEW~KvS^Uk)fbeG><)?7f%dNlYq9F~idtXY$8hgo;(VN^Jp&o2xOdu{y6u?H1O1xZ# z*QX}a!q_M(X!C|Tzq^RWTaSR5Zxt7BuA^#(r(tfYCVbwlgnK<5AyI5L<5jbQ7KUCx zr?4~lF)od5a|^?JKDLbbocn0>RE#=?s-yMaG#HO9_vj?8 zv-D-q35IupQ%0{0CO7l*=x|dTb@dlv4Ogad=mUn!OReOTdRbuE6b!2Q%@8(T&)Z?L zfvi0`7o)l&(e)OB^_pm0@#;B*R?VXd4g}kLt`qM^Ye3KCDYPhOLckcOs@MO?GQZNP zkljoyP*{d3Ts2l;!6vHxcS^-kV^30=*hCjkD5a=ZgU!btz^ChXnNDV*t(n>6^24pUGpu7#@T|I&Sk0Ua zl5#0ut+o)nbMJxf0986aP3CBemcI-L@eNK?$Y zHE8u@4)=eD_00c49G}JDg~&$2_{M5d z<8&T2jESJNrVc2sc!IG3vQYV^lk^${K@6t|tLaKyaNq50q zqXf15Ii1>TDQfANiv{m3_%l1o=^D>?`rw2X9w131go~OE6`qHe>aCo@DFn+N>Vxr8 z6AZY$6+Nm?@^r>bApT4fblsl`)~XxOD65qi$Jf9e{yT_tJ4=_DOrxv9$0!*!fw6aT ztno=35QsR9;;$w$@@+}P{o)m<=rV+k=eexXqjJi(`9#HxH^Qx^>EL~QYK4hxH0(^V zfF(8mnCw_@C^7F87j{_>-~0;^_DrVo%R})w_YBZ@_>4?oP3glP7oz@I6`eN9(`H^Q z##f7S`2-2r)Xbr^8m7XfY-OtdPZ=tf7@%FIE(UznhorBns3xk;O!#z_Zcdn65!EqH z64=A+9;ef^FEj$x8ZTjz#&k%F)WD|=8Aw$oW7f2J^ri7-Ea_hY*%O>Ft#%(;xg5ZM zo->Ku<(D*i*AS^y@8%FGyJ(p8brLz41Zr}AofIBPx zV_QnEeu;)FX9UR056Di8SOXnv&alhg7Z9=T=~&;^Np3%KqOJK0$$j@a_U}|#Y|&mu zq$IDgl{PCd{%APMpZ1f8ym}8YS`u_x-yE!e^_gtm{god%+k*_0`jg1#eR$Wu4_`;# z#?$<1H*e;y072zERxPXuMbf5%jDQj#JNO+xI?F&Fb9 zNlWQ`+NPRHAEs{NjZYN@l|vS!?!+llW!jCtYg6!A;y1qd{M$_D^YxIiwupr2myxh4 zb-XS9k}AybBkCo4sn7}sFqh0>lFwcydoN#LKfNA-?@d=}N}@QXa@nCz502oVQa|-@ z{tYG%<%#TORSviBd>8O)pah>s=<)jMFjY3@zYlNi1BoNaOwv zYgm1J0>+sSvDbJq6|FH}X_3z@dQB*y_89;KDtrm`44H~j#Wg`j0K5< z4e(RBik^`!LZ6lpT*auN-(giY$RZS4v7Xb!ttDdoKV(+bV;a(zPUF=NGD|8h(6V-2 z;_j&hhd4EO-Un_z=qd$t=Qofpb%yTLO%TUn@vdq6;snVQ+8^)exY z3GtC&vThzVjqjkJx3A$Z{FDwa-X4ctm0CD>QVX5rpK=w-Wgw=JPS|6=$gL^HU}aha zk)0)&6jDJGhBlEx4)dT`uLjHQ6FJ@G7sl=2T^iX0oCY`y_rJIfExsYN_QZYi>U0~k zU2Zj=wN1h^iV5^f072^yOW0#~bIAC7FGj0|Q(s!XVoo;$NxzdpgRi7=iqUxbC*T4V zHxeX`Q#fpx>qBP!#Jf=UU=tBJ+QH>*oXEJ#V7`@Bo2;V=t(`dk=|&6|WW-8NKs z-Dg_j(nVO02Kra~7Kzc-BO<5$=+M?|nD}x#iq8n*pNxxSgXXBgy1agBQ_>34Euu-P z%LCYzvzGk&WCe*44#X}YjrM%1r}6DSi5T~;2|6T0~15X`=qd`V9Tnn z6VdrYAlY~=7j$&TNTmU%U9CfU>WnJ(Kd56@DrD0EjhXbx*Ed9Y)fgCy4HIj_eOUN= z8=hM(ho)2O$oUk1GMaP(^O;d%k-3u7C`Hh`3-fS?6$6q^%RrEeQGZujhwpw5kuH5} z7?iCgD;Z-m)3VX`y4ElW6@PLE5G71dE$L;H(vU@l9$NzCS)l)?1`A zBdIre%l%5^c?wRQNDV>Iu-i3&wRd8hZ5GKV6VW*%3{3oW1 z;gcfBv+C#Aa9pUOeL^jc%Lw0WAK3$jhwc+vU5S?mgXzt80x)d32f`05g_5rYcIH0+KU2uct5Agsa1zAi4@$cDnY-#-wA_ti=bh}34B{3K<*whWzT+8 z;XZ!_aeeO0?fKRd_jpbG@9YU2X^kS~kC$Rvb0jSCTtz3@9l^&=8&FM#%Tk?Og020( z*f-5Qu1@O0HXLlgYbl$#v*}o}>v*54gd9g}43%qP zjh5X^uAl+pS535QI69hR;6%z2nkr&oK9|ziIG6Hskq3;mF$+0BXOEO^r5m2=MQMWw4CgbP#~?t}L?Xk10;x5Qry+J;7l8 zOZq5Y8eWfTU|WVTnZ0!?KJb}>*FPL3$of7Z_+2Lr;vj9rednVBd% zQ4Kmjd5|xDA@qPa0so}wpsfFw=*`xJ#TAp8qg#}qq^}+Q`;tkV?*h2^X#xHko`PSd zZi<|6Vwf5#@1%RfPSL5Ow?I-}g6z}nrhAju@-ja!r}I-X_SB}o8{E8W{_G996*Sz*k#JFz3z~vvS^I4gl8$f2wii zotJ|&XM7q+E{LFq;#BeVf~^ei9c8o!FVpYEHl#;<8eHgk$J%z~kfw8~c(~Gqj_!R% z(r7LiZwzKiXE{Q9rYH%zE5RvNX2aqH3y5zz$b`H1GwVCOai8Z>a=_^(R{{J=E#yX+ z=fgp`Ho+dRq$ZKB{Y%T{)bQbunE>u7>}BEcd;KI%C-yQdiim#Up3yO}Gt9>!HE!xG^?+d%Aq0{U;)NfI}fNs>+P=R_^YqR@$D@* zE8;iNFn$bf`5aQ9HGp2`a<$^NEsW{NE@GcH!63EWjEKl|5%S*w7sfLFVRAU=FS%1T6T6MPsj-nLEHDTl`|U%>{_o4n1uWw+rpA^Y#GAvLXY9K|Hr{x^v?XR!jTtwI z)Ys*pcX^1oC=Jm2cE0rQCw2HK+(`QC7D7t7I;OZ3V8~Kcka^~WmuGyZ2cIO62X}XJ znFJn)26Bk(o(pJi{0y@+l*!R4f9SC99EeF_KvOv%nfS@85)MYd;oQ$bFF zDZO!=96t4c%ob6{4SUa#gZ_VMv9Sg&mP#iYPlMsYb1t7-9f+6g4`PRDD0zA`2?IM; zlUJrk@e;2Hg))n=CF?4rR=lE3oQmwkId7DSo`8ZouA+m+({zz21#SQp#IzgCow8Q27SKPqxp{Y^xGc^I#MO; z=(iXcDP=5is)c8dpk`d)dYGZo5h)-j zJef!ex3qFv;#@=W&0lLWd4cV{vtty|J{X#tEKoZ?G9v&50LjiryH!9Zvc}xlymQ=58xy>87scd zL5aorBzpHG97>#x7F;KcpD@ff$0yWF$d`n@41zskrQFE4fVP@a8w>JHIc1)WHQbFX|~{`>>hbzfw*` zTt_Kccb)`_C&Rz97a2X9S8({(Dtbjv0o+6Ln0Se;qREeHsK@MZjFyk4&ko%pCi2ta zWaLJ=I$Vl;+<${6q_>i?x}R)V`gODW0-5ywXG5$Q_(E{m4D9{B2Je@r!%vr+n3L*7 z6P}e3Xc}cV|6W1gJ>&KVAz?gYF(uMqmk81>I*qnw_h?|EA;u)6!|>lenkE&CD?B4C z#@4&TGt-CkbYB(ZR`Y|#-*I|(trV&X#i8J%Cg#?^PNqV4IUY?&fopfTTDXia*|2#U z`f23Tlj{no)Ic8wc#M;nq7c$zC(LEZGVtETERx!^4$726*b5J&p!}x^)szqfkEtO{ zAg9{7dA@-OHufgb(_L}sQw)7@|1zhFG$-x_Ep(yPHrS${54jgukg?rISu{uI*)K3@ z;~R2*{0KbY-v6H*BXMfD8}+o$pk-6TsOf>tM87qFj-DJK>uxNxJXmi=gO?U#Q32Pt z`|*!tdoM-HHzm|5-wH?bgK%Z~4Gy(bS(DGr+m9|!MepwCm^wBejV(s$_|!2X{3RH4 zxLr`tmIGwcqH}OmeT0UebU<0DGCFK}6I_!+A?s^4jp<2-oxA5l#Dq8X=g?LBV-2kO z%^fhb#u&tDBWqG*OZz|Y!Nb@*`gxr@)2>_!N~uP4UYH&R%kC%Xn>4X*V-Ws6;s*VDC8|#fv%!u^jnb^HBk|S$QhTwPKj$ub3KdH-4@{Zwj2AGanIe5$tav#1hsq5 zQQ3u?ar4+Sy6jLLRnJJF-7EYd_04JgY~9a((7Hz(-d@3={9w{jU)6BrS}Ps&O@{+w z-sE1JE*y|v1fQy()8qYSu*y%J&i#~2$ItD-ob!*-dH!emZAJn*_P=Lb1@thiltXX` zz9cW-7n1wQ2{>1^9m4e*&^L7hba{lMFm2_9#ysau+V2j@|5Cv|DhAWM(uii+0x0U9 z1FiRMNl;EIr=<=f3$CpOwWGIaI5tp40dYwGAY4 z*EGnUwTJ|pC*lbcKT<+7NOQUi1}0cz-QGjY!j#WU@WCKPtTYNwDoh8nFOTWJ#~;~Y zgI=7&3yoH=AsCc?mHg=)rVl>b5}K0)p_uB%U%$P-wPp4M-9Bo zS3oDqz}uOogh{gHxB@x&Qns4iG>}0|<&vnH^lAL~x0vOfcumLduxv|d9#%i?;c!5y z^xvd*GTnV9E*~zWqElayi_!Xc$fB599a})Xcd0|C?m>33a1!yqu#}r|Ct{qt4-~b$ zAT`IIkd1cl@Uf<(J!|YqRNn~HY>L4>Bn;Po&BaO1gLIx_BEG#V4Wh3v zQwbw(A71~77Jix!-)zFr;O1srAu|P9?+)V99dp1+CYG#PDha_Z@7Z%pH?p4}$8gAW zE%w8YSdtw#m+8&epGlu5c{3h`t62V>+M82=qhh{P&*B|H9%|+ zyAtPgQ7U&_hCKSEg43<6uv3kX!+i^a052W=+7h8!Ru|MVH(|YU9nt*eCbIYMM;dG# zMMsteqV&vMmhWaQJgpsJSM2XZ#Rt1^^OE&6pr8f?xpRZJ%q6pTy$O)0oIxIn{je~O zF@rN_n!xepdHVMA3wo66^{hHPL>q740J&Y;ajse&KDknWqRL9J+;WJHT?<4HWqDk> zRF#G_&&S+lJj~D6fv^EZF8dlu2OW#S{BEQ~t!gdpMlttcB(UVJZ;5!w%yYw$ULjFt@xU6#lpy zTCT|BIv@E0beN5#pG}fMls!+(KiEQ?(iFVABu7jmZ7AG>xjR0=7$yUxc-6t7;!lN zitxljX^;38a_(m@y_TF0A1jPt=A&X9VCG?Kiv&A!+YsaB7D6X)-3{e@oN`k^78NJi zfi0&L*#5f*$FIzV^z%t5KKg{qay8MTpVHy?W{R^{_mMW`Jd(Gw9`DIzl4jFbcK_W} za$=Dwj4#h%r43Fr_;csFvw;~X{%;{}EVU;pljPBFZYVfR_lG0x57|wfLM&CA3o|=Z z@xJ|07|B-w);N(a+SE@!F0+8^M|N|1ne!-@z$p}*pV97Xdr@+~F{u4_7xk7bMtXqD z>m^M_S#Ia0amxjs1PHu_t+y9zz_13z_Ufd(2Zlgwxy-VM%#335cjB zPvbpsN7j5$o2^MtM|U&9qGou@(1jKLxs=23IB_|%lf5!TPK0zKo?$UX#^`vS@_)=!8#=r zQ@8pp_}@?xMAh}sqf8~t|FePQZgqf#-e!$WtK~`Zye<+UD+)yTDODfsqv!rfpmXFT zR{8fscJIA3`bX0m+Oo#!eck4%w_2DK=?co2thqvqrWRARhIugKKo<@2QKPHoPcS7rjHu~e8~ieDoVEGqjWY5c@LVAd zPiCpWYvokfS;ci53T8i?o^=dOZN;fC{>L@mvZIrf~TF686r4A4H;WH|g8Q(jbLoC{`^Zzt&D60@BkM zIol0TA90UhVp`S7DFI{_K4yb~zIt z1edVcy|pCQ>pJvpI0PpIJ}_&_8)4%uDQXw9iu4*saSG)b*f9AuxwKvx2aETTv66OJ zBJYO5ceau1O?Bj{I)fLMO~GjbHt7G~N9t*I3X~gy=)lJZuy!aEdmCEe>!%ZNthI~2 zv}a)Ko}Y+SZUFtJ_z{CU%9x*5O`$bv3Tj)9ijT#C>AqA{?x0)8?f-2P1$?e6iBWDhmVok}4VUs^dmltCrQ>XxVHw0}{{o|7=k zK@1OlZ6f@yrRn&_a=7kJa80HwObpdhRLTP*r6u%j>{4Pej~@oO9QwGDbCX0feFRaV8gN)c%`39 z>+d`v0wa}FZ(SMcD!*rzE)5`(+6i zBNr=^!0?p;;djXbmHC}yoism2LLKj@z&4`raXr_ad};A~&2>6<(3{Q{JA$X|$7<{< zTw!FPNTZyHEUF*X0@u(Ea*RF($wR4-+#L*glXsv9a~4eY9wox`6i!`#ki<9hVW)xt znw1%n4SHK4&#einkLQsyEsN>Y;AxGeZ~Upl$RrH!i(o8g3pPGI_0=LhWEMW%DaYL( zIOOm@KTJ4NMXm(y$HF~gn0$3Eh928SC&@u{eX{D0%H`!J?sb91*++wa>=s!EIi!PPU33th~@L#_2&rVl~(! zhh50pWnOsTK8lbH| zH0d|#y+n8D3K1WSgyAQN^qtfLQXj5_0UXwHrnEItpm_18dJ#5?l$;w7-}ZzGH5 zEvD{8^U>G*34J27oV13jp_Ae)SbNG6bX|;Km+K^ElClyh6Lafl?BJMz0WSDz3I=hZ=(t4=mYLXsYTZBbXjT|~;qFN0fBc4%p9XUCR(H7DxdUEw z+L9W%YxJ|L1UM+^0YmrEquj1MSD=;nYNq2fuCuY@NiNwST88;|B(WyZh$zkY#9Z?9 zf^*r)kSDi=S@|Uh)|xj!OGN_C{wjwS`_D3K4~?N7HW~$QeMuktvU+})deB*RTura>Hu8XZUql3Z9q~l32G9jSU%Wh zLVg{4NK{n9*hPnXNXB}88tqR&_tsY+aSUSn(F4G{fl-(lxmWsc92){M5An;`}*58T8 z-4%PVYr}Usm3qLsG<7=T_5n^6Izes6-OyhkAJ!VLz$k9_-o%T*?tg($eRqgV<>`~u zfj(MNzYa5(jPa5Rr?DYF4d7f@D0iQ4V}e3uAYU~CyejX2jp+mEeCtdSGgiayP!3rh z-GEgevbY@Ubt~;Bz4-dQ=Dpk3}qK4GAQ-mKl)BU zmYb6aaQ*x3*xsH-_jku&SM__k|8qMCXQv?lVSegalTKw?`796Ic}d4CB;c}aIo+ZV zgr*5!h}EYD>??;V;(W>uhB61SG}H$R?+sxOF2u2WDm0xh2QMbPwEM?PA9giRgo#580Y#QkU? zOTD=7x=1RsB_RfX^-gYF^2HZ~zm<{Y(pQ*2d{uOV?i$!|els@9%V4H*02n6}Q6r