From e6b3b552eb547af620d8e0f03fc2f283b7a84a12 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Fri, 17 Jul 2020 12:43:41 +0300 Subject: [PATCH 01/31] v0.6.2-SNAPSHOT (#889) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 624350d5901..5de3f1116a3 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - 0.6.1 + 0.6.2-SNAPSHOT https://github.com/feast-dev/feast UTF-8 From d86234bd0c6eb7f5574ca5625ad16ec430298d2a Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Fri, 24 Jul 2020 01:19:44 +0000 Subject: [PATCH 02/31] GitBook: [v0.6-branch] 32 pages and 5 assets modified --- docs/.gitbook/assets/blank-diagram-4 (2).svg | 1 + .../feast-docs-overview-diagram-2 (4).svg | 1 + docs/.gitbook/assets/image (2) (2).png | Bin 0 -> 149255 bytes docs/.gitbook/assets/image (3) (2) (1).png | Bin 0 -> 17434 bytes .../assets/statistics-sources (1) (1).png | Bin 0 -> 165507 bytes docs/README.md | 2 +- docs/contributing/adding-a-new-store.md | 6 +-- docs/getting-started/README.md | 2 +- .../connecting-to-feast.md | 2 - .../deploying-feast/docker-compose.md | 6 +-- .../deploying-feast/kubernetes.md | 4 +- docs/reference/design-decisions.md | 2 - docs/user-guide/architecture.md | 14 +++---- docs/user-guide/data-ingestion.md | 2 +- docs/user-guide/entities.md | 3 +- docs/user-guide/feature-retrieval.md | 36 ++++++++++++++++-- docs/user-guide/feature-sets.md | 8 ++-- docs/user-guide/features.md | 4 +- docs/user-guide/overview.md | 2 +- docs/user-guide/sources.md | 2 +- docs/user-guide/statistics.md | 2 +- docs/user-guide/stores.md | 4 +- 22 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 docs/.gitbook/assets/blank-diagram-4 (2).svg create mode 100644 docs/.gitbook/assets/feast-docs-overview-diagram-2 (4).svg create mode 100644 docs/.gitbook/assets/image (2) (2).png create mode 100644 docs/.gitbook/assets/image (3) (2) (1).png create mode 100644 docs/.gitbook/assets/statistics-sources (1) (1).png diff --git a/docs/.gitbook/assets/blank-diagram-4 (2).svg b/docs/.gitbook/assets/blank-diagram-4 (2).svg new file mode 100644 index 00000000000..fb5e0659e55 --- /dev/null +++ b/docs/.gitbook/assets/blank-diagram-4 (2).svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/.gitbook/assets/feast-docs-overview-diagram-2 (4).svg b/docs/.gitbook/assets/feast-docs-overview-diagram-2 (4).svg new file mode 100644 index 00000000000..7f30963ec78 --- /dev/null +++ b/docs/.gitbook/assets/feast-docs-overview-diagram-2 (4).svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/.gitbook/assets/image (2) (2).png b/docs/.gitbook/assets/image (2) (2).png new file mode 100644 index 0000000000000000000000000000000000000000..d3b359a5988f3b0d75afe44b0ae2cd7d9ef0aab1 GIT binary patch literal 149255 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 literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/image (3) (2) (1).png b/docs/.gitbook/assets/image (3) (2) (1).png new file mode 100644 index 0000000000000000000000000000000000000000..2442410112fc9413bf4969dddf296cb06f201d3a GIT binary patch literal 17434 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 literal 0 HcmV?d00001 diff --git a/docs/.gitbook/assets/statistics-sources (1) (1).png b/docs/.gitbook/assets/statistics-sources (1) (1).png new file mode 100644 index 0000000000000000000000000000000000000000..02be233968d3c97e932f8073b66d41b49247782c GIT binary patch literal 165507 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 Date: Fri, 24 Jul 2020 01:31:22 +0000 Subject: [PATCH 03/31] GitBook: [v0.6-branch] one page modified --- docs/user-guide/feature-retrieval.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/feature-retrieval.md b/docs/user-guide/feature-retrieval.md index 897e0afcaeb..8ef9d3a5dfc 100644 --- a/docs/user-guide/feature-retrieval.md +++ b/docs/user-guide/feature-retrieval.md @@ -183,7 +183,7 @@ for feature in features: | Status | Meaning | | :--- | :--- | -| NOT\_FOUND | Unset values returned as no feature value was ingested for this feature. | +| NOT\_FOUND | Unset values returned as the feature value was not found in the online store. This might mean that no feature value was ingested for this feature. | | NULL\_VALUE | Unset values returned as the ingested feature value was also unset. | | OUTSIDE\_MAX\_AGE | Unset values returned as the age of the feature value \(time since the value was ingested\) has exceeded the Feature Set's max age, which the feature was defined in. | | PRESENT | Set values are returned for the requested feature. | From 565ba46bb82f3513e80207eb5922676635cc5ba3 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Mon, 27 Jul 2020 10:22:37 +0000 Subject: [PATCH 04/31] GitBook: [v0.6-branch] one page modified --- docs/contributing/style-guide.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/contributing/style-guide.md b/docs/contributing/style-guide.md index 1cf1c69671f..084e0e638bf 100644 --- a/docs/contributing/style-guide.md +++ b/docs/contributing/style-guide.md @@ -19,6 +19,12 @@ $ mvn verify -Dspotless.check.skip If you're using IntelliJ, you can import [these code style settings](https://github.com/google/styleguide/blob/gh-pages/intellij-java-google-style.xml) if you'd like to use the IDE's reformat function as you develop. +#### 1.1.1 Lombok and AutoValue + +We use AutoValue as the primary means of defining immutable value types, especially builder pattern style classes. + +We also allow the use of Lombok, but only for @Getter and @Setter. + ### 1.2 Go Make sure you apply `go fmt`. From 348cdfdb8e588e71f8e0e245facd42282dcaa9ca Mon Sep 17 00:00:00 2001 From: Willem Pienaar <6728866+woop@users.noreply.github.com> Date: Sat, 1 Aug 2020 12:33:57 +0800 Subject: [PATCH 05/31] Backport delay in Redis acknowledgement of spec (#915) Co-authored-by: pyalex --- .../feast/core/config/FeatureStreamConfig.java | 2 +- .../connectors/redis/writer/RedisFeatureSink.java | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/feast/core/config/FeatureStreamConfig.java b/core/src/main/java/feast/core/config/FeatureStreamConfig.java index 32445456048..795e4f754ff 100644 --- a/core/src/main/java/feast/core/config/FeatureStreamConfig.java +++ b/core/src/main/java/feast/core/config/FeatureStreamConfig.java @@ -45,7 +45,7 @@ public class FeatureStreamConfig { String DEFAULT_KAFKA_REQUEST_TIMEOUT_MS_CONFIG = "15000"; int DEFAULT_SPECS_TOPIC_PARTITIONING = 1; - short DEFAULT_SPECS_TOPIC_REPLICATION = 3; + short DEFAULT_SPECS_TOPIC_REPLICATION = 1; @Bean public KafkaAdmin admin(FeastProperties feastProperties) { diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisFeatureSink.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisFeatureSink.java index 6997cbcb877..4d2ebf14d6d 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisFeatureSink.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisFeatureSink.java @@ -110,7 +110,20 @@ public PCollection prepareWrite( "At least one RedisConfig or RedisClusterConfig must be provided to Redis Sink"); } specsView = featureSetSpecs.apply(ParDo.of(new ReferenceToString())).apply(View.asMultimap()); - return featureSetSpecs.apply(Keys.create()); + return featureSetSpecs + .apply( + "DummyDelay", + ParDo.of( + new DoFn< + KV, + KV>() { + @ProcessElement + public void process(ProcessContext c) throws InterruptedException { + Thread.sleep(1000); + c.output(c.element()); + } + })) + .apply(Keys.create()); } @Override From 648c752a383b9278a4b7bf9a8da37ec76b3b3d8f Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sat, 1 Aug 2020 12:37:58 +0800 Subject: [PATCH 06/31] Fix versions on v0.6-branch --- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 10 +++++----- infra/charts/feast/charts/feast-core/Chart.yaml | 2 +- infra/charts/feast/charts/feast-core/README.md | 2 +- infra/charts/feast/charts/feast-jupyter/Chart.yaml | 2 +- infra/charts/feast/charts/feast-jupyter/README.md | 2 +- infra/charts/feast/charts/feast-serving/Chart.yaml | 2 +- infra/charts/feast/charts/feast-serving/README.md | 2 +- infra/charts/feast/requirements.yaml | 8 ++++---- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index 29365a760f9..58379789127 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feature store for machine learning. name: feast -version: 0.6.1-SNAPSHOT +version: 0.6.2-SNAPSHOT diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 4bae7a43861..6b0325884e7 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -1,7 +1,7 @@ feast ===== -Feature store for machine learning. Current chart version is `0.6.1-SNAPSHOT` +Feature store for machine learning. Current chart version is `0.6.2-SNAPSHOT` ## TL;DR; @@ -32,10 +32,10 @@ This chart install Feast deployment on a Kubernetes cluster using the [Helm](htt | Repository | Name | Version | |------------|------|---------| -| | feast-core | 0.6.1-SNAPSHOT | -| | feast-jupyter | 0.6.1-SNAPSHOT | -| | feast-serving | 0.6.1-SNAPSHOT | -| | feast-serving | 0.6.1-SNAPSHOT | +| | feast-core | 0.6.2-SNAPSHOT | +| | feast-jupyter | 0.6.2-SNAPSHOT | +| | feast-serving | 0.6.2-SNAPSHOT | +| | feast-serving | 0.6.2-SNAPSHOT | | | prometheus-statsd-exporter | 0.1.2 | | https://kubernetes-charts-incubator.storage.googleapis.com/ | kafka | 0.20.8 | | https://kubernetes-charts.storage.googleapis.com/ | grafana | 5.0.5 | diff --git a/infra/charts/feast/charts/feast-core/Chart.yaml b/infra/charts/feast/charts/feast-core/Chart.yaml index 7dedc54465b..40517cfa233 100644 --- a/infra/charts/feast/charts/feast-core/Chart.yaml +++ b/infra/charts/feast/charts/feast-core/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Core registers feature specifications and manage ingestion jobs. name: feast-core -version: 0.6.1-SNAPSHOT +version: 0.6.2-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-core/README.md b/infra/charts/feast/charts/feast-core/README.md index dea2a02b6c2..cbf4d33e8af 100644 --- a/infra/charts/feast/charts/feast-core/README.md +++ b/infra/charts/feast/charts/feast-core/README.md @@ -2,7 +2,7 @@ feast-core ========== Feast Core registers feature specifications and manage ingestion jobs. -Current chart version is `0.6.1-SNAPSHOT` +Current chart version is `0.6.2-SNAPSHOT` diff --git a/infra/charts/feast/charts/feast-jupyter/Chart.yaml b/infra/charts/feast/charts/feast-jupyter/Chart.yaml index 1a72c244a59..d284302ee28 100644 --- a/infra/charts/feast/charts/feast-jupyter/Chart.yaml +++ b/infra/charts/feast/charts/feast-jupyter/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Jupyter provides a Jupyter server with pre-installed Feast SDK name: feast-jupyter -version: 0.6.1-SNAPSHOT +version: 0.6.2-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-jupyter/README.md b/infra/charts/feast/charts/feast-jupyter/README.md index 2499200751e..ee89fbec871 100644 --- a/infra/charts/feast/charts/feast-jupyter/README.md +++ b/infra/charts/feast/charts/feast-jupyter/README.md @@ -2,7 +2,7 @@ feast-jupyter ============= Feast Jupyter provides a Jupyter server with pre-installed Feast SDK -Current chart version is `0.6.1-SNAPSHOT` +Current chart version is `0.6.2-SNAPSHOT` diff --git a/infra/charts/feast/charts/feast-serving/Chart.yaml b/infra/charts/feast/charts/feast-serving/Chart.yaml index 9387774ba4b..78f81b5e531 100644 --- a/infra/charts/feast/charts/feast-serving/Chart.yaml +++ b/infra/charts/feast/charts/feast-serving/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Serving serves low-latency latest features and historical batch features. name: feast-serving -version: 0.6.1-SNAPSHOT +version: 0.6.2-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-serving/README.md b/infra/charts/feast/charts/feast-serving/README.md index 5ff562e6e57..3d045f0ae87 100644 --- a/infra/charts/feast/charts/feast-serving/README.md +++ b/infra/charts/feast/charts/feast-serving/README.md @@ -2,7 +2,7 @@ feast-serving ============= Feast Serving serves low-latency latest features and historical batch features. -Current chart version is `0.6.1-SNAPSHOT` +Current chart version is `0.6.2-SNAPSHOT` diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index b7ea8c905a5..b46025b31f5 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,17 +1,17 @@ dependencies: - name: feast-core - version: 0.6.1-SNAPSHOT + version: 0.6.2-SNAPSHOT condition: feast-core.enabled - name: feast-serving alias: feast-online-serving - version: 0.6.1-SNAPSHOT + version: 0.6.2-SNAPSHOT condition: feast-online-serving.enabled - name: feast-serving alias: feast-batch-serving - version: 0.6.1-SNAPSHOT + version: 0.6.2-SNAPSHOT condition: feast-batch-serving.enabled - name: feast-jupyter - version: 0.6.1-SNAPSHOT + version: 0.6.2-SNAPSHOT condition: feast-jupyter.enabled - name: postgresql version: 8.6.1 From d108f82d35884b89a53f842196ff38b199b27b4a Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Mon, 13 Jul 2020 15:10:05 +0300 Subject: [PATCH 07/31] Upgrade Feast dependencies (#876) * Clean commit of dependency changes for spring-boot 2.3.0 * Bump to 2.3.1 * Remove exlusions and Javadoc config * Remove logging exclusion for ingestion * Increase versions of grpc, protoc, protobuf, hibernate, and grpc-starter * Bump version of kafka-clients and spring-security-oauth * Upgrade opencensus, beam, and google-cloud versions * fix ingestion tests * runtime deps * ingestion spec update: should take some time before ack Co-authored-by: Christopher Wirick Co-authored-by: Willem Pienaar --- auth/pom.xml | 6 +- core/pom.xml | 40 +++++---- datatypes/java/pom.xml | 4 +- infra/docker/ci/Dockerfile | 2 +- .../specs/FeatureSetSpecReadAndWriteTest.java | 12 ++- .../src/test/java/feast/test/TestUtil.java | 10 ++- pom.xml | 90 ++++++++++++++----- serving/pom.xml | 18 ++-- .../serving/service/OnlineServingService.java | 2 +- .../storage/common/testing/TestUtil.java | 10 ++- storage/connectors/bigquery/pom.xml | 6 ++ .../compression/FeatureRowsBatch.java | 2 +- .../io/gcp/bigquery/BatchLoadsWithResult.java | 4 +- storage/connectors/redis/pom.xml | 2 +- 14 files changed, 139 insertions(+), 69 deletions(-) diff --git a/auth/pom.xml b/auth/pom.xml index 42c6b25c3de..d90dad49059 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -31,17 +31,17 @@ net.devh grpc-server-spring-boot-starter - 2.4.0.RELEASE + ${grpc.spring.boot.starter.version} org.springframework.security spring-security-oauth2-resource-server - 5.3.0.RELEASE + ${spring.security.version} org.springframework.security spring-security-oauth2-jose - 5.3.0.RELEASE + ${spring.security.version} org.projectlombok diff --git a/core/pom.xml b/core/pom.xml index 4a0d3791c92..716d8fdd394 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -100,7 +100,7 @@ javax.inject 1 - + org.springframework.boot spring-boot-starter-web @@ -117,37 +117,42 @@ org.springframework.security spring-security-core - 5.3.0.RELEASE + ${spring.security.version} org.springframework.security spring-security-config - 5.3.0.RELEASE + ${spring.security.version} org.springframework.security.oauth spring-security-oauth2 - 2.4.0.RELEASE + 2.5.0.RELEASE org.springframework.security spring-security-oauth2-client - 5.3.0.RELEASE + ${spring.security.version} org.springframework.security spring-security-web - 5.3.0.RELEASE + ${spring.security.version} + + + org.springframework.security + spring-security-oauth2-resource-server + ${spring.security.version} org.springframework.security spring-security-oauth2-jose - 5.3.0.RELEASE - + ${spring.security.version} + net.devh grpc-server-spring-boot-starter - 2.4.0.RELEASE + ${grpc.spring.boot.starter.version} com.nimbusds @@ -157,14 +162,14 @@ org.springframework.security spring-security-oauth2-core - 5.3.0.RELEASE + ${spring.security.version} - + org.springframework.boot spring-boot-starter-data-jpa - + org.springframework.boot spring-boot-starter-actuator @@ -175,17 +180,17 @@ org.springframework.boot spring-boot-configuration-processor - + io.grpc grpc-services - + io.grpc grpc-stub - + com.google.protobuf protobuf-java-util @@ -288,8 +293,6 @@ javax.xml.bind jaxb-api - - org.flywaydb flyway-core @@ -300,11 +303,10 @@ hibernate-validator-annotation-processor 6.1.2.Final - org.mockito mockito-core - 2.23.0 + ${mockito.version} test diff --git a/datatypes/java/pom.xml b/datatypes/java/pom.xml index 5810a6db96a..dd2a162c01c 100644 --- a/datatypes/java/pom.xml +++ b/datatypes/java/pom.xml @@ -54,11 +54,11 @@ true - com.google.protobuf:protoc:${protocVersion}:exe:${os.detected.classifier} + com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier} grpc-java - io.grpc:protoc-gen-grpc-java:${grpcVersion}:exe:${os.detected.classifier} + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} diff --git a/infra/docker/ci/Dockerfile b/infra/docker/ci/Dockerfile index 08da02ae202..4e7c383524e 100644 --- a/infra/docker/ci/Dockerfile +++ b/infra/docker/ci/Dockerfile @@ -30,7 +30,7 @@ ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH ENV PATH="$HOME/bin:${PATH}" # Install Protoc and Plugins -ENV PROTOC_VERSION 3.10.0 +ENV PROTOC_VERSION 3.12.2 RUN PROTOC_ZIP=protoc-${PROTOC_VERSION}-linux-x86_64.zip && \ curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/$PROTOC_ZIP && \ diff --git a/ingestion/src/test/java/feast/ingestion/transform/specs/FeatureSetSpecReadAndWriteTest.java b/ingestion/src/test/java/feast/ingestion/transform/specs/FeatureSetSpecReadAndWriteTest.java index e73123a810e..340af58e8c5 100644 --- a/ingestion/src/test/java/feast/ingestion/transform/specs/FeatureSetSpecReadAndWriteTest.java +++ b/ingestion/src/test/java/feast/ingestion/transform/specs/FeatureSetSpecReadAndWriteTest.java @@ -34,7 +34,6 @@ import java.util.stream.Collectors; import org.apache.beam.repackaged.core.org.apache.commons.lang3.tuple.Pair; import org.apache.beam.runners.direct.DirectOptions; -import org.apache.beam.sdk.PipelineResult; import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.options.PipelineOptionsFactory; import org.apache.beam.sdk.testing.TestPipeline; @@ -44,7 +43,6 @@ import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.common.serialization.ByteArraySerializer; import org.apache.kafka.common.serialization.Deserializer; -import org.joda.time.Duration; import org.junit.*; public class FeatureSetSpecReadAndWriteTest { @@ -101,7 +99,7 @@ public static PipelineOptions makePipelineOptions() { } @Test - public void pipelineShouldReadSpecsAndAcknowledge() { + public void pipelineShouldReadSpecsAndAcknowledge() throws InterruptedException { SourceProto.Source source = SourceProto.Source.newBuilder() .setKafkaSourceConfig( @@ -153,8 +151,8 @@ public void pipelineShouldReadSpecsAndAcknowledge() { publishSpecToKafka("project", "fs", 3, source); publishSpecToKafka("project", "fs_2", 2, source); - PipelineResult run = p.run(); - run.waitUntilFinish(Duration.standardSeconds(10)); + p.run(); + Thread.sleep(10000); List acks = getFeatureSetSpecAcks(); @@ -178,7 +176,7 @@ public void pipelineShouldReadSpecsAndAcknowledge() { // in-flight update 1 publishSpecToKafka("project", "fs", 4, source); - run.waitUntilFinish(Duration.standardSeconds(5)); + Thread.sleep(5000); assertThat( getFeatureSetSpecAcks(), @@ -192,7 +190,7 @@ public void pipelineShouldReadSpecsAndAcknowledge() { // in-flight update 2 publishSpecToKafka("project", "fs_2", 3, source); - run.waitUntilFinish(Duration.standardSeconds(5)); + Thread.sleep(5000); assertThat( getFeatureSetSpecAcks(), diff --git a/ingestion/src/test/java/feast/test/TestUtil.java b/ingestion/src/test/java/feast/test/TestUtil.java index f3ae9f6a988..b003137846b 100644 --- a/ingestion/src/test/java/feast/test/TestUtil.java +++ b/ingestion/src/test/java/feast/test/TestUtil.java @@ -22,7 +22,7 @@ import com.google.common.io.Files; import com.google.protobuf.ByteString; import com.google.protobuf.Message; -import com.google.protobuf.util.Timestamps; +import com.google.protobuf.Timestamp; import feast.ingestion.transform.metrics.WriteSuccessMetricsTransform; import feast.proto.core.FeatureSetProto.FeatureSet; import feast.proto.core.FeatureSetProto.FeatureSetSpec; @@ -36,6 +36,7 @@ import java.net.DatagramSocket; import java.net.SocketException; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -218,10 +219,15 @@ public static FeatureRow createRandomFeatureRow(FeatureSetSpec featureSetSpec) { */ public static FeatureRow createRandomFeatureRow( FeatureSetSpec featureSetSpec, int randomStringSize) { + + Instant time = Instant.now(); + Timestamp timestamp = + Timestamp.newBuilder().setSeconds(time.getEpochSecond()).setNanos(time.getNano()).build(); + Builder builder = FeatureRow.newBuilder() .setFeatureSet(getFeatureSetStringRef(featureSetSpec)) - .setEventTimestamp(Timestamps.fromMillis(System.currentTimeMillis())); + .setEventTimestamp(timestamp); featureSetSpec .getEntitiesList() diff --git a/pom.xml b/pom.xml index 5de3f1116a3..204b4052fdc 100644 --- a/pom.xml +++ b/pom.xml @@ -47,23 +47,27 @@ UTF-8 UTF-8 - 1.17.1 - 3.10.0 - 3.10.0 - 2.0.9.RELEASE - 2.18.0 - 1.91.0 + 1.30.2 + 3.12.2 + 3.12.2 + 2.3.1.RELEASE + 5.3.0.RELEASE + 2.9.0.RELEASE + 2.22.0 + 1.111.1 0.8.0 1.9.10 1.3 - 5.3.6.Final - 2.3.0 + 5.4.18.Final + 2.5.0 2.28.2 - 0.21.0 + 0.26.0 2.12.1 - 5.2.4 + 6.0.8 + 2.9.9 + 2.0.2 @@ -153,37 +157,72 @@ io.grpc grpc-core - ${grpcVersion} + ${grpc.version} + + + io.grpc + grpc-api + ${grpc.version} + + + io.grpc + grpc-context + ${grpc.version} + + + io.grpc + grpc-all + ${grpc.version} + + + io.grpc + grpc-okhttp + ${grpc.version} + + + io.grpc + grpc-auth + ${grpc.version} + + + io.grpc + grpc-grpclb + ${grpc.version} + + + io.grpc + grpc-alts + ${grpc.version} io.grpc grpc-netty - ${grpcVersion} + ${grpc.version} io.grpc grpc-netty-shaded - ${grpcVersion} + ${grpc.version} io.grpc grpc-protobuf - ${grpcVersion} + ${grpc.version} io.grpc grpc-services - ${grpcVersion} + ${grpc.version} io.grpc grpc-stub - ${grpcVersion} + ${grpc.version} io.grpc grpc-testing - ${grpcVersion} + ${grpc.version} test @@ -191,7 +230,7 @@ io.github.lognet grpc-spring-boot-starter - 3.0.2 + 3.5.5 @@ -207,6 +246,11 @@ + + joda-time + joda-time + ${joda.time.version} + com.datadoghq java-dogstatsd-client @@ -220,12 +264,12 @@ com.google.protobuf protobuf-java - ${protobufVersion} + ${protobuf.version} com.google.protobuf protobuf-java-util - ${protobufVersion} + ${protobuf.version} org.projectlombok @@ -272,7 +316,7 @@ org.springframework.boot spring-boot-starter-web - ${springBootVersion} + ${spring.boot.version} org.springframework.boot @@ -316,7 +360,7 @@ org.springframework.boot spring-boot-dependencies - ${springBootVersion} + ${spring.boot.version} pom import @@ -643,7 +687,7 @@ org.springframework.boot spring-boot-maven-plugin - ${springBootVersion} + ${spring.boot.version} diff --git a/serving/pom.xml b/serving/pom.xml index 66a840e0097..986775058d8 100644 --- a/serving/pom.xml +++ b/serving/pom.xml @@ -134,7 +134,7 @@ true - + org.springframework.boot spring-boot-starter-web @@ -161,23 +161,23 @@ grpc-spring-boot-starter - + org.springframework.boot spring-boot-starter-actuator - + io.grpc grpc-services - + io.grpc grpc-stub - + com.google.protobuf protobuf-java-util @@ -249,7 +249,7 @@ - + io.grpc grpc-testing @@ -278,6 +278,12 @@ embedded-redis test + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.api.version} + diff --git a/serving/src/main/java/feast/serving/service/OnlineServingService.java b/serving/src/main/java/feast/serving/service/OnlineServingService.java index a357904e32f..a7d9d284aa2 100644 --- a/serving/src/main/java/feast/serving/service/OnlineServingService.java +++ b/serving/src/main/java/feast/serving/service/OnlineServingService.java @@ -17,6 +17,7 @@ package feast.serving.service; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Streams; import com.google.protobuf.Duration; import feast.common.models.Feature; import feast.common.models.FeatureSet; @@ -36,7 +37,6 @@ import io.opentracing.Tracer; import java.util.*; import java.util.stream.Collectors; -import org.apache.beam.vendor.grpc.v1p21p0.com.google.common.collect.Streams; import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; diff --git a/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java b/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java index 43a96e97efa..5f191d276ce 100644 --- a/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java +++ b/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java @@ -17,13 +17,14 @@ package feast.storage.common.testing; import com.google.protobuf.ByteString; -import com.google.protobuf.util.Timestamps; +import com.google.protobuf.Timestamp; import feast.proto.core.FeatureSetProto.FeatureSet; import feast.proto.core.FeatureSetProto.FeatureSetSpec; import feast.proto.types.FeatureRowProto.FeatureRow; import feast.proto.types.FeatureRowProto.FeatureRow.Builder; import feast.proto.types.FieldProto.Field; import feast.proto.types.ValueProto.*; +import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; import org.apache.commons.lang3.RandomStringUtils; @@ -53,10 +54,15 @@ public static FeatureRow createRandomFeatureRow(FeatureSet featureSet) { * @return {@link FeatureRow} */ public static FeatureRow createRandomFeatureRow(FeatureSet featureSet, int randomStringSize) { + + Instant time = Instant.now(); + Timestamp timestamp = + Timestamp.newBuilder().setSeconds(time.getEpochSecond()).setNanos(time.getNano()).build(); + Builder builder = FeatureRow.newBuilder() .setFeatureSet(getFeatureSetReference(featureSet)) - .setEventTimestamp(Timestamps.fromMillis(System.currentTimeMillis())); + .setEventTimestamp(timestamp); featureSet .getSpec() diff --git a/storage/connectors/bigquery/pom.xml b/storage/connectors/bigquery/pom.xml index 32c6dda4810..1b97d57b2cb 100644 --- a/storage/connectors/bigquery/pom.xml +++ b/storage/connectors/bigquery/pom.xml @@ -96,5 +96,11 @@ hamcrest-library test + + org.mockito + mockito-core + ${mockito.version} + test + diff --git a/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/compression/FeatureRowsBatch.java b/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/compression/FeatureRowsBatch.java index 1befae221b7..f9f74c15bf3 100644 --- a/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/compression/FeatureRowsBatch.java +++ b/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/compression/FeatureRowsBatch.java @@ -200,7 +200,7 @@ public FeatureRowsBatch withFeatureSetReference(String featureSetReference) { } public Row toRow() { - return Row.withSchema(schema).attachValues(values).build(); + return Row.withSchema(schema).attachValues(values); } public static FeatureRowsBatch fromRow(Row row) { diff --git a/storage/connectors/bigquery/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BatchLoadsWithResult.java b/storage/connectors/bigquery/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BatchLoadsWithResult.java index f6be75fe13e..1cfb4087460 100644 --- a/storage/connectors/bigquery/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BatchLoadsWithResult.java +++ b/storage/connectors/bigquery/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BatchLoadsWithResult.java @@ -1,7 +1,7 @@ package org.apache.beam.sdk.io.gcp.bigquery; +import static com.google.common.base.Preconditions.checkArgument; import static org.apache.beam.sdk.io.gcp.bigquery.BigQueryHelpers.resolveTempLocation; -import static org.apache.beam.vendor.grpc.v1p21p0.com.google.common.base.Preconditions.checkArgument; import com.google.api.services.bigquery.model.TableRow; import com.google.auto.value.AutoValue; @@ -306,6 +306,8 @@ PCollection> writeSinglePartitionWithResult( getIgnoreUnknownValues(), getKmsKey(), getRowWriterFactory().getSourceFormat(), + true, getSchemaUpdateOptions())); } + } diff --git a/storage/connectors/redis/pom.xml b/storage/connectors/redis/pom.xml index 3aa863f6811..807ee86fe5e 100644 --- a/storage/connectors/redis/pom.xml +++ b/storage/connectors/redis/pom.xml @@ -45,7 +45,7 @@ org.mockito mockito-core - 2.23.0 + ${mockito.version} test From 9847b979399de9945827d933880bb3fbe3d96c45 Mon Sep 17 00:00:00 2001 From: Jayanth Kumar M J Date: Tue, 21 Jul 2020 09:47:08 -0400 Subject: [PATCH 08/31] Add caching to authorization (#884) * Cache authorization. * fix formatting, removed default key generator and added a bean. * fix rebase errors. --- auth/pom.xml | 19 +++ .../HttpAuthorizationProvider.java | 42 ++----- .../feast/auth/config/CacheConfiguration.java | 107 ++++++++++++++++ .../main/java/feast/auth/utils/AuthUtils.java | 54 ++++++++ .../HttpAuthorizationProviderCachingTest.java | 119 ++++++++++++++++++ core/src/main/resources/application.yml | 1 + 6 files changed, 310 insertions(+), 32 deletions(-) create mode 100644 auth/src/main/java/feast/auth/config/CacheConfiguration.java create mode 100644 auth/src/main/java/feast/auth/utils/AuthUtils.java create mode 100644 auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java diff --git a/auth/pom.xml b/auth/pom.xml index d90dad49059..65311586e99 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -28,6 +28,10 @@ feast-common ${project.version} + + org.springframework + spring-context-support + net.devh grpc-server-spring-boot-starter @@ -91,6 +95,17 @@ jsr305 3.0.2 + + org.springframework + spring-test + test + + + org.mockito + mockito-core + ${mockito.version} + test + @@ -131,6 +146,10 @@ feast.auth.generated.client.api + + org.jacoco + jacoco-maven-plugin + diff --git a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java index 6abe76f3b20..534f0182ad6 100644 --- a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java +++ b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java @@ -16,14 +16,16 @@ */ package feast.auth.authorization; +import feast.auth.config.CacheConfiguration; import feast.auth.generated.client.api.DefaultApi; import feast.auth.generated.client.invoker.ApiClient; import feast.auth.generated.client.invoker.ApiException; import feast.auth.generated.client.model.CheckAccessRequest; +import feast.auth.utils.AuthUtils; import java.util.Map; -import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.jwt.Jwt; @@ -41,7 +43,7 @@ public class HttpAuthorizationProvider implements AuthorizationProvider { * The default subject claim is the key within the Authentication object where the user's identity * can be found */ - private final String DEFAULT_SUBJECT_CLAIM = "email"; + private final String subjectClaim; /** * Initializes the HTTPAuthorizationProvider @@ -58,26 +60,29 @@ public HttpAuthorizationProvider(Map options) { ApiClient apiClient = new ApiClient(); apiClient.setBasePath(options.get("authorizationUrl")); this.defaultApiClient = new DefaultApi(apiClient); + subjectClaim = options.get("subjectClaim"); } /** - * Validates whether a user has access to a project + * Validates whether a user has access to a project. @Cacheable is using {@link + * CacheConfiguration} settings to cache output of the method {@link AuthorizationResult} for a + * specified duration set in cache settings. * * @param projectId Name of the Feast project * @param authentication Spring Security Authentication object * @return AuthorizationResult result of authorization query */ + @Cacheable(value = CacheConfiguration.AUTHORIZATION_CACHE, keyGenerator = "authKeyGenerator") public AuthorizationResult checkAccessToProject(String projectId, Authentication authentication) { CheckAccessRequest checkAccessRequest = new CheckAccessRequest(); Object context = getContext(authentication); - String subject = getSubjectFromAuth(authentication, DEFAULT_SUBJECT_CLAIM); + String subject = AuthUtils.getSubjectFromAuth(authentication, subjectClaim); String resource = "projects:" + projectId; checkAccessRequest.setAction("ALL"); checkAccessRequest.setContext(context); checkAccessRequest.setResource(resource); checkAccessRequest.setSubject(subject); - try { // Make authorization request to external service feast.auth.generated.client.model.AuthorizationResult authResult = @@ -112,31 +117,4 @@ private Object getContext(Authentication authentication) { // Not implemented yet, left empty return new Object(); } - - /** - * Get user email from their authentication object. - * - * @param authentication Spring Security Authentication object, used to extract user details - * @param subjectClaim Indicates the claim where the subject can be found - * @return String user email - */ - private String getSubjectFromAuth(Authentication authentication, String subjectClaim) { - Jwt principle = ((Jwt) authentication.getPrincipal()); - Map claims = principle.getClaims(); - String subjectValue = (String) claims.get(subjectClaim); - - if (subjectValue.isEmpty()) { - throw new IllegalStateException( - String.format("JWT does not have a valid claim %s.", subjectClaim)); - } - - if (subjectClaim.equals("email")) { - boolean validEmail = (new EmailValidator()).isValid(subjectValue, null); - if (!validEmail) { - throw new IllegalStateException("JWT contains an invalid email address"); - } - } - - return subjectValue; - } } diff --git a/auth/src/main/java/feast/auth/config/CacheConfiguration.java b/auth/src/main/java/feast/auth/config/CacheConfiguration.java new file mode 100644 index 00000000000..e8c46b3613c --- /dev/null +++ b/auth/src/main/java/feast/auth/config/CacheConfiguration.java @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.config; + +import com.google.common.cache.CacheBuilder; +import feast.auth.utils.AuthUtils; +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.interceptor.CacheResolver; +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; + +/** CacheConfiguration class defines Cache settings for HttpAuthorizationProvider class. */ +@Configuration +@EnableCaching +@Setter +@Getter +public class CacheConfiguration implements CachingConfigurer { + + private static final int CACHE_SIZE = 10000; + + public static int TTL = 60; + + public static final String AUTHORIZATION_CACHE = "authorization"; + + @Autowired SecurityProperties secutiryProps; + + @Bean + public CacheManager cacheManager() { + ConcurrentMapCacheManager cacheManager = + new ConcurrentMapCacheManager(AUTHORIZATION_CACHE) { + + @Override + protected Cache createConcurrentMapCache(final String name) { + return new ConcurrentMapCache( + name, + CacheBuilder.newBuilder() + .expireAfterWrite(TTL, TimeUnit.SECONDS) + .maximumSize(CACHE_SIZE) + .build() + .asMap(), + false); + } + }; + + return cacheManager; + } + + /* + * KeyGenerator used by {@link Cacheable} for caching authorization requests. + * Key format : checkAccessToProject-- + */ + @Bean + public KeyGenerator authKeyGenerator() { + return (Object target, Method method, Object... params) -> { + String projectId = (String) params[0]; + Authentication authentication = (Authentication) params[1]; + String subject = + AuthUtils.getSubjectFromAuth( + authentication, secutiryProps.getAuthorization().getOptions().get("subjectClaim")); + return String.format("%s-%s-%s", method.getName(), projectId, subject); + }; + } + + @Override + public CacheResolver cacheResolver() { + // TODO Auto-generated method stub + return null; + } + + @Override + public KeyGenerator keyGenerator() { + return null; + } + + @Override + public CacheErrorHandler errorHandler() { + // TODO Auto-generated method stub + return null; + } +} diff --git a/auth/src/main/java/feast/auth/utils/AuthUtils.java b/auth/src/main/java/feast/auth/utils/AuthUtils.java new file mode 100644 index 00000000000..d211165c86e --- /dev/null +++ b/auth/src/main/java/feast/auth/utils/AuthUtils.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.utils; + +import java.util.Map; +import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; + +public class AuthUtils { + + // Suppresses default constructor, ensuring non-instantiability. + private AuthUtils() {} + + /** + * Get user email from their authentication object. + * + * @param authentication Spring Security Authentication object, used to extract user details + * @param subjectClaim Indicates the claim where the subject can be found + * @return String user email + */ + public static String getSubjectFromAuth(Authentication authentication, String subjectClaim) { + Jwt principle = ((Jwt) authentication.getPrincipal()); + Map claims = principle.getClaims(); + String subjectValue = (String) claims.getOrDefault(subjectClaim, ""); + + if (subjectValue.isEmpty()) { + throw new IllegalStateException( + String.format("JWT does not have a valid claim %s.", subjectClaim)); + } + + if (subjectClaim.equals("email")) { + boolean validEmail = (new EmailValidator()).isValid(subjectValue, null); + if (!validEmail) { + throw new IllegalStateException("JWT contains an invalid email address"); + } + } + return subjectValue; + } +} diff --git a/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java b/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java new file mode 100644 index 00000000000..44129d41ce9 --- /dev/null +++ b/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java @@ -0,0 +1,119 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.authorization; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import feast.auth.config.CacheConfiguration; +import feast.auth.config.SecurityProperties; +import feast.auth.config.SecurityProperties.AuthenticationProperties; +import feast.auth.config.SecurityProperties.AuthorizationProperties; +import feast.auth.generated.client.api.DefaultApi; +import feast.auth.generated.client.model.AuthorizationResult; +import feast.auth.generated.client.model.CheckAccessRequest; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.internal.util.reflection.FieldSetter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@ContextConfiguration( + classes = {CacheConfiguration.class, HttpAuthorizationProviderCachingTest.Config.class}) +public class HttpAuthorizationProviderCachingTest { + + // static since field needs to updated in provider() bean + private static DefaultApi api = Mockito.mock(DefaultApi.class); + + @Autowired AuthorizationProvider provider; + + @Configuration + static class Config { + @Bean + SecurityProperties securityProps() { + // setting TTL static variable in SecurityProperties bean, since CacheConfiguration bean is + // dependent on SecurityProperties. + CacheConfiguration.TTL = 1; + AuthenticationProperties authentication = Mockito.mock(AuthenticationProperties.class); + AuthorizationProperties authorization = new AuthorizationProperties(); + authorization.setEnabled(true); + authorization.setProvider("http"); + Map options = new HashMap<>(); + options.put("authorizationUrl", "localhost"); + options.put("subjectClaim", "email"); + authorization.setOptions(options); + SecurityProperties sp = new SecurityProperties(); + sp.setAuthentication(authentication); + sp.setAuthorization(authorization); + return sp; + } + + @Bean + AuthorizationProvider provider() throws NoSuchFieldException, SecurityException { + Map options = new HashMap<>(); + options.put("authorizationUrl", "localhost"); + options.put("subjectClaim", "email"); + HttpAuthorizationProvider provider = new HttpAuthorizationProvider(options); + FieldSetter.setField(provider, provider.getClass().getDeclaredField("defaultApiClient"), api); + return provider; + } + } + + @Test + public void testCheckAccessToProjectShouldReadFromCacheWhenAvailable() throws Exception { + Authentication auth = Mockito.mock(Authentication.class); + Jwt jwt = Mockito.mock(Jwt.class); + Map claims = new HashMap<>(); + claims.put("email", "test@test.com"); + doReturn(jwt).when(auth).getCredentials(); + doReturn(jwt).when(auth).getPrincipal(); + doReturn(claims).when(jwt).getClaims(); + doReturn("test_token").when(jwt).getTokenValue(); + AuthorizationResult authResult = new AuthorizationResult(); + authResult.setAllowed(true); + doReturn(authResult) + .when(api) + .checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + + // Should save the result in cache + provider.checkAccessToProject("test", auth); + // Should read from cache + provider.checkAccessToProject("test", auth); + verify(api, times(1)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + + // cache ttl is set to 1 second for testing. + Thread.sleep(1100); + + // Should make an invocation to external service + provider.checkAccessToProject("test", auth); + verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + // Should read from cache + provider.checkAccessToProject("test", auth); + verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + } +} diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index fbdf6036328..669192f3367 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -94,6 +94,7 @@ feast: provider: http options: authorizationUrl: http://localhost:8082 + subjectClaim: email grpc: server: From e11d860f65682cc6b9c1b579b38c019f5026f821 Mon Sep 17 00:00:00 2001 From: Jayanth Kumar M J Date: Wed, 29 Jul 2020 11:41:50 -0400 Subject: [PATCH 09/31] Add Authentication and Authorization for feast serving (#865) * Authentication and authorization for feast serving, squashed on 07/21 * fix e2e, add metadata plugin in jobs, merge labels, auth failure test, removed unwanted expire time validation from gauth. * fix rebase adaption. * Fix core integration test. * Authentication integration test. * Add authorization test and minor refactoring. * fix failing integration test. * fix lint error. --- auth/pom.xml | 60 +++- .../HttpAuthorizationProvider.java | 1 - .../feast/auth/config/SecurityConfig.java | 6 +- .../CoreAuthenticationProperties.java | 56 ++++ .../credentials/GoogleAuthCredentials.java | 79 +++++ .../auth/credentials/OAuthCredentials.java | 120 ++++++++ .../auth/service/AuthorizationService.java | 63 ++++ .../HttpAuthorizationProviderCachingTest.java | 10 +- core/pom.xml | 2 +- .../feast/core/config/CoreSecurityConfig.java | 2 +- .../feast/core/config/FeastProperties.java | 16 + .../java/feast/core/grpc/CoreServiceImpl.java | 47 ++- .../feast/core/grpc/HealthServiceImpl.java | 10 +- ...gementService.java => ProjectService.java} | 51 +--- .../feast/core/grpc/CoreServiceAuthTest.java | 20 +- ...rviceTest.java => ProjectServiceTest.java} | 45 +-- .../docker-compose/docker-compose.online.yml | 5 + infra/scripts/test-end-to-end-batch.sh | 4 - .../scripts/test-end-to-end-redis-cluster.sh | 4 - infra/scripts/test-end-to-end.sh | 38 ++- pom.xml | 3 + sdk/python/feast/client.py | 47 +-- sdk/python/feast/constants.py | 10 +- sdk/python/feast/grpc/auth.py | 16 +- sdk/python/feast/job.py | 27 +- sdk/python/tests/grpc/test_auth.py | 17 +- sdk/python/tests/test_client.py | 113 ++++--- serving/pom.xml | 84 +++++- .../feast/serving/config/FeastProperties.java | 85 ++++++ .../serving/config/ServingSecurityConfig.java | 94 ++++++ .../serving/config/SpecServiceConfig.java | 8 +- .../controller/HealthServiceController.java | 4 +- .../ServingServiceGRpcController.java | 58 +++- .../feast/serving/specs/CoreSpecService.java | 12 +- serving/src/main/resources/application.yml | 42 ++- .../ServingServiceGRpcControllerTest.java | 62 +++- .../java/feast/serving/it/AuthTestUtils.java | 283 ++++++++++++++++++ .../java/feast/serving/it/BaseAuthIT.java | 81 +++++ .../feast/serving/it/CoreSimpleAPIClient.java | 43 +++ .../ServingServiceOauthAuthenticationIT.java | 124 ++++++++ .../ServingServiceOauthAuthorizationIT.java | 212 +++++++++++++ .../test/resources/application-it.properties | 18 ++ .../docker-compose/core/application-it.yml | 21 ++ .../docker-compose/docker-compose-it-core.yml | 53 ++++ .../docker-compose-it-hydra.yml | 54 ++++ .../docker-compose/docker-compose-it-keto.yml | 44 +++ tests/e2e/redis/basic-ingest-redis-serving.py | 46 ++- 47 files changed, 2037 insertions(+), 263 deletions(-) create mode 100644 auth/src/main/java/feast/auth/credentials/CoreAuthenticationProperties.java create mode 100644 auth/src/main/java/feast/auth/credentials/GoogleAuthCredentials.java create mode 100644 auth/src/main/java/feast/auth/credentials/OAuthCredentials.java create mode 100644 auth/src/main/java/feast/auth/service/AuthorizationService.java rename core/src/main/java/feast/core/service/{AccessManagementService.java => ProjectService.java} (52%) rename core/src/test/java/feast/core/service/{AccessManagementServiceTest.java => ProjectServiceTest.java} (63%) create mode 100644 serving/src/main/java/feast/serving/config/ServingSecurityConfig.java create mode 100644 serving/src/test/java/feast/serving/it/AuthTestUtils.java create mode 100644 serving/src/test/java/feast/serving/it/BaseAuthIT.java create mode 100644 serving/src/test/java/feast/serving/it/CoreSimpleAPIClient.java create mode 100644 serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java create mode 100644 serving/src/test/java/feast/serving/it/ServingServiceOauthAuthorizationIT.java create mode 100644 serving/src/test/resources/application-it.properties create mode 100644 serving/src/test/resources/docker-compose/core/application-it.yml create mode 100644 serving/src/test/resources/docker-compose/docker-compose-it-core.yml create mode 100644 serving/src/test/resources/docker-compose/docker-compose-it-hydra.yml create mode 100644 serving/src/test/resources/docker-compose/docker-compose-it-keto.yml diff --git a/auth/pom.xml b/auth/pom.xml index 65311586e99..c15f57f5d14 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -21,6 +21,8 @@ 3.10 1.3.2 4.13 + 2.8.0 + 0.20.0 @@ -60,10 +62,6 @@ com.fasterxml.jackson.core jackson-databind - - junit - junit - io.swagger swagger-annotations @@ -106,6 +104,60 @@ ${mockito.version} test + + org.springframework.boot + spring-boot-starter-web + + + io.springfox + springfox-swagger2 + ${springfox-version} + + + io.springfox + springfox-swagger-ui + ${springfox-version} + + + javax.xml.bind + jaxb-api + 2.2.11 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.openapitools + jackson-databind-nullable + 0.1.0 + + + + javax.validation + validation-api + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + junit + junit + 4.12 + + + com.google.auth + google-auth-library-oauth2-http + ${google-auth-library-oauth2-http-version} + diff --git a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java index 534f0182ad6..44c0a49634d 100644 --- a/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java +++ b/auth/src/main/java/feast/auth/authorization/HttpAuthorizationProvider.java @@ -27,7 +27,6 @@ import org.slf4j.LoggerFactory; import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.jwt.Jwt; /** * HTTPAuthorizationProvider uses an external HTTP service for authorizing requests. Please see diff --git a/auth/src/main/java/feast/auth/config/SecurityConfig.java b/auth/src/main/java/feast/auth/config/SecurityConfig.java index f377c76a874..8229702b3ed 100644 --- a/auth/src/main/java/feast/auth/config/SecurityConfig.java +++ b/auth/src/main/java/feast/auth/config/SecurityConfig.java @@ -83,13 +83,13 @@ GrpcAuthenticationReader authenticationReader() { } /** - * Creates an AccessDecisionManager if authorization is enabled. This object determines the policy - * used to make authorization decisions. + * Creates an AccessDecisionManager if authentication is enabled. This object determines the + * policy used to make authentication decisions. * * @return AccessDecisionManager */ @Bean - @ConditionalOnProperty(prefix = "feast.security.authorization", name = "enabled") + @ConditionalOnProperty(prefix = "feast.security.authentication", name = "enabled") AccessDecisionManager accessDecisionManager() { final List> voters = new ArrayList<>(); voters.add(new AccessPredicateVoter()); diff --git a/auth/src/main/java/feast/auth/credentials/CoreAuthenticationProperties.java b/auth/src/main/java/feast/auth/credentials/CoreAuthenticationProperties.java new file mode 100644 index 00000000000..e307dfb1c83 --- /dev/null +++ b/auth/src/main/java/feast/auth/credentials/CoreAuthenticationProperties.java @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.credentials; + +import feast.common.validators.OneOfStrings; +import java.util.Map; + +public class CoreAuthenticationProperties { + // needs to be set to true if authentication is enabled on core + private boolean enabled; + + // authentication provider to use + @OneOfStrings({"google", "oauth"}) + private String provider; + + // K/V options to initialize the provider. + Map options; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public Map getOptions() { + return options; + } + + public void setOptions(Map options) { + this.options = options; + } +} diff --git a/auth/src/main/java/feast/auth/credentials/GoogleAuthCredentials.java b/auth/src/main/java/feast/auth/credentials/GoogleAuthCredentials.java new file mode 100644 index 00000000000..709b803ce08 --- /dev/null +++ b/auth/src/main/java/feast/auth/credentials/GoogleAuthCredentials.java @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.credentials; + +import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; + +import com.google.auth.oauth2.IdTokenCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.Status; +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.Executor; + +/* + * Google auth provider's callCredentials Implementation for serving. + * Used by CoreSpecService to connect to core. + */ +public class GoogleAuthCredentials extends CallCredentials { + private final IdTokenCredentials credentials; + private static final String BEARER_TYPE = "Bearer"; + private static final Metadata.Key AUTHORIZATION_METADATA_KEY = + Metadata.Key.of("Authorization", ASCII_STRING_MARSHALLER); + + public GoogleAuthCredentials(Map options) throws IOException { + + String targetAudience = options.getOrDefault("audience", "https://localhost"); + ServiceAccountCredentials serviceCreds = + (ServiceAccountCredentials) + ServiceAccountCredentials.getApplicationDefault() + .createScoped(Arrays.asList("openid", "email")); + + credentials = + IdTokenCredentials.newBuilder() + .setIdTokenProvider(serviceCreds) + .setTargetAudience(targetAudience) + .build(); + } + + @Override + public void applyRequestMetadata( + RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) { + appExecutor.execute( + () -> { + try { + credentials.refreshIfExpired(); + Metadata headers = new Metadata(); + headers.put( + AUTHORIZATION_METADATA_KEY, + String.format("%s %s", BEARER_TYPE, credentials.getIdToken().getTokenValue())); + applier.apply(headers); + } catch (Throwable e) { + applier.fail(Status.UNAUTHENTICATED.withCause(e)); + } + }); + } + + @Override + public void thisUsesUnstableApi() { + // TODO Auto-generated method stub + + } +} diff --git a/auth/src/main/java/feast/auth/credentials/OAuthCredentials.java b/auth/src/main/java/feast/auth/credentials/OAuthCredentials.java new file mode 100644 index 00000000000..e7ad47f3778 --- /dev/null +++ b/auth/src/main/java/feast/auth/credentials/OAuthCredentials.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.credentials; + +import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; + +import com.nimbusds.jose.util.JSONObjectUtils; +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.Status; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.Executor; +import javax.security.sasl.AuthenticationException; +import net.minidev.json.JSONObject; +import okhttp3.FormBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +/* + * Oauth Credentials Implementation for serving. + * Used by CoreSpecService to connect to core. + */ +public class OAuthCredentials extends CallCredentials { + + private static final String JWK_ENDPOINT_URI = "jwkEndpointURI"; + static final String APPLICATION_JSON = "application/json"; + static final String CONTENT_TYPE = "content-type"; + static final String BEARER_TYPE = "Bearer"; + static final String GRANT_TYPE = "grant_type"; + static final String CLIENT_ID = "client_id"; + static final String CLIENT_SECRET = "client_secret"; + static final String AUDIENCE = "audience"; + static final String OAUTH_URL = "oauth_url"; + static final Metadata.Key AUTHORIZATION_METADATA_KEY = + Metadata.Key.of("Authorization", ASCII_STRING_MARSHALLER); + + private OkHttpClient httpClient; + private Request request; + private String accessToken; + private Instant tokenExpiryTime; + private NimbusJwtDecoder jwtDecoder; + + public OAuthCredentials(Map options) { + this.httpClient = new OkHttpClient(); + if (!(options.containsKey(GRANT_TYPE) + && options.containsKey(CLIENT_ID) + && options.containsKey(AUDIENCE) + && options.containsKey(CLIENT_SECRET) + && options.containsKey(OAUTH_URL) + && options.containsKey(JWK_ENDPOINT_URI))) { + throw new AssertionError( + "please configure the properties:" + + " grant_type, client_id, client_secret, audience, oauth_url, jwkEndpointURI"); + } + RequestBody requestBody = + new FormBody.Builder() + .add(GRANT_TYPE, options.get(GRANT_TYPE)) + .add(CLIENT_ID, options.get(CLIENT_ID)) + .add(CLIENT_SECRET, options.get(CLIENT_SECRET)) + .add(AUDIENCE, options.get(AUDIENCE)) + .build(); + this.request = + new Request.Builder() + .url(options.get(OAUTH_URL)) + .addHeader(CONTENT_TYPE, APPLICATION_JSON) + .post(requestBody) + .build(); + this.jwtDecoder = NimbusJwtDecoder.withJwkSetUri(options.get(JWK_ENDPOINT_URI)).build(); + } + + @Override + public void thisUsesUnstableApi() { + // TODO Auto-generated method stub + + } + + @Override + public void applyRequestMetadata( + RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) { + appExecutor.execute( + () -> { + try { + // Fetches new token if it is not available or if token has expired. + if (this.accessToken == null || Instant.now().isAfter(this.tokenExpiryTime)) { + Response response = httpClient.newCall(request).execute(); + if (!response.isSuccessful()) { + throw new AuthenticationException(response.message()); + } + JSONObject json = JSONObjectUtils.parse(response.body().string()); + this.accessToken = json.getAsString("access_token"); + this.tokenExpiryTime = jwtDecoder.decode(this.accessToken).getExpiresAt(); + } + Metadata headers = new Metadata(); + headers.put( + AUTHORIZATION_METADATA_KEY, String.format("%s %s", BEARER_TYPE, this.accessToken)); + applier.apply(headers); + } catch (Throwable e) { + applier.fail(Status.UNAUTHENTICATED.withCause(e)); + } + }); + } +} diff --git a/auth/src/main/java/feast/auth/service/AuthorizationService.java b/auth/src/main/java/feast/auth/service/AuthorizationService.java new file mode 100644 index 00000000000..24942611857 --- /dev/null +++ b/auth/src/main/java/feast/auth/service/AuthorizationService.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.auth.service; + +import feast.auth.authorization.AuthorizationProvider; +import feast.auth.authorization.AuthorizationResult; +import feast.auth.config.SecurityProperties; +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Service; + +@AllArgsConstructor +@Service +public class AuthorizationService { + + private final SecurityProperties securityProperties; + private final AuthorizationProvider authorizationProvider; + + @Autowired + public AuthorizationService( + SecurityProperties securityProperties, + ObjectProvider authorizationProvider) { + this.securityProperties = securityProperties; + this.authorizationProvider = authorizationProvider.getIfAvailable(); + } + + /** + * Determine whether a user has access to a project. + * + * @param securityContext Spring Security Context used to identify a user or service. + * @param project Name of the project for which membership should be tested. + */ + public void authorizeRequest(SecurityContext securityContext, String project) { + Authentication authentication = securityContext.getAuthentication(); + if (!this.securityProperties.getAuthorization().isEnabled()) { + return; + } + + AuthorizationResult result = + this.authorizationProvider.checkAccessToProject(project, authentication); + if (!result.isAllowed()) { + throw new AccessDeniedException(result.getFailureReason().orElse("Access Denied")); + } + } +} diff --git a/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java b/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java index 44129d41ce9..7d683470264 100644 --- a/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java +++ b/auth/src/test/java/feast/auth/authorization/HttpAuthorizationProviderCachingTest.java @@ -96,24 +96,22 @@ public void testCheckAccessToProjectShouldReadFromCacheWhenAvailable() throws Ex doReturn("test_token").when(jwt).getTokenValue(); AuthorizationResult authResult = new AuthorizationResult(); authResult.setAllowed(true); - doReturn(authResult) - .when(api) - .checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + doReturn(authResult).when(api).checkAccessPost(any(CheckAccessRequest.class)); // Should save the result in cache provider.checkAccessToProject("test", auth); // Should read from cache provider.checkAccessToProject("test", auth); - verify(api, times(1)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + verify(api, times(1)).checkAccessPost(any(CheckAccessRequest.class)); // cache ttl is set to 1 second for testing. Thread.sleep(1100); // Should make an invocation to external service provider.checkAccessToProject("test", auth); - verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class)); // Should read from cache provider.checkAccessToProject("test", auth); - verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class), any(String.class)); + verify(api, times(2)).checkAccessPost(any(CheckAccessRequest.class)); } } diff --git a/core/pom.xml b/core/pom.xml index 716d8fdd394..d2f881bb5a2 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -127,7 +127,7 @@ org.springframework.security.oauth spring-security-oauth2 - 2.5.0.RELEASE + ${spring.security.oauth2.version} org.springframework.security diff --git a/core/src/main/java/feast/core/config/CoreSecurityConfig.java b/core/src/main/java/feast/core/config/CoreSecurityConfig.java index 6689db60c1d..3e4c2baa9eb 100644 --- a/core/src/main/java/feast/core/config/CoreSecurityConfig.java +++ b/core/src/main/java/feast/core/config/CoreSecurityConfig.java @@ -28,7 +28,7 @@ @Configuration @Slf4j -@ComponentScan("feast.auth") +@ComponentScan(basePackages = {"feast.auth.config", "feast.auth.service"}) public class CoreSecurityConfig { /** diff --git a/core/src/main/java/feast/core/config/FeastProperties.java b/core/src/main/java/feast/core/config/FeastProperties.java index 799000631d5..c50b32b1749 100644 --- a/core/src/main/java/feast/core/config/FeastProperties.java +++ b/core/src/main/java/feast/core/config/FeastProperties.java @@ -17,6 +17,8 @@ package feast.core.config; import feast.auth.config.SecurityProperties; +import feast.auth.config.SecurityProperties.AuthenticationProperties; +import feast.auth.config.SecurityProperties.AuthorizationProperties; import feast.common.validators.OneOfStrings; import feast.core.config.FeastProperties.StreamProperties.FeatureStreamOptions; import java.net.InetAddress; @@ -278,5 +280,19 @@ public void validate() { + e.getMessage()); } } + + // Validate AuthenticationProperties + Set> authenticationPropsViolations = + validator.validate(getSecurity().getAuthentication()); + if (!authenticationPropsViolations.isEmpty()) { + throw new ConstraintViolationException(authenticationPropsViolations); + } + + // Validate AuthorizationProperties + Set> authorizationPropsViolations = + validator.validate(getSecurity().getAuthorization()); + if (!authorizationPropsViolations.isEmpty()) { + throw new ConstraintViolationException(authorizationPropsViolations); + } } } diff --git a/core/src/main/java/feast/core/grpc/CoreServiceImpl.java b/core/src/main/java/feast/core/grpc/CoreServiceImpl.java index d014f582a3f..e920464a50c 100644 --- a/core/src/main/java/feast/core/grpc/CoreServiceImpl.java +++ b/core/src/main/java/feast/core/grpc/CoreServiceImpl.java @@ -18,12 +18,13 @@ import com.google.api.gax.rpc.InvalidArgumentException; import com.google.protobuf.InvalidProtocolBufferException; +import feast.auth.service.AuthorizationService; import feast.core.config.FeastProperties; import feast.core.exception.RetrievalException; import feast.core.grpc.interceptors.MonitoringInterceptor; import feast.core.model.Project; -import feast.core.service.AccessManagementService; import feast.core.service.JobService; +import feast.core.service.ProjectService; import feast.core.service.SpecService; import feast.core.service.StatsService; import feast.proto.core.CoreServiceGrpc.CoreServiceImplBase; @@ -37,6 +38,7 @@ import lombok.extern.slf4j.Slf4j; import net.devh.boot.grpc.server.service.GrpcService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.context.SecurityContextHolder; /** Implementation of the feast core GRPC service. */ @@ -48,20 +50,23 @@ public class CoreServiceImpl extends CoreServiceImplBase { private SpecService specService; private JobService jobService; private StatsService statsService; - private AccessManagementService accessManagementService; + private ProjectService projectService; + private final AuthorizationService authorizationService; @Autowired public CoreServiceImpl( SpecService specService, - AccessManagementService accessManagementService, + ProjectService projectService, StatsService statsService, JobService jobService, - FeastProperties feastProperties) { + FeastProperties feastProperties, + AuthorizationService authorizationService) { this.specService = specService; - this.accessManagementService = accessManagementService; + this.projectService = projectService; this.jobService = jobService; this.feastProperties = feastProperties; this.statsService = statsService; + this.authorizationService = authorizationService; } @Override @@ -178,10 +183,11 @@ public void listStores( public void applyFeatureSet( ApplyFeatureSetRequest request, StreamObserver responseObserver) { - accessManagementService.checkIfProjectMember( - SecurityContextHolder.getContext(), request.getFeatureSet().getSpec().getProject()); + String projectId = null; try { + projectId = request.getFeatureSet().getSpec().getProject(); + authorizationService.authorizeRequest(SecurityContextHolder.getContext(), projectId); ApplyFeatureSetResponse response = specService.applyFeatureSet(request.getFeatureSet()); responseObserver.onNext(response); responseObserver.onCompleted(); @@ -192,6 +198,13 @@ public void applyFeatureSet( e); responseObserver.onError( Status.ALREADY_EXISTS.withDescription(e.getMessage()).withCause(e).asRuntimeException()); + } catch (AccessDeniedException e) { + log.info(String.format("User prevented from accessing project: %s", projectId)); + responseObserver.onError( + Status.PERMISSION_DENIED + .withDescription(e.getMessage()) + .withCause(e) + .asRuntimeException()); } catch (Exception e) { log.error("Exception has occurred in ApplyFeatureSet method: ", e); responseObserver.onError( @@ -217,7 +230,7 @@ public void updateStore( public void createProject( CreateProjectRequest request, StreamObserver responseObserver) { try { - accessManagementService.createProject(request.getName()); + projectService.createProject(request.getName()); responseObserver.onNext(CreateProjectResponse.getDefaultInstance()); responseObserver.onCompleted(); } catch (Exception e) { @@ -230,12 +243,11 @@ public void createProject( @Override public void archiveProject( ArchiveProjectRequest request, StreamObserver responseObserver) { - - accessManagementService.checkIfProjectMember( - SecurityContextHolder.getContext(), request.getName()); - + String projectId = null; try { - accessManagementService.archiveProject(request.getName()); + projectId = request.getName(); + authorizationService.authorizeRequest(SecurityContextHolder.getContext(), projectId); + projectService.archiveProject(projectId); responseObserver.onNext(ArchiveProjectResponse.getDefaultInstance()); responseObserver.onCompleted(); } catch (IllegalArgumentException e) { @@ -249,6 +261,13 @@ public void archiveProject( log.error("Attempted to archive an unsupported project:", e); responseObserver.onError( Status.UNIMPLEMENTED.withDescription(e.getMessage()).withCause(e).asRuntimeException()); + } catch (AccessDeniedException e) { + log.info(String.format("User prevented from accessing project: %s", projectId)); + responseObserver.onError( + Status.PERMISSION_DENIED + .withDescription(e.getMessage()) + .withCause(e) + .asRuntimeException()); } catch (Exception e) { log.error("Exception has occurred in the createProject method: ", e); responseObserver.onError( @@ -260,7 +279,7 @@ public void archiveProject( public void listProjects( ListProjectsRequest request, StreamObserver responseObserver) { try { - List projects = accessManagementService.listProjects(); + List projects = projectService.listProjects(); responseObserver.onNext( ListProjectsResponse.newBuilder() .addAllProjects(projects.stream().map(Project::getName).collect(Collectors.toList())) diff --git a/core/src/main/java/feast/core/grpc/HealthServiceImpl.java b/core/src/main/java/feast/core/grpc/HealthServiceImpl.java index b83a05b7f03..0a1f10109ca 100644 --- a/core/src/main/java/feast/core/grpc/HealthServiceImpl.java +++ b/core/src/main/java/feast/core/grpc/HealthServiceImpl.java @@ -16,7 +16,7 @@ */ package feast.core.grpc; -import feast.core.service.AccessManagementService; +import feast.core.service.ProjectService; import io.grpc.Status; import io.grpc.health.v1.HealthGrpc.HealthImplBase; import io.grpc.health.v1.HealthProto.HealthCheckRequest; @@ -30,18 +30,18 @@ @Slf4j @GrpcService public class HealthServiceImpl extends HealthImplBase { - private final AccessManagementService accessManagementService; + private final ProjectService projectService; @Autowired - public HealthServiceImpl(AccessManagementService accessManagementService) { - this.accessManagementService = accessManagementService; + public HealthServiceImpl(ProjectService projectService) { + this.projectService = projectService; } @Override public void check( HealthCheckRequest request, StreamObserver responseObserver) { try { - accessManagementService.listProjects(); + projectService.listProjects(); responseObserver.onNext( HealthCheckResponse.newBuilder().setStatus(ServingStatus.SERVING).build()); responseObserver.onCompleted(); diff --git a/core/src/main/java/feast/core/service/AccessManagementService.java b/core/src/main/java/feast/core/service/ProjectService.java similarity index 52% rename from core/src/main/java/feast/core/service/AccessManagementService.java rename to core/src/main/java/feast/core/service/ProjectService.java index bd5eeed906b..308c79bccf6 100644 --- a/core/src/main/java/feast/core/service/AccessManagementService.java +++ b/core/src/main/java/feast/core/service/ProjectService.java @@ -16,53 +16,24 @@ */ package feast.core.service; -import feast.auth.authorization.AuthorizationProvider; -import feast.auth.authorization.AuthorizationResult; -import feast.auth.config.SecurityProperties; -import feast.core.config.FeastProperties; import feast.core.dao.ProjectRepository; import feast.core.model.Project; import java.util.List; import java.util.Optional; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Slf4j @Service -public class AccessManagementService { +public class ProjectService { - private SecurityProperties securityProperties; - - private AuthorizationProvider authorizationProvider; private ProjectRepository projectRepository; - public AccessManagementService( - FeastProperties feastProperties, - ProjectRepository projectRepository, - AuthorizationProvider authorizationProvider) { - this.projectRepository = projectRepository; - this.authorizationProvider = authorizationProvider; - this.securityProperties = feastProperties.getSecurity(); - } - @Autowired - public AccessManagementService( - FeastProperties feastProperties, - ProjectRepository projectRepository, - ObjectProvider authorizationProvider) { + public ProjectService(ProjectRepository projectRepository) { this.projectRepository = projectRepository; - // create default project if it does not yet exist. - if (!projectRepository.existsById(Project.DEFAULT_NAME)) { - this.createProject(Project.DEFAULT_NAME); - } - this.authorizationProvider = authorizationProvider.getIfUnique(); - this.securityProperties = feastProperties.getSecurity(); } /** @@ -107,22 +78,4 @@ public void archiveProject(String name) { public List listProjects() { return projectRepository.findAllByArchivedIsFalse(); } - - /** - * Determine whether a user belongs to a Project - * - * @param securityContext User's Spring Security Context. Used to identify user. - * @param projectId Id (name) of the project for which membership should be tested. - */ - public void checkIfProjectMember(SecurityContext securityContext, String projectId) { - Authentication authentication = securityContext.getAuthentication(); - if (!this.securityProperties.getAuthorization().isEnabled()) { - return; - } - AuthorizationResult result = - this.authorizationProvider.checkAccessToProject(projectId, authentication); - if (!result.isAllowed()) { - throw new AccessDeniedException(result.getFailureReason().orElse("AccessDenied")); - } - } } diff --git a/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java b/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java index bd59510c673..39f80429fc7 100644 --- a/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java +++ b/core/src/test/java/feast/core/grpc/CoreServiceAuthTest.java @@ -27,14 +27,15 @@ import feast.auth.authorization.AuthorizationProvider; import feast.auth.authorization.AuthorizationResult; import feast.auth.config.SecurityProperties; +import feast.auth.service.AuthorizationService; import feast.core.config.FeastProperties; import feast.core.dao.ProjectRepository; import feast.core.model.Entity; import feast.core.model.Feature; import feast.core.model.FeatureSet; import feast.core.model.Source; -import feast.core.service.AccessManagementService; import feast.core.service.JobService; +import feast.core.service.ProjectService; import feast.core.service.SpecService; import feast.core.service.StatsService; import feast.proto.core.CoreServiceProto.ApplyFeatureSetRequest; @@ -45,6 +46,7 @@ import feast.proto.core.SourceProto.KafkaSourceConfig; import feast.proto.core.SourceProto.SourceType; import feast.proto.types.ValueProto.ValueType.Enum; +import io.grpc.StatusRuntimeException; import io.grpc.internal.testing.StreamRecorder; import java.sql.Date; import java.time.Instant; @@ -53,7 +55,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; @@ -61,7 +62,7 @@ class CoreServiceAuthTest { private CoreServiceImpl coreService; - private AccessManagementService accessManagementService; + private ProjectService projectService; @Mock private SpecService specService; @Mock private ProjectRepository projectRepository; @@ -78,11 +79,12 @@ class CoreServiceAuthTest { sp.setAuthorization(authProp); FeastProperties feastProperties = new FeastProperties(); feastProperties.setSecurity(sp); - accessManagementService = - new AccessManagementService(feastProperties, projectRepository, authProvider); + projectService = new ProjectService(projectRepository); + AuthorizationService authService = + new AuthorizationService(feastProperties.getSecurity(), authProvider); coreService = new CoreServiceImpl( - specService, accessManagementService, statsService, jobService, feastProperties); + specService, projectService, statsService, jobService, feastProperties, authService); } @Test @@ -108,7 +110,11 @@ void cantApplyFeatureSetIfNotProjectMember() throws InvalidProtocolBufferExcepti ApplyFeatureSetRequest.newBuilder().setFeatureSet(spec).build(); assertThrows( - AccessDeniedException.class, () -> coreService.applyFeatureSet(request, responseObserver)); + StatusRuntimeException.class, + () -> { + coreService.applyFeatureSet(request, responseObserver); + throw responseObserver.getError(); + }); } @Test diff --git a/core/src/test/java/feast/core/service/AccessManagementServiceTest.java b/core/src/test/java/feast/core/service/ProjectServiceTest.java similarity index 63% rename from core/src/test/java/feast/core/service/AccessManagementServiceTest.java rename to core/src/test/java/feast/core/service/ProjectServiceTest.java index fa69a7e7a83..a32a85c991a 100644 --- a/core/src/test/java/feast/core/service/AccessManagementServiceTest.java +++ b/core/src/test/java/feast/core/service/ProjectServiceTest.java @@ -16,16 +16,12 @@ */ package feast.core.service; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; -import feast.auth.authorization.AuthorizationProvider; -import feast.auth.config.SecurityProperties; -import feast.core.config.FeastProperties; import feast.core.dao.ProjectRepository; import feast.core.model.Project; import java.util.Arrays; @@ -38,70 +34,57 @@ import org.junit.rules.ExpectedException; import org.mockito.Mock; -public class AccessManagementServiceTest { +public class ProjectServiceTest { @Mock private ProjectRepository projectRepository; @Rule public final ExpectedException expectedException = ExpectedException.none(); - private AccessManagementService accessManagementService; + private ProjectService projectService; @Before public void setUp() { initMocks(this); projectRepository = mock(ProjectRepository.class); - SecurityProperties.AuthorizationProperties authProp = - new SecurityProperties.AuthorizationProperties(); - authProp.setEnabled(false); - SecurityProperties sp = new SecurityProperties(); - sp.setAuthorization(authProp); - FeastProperties feastProperties = new FeastProperties(); - feastProperties.setSecurity(sp); - accessManagementService = - new AccessManagementService( - feastProperties, projectRepository, mock(AuthorizationProvider.class)); - } - - @Test - public void testDefaultProjectCreateInConstructor() { - verify(this.projectRepository).saveAndFlush(new Project(Project.DEFAULT_NAME)); + projectService = new ProjectService(projectRepository); } @Test public void shouldCreateProjectIfItDoesntExist() { String projectName = "project1"; Project project = new Project(projectName); - when(projectRepository.saveAndFlush(any(Project.class))).thenReturn(project); - accessManagementService.createProject(projectName); - verify(projectRepository, times(1)).saveAndFlush(any()); + when(projectRepository.saveAndFlush(project)).thenReturn(project); + projectService.createProject(projectName); + verify(projectRepository, times(1)).saveAndFlush(project); } @Test(expected = IllegalArgumentException.class) public void shouldNotCreateProjectIfItExist() { String projectName = "project1"; when(projectRepository.existsById(projectName)).thenReturn(true); - accessManagementService.createProject(projectName); + projectService.createProject(projectName); } @Test public void shouldArchiveProjectIfItExists() { String projectName = "project1"; - when(projectRepository.findById(projectName)).thenReturn(Optional.of(new Project(projectName))); - accessManagementService.archiveProject(projectName); - verify(projectRepository, times(1)).saveAndFlush(any(Project.class)); + Project project = new Project(projectName); + when(projectRepository.findById(projectName)).thenReturn(Optional.of(project)); + projectService.archiveProject(projectName); + verify(projectRepository, times(1)).saveAndFlush(project); } @Test public void shouldNotArchiveDefaultProject() { expectedException.expect(IllegalArgumentException.class); - this.accessManagementService.archiveProject(Project.DEFAULT_NAME); + this.projectService.archiveProject(Project.DEFAULT_NAME); } @Test(expected = IllegalArgumentException.class) public void shouldNotArchiveProjectIfItIsAlreadyArchived() { String projectName = "project1"; when(projectRepository.findById(projectName)).thenReturn(Optional.empty()); - accessManagementService.archiveProject(projectName); + projectService.archiveProject(projectName); } @Test @@ -110,7 +93,7 @@ public void shouldListProjects() { Project project = new Project(projectName); List expected = Arrays.asList(project); when(projectRepository.findAllByArchivedIsFalse()).thenReturn(expected); - List actual = accessManagementService.listProjects(); + List actual = projectService.listProjects(); Assert.assertEquals(expected, actual); } } diff --git a/infra/docker-compose/docker-compose.online.yml b/infra/docker-compose/docker-compose.online.yml index b01d0882fb4..0e5a3cfaec6 100644 --- a/infra/docker-compose/docker-compose.online.yml +++ b/infra/docker-compose/docker-compose.online.yml @@ -5,11 +5,16 @@ services: image: ${FEAST_SERVING_IMAGE}:${FEAST_VERSION} volumes: - ./serving/${FEAST_ONLINE_SERVING_CONFIG}:/etc/feast/application.yml + # Required if authentication is enabled on core and + # provider is 'google'. GOOGLE_APPLICATION_CREDENTIALS is used for connecting to core. + - ./gcp-service-accounts/${FEAST_BATCH_SERVING_GCP_SERVICE_ACCOUNT_KEY}:/etc/gcloud/service-accounts/key.json depends_on: - redis ports: - 6566:6566 restart: on-failure + environment: + GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json command: - java - -jar diff --git a/infra/scripts/test-end-to-end-batch.sh b/infra/scripts/test-end-to-end-batch.sh index 60f09cb4163..cf48a7e14a6 100755 --- a/infra/scripts/test-end-to-end-batch.sh +++ b/infra/scripts/test-end-to-end-batch.sh @@ -113,10 +113,6 @@ feast: tracing: enabled: false -grpc: - port: 6566 - enable-reflection: true - server: port: 8081 diff --git a/infra/scripts/test-end-to-end-redis-cluster.sh b/infra/scripts/test-end-to-end-redis-cluster.sh index 9094fc3a2e0..be38fe765bc 100755 --- a/infra/scripts/test-end-to-end-redis-cluster.sh +++ b/infra/scripts/test-end-to-end-redis-cluster.sh @@ -67,10 +67,6 @@ feast: tracing: enabled: false -grpc: - port: 6566 - enable-reflection: true - spring: main: web-environment: false diff --git a/infra/scripts/test-end-to-end.sh b/infra/scripts/test-end-to-end.sh index 75bacd3560a..84d65aebe25 100755 --- a/infra/scripts/test-end-to-end.sh +++ b/infra/scripts/test-end-to-end.sh @@ -2,10 +2,7 @@ set -e set -o pipefail -ENABLE_AUTH="False" -if [[ -n $1 ]]; then - ENABLE_AUTH=$1 -fi +[[ $1 == "True" ]] && ENABLE_AUTH="true" || ENABLE_AUTH="false" echo "Authenication enabled : ${ENABLE_AUTH}" test -z ${GOOGLE_APPLICATION_CREDENTIALS} && GOOGLE_APPLICATION_CREDENTIALS="/etc/gcloud/service-account.json" @@ -60,20 +57,13 @@ feast: authentication: enabled: true provider: jwt + options: + jwkEndpointURI: "https://www.googleapis.com/oauth2/v3/certs" authorization: enabled: false provider: none EOF -if [[ ${ENABLE_AUTH} = "True" ]]; - then - print_banner "Starting 'Feast core with auth'." - start_feast_core /tmp/core.warehouse.application.yml - else - print_banner "Starting 'Feast core without auth'." - start_feast_core -fi - cat < /tmp/serving.warehouse.application.yml feast: stores: @@ -86,8 +76,30 @@ feast: subscriptions: - name: "*" project: "*" + core-authentication: + enabled: $ENABLE_AUTH + provider: google + security: + authentication: + enabled: $ENABLE_AUTH + provider: jwt + authorization: + enabled: false + provider: none EOF +if [[ ${ENABLE_AUTH} = "true" ]]; + then + print_banner "Starting Feast core with auth" + start_feast_core /tmp/core.warehouse.application.yml + print_banner "Starting Feast Serving with auth" + else + print_banner "Starting Feast core without auth" + start_feast_core + print_banner "Starting Feast Serving without auth" +fi + + start_feast_serving /tmp/serving.warehouse.application.yml install_python_with_miniconda_and_feast_sdk diff --git a/pom.xml b/pom.xml index 204b4052fdc..b42edab7b67 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,9 @@ 6.0.8 2.9.9 2.0.2 + 2.5.0.RELEASE + + false diff --git a/sdk/python/feast/client.py b/sdk/python/feast/client.py index 01c9ed83bea..c8c19496591 100644 --- a/sdk/python/feast/client.py +++ b/sdk/python/feast/client.py @@ -32,10 +32,10 @@ from feast.config import Config from feast.constants import ( - CONFIG_CORE_ENABLE_AUTH_KEY, CONFIG_CORE_ENABLE_SSL_KEY, CONFIG_CORE_SERVER_SSL_CERT_KEY, CONFIG_CORE_URL_KEY, + CONFIG_ENABLE_AUTH_KEY, CONFIG_GRPC_CONNECTION_TIMEOUT_DEFAULT_KEY, CONFIG_PROJECT_KEY, CONFIG_SERVING_ENABLE_SSL_KEY, @@ -112,9 +112,9 @@ def __init__(self, options: Optional[Dict[str, str]] = None, **kwargs): project: Sets the active project. This field is optional. core_secure: Use client-side SSL/TLS for Core gRPC API serving_secure: Use client-side SSL/TLS for Serving gRPC API - core_enable_auth: Enable authentication and authorization - core_auth_provider: Authentication provider – "google" or "oauth" - if core_auth_provider is "oauth", the following fields are mandatory – + enable_auth: Enable authentication and authorization + auth_provider: Authentication provider – "google" or "oauth" + if auth_provider is "oauth", the following fields are mandatory – oauth_grant_type, oauth_client_id, oauth_client_secret, oauth_audience, oauth_token_request_url Args: @@ -132,7 +132,7 @@ def __init__(self, options: Optional[Dict[str, str]] = None, **kwargs): self._auth_metadata: Optional[grpc.AuthMetadataPlugin] = None # Configure Auth Metadata Plugin if auth is enabled - if self._config.getboolean(CONFIG_CORE_ENABLE_AUTH_KEY): + if self._config.getboolean(CONFIG_ENABLE_AUTH_KEY): self._auth_metadata = feast_auth.get_auth_metadata_plugin(self._config) @property @@ -146,7 +146,7 @@ def _core_service(self): channel = create_grpc_channel( url=self._config.get(CONFIG_CORE_URL_KEY), enable_ssl=self._config.getboolean(CONFIG_CORE_ENABLE_SSL_KEY), - enable_auth=self._config.getboolean(CONFIG_CORE_ENABLE_AUTH_KEY), + enable_auth=self._config.getboolean(CONFIG_ENABLE_AUTH_KEY), ssl_server_cert_path=self._config.get(CONFIG_CORE_SERVER_SSL_CERT_KEY), auth_metadata_plugin=self._auth_metadata, timeout=self._config.getint(CONFIG_GRPC_CONNECTION_TIMEOUT_DEFAULT_KEY), @@ -165,11 +165,11 @@ def _serving_service(self): channel = create_grpc_channel( url=self._config.get(CONFIG_SERVING_URL_KEY), enable_ssl=self._config.getboolean(CONFIG_SERVING_ENABLE_SSL_KEY), - enable_auth=False, + enable_auth=self._config.getboolean(CONFIG_ENABLE_AUTH_KEY), ssl_server_cert_path=self._config.get( CONFIG_SERVING_SERVER_SSL_CERT_KEY ), - auth_metadata_plugin=None, + auth_metadata_plugin=self._auth_metadata, timeout=self._config.getint(CONFIG_GRPC_CONNECTION_TIMEOUT_DEFAULT_KEY), ) self._serving_service_stub = ServingServiceStub(channel) @@ -271,6 +271,7 @@ def version(self): serving_version = self._serving_service.GetFeastServingInfo( GetFeastServingInfoRequest(), timeout=self._config.getint(CONFIG_GRPC_CONNECTION_TIMEOUT_DEFAULT_KEY), + metadata=self._get_grpc_metadata(), ).version result["serving"] = {"url": self.serving_url, "version": serving_version} @@ -522,7 +523,7 @@ def list_features_by_ref( ) feature_protos = self._core_service.ListFeatures( - ListFeaturesRequest(filter=filter) + ListFeaturesRequest(filter=filter), metadata=self._get_grpc_metadata(), ) # type: ListFeaturesResponse features_dict = {} @@ -619,6 +620,7 @@ def get_historical_features( serving_info = self._serving_service.GetFeastServingInfo( GetFeastServingInfoRequest(), timeout=self._config.getint(CONFIG_GRPC_CONNECTION_TIMEOUT_DEFAULT_KEY), + metadata=self._get_grpc_metadata(), ) # type: GetFeastServingInfoResponse if serving_info.type != FeastServingType.FEAST_SERVING_TYPE_BATCH: @@ -669,11 +671,17 @@ def get_historical_features( # Retrieve Feast Job object to manage life cycle of retrieval try: - response = self._serving_service.GetBatchFeatures(request) + response = self._serving_service.GetBatchFeatures( + request, metadata=self._get_grpc_metadata() + ) except grpc.RpcError as e: raise grpc.RpcError(e.details()) - return RetrievalJob(response.job, self._serving_service) + return RetrievalJob( + response.job, + self._serving_service, + auth_metadata_plugin=self._auth_metadata, + ) def get_online_features( self, @@ -722,7 +730,8 @@ def get_online_features( features=_build_feature_references(feature_ref_strs=feature_refs), entity_rows=_infer_online_entity_rows(entity_rows), project=project if project is not None else self.project, - ) + ), + metadata=self._get_grpc_metadata(), ) except grpc.RpcError as e: raise grpc.RpcError(e.details()) @@ -759,9 +768,9 @@ def list_ingest_jobs( ) request = ListIngestionJobsRequest(filter=list_filter) # make list request & unpack response - response = self._core_service.ListIngestionJobs(request) # type: ignore + response = self._core_service.ListIngestionJobs(request, metadata=self._get_grpc_metadata(),) # type: ignore ingest_jobs = [ - IngestJob(proto, self._core_service) for proto in response.jobs # type: ignore + IngestJob(proto, self._core_service, auth_metadata_plugin=self._auth_metadata) for proto in response.jobs # type: ignore ] return ingest_jobs @@ -778,7 +787,9 @@ def restart_ingest_job(self, job: IngestJob): """ request = RestartIngestionJobRequest(id=job.id) try: - self._core_service.RestartIngestionJob(request) # type: ignore + self._core_service.RestartIngestionJob( + request, metadata=self._get_grpc_metadata(), + ) # type: ignore except grpc.RpcError as e: raise grpc.RpcError(e.details()) @@ -794,7 +805,9 @@ def stop_ingest_job(self, job: IngestJob): """ request = StopIngestionJobRequest(id=job.id) try: - self._core_service.StopIngestionJob(request) # type: ignore + self._core_service.StopIngestionJob( + request, metadata=self._get_grpc_metadata(), + ) # type: ignore except grpc.RpcError as e: raise grpc.RpcError(e.details()) @@ -996,7 +1009,7 @@ def _get_grpc_metadata(self): Returns: Tuple of metadata to attach to each gRPC call """ - if self._config.getboolean(CONFIG_CORE_ENABLE_AUTH_KEY) and self._auth_metadata: + if self._config.getboolean(CONFIG_ENABLE_AUTH_KEY) and self._auth_metadata: return self._auth_metadata.get_signed_meta() return () diff --git a/sdk/python/feast/constants.py b/sdk/python/feast/constants.py index 911432326a9..67f4808010e 100644 --- a/sdk/python/feast/constants.py +++ b/sdk/python/feast/constants.py @@ -42,8 +42,8 @@ class AuthProvider(Enum): CONFIG_PROJECT_KEY = "project" CONFIG_CORE_URL_KEY = "core_url" CONFIG_CORE_ENABLE_SSL_KEY = "core_enable_ssl" -CONFIG_CORE_ENABLE_AUTH_KEY = "core_enable_auth" -CONFIG_CORE_ENABLE_AUTH_TOKEN_KEY = "core_auth_token" +CONFIG_ENABLE_AUTH_KEY = "enable_auth" +CONFIG_ENABLE_AUTH_TOKEN_KEY = "auth_token" CONFIG_CORE_SERVER_SSL_CERT_KEY = "core_server_ssl_cert" CONFIG_SERVING_URL_KEY = "serving_url" CONFIG_SERVING_ENABLE_SSL_KEY = "serving_enable_ssl" @@ -58,7 +58,7 @@ class AuthProvider(Enum): CONFIG_OAUTH_CLIENT_SECRET_KEY = "oauth_client_secret" CONFIG_OAUTH_AUDIENCE_KEY = "oauth_audience" CONFIG_OAUTH_TOKEN_REQUEST_URL_KEY = "oauth_token_request_url" -CONFIG_CORE_AUTH_PROVIDER = "core_auth_provider" +CONFIG_AUTH_PROVIDER = "auth_provider" CONFIG_TIMEOUT_KEY = "timeout" CONFIG_MAX_WAIT_INTERVAL_KEY = "max_wait_interval" @@ -72,7 +72,7 @@ class AuthProvider(Enum): # Enable or disable TLS/SSL to Feast Core CONFIG_CORE_ENABLE_SSL_KEY: "False", # Enable user authentication to Feast Core - CONFIG_CORE_ENABLE_AUTH_KEY: "False", + CONFIG_ENABLE_AUTH_KEY: "False", # Path to certificate(s) to secure connection to Feast Core CONFIG_CORE_SERVER_SSL_CERT_KEY: "", # Default Feast Serving URL @@ -91,5 +91,5 @@ class AuthProvider(Enum): CONFIG_TIMEOUT_KEY: "21600", CONFIG_MAX_WAIT_INTERVAL_KEY: "60", # Authentication Provider - Google OpenID/OAuth - CONFIG_CORE_AUTH_PROVIDER: "google", + CONFIG_AUTH_PROVIDER: "google", } diff --git a/sdk/python/feast/grpc/auth.py b/sdk/python/feast/grpc/auth.py index ab1de836311..9680607b8e3 100644 --- a/sdk/python/feast/grpc/auth.py +++ b/sdk/python/feast/grpc/auth.py @@ -19,8 +19,8 @@ from feast.config import Config from feast.constants import ( - CONFIG_CORE_AUTH_PROVIDER, - CONFIG_CORE_ENABLE_AUTH_TOKEN_KEY, + CONFIG_AUTH_PROVIDER, + CONFIG_ENABLE_AUTH_TOKEN_KEY, CONFIG_OAUTH_AUDIENCE_KEY, CONFIG_OAUTH_CLIENT_ID_KEY, CONFIG_OAUTH_CLIENT_SECRET_KEY, @@ -44,9 +44,9 @@ def get_auth_metadata_plugin(config: Config) -> grpc.AuthMetadataPlugin: Args: config: Feast Configuration object """ - if AuthProvider(config.get(CONFIG_CORE_AUTH_PROVIDER)) == AuthProvider.GOOGLE: + if AuthProvider(config.get(CONFIG_AUTH_PROVIDER)) == AuthProvider.GOOGLE: return GoogleOpenIDAuthMetadataPlugin(config) - elif AuthProvider(config.get(CONFIG_CORE_AUTH_PROVIDER)) == AuthProvider.OAUTH: + elif AuthProvider(config.get(CONFIG_AUTH_PROVIDER)) == AuthProvider.OAUTH: return OAuthMetadataPlugin(config) else: raise RuntimeError( @@ -75,8 +75,8 @@ def __init__(self, config: Config): self._token = None # If provided, set a static token - if config.exists(CONFIG_CORE_ENABLE_AUTH_TOKEN_KEY): - self._static_token = config.get(CONFIG_CORE_ENABLE_AUTH_TOKEN_KEY) + if config.exists(CONFIG_ENABLE_AUTH_TOKEN_KEY): + self._static_token = config.get(CONFIG_ENABLE_AUTH_TOKEN_KEY) self._refresh_token(config) elif ( config.exists(CONFIG_OAUTH_GRANT_TYPE_KEY) @@ -171,8 +171,8 @@ def __init__(self, config: Config): self._token = None # If provided, set a static token - if config.exists(CONFIG_CORE_ENABLE_AUTH_TOKEN_KEY): - self._static_token = config.get(CONFIG_CORE_ENABLE_AUTH_TOKEN_KEY) + if config.exists(CONFIG_ENABLE_AUTH_TOKEN_KEY): + self._static_token = config.get(CONFIG_ENABLE_AUTH_TOKEN_KEY) self._request = requests.Request() self._refresh_token() diff --git a/sdk/python/feast/job.py b/sdk/python/feast/job.py index 25396213e47..cda4b26d300 100644 --- a/sdk/python/feast/job.py +++ b/sdk/python/feast/job.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse import fastavro +import grpc import pandas as pd from google.protobuf.json_format import MessageToJson @@ -39,15 +40,20 @@ class RetrievalJob: """ def __init__( - self, job_proto: JobProto, serving_stub: ServingServiceStub, + self, + job_proto: JobProto, + serving_stub: ServingServiceStub, + auth_metadata_plugin: grpc.AuthMetadataPlugin = None, ): """ Args: job_proto: Job proto object (wrapped by this job object) serving_stub: Stub for Feast serving service + auth_metadata_plugin: plugin to fetch auth metadata """ self.job_proto = job_proto self.serving_stub = serving_stub + self.auth_metadata = auth_metadata_plugin @property def id(self): @@ -68,7 +74,10 @@ def reload(self): Reload the latest job status Returns: None """ - self.job_proto = self.serving_stub.GetJob(GetJobRequest(job=self.job_proto)).job + self.job_proto = self.serving_stub.GetJob( + GetJobRequest(job=self.job_proto), + metadata=self.auth_metadata.get_signed_meta() if self.auth_metadata else (), + ).job def get_avro_files(self, timeout_sec: int = int(defaults[CONFIG_TIMEOUT_KEY])): """ @@ -218,16 +227,23 @@ class IngestJob: Defines a job for feature ingestion in feast. """ - def __init__(self, job_proto: IngestJobProto, core_stub: CoreServiceStub): + def __init__( + self, + job_proto: IngestJobProto, + core_stub: CoreServiceStub, + auth_metadata_plugin: grpc.AuthMetadataPlugin = None, + ): """ Construct a native ingest job from its protobuf version. Args: job_proto: Job proto object to construct from. core_stub: stub for Feast CoreService + auth_metadata_plugin: plugin to fetch auth metadata """ self.proto = job_proto self.core_svc = core_stub + self.auth_metadata = auth_metadata_plugin def reload(self): """ @@ -235,7 +251,10 @@ def reload(self): """ # pull latest proto from feast core response = self.core_svc.ListIngestionJobs( - ListIngestionJobsRequest(filter=ListIngestionJobsRequest.Filter(id=self.id)) + ListIngestionJobsRequest( + filter=ListIngestionJobsRequest.Filter(id=self.id) + ), + metadata=self.auth_metadata.get_signed_meta() if self.auth_metadata else (), ) self.proto = response.jobs[0] diff --git a/sdk/python/tests/grpc/test_auth.py b/sdk/python/tests/grpc/test_auth.py index 90896ee925f..7f023aabcfd 100644 --- a/sdk/python/tests/grpc/test_auth.py +++ b/sdk/python/tests/grpc/test_auth.py @@ -76,8 +76,8 @@ def refresh(self, request): def config_oauth(): config_dict = { "core_url": "localhost:50051", - "core_enable_auth": True, - "core_auth_provider": "oauth", + "enable_auth": True, + "auth_provider": "oauth", "oauth_grant_type": "client_credentials", "oauth_client_id": "fakeID", "oauth_client_secret": "fakeSecret", @@ -91,13 +91,8 @@ def config_oauth(): def config_google(): config_dict = { "core_url": "localhost:50051", - "core_enable_auth": True, - "core_auth_provider": "google", - "oauth_grant_type": "client_credentials", - "oauth_client_id": "fakeID", - "oauth_client_secret": "fakeSecret", - "oauth_audience": AUDIENCE, - "oauth_token_request_url": AUTH_URL, + "enable_auth": True, + "auth_provider": "google", } return Config(config_dict) @@ -106,8 +101,8 @@ def config_google(): def config_with_missing_variable(): config_dict = { "core_url": "localhost:50051", - "core_enable_auth": True, - "core_auth_provider": "oauth", + "enable_auth": True, + "auth_provider": "oauth", "oauth_grant_type": "client_credentials", "oauth_client_id": "fakeID", "oauth_client_secret": "fakeSecret", diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 8712847b4bb..416d4b2dde8 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -24,6 +24,7 @@ import pytest from google.protobuf.duration_pb2 import Duration from mock import MagicMock, patch +from pytest_lazyfixture import lazy_fixture from pytz import timezone from feast.client import Client @@ -81,6 +82,7 @@ "TY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDI" "yfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ) +AUTH_METADATA = (("authorization", f"Bearer {_FAKE_JWT_TOKEN}"),) class TestClient: @@ -103,6 +105,32 @@ def mock_client(self): client._serving_url = SERVING_URL return client + @pytest.fixture + def mock_client_with_auth(self): + client = Client( + core_url=CORE_URL, + serving_url=SERVING_URL, + enable_auth=True, + auth_token=_FAKE_JWT_TOKEN, + ) + client._core_url = CORE_URL + client._serving_url = SERVING_URL + return client + + @pytest.fixture + def secure_mock_client_with_auth(self): + client = Client( + core_url=CORE_URL, + serving_url=SERVING_URL, + core_enable_ssl=True, + serving_enable_ssl=True, + enable_auth=True, + auth_token=_FAKE_JWT_TOKEN, + ) + client._core_url = CORE_URL + client._serving_url = SERVING_URL + return client + @pytest.fixture def server_credentials(self): private_key = pkgutil.get_data(__name__, _PRIVATE_KEY_RESOURCE_PATH) @@ -216,8 +244,8 @@ def secure_core_client_with_auth(self, secure_core_server_with_auth): yield Client( core_url="localhost:50055", core_enable_ssl=True, - core_enable_auth=True, - core_auth_token=_FAKE_JWT_TOKEN, + enable_auth=True, + auth_token=_FAKE_JWT_TOKEN, ) @pytest.fixture @@ -226,7 +254,7 @@ def client(self, core_server, serving_server): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_version(self, mocked_client, mocker): mocked_client._core_service_stub = Core.CoreServiceStub( @@ -257,10 +285,21 @@ def test_version(self, mocked_client, mocker): ) @pytest.mark.parametrize( - "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + "mocked_client,auth_metadata", + [ + (lazy_fixture("mock_client"), ()), + (lazy_fixture("mock_client_with_auth"), (AUTH_METADATA)), + (lazy_fixture("secure_mock_client"), ()), + (lazy_fixture("secure_mock_client_with_auth"), (AUTH_METADATA)), + ], + ids=[ + "mock_client_without_auth", + "mock_client_with_auth", + "secure_mock_client_without_auth", + "secure_mock_client_with_auth", + ], ) - def test_get_online_features(self, mocked_client, mocker): + def test_get_online_features(self, mocked_client, auth_metadata, mocker): ROW_COUNT = 300 mocked_client._serving_service_stub = Serving.ServingServiceStub( @@ -312,7 +351,7 @@ def int_val(x): project="driver_project", ) # type: GetOnlineFeaturesResponse mocked_client._serving_service_stub.GetOnlineFeatures.assert_called_with( - request + request, metadata=auth_metadata ) got_fields = got_response.field_values[0].fields @@ -333,7 +372,7 @@ def int_val(x): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_get_feature_set(self, mocked_client, mocker): mocked_client._core_service_stub = Core.CoreServiceStub( @@ -397,7 +436,7 @@ def test_get_feature_set(self, mocked_client, mocker): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_list_feature_sets(self, mocked_client, mocker): mocker.patch.object( @@ -458,7 +497,7 @@ def test_list_feature_sets(self, mocked_client, mocker): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_list_features(self, mocked_client, mocker): mocker.patch.object( @@ -504,7 +543,7 @@ def test_list_features(self, mocked_client, mocker): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_list_ingest_jobs(self, mocked_client, mocker): mocker.patch.object( @@ -560,7 +599,7 @@ def test_list_ingest_jobs(self, mocked_client, mocker): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_restart_ingest_job(self, mocked_client, mocker): mocker.patch.object( @@ -583,7 +622,7 @@ def test_restart_ingest_job(self, mocked_client, mocker): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [lazy_fixture("mock_client"), lazy_fixture("secure_mock_client")], ) def test_stop_ingest_job(self, mocked_client, mocker): mocker.patch.object( @@ -606,7 +645,12 @@ def test_stop_ingest_job(self, mocked_client, mocker): @pytest.mark.parametrize( "mocked_client", - [pytest.lazy_fixture("mock_client"), pytest.lazy_fixture("secure_mock_client")], + [ + lazy_fixture("mock_client"), + lazy_fixture("mock_client_with_auth"), + lazy_fixture("secure_mock_client"), + lazy_fixture("secure_mock_client_with_auth"), + ], ) def test_get_historical_features(self, mocked_client, mocker): @@ -725,8 +769,7 @@ def test_get_historical_features(self, mocked_client, mocker): assert actual_dataframe[["driver_id"]].equals(expected_dataframe[["driver_id"]]) @pytest.mark.parametrize( - "test_client", - [pytest.lazy_fixture("client"), pytest.lazy_fixture("secure_client")], + "test_client", [lazy_fixture("client"), lazy_fixture("secure_client")], ) def test_apply_feature_set_success(self, test_client): @@ -770,8 +813,8 @@ def test_apply_feature_set_success(self, test_client): @pytest.mark.parametrize( "dataframe,test_client", [ - (dataframes.GOOD, pytest.lazy_fixture("client")), - (dataframes.GOOD, pytest.lazy_fixture("secure_client")), + (dataframes.GOOD, lazy_fixture("client")), + (dataframes.GOOD, lazy_fixture("secure_client")), ], ) def test_feature_set_ingest_success(self, dataframe, test_client, mocker): @@ -802,7 +845,7 @@ def test_feature_set_ingest_success(self, dataframe, test_client, mocker): @pytest.mark.parametrize( "dataframe,test_client,exception", - [(dataframes.GOOD, pytest.lazy_fixture("client"), Exception)], + [(dataframes.GOOD, lazy_fixture("client"), Exception)], ) def test_feature_set_ingest_throws_exception_if_kafka_down( self, dataframe, test_client, exception, mocker @@ -835,8 +878,8 @@ def test_feature_set_ingest_throws_exception_if_kafka_down( @pytest.mark.parametrize( "dataframe,exception,test_client", [ - (dataframes.GOOD, TimeoutError, pytest.lazy_fixture("client")), - (dataframes.GOOD, TimeoutError, pytest.lazy_fixture("secure_client")), + (dataframes.GOOD, TimeoutError, lazy_fixture("client")), + (dataframes.GOOD, TimeoutError, lazy_fixture("secure_client")), ], ) def test_feature_set_ingest_fail_if_pending( @@ -872,26 +915,22 @@ def test_feature_set_ingest_fail_if_pending( @pytest.mark.parametrize( "dataframe,exception,test_client", [ - (dataframes.BAD_NO_DATETIME, Exception, pytest.lazy_fixture("client")), + (dataframes.BAD_NO_DATETIME, Exception, lazy_fixture("client")), ( dataframes.BAD_INCORRECT_DATETIME_TYPE, Exception, - pytest.lazy_fixture("client"), - ), - (dataframes.BAD_NO_ENTITY, Exception, pytest.lazy_fixture("client")), - (dataframes.NO_FEATURES, Exception, pytest.lazy_fixture("client")), - ( - dataframes.BAD_NO_DATETIME, - Exception, - pytest.lazy_fixture("secure_client"), + lazy_fixture("client"), ), + (dataframes.BAD_NO_ENTITY, Exception, lazy_fixture("client")), + (dataframes.NO_FEATURES, Exception, lazy_fixture("client")), + (dataframes.BAD_NO_DATETIME, Exception, lazy_fixture("secure_client"),), ( dataframes.BAD_INCORRECT_DATETIME_TYPE, Exception, - pytest.lazy_fixture("secure_client"), + lazy_fixture("secure_client"), ), - (dataframes.BAD_NO_ENTITY, Exception, pytest.lazy_fixture("secure_client")), - (dataframes.NO_FEATURES, Exception, pytest.lazy_fixture("secure_client")), + (dataframes.BAD_NO_ENTITY, Exception, lazy_fixture("secure_client")), + (dataframes.NO_FEATURES, Exception, lazy_fixture("secure_client")), ], ) def test_feature_set_ingest_failure(self, test_client, dataframe, exception): @@ -911,8 +950,8 @@ def test_feature_set_ingest_failure(self, test_client, dataframe, exception): @pytest.mark.parametrize( "dataframe,test_client", [ - (dataframes.ALL_TYPES, pytest.lazy_fixture("client")), - (dataframes.ALL_TYPES, pytest.lazy_fixture("secure_client")), + (dataframes.ALL_TYPES, lazy_fixture("client")), + (dataframes.ALL_TYPES, lazy_fixture("secure_client")), ], ) def test_feature_set_types_success(self, test_client, dataframe, mocker): @@ -1007,9 +1046,7 @@ def test_auth_success_with_insecure_channel_on_core_url( self, insecure_core_server_with_auth ): client = Client( - core_url="localhost:50056", - core_enable_auth=True, - core_auth_token=_FAKE_JWT_TOKEN, + core_url="localhost:50056", enable_auth=True, auth_token=_FAKE_JWT_TOKEN, ) client.list_feature_sets() diff --git a/serving/pom.xml b/serving/pom.xml index 986775058d8..e881d63966e 100644 --- a/serving/pom.xml +++ b/serving/pom.xml @@ -121,6 +121,12 @@ feast-common ${project.version} + + + dev.feast + feast-auth + ${project.version} + @@ -155,13 +161,7 @@ true - - - io.github.lognet - grpc-spring-boot-starter - - - + org.springframework.boot spring-boot-starter-actuator @@ -278,12 +278,80 @@ embedded-redis test - jakarta.validation jakarta.validation-api ${jakarta.validation.api.version} + + org.springframework.security + spring-security-core + ${spring.security.version} + + + org.springframework.security + spring-security-config + ${spring.security.version} + + + org.springframework.security.oauth + spring-security-oauth2 + ${spring.security.oauth2.version} + + + org.springframework.security + spring-security-oauth2-client + ${spring.security.version} + + + org.springframework.security + spring-security-web + ${spring.security.version} + + + org.springframework.security + spring-security-oauth2-jose + ${spring.security.version} + + + net.devh + grpc-server-spring-boot-starter + ${grpc.spring.boot.starter.version} + + + com.nimbusds + nimbus-jose-jwt + 8.2.1 + + + org.springframework.security + spring-security-oauth2-core + ${spring.security.version} + + + org.testcontainers + testcontainers + 1.14.3 + test + + + org.testcontainers + junit-jupiter + 1.14.3 + test + + + org.awaitility + awaitility + 3.0.0 + test + + + sh.ory.keto + keto-client + 0.4.4-alpha.1 + test + diff --git a/serving/src/main/java/feast/serving/config/FeastProperties.java b/serving/src/main/java/feast/serving/config/FeastProperties.java index f905f5f5c02..18d9ad7221e 100644 --- a/serving/src/main/java/feast/serving/config/FeastProperties.java +++ b/serving/src/main/java/feast/serving/config/FeastProperties.java @@ -25,17 +25,30 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.util.JsonFormat; +import feast.auth.config.SecurityProperties; +import feast.auth.config.SecurityProperties.AuthenticationProperties; +import feast.auth.config.SecurityProperties.AuthorizationProperties; +import feast.auth.credentials.CoreAuthenticationProperties; import feast.proto.core.StoreProto; import java.util.*; import java.util.stream.Collectors; +import javax.annotation.PostConstruct; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Positive; import org.apache.logging.log4j.core.config.plugins.validation.constraints.ValidHost; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.info.BuildProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; /** Feast Serving properties. */ +@Configuration @ConfigurationProperties(prefix = "feast", ignoreInvalidFields = true) public class FeastProperties { @@ -61,6 +74,41 @@ public FeastProperties() {} /* Feast Core port to connect to. */ @Positive private int coreGrpcPort; + private CoreAuthenticationProperties coreAuthentication; + + public CoreAuthenticationProperties getCoreAuthentication() { + return coreAuthentication; + } + + public void setCoreAuthentication(CoreAuthenticationProperties coreAuthentication) { + this.coreAuthentication = coreAuthentication; + } + + private SecurityProperties security; + + @Bean + SecurityProperties securityProperties() { + return this.getSecurity(); + } + + /** + * Getter for SecurityProperties + * + * @return Returns the {@link SecurityProperties} object. + */ + public SecurityProperties getSecurity() { + return security; + } + + /** + * Setter for SecurityProperties + * + * @param security :input {@link SecurityProperties} object + */ + public void setSecurity(SecurityProperties security) { + this.security = security; + } + /** * Finds and returns the active store * @@ -539,4 +587,41 @@ public void setServiceName(String serviceName) { this.serviceName = serviceName; } } + + /** + * Validates all FeastProperties. This method runs after properties have been initialized and + * individually and conditionally validates each class. + */ + @PostConstruct + public void validate() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + Validator validator = factory.getValidator(); + + // Validate root fields in FeastProperties + Set> violations = validator.validate(this); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + + // Validate CoreAuthenticationProperties + Set> coreAuthenticationPropsViolations = + validator.validate(getCoreAuthentication()); + if (!coreAuthenticationPropsViolations.isEmpty()) { + throw new ConstraintViolationException(coreAuthenticationPropsViolations); + } + + // Validate AuthenticationProperties + Set> authenticationPropsViolations = + validator.validate(getSecurity().getAuthentication()); + if (!authenticationPropsViolations.isEmpty()) { + throw new ConstraintViolationException(authenticationPropsViolations); + } + + // Validate AuthorizationProperties + Set> authorizationPropsViolations = + validator.validate(getSecurity().getAuthorization()); + if (!authorizationPropsViolations.isEmpty()) { + throw new ConstraintViolationException(authorizationPropsViolations); + } + } } diff --git a/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java b/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java new file mode 100644 index 00000000000..2d0a46763a7 --- /dev/null +++ b/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java @@ -0,0 +1,94 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.config; + +import feast.auth.credentials.GoogleAuthCredentials; +import feast.auth.credentials.OAuthCredentials; +import io.grpc.CallCredentials; +import java.io.IOException; +import net.devh.boot.grpc.server.security.check.AccessPredicate; +import net.devh.boot.grpc.server.security.check.GrpcSecurityMetadataSource; +import net.devh.boot.grpc.server.security.check.ManualGrpcSecurityMetadataSource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/* + * Copyright 2020 the original author or authors. + * + * 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 + * + * https://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. + */ + +@Configuration +@ComponentScan(basePackages = {"feast.auth.config", "feast.auth.service"}) +public class ServingSecurityConfig { + + private final FeastProperties feastProperties; + + public ServingSecurityConfig(FeastProperties feastProperties) { + this.feastProperties = feastProperties; + } + + /** + * Creates a SecurityMetadataSource when authentication is enabled. This allows for the + * configuration of endpoint level security rules. + * + * @return GrpcSecurityMetadataSource + */ + @Bean + @ConditionalOnProperty(prefix = "feast.security.authentication", name = "enabled") + GrpcSecurityMetadataSource grpcSecurityMetadataSource() { + final ManualGrpcSecurityMetadataSource source = new ManualGrpcSecurityMetadataSource(); + + // Authentication is enabled for all gRPC endpoints + source.setDefault(AccessPredicate.authenticated()); + return source; + } + + /** + * Creates a CallCredentials when authentication is enabled on core. This allows serving to + * connect to core with CallCredentials + * + * @return CallCredentials + */ + @Bean + @ConditionalOnProperty(prefix = "feast.core-authentication", name = "enabled") + CallCredentials CoreGrpcAuthenticationCredentials() throws IOException { + switch (feastProperties.getCoreAuthentication().getProvider()) { + case "google": + return new GoogleAuthCredentials(feastProperties.getCoreAuthentication().getOptions()); + case "oauth": + return new OAuthCredentials(feastProperties.getCoreAuthentication().getOptions()); + default: + throw new IllegalArgumentException( + "Please configure an Core Authentication Provider " + + "if you have enabled Authentication on core. " + + "Currently `google` and `oauth` are supported"); + } + } +} diff --git a/serving/src/main/java/feast/serving/config/SpecServiceConfig.java b/serving/src/main/java/feast/serving/config/SpecServiceConfig.java index 0a62557077f..75b77a29a03 100644 --- a/serving/src/main/java/feast/serving/config/SpecServiceConfig.java +++ b/serving/src/main/java/feast/serving/config/SpecServiceConfig.java @@ -21,10 +21,12 @@ import feast.proto.core.StoreProto; import feast.serving.specs.CachedSpecService; import feast.serving.specs.CoreSpecService; +import io.grpc.CallCredentials; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -58,9 +60,11 @@ public ScheduledExecutorService cachedSpecServiceScheduledExecutorService( } @Bean - public CachedSpecService specService(FeastProperties feastProperties) + public CachedSpecService specService( + FeastProperties feastProperties, ObjectProvider callCredentials) throws InvalidProtocolBufferException, JsonProcessingException { - CoreSpecService coreService = new CoreSpecService(feastCoreHost, feastCorePort); + CoreSpecService coreService = + new CoreSpecService(feastCoreHost, feastCorePort, callCredentials); StoreProto.Store storeProto = feastProperties.getActiveStore().toProto(); CachedSpecService cachedSpecStorage = new CachedSpecService(coreService, storeProto); try { diff --git a/serving/src/main/java/feast/serving/controller/HealthServiceController.java b/serving/src/main/java/feast/serving/controller/HealthServiceController.java index 0810429183e..5225a7ea2ed 100644 --- a/serving/src/main/java/feast/serving/controller/HealthServiceController.java +++ b/serving/src/main/java/feast/serving/controller/HealthServiceController.java @@ -26,12 +26,12 @@ import io.grpc.health.v1.HealthProto.HealthCheckResponse; import io.grpc.health.v1.HealthProto.HealthCheckResponse.ServingStatus; import io.grpc.stub.StreamObserver; -import org.lognet.springboot.grpc.GRpcService; +import net.devh.boot.grpc.server.service.GrpcService; import org.springframework.beans.factory.annotation.Autowired; // Reference: https://github.com/grpc/grpc/blob/master/doc/health-checking.md -@GRpcService(interceptors = {GrpcMonitoringInterceptor.class}) +@GrpcService(interceptors = {GrpcMonitoringInterceptor.class}) public class HealthServiceController extends HealthImplBase { private CachedSpecService specService; private ServingService servingService; diff --git a/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java b/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java index 3fae6ae65a7..ad97747ab15 100644 --- a/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java +++ b/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java @@ -16,6 +16,8 @@ */ package feast.serving.controller; +import feast.auth.service.AuthorizationService; +import feast.proto.serving.ServingAPIProto.FeatureReference; import feast.proto.serving.ServingAPIProto.GetBatchFeaturesRequest; import feast.proto.serving.ServingAPIProto.GetBatchFeaturesResponse; import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; @@ -35,11 +37,16 @@ import io.opentracing.Scope; import io.opentracing.Span; import io.opentracing.Tracer; -import org.lognet.springboot.grpc.GRpcService; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import net.devh.boot.grpc.server.service.GrpcService; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; -@GRpcService(interceptors = {GrpcMonitoringInterceptor.class}) +@GrpcService(interceptors = {GrpcMonitoringInterceptor.class}) public class ServingServiceGRpcController extends ServingServiceImplBase { private static final Logger log = @@ -47,10 +54,15 @@ public class ServingServiceGRpcController extends ServingServiceImplBase { private final ServingService servingService; private final String version; private final Tracer tracer; + private final AuthorizationService authorizationService; @Autowired public ServingServiceGRpcController( - ServingService servingService, FeastProperties feastProperties, Tracer tracer) { + AuthorizationService authorizationService, + ServingService servingService, + FeastProperties feastProperties, + Tracer tracer) { + this.authorizationService = authorizationService; this.servingService = servingService; this.version = feastProperties.getVersion(); this.tracer = tracer; @@ -72,6 +84,16 @@ public void getOnlineFeatures( StreamObserver responseObserver) { Span span = tracer.buildSpan("getOnlineFeatures").start(); try (Scope scope = tracer.scopeManager().activate(span, false)) { + // authorize for the project in request object. + if (request.getProject() != null && !request.getProject().isEmpty()) { + // project set at root level overrides the project set at feature set level + this.authorizationService.authorizeRequest( + SecurityContextHolder.getContext(), request.getProject()); + } else { + // authorize for projects set in feature list, backward compatibility for + // <=v0.5.X + this.checkProjectAccess(request.getFeaturesList()); + } RequestHelper.validateOnlineRequest(request); GetOnlineFeaturesResponse onlineFeatures = servingService.getOnlineFeatures(request); responseObserver.onNext(onlineFeatures); @@ -80,6 +102,13 @@ public void getOnlineFeatures( log.error("Failed to retrieve specs in SpecService", e); responseObserver.onError( Status.NOT_FOUND.withDescription(e.getMessage()).withCause(e).asException()); + } catch (AccessDeniedException e) { + log.info(String.format("User prevented from accessing one of the projects in request")); + responseObserver.onError( + Status.PERMISSION_DENIED + .withDescription(e.getMessage()) + .withCause(e) + .asRuntimeException()); } catch (Exception e) { log.warn("Failed to get Online Features", e); responseObserver.onError(e); @@ -92,6 +121,7 @@ public void getBatchFeatures( GetBatchFeaturesRequest request, StreamObserver responseObserver) { try { RequestHelper.validateBatchRequest(request); + this.checkProjectAccess(request.getFeaturesList()); GetBatchFeaturesResponse batchFeatures = servingService.getBatchFeatures(request); responseObserver.onNext(batchFeatures); responseObserver.onCompleted(); @@ -99,6 +129,13 @@ public void getBatchFeatures( log.error("Failed to retrieve specs in SpecService", e); responseObserver.onError( Status.NOT_FOUND.withDescription(e.getMessage()).withCause(e).asException()); + } catch (AccessDeniedException e) { + log.info(String.format("User prevented from accessing one of the projects in request")); + responseObserver.onError( + Status.PERMISSION_DENIED + .withDescription(e.getMessage()) + .withCause(e) + .asRuntimeException()); } catch (Exception e) { log.warn("Failed to get Batch Features", e); responseObserver.onError(e); @@ -116,4 +153,19 @@ public void getJob(GetJobRequest request, StreamObserver respons responseObserver.onError(e); } } + + private void checkProjectAccess(List featureList) { + Set projectList = + featureList.stream().map(FeatureReference::getProject).collect(Collectors.toSet()); + if (projectList.isEmpty()) { + authorizationService.authorizeRequest(SecurityContextHolder.getContext(), "default"); + } else { + projectList.stream() + .forEach( + project -> { + this.authorizationService.authorizeRequest( + SecurityContextHolder.getContext(), project); + }); + } + } } diff --git a/serving/src/main/java/feast/serving/specs/CoreSpecService.java b/serving/src/main/java/feast/serving/specs/CoreSpecService.java index e2feaebccb2..8dcfd0695eb 100644 --- a/serving/src/main/java/feast/serving/specs/CoreSpecService.java +++ b/serving/src/main/java/feast/serving/specs/CoreSpecService.java @@ -24,9 +24,11 @@ import feast.proto.core.CoreServiceProto.UpdateStoreRequest; import feast.proto.core.CoreServiceProto.UpdateStoreResponse; import feast.proto.core.StoreProto.Store; +import io.grpc.CallCredentials; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import org.slf4j.Logger; +import org.springframework.beans.factory.ObjectProvider; /** Client for interfacing with specs in Feast Core. */ public class CoreSpecService { @@ -34,10 +36,16 @@ public class CoreSpecService { private static final Logger log = org.slf4j.LoggerFactory.getLogger(CoreSpecService.class); private final CoreServiceGrpc.CoreServiceBlockingStub blockingStub; - public CoreSpecService(String feastCoreHost, int feastCorePort) { + public CoreSpecService( + String feastCoreHost, int feastCorePort, ObjectProvider callCredentials) { ManagedChannel channel = ManagedChannelBuilder.forAddress(feastCoreHost, feastCorePort).usePlaintext().build(); - blockingStub = CoreServiceGrpc.newBlockingStub(channel); + CallCredentials creds = callCredentials.getIfAvailable(); + if (creds != null) { + blockingStub = CoreServiceGrpc.newBlockingStub(channel).withCallCredentials(creds); + } else { + blockingStub = CoreServiceGrpc.newBlockingStub(channel); + } } public GetFeatureSetResponse getFeatureSet(GetFeatureSetRequest getFeatureSetRequest) { diff --git a/serving/src/main/resources/application.yml b/serving/src/main/resources/application.yml index 2399d132ef9..08fcdf28747 100644 --- a/serving/src/main/resources/application.yml +++ b/serving/src/main/resources/application.yml @@ -3,10 +3,36 @@ feast: # Feast Serving requires connection to Feast Core to retrieve and reload Feast metadata (e.g. FeatureSpecs, Store information) core-host: ${FEAST_CORE_HOST:localhost} core-grpc-port: ${FEAST_CORE_GRPC_PORT:6565} + + core-authentication: + enabled: false # should be set to true if authentication is enabled on core. + provider: google # can be set to `oauth` or `google` + # if google, GOOGLE_APPLICATION_CREDENTIALS environment variable should be set. + options: + #if provider is oauth following properties need to be set, else serving boot up will fail. + oauth_url: https://localhost/oauth/token #oauth token request url + grant_type: client_credentials #oauth grant type + client_id: #oauth client id which will be used for jwt token token request + client_secret: #oauth client secret which will be used for jwt token token request + audience: https://localhost #token audience. + jwkEndpointURI: #jwk enpoint uri, used for caching token till expiry. + # Indicates the active store. Only a single store in the last can be active at one time. In the future this key # will be deprecated in order to allow multiple stores to be served from a single serving instance active_store: online + + security: + authentication: + enabled: false + provider: jwt + options: + jwkEndpointURI: "https://www.googleapis.com/oauth2/v3/certs" + authorization: + enabled: false + provider: http + options: + basePath: http://localhost:3000 # List of store configurations stores: @@ -72,13 +98,15 @@ feast: redis_port: 6379 grpc: - # The port number Feast Serving GRPC service should listen on - # It is set default to 6566 so it does not conflict with the GRPC server on Feast Core - # which defaults to port 6565 - port: ${GRPC_PORT:6566} - # This allows client to discover GRPC endpoints easily - # https://github.com/grpc/grpc-java/blob/master/documentation/server-reflection-tutorial.md - enable-reflection: ${GRPC_ENABLE_REFLECTION:true} + server: + # The port number Feast Serving GRPC service should listen on + # It is set default to 6566 so it does not conflict with the GRPC server on Feast Core + # which defaults to port 6565 + port: ${GRPC_PORT:6566} + security: + enabled: false + certificateChainPath: server.crt + privateKeyPath: server.key server: # The port number on which the Tomcat webserver that serves REST API endpoints should listen diff --git a/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java b/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java index 5c8308daea5..3577f098c1e 100644 --- a/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java +++ b/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java @@ -16,9 +16,20 @@ */ package feast.serving.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; import com.google.protobuf.Timestamp; +import feast.auth.authorization.AuthorizationProvider; +import feast.auth.authorization.AuthorizationResult; +import feast.auth.config.SecurityProperties; +import feast.auth.config.SecurityProperties.AuthenticationProperties; +import feast.auth.config.SecurityProperties.AuthorizationProperties; +import feast.auth.service.AuthorizationService; import feast.proto.serving.ServingAPIProto.FeatureReference; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest.EntityRow; @@ -34,6 +45,9 @@ import org.junit.Test; import org.mockito.Mock; import org.mockito.Mockito; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; public class ServingServiceGRpcControllerTest { @@ -45,6 +59,10 @@ public class ServingServiceGRpcControllerTest { private ServingServiceGRpcController service; + @Mock private Authentication authentication; + + @Mock private AuthorizationProvider authProvider; + @Before public void setUp() { initMocks(this); @@ -59,23 +77,65 @@ public void setUp() { .putFields("entity1", Value.newBuilder().setInt64Val(1).build()) .putFields("entity2", Value.newBuilder().setInt64Val(1).build())) .build(); + } + private ServingServiceGRpcController getServingServiceGRpcController(boolean enableAuth) { Tracer tracer = Configuration.fromEnv("dummy").getTracer(); FeastProperties feastProperties = new FeastProperties(); - service = new ServingServiceGRpcController(mockServingService, feastProperties, tracer); + + AuthorizationProperties authorizationProps = new AuthorizationProperties(); + authorizationProps.setEnabled(enableAuth); + AuthenticationProperties authenticationProps = new AuthenticationProperties(); + authenticationProps.setEnabled(enableAuth); + SecurityProperties securityProperties = new SecurityProperties(); + securityProperties.setAuthentication(authenticationProps); + securityProperties.setAuthorization(authorizationProps); + feastProperties.setSecurity(securityProperties); + AuthorizationService authorizationservice = + new AuthorizationService(feastProperties.getSecurity(), authProvider); + return new ServingServiceGRpcController( + authorizationservice, mockServingService, feastProperties, tracer); } @Test public void shouldPassValidRequestAsIs() { + service = getServingServiceGRpcController(false); service.getOnlineFeatures(validRequest, mockStreamObserver); Mockito.verify(mockServingService).getOnlineFeatures(validRequest); } @Test public void shouldCallOnErrorIfEntityDatasetIsNotSet() { + service = getServingServiceGRpcController(false); GetOnlineFeaturesRequest missingEntityName = GetOnlineFeaturesRequest.newBuilder(validRequest).clearEntityRows().build(); service.getOnlineFeatures(missingEntityName, mockStreamObserver); Mockito.verify(mockStreamObserver).onError(Mockito.any(StatusRuntimeException.class)); } + + @Test + public void shouldPassValidRequestAsIsIfRequestIsAuthorized() { + service = getServingServiceGRpcController(true); + SecurityContext context = mock(SecurityContext.class); + SecurityContextHolder.setContext(context); + when(context.getAuthentication()).thenReturn(authentication); + doReturn(AuthorizationResult.success()) + .when(authProvider) + .checkAccessToProject(anyString(), any(Authentication.class)); + service.getOnlineFeatures(validRequest, mockStreamObserver); + Mockito.verify(mockServingService).getOnlineFeatures(validRequest); + } + + @Test + public void shouldThrowErrorOnValidRequestIfRequestIsUnauthorized() { + service = getServingServiceGRpcController(true); + SecurityContext context = mock(SecurityContext.class); + SecurityContextHolder.setContext(context); + when(context.getAuthentication()).thenReturn(authentication); + doReturn(AuthorizationResult.failed(null)) + .when(authProvider) + .checkAccessToProject(anyString(), any(Authentication.class)); + service.getOnlineFeatures(validRequest, mockStreamObserver); + Mockito.verify(mockStreamObserver).onError(Mockito.any(StatusRuntimeException.class)); + } } diff --git a/serving/src/test/java/feast/serving/it/AuthTestUtils.java b/serving/src/test/java/feast/serving/it/AuthTestUtils.java new file mode 100644 index 00000000000..5ec7298e988 --- /dev/null +++ b/serving/src/test/java/feast/serving/it/AuthTestUtils.java @@ -0,0 +1,283 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.it; + +import static org.awaitility.Awaitility.waitAtMost; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.beans.HasPropertyWithValue.hasProperty; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.protobuf.Timestamp; +import feast.auth.credentials.OAuthCredentials; +import feast.proto.core.CoreServiceGrpc; +import feast.proto.core.FeatureSetProto; +import feast.proto.core.FeatureSetProto.FeatureSetStatus; +import feast.proto.core.SourceProto; +import feast.proto.serving.ServingAPIProto.FeatureReference; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest.EntityRow; +import feast.proto.serving.ServingServiceGrpc; +import feast.proto.types.ValueProto; +import feast.proto.types.ValueProto.Value; +import io.grpc.CallCredentials; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.runners.model.InitializationError; +import sh.ory.keto.ApiClient; +import sh.ory.keto.ApiException; +import sh.ory.keto.Configuration; +import sh.ory.keto.api.EnginesApi; +import sh.ory.keto.model.OryAccessControlPolicy; +import sh.ory.keto.model.OryAccessControlPolicyRole; + +public class AuthTestUtils { + + private static final String DEFAULT_FLAVOR = "glob"; + + static SourceProto.Source defaultSource = + createSource("kafka:9092,localhost:9094", "feast-features"); + + public static SourceProto.Source getDefaultSource() { + return defaultSource; + } + + public static SourceProto.Source createSource(String server, String topic) { + return SourceProto.Source.newBuilder() + .setType(SourceProto.SourceType.KAFKA) + .setKafkaSourceConfig( + SourceProto.KafkaSourceConfig.newBuilder() + .setBootstrapServers(server) + .setTopic(topic) + .build()) + .build(); + } + + public static FeatureSetProto.FeatureSet createFeatureSet( + SourceProto.Source source, + String projectName, + String name, + List> entities, + List> features) { + return FeatureSetProto.FeatureSet.newBuilder() + .setSpec( + FeatureSetProto.FeatureSetSpec.newBuilder() + .setSource(source) + .setName(name) + .setProject(projectName) + .addAllEntities( + entities.stream() + .map( + pair -> + FeatureSetProto.EntitySpec.newBuilder() + .setName(pair.getLeft()) + .setValueType(pair.getRight()) + .build()) + .collect(Collectors.toList())) + .addAllFeatures( + features.stream() + .map( + pair -> + FeatureSetProto.FeatureSpec.newBuilder() + .setName(pair.getLeft()) + .setValueType(pair.getRight()) + .build()) + .collect(Collectors.toList())) + .build()) + .build(); + } + + public static GetOnlineFeaturesRequest createOnlineFeatureRequest( + String projectName, String featureName, String entityId, int entityValue) { + return GetOnlineFeaturesRequest.newBuilder() + .setProject(projectName) + .addFeatures(FeatureReference.newBuilder().setName(featureName).build()) + .addEntityRows( + EntityRow.newBuilder() + .setEntityTimestamp(Timestamp.newBuilder().setSeconds(100)) + .putFields(entityId, Value.newBuilder().setInt64Val(entityValue).build())) + .build(); + } + + public static void applyFeatureSet( + CoreSimpleAPIClient secureApiClient, + String projectName, + String entityId, + String featureName) { + List> entities = new ArrayList<>(); + entities.add(Pair.of(entityId, ValueProto.ValueType.Enum.INT64)); + List> features = new ArrayList<>(); + features.add(Pair.of(featureName, ValueProto.ValueType.Enum.INT64)); + String featureSetName = "test_1"; + FeatureSetProto.FeatureSet expectedFeatureSet = + AuthTestUtils.createFeatureSet( + AuthTestUtils.getDefaultSource(), projectName, featureSetName, entities, features); + secureApiClient.simpleApplyFeatureSet(expectedFeatureSet); + waitAtMost(2, TimeUnit.MINUTES) + .until( + () -> { + return secureApiClient.simpleGetFeatureSet(projectName, featureSetName).getMeta(); + }, + hasProperty("status", equalTo(FeatureSetStatus.STATUS_READY))); + FeatureSetProto.FeatureSet actualFeatureSet = + secureApiClient.simpleGetFeatureSet(projectName, featureSetName); + assertEquals( + expectedFeatureSet.getSpec().getProject(), actualFeatureSet.getSpec().getProject()); + assertEquals(expectedFeatureSet.getSpec().getName(), actualFeatureSet.getSpec().getName()); + assertEquals(expectedFeatureSet.getSpec().getSource(), actualFeatureSet.getSpec().getSource()); + assertEquals(FeatureSetStatus.STATUS_READY, actualFeatureSet.getMeta().getStatus()); + } + + public static CoreSimpleAPIClient getSecureApiClientForCore( + int feastCorePort, Map options) { + CallCredentials callCredentials = null; + callCredentials = new OAuthCredentials(options); + Channel secureChannel = + ManagedChannelBuilder.forAddress("localhost", feastCorePort).usePlaintext().build(); + + CoreServiceGrpc.CoreServiceBlockingStub secureCoreService = + CoreServiceGrpc.newBlockingStub(secureChannel).withCallCredentials(callCredentials); + + return new CoreSimpleAPIClient(secureCoreService); + } + + public static ServingServiceGrpc.ServingServiceBlockingStub getServingServiceStub( + boolean isSecure, int feastServingPort, Map options) { + Channel secureChannel = + ManagedChannelBuilder.forAddress("localhost", feastServingPort).usePlaintext().build(); + + if (isSecure) { + CallCredentials callCredentials = null; + callCredentials = new OAuthCredentials(options); + return ServingServiceGrpc.newBlockingStub(secureChannel).withCallCredentials(callCredentials); + } else { + return ServingServiceGrpc.newBlockingStub(secureChannel); + } + } + + public static void seedHydra( + String hydraExternalUrl, + String clientId, + String clientSecrret, + String audience, + String grantType) + throws IOException, InitializationError { + + OkHttpClient httpClient = new OkHttpClient(); + String createClientEndpoint = String.format("%s/%s", hydraExternalUrl, "clients"); + JsonObject jsonObject = new JsonObject(); + JsonArray audienceArrray = new JsonArray(); + audienceArrray.add(audience); + JsonArray grantTypes = new JsonArray(); + grantTypes.add(grantType); + jsonObject.addProperty("client_id", clientId); + jsonObject.addProperty("client_secret", clientSecrret); + jsonObject.addProperty("token_endpoint_auth_method", "client_secret_post"); + jsonObject.add("audience", audienceArrray); + jsonObject.add("grant_types", grantTypes); + MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + RequestBody requestBody = RequestBody.create(JSON, jsonObject.toString()); + Request request = + new Request.Builder() + .url(createClientEndpoint) + .addHeader("Content-Type", "application/json") + .post(requestBody) + .build(); + Response response = httpClient.newCall(request).execute(); + if (!response.isSuccessful()) { + throw new InitializationError(response.message()); + } + } + + public static void seedKeto(String url, String project, String subjectInProject, String admin) + throws ApiException { + ApiClient ketoClient = Configuration.getDefaultApiClient(); + ketoClient.setBasePath(url); + EnginesApi enginesApi = new EnginesApi(ketoClient); + + // Add policies + OryAccessControlPolicy adminPolicy = getAdminPolicy(); + enginesApi.upsertOryAccessControlPolicy(DEFAULT_FLAVOR, adminPolicy); + + OryAccessControlPolicy projectPolicy = getMyProjectMemberPolicy(project); + enginesApi.upsertOryAccessControlPolicy(DEFAULT_FLAVOR, projectPolicy); + + // Add policy roles + OryAccessControlPolicyRole adminPolicyRole = getAdminPolicyRole(admin); + enginesApi.upsertOryAccessControlPolicyRole(DEFAULT_FLAVOR, adminPolicyRole); + + OryAccessControlPolicyRole myProjectMemberPolicyRole = + getMyProjectMemberPolicyRole(project, subjectInProject); + enginesApi.upsertOryAccessControlPolicyRole(DEFAULT_FLAVOR, myProjectMemberPolicyRole); + } + + private static OryAccessControlPolicyRole getMyProjectMemberPolicyRole( + String project, String subjectInProject) { + OryAccessControlPolicyRole role = new OryAccessControlPolicyRole(); + role.setId(String.format("roles:%s-project-members", project)); + role.setMembers(Collections.singletonList("users:" + subjectInProject)); + return role; + } + + private static OryAccessControlPolicyRole getAdminPolicyRole(String subjectIsAdmin) { + OryAccessControlPolicyRole role = new OryAccessControlPolicyRole(); + role.setId("roles:admin"); + role.setMembers(Collections.singletonList("users:" + subjectIsAdmin)); + return role; + } + + private static OryAccessControlPolicy getAdminPolicy() { + OryAccessControlPolicy policy = new OryAccessControlPolicy(); + policy.setId("policies:admin"); + policy.subjects(Collections.singletonList("roles:admin")); + policy.resources(Collections.singletonList("resources:**")); + policy.actions(Collections.singletonList("actions:**")); + policy.effect("allow"); + policy.conditions(null); + return policy; + } + + private static OryAccessControlPolicy getMyProjectMemberPolicy(String project) { + OryAccessControlPolicy policy = new OryAccessControlPolicy(); + policy.setId(String.format("policies:%s-project-members-policy", project)); + policy.subjects(Collections.singletonList(String.format("roles:%s-project-members", project))); + policy.resources( + Arrays.asList( + String.format("resources:projects:%s", project), + String.format("resources:projects:%s:**", project))); + policy.actions(Collections.singletonList("actions:**")); + policy.effect("allow"); + policy.conditions(null); + return policy; + } +} diff --git a/serving/src/test/java/feast/serving/it/BaseAuthIT.java b/serving/src/test/java/feast/serving/it/BaseAuthIT.java new file mode 100644 index 00000000000..bdbe432ed8e --- /dev/null +++ b/serving/src/test/java/feast/serving/it/BaseAuthIT.java @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.it; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@ActiveProfiles("it") +@SpringBootTest +public class BaseAuthIT { + + static final String FEATURE_NAME = "feature_1"; + static final String ENTITY_ID = "entity_id"; + static final String PROJECT_NAME = "project_1"; + static final int CORE_START_MAX_WAIT_TIME_IN_MINUTES = 3; + static final String CLIENT_ID = "client_id"; + static final String CLIENT_SECRET = "client_secret"; + static final String TOKEN_URL = "http://localhost:4444/oauth2/token"; + static final String JWK_URI = "http://localhost:4444/.well-known/jwks.json"; + + static final String GRANT_TYPE = "client_credentials"; + + static final String AUDIENCE = "https://localhost"; + + static final String CORE = "core_1"; + + static final String HYDRA = "hydra_1"; + static final int HYDRA_PORT = 4445; + + static CoreSimpleAPIClient insecureApiClient; + + static final int REDIS_PORT = 6379; + + static final int FEAST_CORE_PORT = 6565; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("feast.stores[0].name", () -> "online"); + registry.add("feast.stores[0].type", () -> "REDIS"); + // Redis needs to accessible by both core and serving, hence using host address + registry.add( + "feast.stores[0].config.host", + () -> { + try { + return InetAddress.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + e.printStackTrace(); + return ""; + } + }); + registry.add("feast.stores[0].config.port", () -> REDIS_PORT); + registry.add("feast.stores[0].subscriptions[0].name", () -> "*"); + registry.add("feast.stores[0].subscriptions[0].project", () -> "*"); + + registry.add("feast.core-authentication.options.oauth_url", () -> TOKEN_URL); + registry.add("feast.core-authentication.options.grant_type", () -> GRANT_TYPE); + registry.add("feast.core-authentication.options.client_id", () -> CLIENT_ID); + registry.add("feast.core-authentication.options.client_secret", () -> CLIENT_SECRET); + registry.add("feast.core-authentication.options.audience", () -> AUDIENCE); + registry.add("feast.core-authentication.options.jwkEndpointURI", () -> JWK_URI); + registry.add("feast.security.authentication.options.jwkEndpointURI", () -> JWK_URI); + } +} diff --git a/serving/src/test/java/feast/serving/it/CoreSimpleAPIClient.java b/serving/src/test/java/feast/serving/it/CoreSimpleAPIClient.java new file mode 100644 index 00000000000..7d9313150d7 --- /dev/null +++ b/serving/src/test/java/feast/serving/it/CoreSimpleAPIClient.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.it; + +import feast.proto.core.CoreServiceGrpc; +import feast.proto.core.CoreServiceProto; +import feast.proto.core.FeatureSetProto; + +public class CoreSimpleAPIClient { + private CoreServiceGrpc.CoreServiceBlockingStub stub; + + public CoreSimpleAPIClient(CoreServiceGrpc.CoreServiceBlockingStub stub) { + this.stub = stub; + } + + public void simpleApplyFeatureSet(FeatureSetProto.FeatureSet featureSet) { + stub.applyFeatureSet( + CoreServiceProto.ApplyFeatureSetRequest.newBuilder().setFeatureSet(featureSet).build()); + } + + public FeatureSetProto.FeatureSet simpleGetFeatureSet(String projectName, String name) { + return stub.getFeatureSet( + CoreServiceProto.GetFeatureSetRequest.newBuilder() + .setName(name) + .setProject(projectName) + .build()) + .getFeatureSet(); + } +} diff --git a/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java b/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java new file mode 100644 index 00000000000..edd16c24a87 --- /dev/null +++ b/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testcontainers.containers.wait.strategy.Wait.forHttp; + +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingServiceGrpc.ServingServiceBlockingStub; +import feast.proto.types.ValueProto.Value; +import io.grpc.ManagedChannel; +import io.grpc.StatusRuntimeException; +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.junit.ClassRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.runners.model.InitializationError; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@ActiveProfiles("it") +@SpringBootTest( + properties = { + "feast.core-authentication.enabled=true", + "feast.core-authentication.provider=oauth", + "feast.security.authentication.enabled=true", + "feast.security.authorization.enabled=false" + }) +@Testcontainers +public class ServingServiceOauthAuthenticationIT extends BaseAuthIT { + + static final Map options = new HashMap<>(); + + static final int FEAST_SERVING_PORT = 6566; + + @ClassRule @Container + public static DockerComposeContainer environment = + new DockerComposeContainer( + new File("src/test/resources/docker-compose/docker-compose-it-hydra.yml"), + new File("src/test/resources/docker-compose/docker-compose-it-core.yml")) + .withExposedService(HYDRA, HYDRA_PORT, forHttp("/health/alive").forStatusCode(200)) + .withExposedService( + CORE, + 6565, + Wait.forLogMessage(".*gRPC Server started.*\\n", 1) + .withStartupTimeout(Duration.ofMinutes(CORE_START_MAX_WAIT_TIME_IN_MINUTES))); + + @BeforeAll + static void globalSetup() throws IOException, InitializationError, InterruptedException { + String hydraExternalHost = environment.getServiceHost(HYDRA, HYDRA_PORT); + Integer hydraExternalPort = environment.getServicePort(HYDRA, HYDRA_PORT); + String hydraExternalUrl = String.format("http://%s:%s", hydraExternalHost, hydraExternalPort); + AuthTestUtils.seedHydra(hydraExternalUrl, CLIENT_ID, CLIENT_SECRET, AUDIENCE, GRANT_TYPE); + + // set up options for call credentials + options.put("oauth_url", TOKEN_URL); + options.put(CLIENT_ID, CLIENT_ID); + options.put(CLIENT_SECRET, CLIENT_SECRET); + options.put("jwkEndpointURI", JWK_URI); + options.put("audience", AUDIENCE); + options.put("grant_type", GRANT_TYPE); + } + + @Test + public void shouldNotAllowUnauthenticatedGetOnlineFeatures() { + ServingServiceBlockingStub servingStub = + AuthTestUtils.getServingServiceStub(false, FEAST_SERVING_PORT, null); + GetOnlineFeaturesRequest onlineFeatureRequest = + AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); + Exception exception = + assertThrows( + StatusRuntimeException.class, + () -> { + servingStub.getOnlineFeatures(onlineFeatureRequest); + }); + + String expectedMessage = "UNAUTHENTICATED: Authentication failed"; + String actualMessage = exception.getMessage(); + assertEquals(actualMessage, expectedMessage); + } + + @Test + void canGetOnlineFeaturesIfAuthenticated() { + // apply feature set + CoreSimpleAPIClient coreClient = + AuthTestUtils.getSecureApiClientForCore(FEAST_CORE_PORT, options); + AuthTestUtils.applyFeatureSet(coreClient, PROJECT_NAME, ENTITY_ID, FEATURE_NAME); + ServingServiceBlockingStub servingStub = + AuthTestUtils.getServingServiceStub(true, FEAST_SERVING_PORT, options); + GetOnlineFeaturesRequest onlineFeatureRequest = + AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); + GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(onlineFeatureRequest); + assertEquals(1, featureResponse.getFieldValuesCount()); + Map fieldsMap = featureResponse.getFieldValues(0).getFieldsMap(); + assertTrue(fieldsMap.containsKey(ENTITY_ID)); + assertTrue(fieldsMap.containsKey(FEATURE_NAME)); + ((ManagedChannel) servingStub.getChannel()).shutdown(); + } +} diff --git a/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthorizationIT.java b/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthorizationIT.java new file mode 100644 index 00000000000..aaee2321a5f --- /dev/null +++ b/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthorizationIT.java @@ -0,0 +1,212 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testcontainers.containers.wait.strategy.Wait.forHttp; + +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingServiceGrpc.ServingServiceBlockingStub; +import feast.proto.types.ValueProto.Value; +import io.grpc.ManagedChannel; +import io.grpc.StatusRuntimeException; +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.junit.ClassRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.runners.model.InitializationError; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import sh.ory.keto.ApiException; + +@ActiveProfiles("it") +@SpringBootTest( + properties = { + "feast.core-authentication.enabled=true", + "feast.core-authentication.provider=oauth", + "feast.security.authentication.enabled=true", + "feast.security.authorization.enabled=true" + }) +@Testcontainers +public class ServingServiceOauthAuthorizationIT extends BaseAuthIT { + + static final Map adminCredentials = new HashMap<>(); + static final Map memberCredentials = new HashMap<>(); + static final String PROJECT_MEMBER_CLIENT_ID = "client_id_1"; + static final String NOT_PROJECT_MEMBER_CLIENT_ID = "client_id_2"; + private static int KETO_PORT = 4466; + private static int KETO_ADAPTOR_PORT = 8080; + static String subjectClaim = "sub"; + static CoreSimpleAPIClient coreClient; + static final int FEAST_SERVING_PORT = 6766; + + @ClassRule @Container + public static DockerComposeContainer environment = + new DockerComposeContainer( + new File("src/test/resources/docker-compose/docker-compose-it-hydra.yml"), + new File("src/test/resources/docker-compose/docker-compose-it-core.yml"), + new File("src/test/resources/docker-compose/docker-compose-it-keto.yml")) + .withExposedService(HYDRA, HYDRA_PORT, forHttp("/health/alive").forStatusCode(200)) + .withExposedService( + CORE, + 6565, + Wait.forLogMessage(".*gRPC Server started.*\\n", 1) + .withStartupTimeout(Duration.ofMinutes(CORE_START_MAX_WAIT_TIME_IN_MINUTES))) + .withExposedService("adaptor_1", KETO_ADAPTOR_PORT) + .withExposedService("keto_1", KETO_PORT, forHttp("/health/ready").forStatusCode(200));; + + @DynamicPropertySource + static void initialize(DynamicPropertyRegistry registry) { + + // Seed Keto with data + String ketoExternalHost = environment.getServiceHost("keto_1", KETO_PORT); + Integer ketoExternalPort = environment.getServicePort("keto_1", KETO_PORT); + String ketoExternalUrl = String.format("http://%s:%s", ketoExternalHost, ketoExternalPort); + try { + AuthTestUtils.seedKeto(ketoExternalUrl, PROJECT_NAME, PROJECT_MEMBER_CLIENT_ID, CLIENT_ID); + } catch (ApiException e) { + throw new RuntimeException(String.format("Could not seed Keto store %s", ketoExternalUrl)); + } + + // Get Keto Authorization Server (Adaptor) url + String ketoAdaptorHost = environment.getServiceHost("adaptor_1", KETO_ADAPTOR_PORT); + Integer ketoAdaptorPort = environment.getServicePort("adaptor_1", KETO_ADAPTOR_PORT); + String ketoAdaptorUrl = String.format("http://%s:%s", ketoAdaptorHost, ketoAdaptorPort); + + // Initialize dynamic properties + registry.add("feast.security.authorization.options.subjectClaim", () -> subjectClaim); + registry.add("feast.security.authentication.options.jwkEndpointURI", () -> JWK_URI); + registry.add("feast.security.authorization.options.authorizationUrl", () -> ketoAdaptorUrl); + registry.add("grpc.server.port", () -> FEAST_SERVING_PORT); + } + + @BeforeAll + static void globalSetup() throws IOException, InitializationError, InterruptedException { + String hydraExternalHost = environment.getServiceHost(HYDRA, HYDRA_PORT); + Integer hydraExternalPort = environment.getServicePort(HYDRA, HYDRA_PORT); + String hydraExternalUrl = String.format("http://%s:%s", hydraExternalHost, hydraExternalPort); + AuthTestUtils.seedHydra(hydraExternalUrl, CLIENT_ID, CLIENT_SECRET, AUDIENCE, GRANT_TYPE); + AuthTestUtils.seedHydra( + hydraExternalUrl, PROJECT_MEMBER_CLIENT_ID, CLIENT_SECRET, AUDIENCE, GRANT_TYPE); + AuthTestUtils.seedHydra( + hydraExternalUrl, NOT_PROJECT_MEMBER_CLIENT_ID, CLIENT_SECRET, AUDIENCE, GRANT_TYPE); + // set up options for call credentials + adminCredentials.put("oauth_url", TOKEN_URL); + adminCredentials.put(CLIENT_ID, CLIENT_ID); + adminCredentials.put(CLIENT_SECRET, CLIENT_SECRET); + adminCredentials.put("jwkEndpointURI", JWK_URI); + adminCredentials.put("audience", AUDIENCE); + adminCredentials.put("grant_type", GRANT_TYPE); + + coreClient = AuthTestUtils.getSecureApiClientForCore(FEAST_CORE_PORT, adminCredentials); + } + + @BeforeEach + public void setUp() { + // seed core + AuthTestUtils.applyFeatureSet(coreClient, PROJECT_NAME, ENTITY_ID, FEATURE_NAME); + } + + @Test + public void shouldNotAllowUnauthenticatedGetOnlineFeatures() { + ServingServiceBlockingStub servingStub = + AuthTestUtils.getServingServiceStub(false, FEAST_SERVING_PORT, null); + + GetOnlineFeaturesRequest onlineFeatureRequest = + AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); + Exception exception = + assertThrows( + StatusRuntimeException.class, + () -> { + servingStub.getOnlineFeatures(onlineFeatureRequest); + }); + + String expectedMessage = "UNAUTHENTICATED: Authentication failed"; + String actualMessage = exception.getMessage(); + assertEquals(actualMessage, expectedMessage); + ((ManagedChannel) servingStub.getChannel()).shutdown(); + } + + @Test + void canGetOnlineFeaturesIfAdmin() { + // apply feature set + ServingServiceBlockingStub servingStub = + AuthTestUtils.getServingServiceStub(true, FEAST_SERVING_PORT, adminCredentials); + GetOnlineFeaturesRequest onlineFeatureRequest = + AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); + GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(onlineFeatureRequest); + assertEquals(1, featureResponse.getFieldValuesCount()); + Map fieldsMap = featureResponse.getFieldValues(0).getFieldsMap(); + assertTrue(fieldsMap.containsKey(ENTITY_ID)); + assertTrue(fieldsMap.containsKey(FEATURE_NAME)); + ((ManagedChannel) servingStub.getChannel()).shutdown(); + } + + @Test + void canGetOnlineFeaturesIfProjectMember() { + Map memberCredsOptions = new HashMap<>(); + memberCredsOptions.putAll(adminCredentials); + memberCredsOptions.put(CLIENT_ID, PROJECT_MEMBER_CLIENT_ID); + ServingServiceBlockingStub servingStub = + AuthTestUtils.getServingServiceStub(true, FEAST_SERVING_PORT, memberCredsOptions); + GetOnlineFeaturesRequest onlineFeatureRequest = + AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); + GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(onlineFeatureRequest); + assertEquals(1, featureResponse.getFieldValuesCount()); + Map fieldsMap = featureResponse.getFieldValues(0).getFieldsMap(); + assertTrue(fieldsMap.containsKey(ENTITY_ID)); + assertTrue(fieldsMap.containsKey(FEATURE_NAME)); + ((ManagedChannel) servingStub.getChannel()).shutdown(); + } + + @Test + void cantGetOnlineFeaturesIfNotProjectMember() { + Map notMemberCredsOptions = new HashMap<>(); + notMemberCredsOptions.putAll(adminCredentials); + notMemberCredsOptions.put(CLIENT_ID, NOT_PROJECT_MEMBER_CLIENT_ID); + ServingServiceBlockingStub servingStub = + AuthTestUtils.getServingServiceStub(true, FEAST_SERVING_PORT, notMemberCredsOptions); + GetOnlineFeaturesRequest onlineFeatureRequest = + AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); + StatusRuntimeException exception = + assertThrows( + StatusRuntimeException.class, + () -> servingStub.getOnlineFeatures(onlineFeatureRequest)); + + String expectedMessage = + String.format( + "PERMISSION_DENIED: Access denied to project %s for subject %s", + PROJECT_NAME, NOT_PROJECT_MEMBER_CLIENT_ID); + String actualMessage = exception.getMessage(); + assertEquals(actualMessage, expectedMessage); + ((ManagedChannel) servingStub.getChannel()).shutdown(); + } +} diff --git a/serving/src/test/resources/application-it.properties b/serving/src/test/resources/application-it.properties new file mode 100644 index 00000000000..000e512a680 --- /dev/null +++ b/serving/src/test/resources/application-it.properties @@ -0,0 +1,18 @@ +# +# Copyright 2018 The Feast Authors +# +# 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 +# +# https://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. + +feast.core-authentication.enabled=false +feast.security.authentication.enabled=false +feast.security.authorization.enabled=false \ No newline at end of file diff --git a/serving/src/test/resources/docker-compose/core/application-it.yml b/serving/src/test/resources/docker-compose/core/application-it.yml new file mode 100644 index 00000000000..35f2ee54631 --- /dev/null +++ b/serving/src/test/resources/docker-compose/core/application-it.yml @@ -0,0 +1,21 @@ +feast: + jobs: + polling_interval_milliseconds: 30000 + job_update_timeout_seconds: 240 + active_runner: direct + runners: + - name: direct + type: DirectRunner + options: {} + stream: + type: kafka + options: + topic: feast-features + bootstrapServers: "kafka:9092,localhost:9094" + + security: + authentication: + enabled: true + provider: jwt + options: + jwkEndpointURI: http://hydra:4444/.well-known/jwks.json \ No newline at end of file diff --git a/serving/src/test/resources/docker-compose/docker-compose-it-core.yml b/serving/src/test/resources/docker-compose/docker-compose-it-core.yml new file mode 100644 index 00000000000..bb7cdce8abb --- /dev/null +++ b/serving/src/test/resources/docker-compose/docker-compose-it-core.yml @@ -0,0 +1,53 @@ +version: '3' + +services: + core: + image: gcr.io/kf-feast/feast-core:latest + volumes: + - ./core/application-it.yml:/etc/feast/application.yml + environment: + DB_HOST: db + restart: on-failure + depends_on: + - db + - kafka + ports: + - 6565:6565 + command: + - java + - -jar + - /opt/feast/feast-core.jar + - --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml + + kafka: + image: confluentinc/cp-kafka:5.2.1 + environment: + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_ADVERTISED_LISTENERS: INSIDE://kafka:9092,OUTSIDE://localhost:9094 + KAFKA_LISTENERS: INSIDE://:9092,OUTSIDE://:9094 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE + ports: + - "9092:9092" + - "9094:9094" + + depends_on: + - zookeeper + + zookeeper: + image: confluentinc/cp-zookeeper:5.2.1 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + + db: + image: postgres:12-alpine + environment: + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + + redis: + image: redis:5-alpine + ports: + - "6379:6379" \ No newline at end of file diff --git a/serving/src/test/resources/docker-compose/docker-compose-it-hydra.yml b/serving/src/test/resources/docker-compose/docker-compose-it-hydra.yml new file mode 100644 index 00000000000..1c20610cc73 --- /dev/null +++ b/serving/src/test/resources/docker-compose/docker-compose-it-hydra.yml @@ -0,0 +1,54 @@ +version: '3' + +services: + hydra-migrate: + image: oryd/hydra:v1.6.0 + environment: + - DSN=postgres://hydra:secret@postgresd:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4 + command: + migrate sql -e --yes + restart: on-failure + + hydra: + depends_on: + - hydra-migrate + environment: + - DSN=postgres://hydra:secret@postgresd:5432/hydra?sslmode=disable&max_conns=20&max_idle_conns=4 + + postgresd: + image: postgres:9.6 + ports: + - "54320:5432" + environment: + - POSTGRES_USER=hydra + - POSTGRES_PASSWORD=secret + - POSTGRES_DB=hydra + + hydra: + image: oryd/hydra:v1.6.0 + ports: + - "4444:4444" # Public port + - "4445:4445" # Admin port + #- "5555:5555" # Port for hydra token user + command: + serve all --dangerous-force-http + environment: + - URLS_SELF_ISSUER=http://hydra:4444 + - URLS_CONSENT=http://hydra:3000/consent + - URLS_LOGIN=http://hydra:3000/login + - URLS_LOGOUT=http://hydra:3000/logout + - DSN=memory + - SECRETS_SYSTEM=youReallyNeedToChangeThis + - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public,pairwise + - OIDC_SUBJECT_IDENTIFIERS_PAIRWISE_SALT=youReallyNeedToChangeThis + - OAUTH2_ACCESS_TOKEN_STRATEGY=jwt + - OIDC_SUBJECT_IDENTIFIERS_SUPPORTED_TYPES=public + restart: unless-stopped + + consent: + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + image: oryd/hydra-login-consent-node:v1.5.2 + ports: + - "3000:3000" + restart: unless-stopped diff --git a/serving/src/test/resources/docker-compose/docker-compose-it-keto.yml b/serving/src/test/resources/docker-compose/docker-compose-it-keto.yml new file mode 100644 index 00000000000..8ebf7f225e0 --- /dev/null +++ b/serving/src/test/resources/docker-compose/docker-compose-it-keto.yml @@ -0,0 +1,44 @@ +version: '3' +services: + keto: + depends_on: + - ketodb + - migrations + image: oryd/keto:v0.4.3-alpha.2 + environment: + - DSN=postgres://keto:keto@ketodb:5432/keto?sslmode=disable + command: + - serve + ports: + - 4466 + + ketodb: + image: bitnami/postgresql:9.6 + environment: + - POSTGRESQL_USERNAME=keto + - POSTGRESQL_PASSWORD=keto + - POSTGRESQL_DATABASE=keto + ports: + - "54340:5432" + + migrations: + depends_on: + - ketodb + image: oryd/keto:v0.4.3-alpha.2 + environment: + - DSN=postgres://keto:keto@ketodb:5432/keto?sslmode=disable + command: + - migrate + - sql + - -e + + adaptor: + depends_on: + - keto + image: gcr.io/kf-feast/feast-keto-auth-server:latest + environment: + SERVER_PORT: 8080 + KETO_URL: http://keto:4466 + ports: + - 8080 + restart: on-failure \ No newline at end of file diff --git a/tests/e2e/redis/basic-ingest-redis-serving.py b/tests/e2e/redis/basic-ingest-redis-serving.py index 5bb79bc8e8f..e617f5f2c6e 100644 --- a/tests/e2e/redis/basic-ingest-redis-serving.py +++ b/tests/e2e/redis/basic-ingest-redis-serving.py @@ -14,6 +14,8 @@ from google.protobuf.duration_pb2 import Duration from feast.client import Client +from feast.config import Config +from feast.constants import CONFIG_AUTH_PROVIDER from feast.core import CoreService_pb2 from feast.core.CoreService_pb2 import ApplyFeatureSetResponse, GetFeatureSetResponse from feast.core.CoreService_pb2_grpc import CoreServiceStub @@ -21,6 +23,7 @@ from feast.entity import Entity from feast.feature import Feature from feast.feature_set import FeatureSet, FeatureSetRef +from feast.grpc.auth import get_auth_metadata_plugin from feast.serving.ServingService_pb2 import ( GetOnlineFeaturesRequest, GetOnlineFeaturesResponse, @@ -34,6 +37,7 @@ FLOAT_TOLERANCE = 0.00001 PROJECT_NAME = "basic_" + uuid.uuid4().hex.upper()[0:6] DIR_PATH = os.path.dirname(os.path.realpath(__file__)) +AUTH_PROVIDER = "google" def basic_dataframe(entities, features, ingest_time, n_size, null_features=[]): @@ -103,7 +107,7 @@ def allow_dirty(pytestconfig): @pytest.fixture(scope="module") def enable_auth(pytestconfig): - return pytestconfig.getoption("enable_auth") + return True if pytestconfig.getoption("enable_auth").lower() == "true" else False @pytest.fixture(scope="module") @@ -114,8 +118,8 @@ def client(core_url, serving_url, allow_dirty, enable_auth): client = Client( core_url=core_url, serving_url=serving_url, - core_enable_auth=enable_auth, - core_auth_provider="google", + enable_auth=enable_auth, + auth_provider=AUTH_PROVIDER, ) client.create_project(PROJECT_NAME) @@ -162,6 +166,13 @@ def test_version_returns_results(client): assert not version_info["serving"] == "not configured" +def test_list_feature_sets_when_auth_enabled_should_raise(enable_auth): + if enable_auth: + client = Client(core_url=core_url, serving_url=serving_url, enable_auth=False) + with pytest.raises(ConnectionError): + client.list_feature_sets() + + @pytest.mark.timeout(45) @pytest.mark.run(order=10) def test_basic_register_feature_set_success(client): @@ -1091,22 +1102,33 @@ def core_service_stub(self, core_url): core_service_stub = CoreServiceStub(core_channel) return core_service_stub - def apply_feature_set(self, core_service_stub, feature_set_proto): + @pytest.fixture(scope="module") + def auth_meta_data(self, enable_auth): + if not enable_auth: + return None + else: + metadata = {CONFIG_AUTH_PROVIDER: AUTH_PROVIDER} + metadata_plugin = get_auth_metadata_plugin(config=Config(metadata)) + return metadata_plugin.get_signed_meta() + + def apply_feature_set(self, core_service_stub, feature_set_proto, auth_meta_data): try: apply_fs_response = core_service_stub.ApplyFeatureSet( CoreService_pb2.ApplyFeatureSetRequest(feature_set=feature_set_proto), timeout=self.GRPC_CONNECTION_TIMEOUT, + metadata=auth_meta_data, ) # type: ApplyFeatureSetResponse except grpc.RpcError as e: raise grpc.RpcError(e.details()) return apply_fs_response.feature_set - def get_feature_set(self, core_service_stub, name, project): + def get_feature_set(self, core_service_stub, name, project, auth_meta_data): try: get_feature_set_response = core_service_stub.GetFeatureSet( CoreService_pb2.GetFeatureSetRequest( project=project, name=name.strip(), - ) + ), + metadata=auth_meta_data, ) # type: GetFeatureSetResponse except grpc.RpcError as e: raise grpc.RpcError(e.details()) @@ -1114,17 +1136,17 @@ def get_feature_set(self, core_service_stub, name, project): @pytest.mark.timeout(45) @pytest.mark.run(order=51) - def test_register_feature_set_with_labels(self, core_service_stub): + def test_register_feature_set_with_labels(self, core_service_stub, auth_meta_data): feature_set_name = "test_feature_set_labels" feature_set_proto = FeatureSet( name=feature_set_name, project=PROJECT_NAME, labels={self.LABEL_KEY: self.LABEL_VALUE}, ).to_proto() - self.apply_feature_set(core_service_stub, feature_set_proto) + self.apply_feature_set(core_service_stub, feature_set_proto, auth_meta_data) retrieved_feature_set = self.get_feature_set( - core_service_stub, feature_set_name, PROJECT_NAME + core_service_stub, feature_set_name, PROJECT_NAME, auth_meta_data ) assert self.LABEL_KEY in retrieved_feature_set.spec.labels @@ -1132,7 +1154,7 @@ def test_register_feature_set_with_labels(self, core_service_stub): @pytest.mark.timeout(45) @pytest.mark.run(order=52) - def test_register_feature_with_labels(self, core_service_stub): + def test_register_feature_with_labels(self, core_service_stub, auth_meta_data): feature_set_name = "test_feature_labels" feature_set_proto = FeatureSet( name=feature_set_name, @@ -1145,10 +1167,10 @@ def test_register_feature_with_labels(self, core_service_stub): ) ], ).to_proto() - self.apply_feature_set(core_service_stub, feature_set_proto) + self.apply_feature_set(core_service_stub, feature_set_proto, auth_meta_data) retrieved_feature_set = self.get_feature_set( - core_service_stub, feature_set_name, PROJECT_NAME + core_service_stub, feature_set_name, PROJECT_NAME, auth_meta_data ) retrieved_feature = retrieved_feature_set.spec.features[0] From bf24595e4e1517c597656d346e42418c644b08c1 Mon Sep 17 00:00:00 2001 From: Terence Lim Date: Sun, 26 Jul 2020 16:13:32 +0800 Subject: [PATCH 10/31] Fix bug where default project is always used for ingestion (#868) * Fix ingestion with same featureset name * Address PR comments * Remove unrelated test * Remove unnecessary line Co-authored-by: Terence Co-authored-by: Willem Pienaar <6728866+woop@users.noreply.github.com> --- sdk/python/feast/client.py | 26 ++++- tests/e2e/redis/basic-ingest-redis-serving.py | 106 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/sdk/python/feast/client.py b/sdk/python/feast/client.py index c8c19496591..86b9a2c57f6 100644 --- a/sdk/python/feast/client.py +++ b/sdk/python/feast/client.py @@ -849,11 +849,33 @@ def ingest( Returns: str: ingestion id for this dataset + + Examples: + >>> from feast import Client + >>> + >>> client = Client(core_url="localhost:6565") + >>> fs_df = pd.DataFrame( + >>> { + >>> "datetime": [pd.datetime.now()], + >>> "driver": [1001], + >>> "rating": [4.3], + >>> } + >>> ) + >>> client.set_project("project1") + >>> client.ingest("driver", fs_df) + >>> + >>> driver_fs = client.get_feature_set(name="driver", project="project1") + >>> client.ingest(driver_fs, fs_df) """ if isinstance(feature_set, FeatureSet): name = feature_set.name + project = feature_set.project elif isinstance(feature_set, str): + if self.project is not None: + project = self.project + else: + project = "default" name = feature_set else: raise Exception("Feature set name must be provided") @@ -871,7 +893,9 @@ def ingest( while True: if timeout is not None and time.time() - current_time >= timeout: raise TimeoutError("Timed out waiting for feature set to be ready") - fetched_feature_set: Optional[FeatureSet] = self.get_feature_set(name) + fetched_feature_set: Optional[FeatureSet] = self.get_feature_set( + name, project + ) if ( fetched_feature_set is not None and fetched_feature_set.status == FeatureSetStatus.STATUS_READY diff --git a/tests/e2e/redis/basic-ingest-redis-serving.py b/tests/e2e/redis/basic-ingest-redis-serving.py index e617f5f2c6e..341c789f765 100644 --- a/tests/e2e/redis/basic-ingest-redis-serving.py +++ b/tests/e2e/redis/basic-ingest-redis-serving.py @@ -569,6 +569,112 @@ def try_get_features2(): ) +@pytest.mark.timeout(600) +@pytest.mark.run(order=16) +def test_basic_ingest_retrieval_fs(client): + # Set to another project to test ingestion based on current project context + client.set_project(PROJECT_NAME + "_NS1") + driver_fs = FeatureSet( + name="driver_fs", + features=[ + Feature(name="driver_fs_rating", dtype=ValueType.FLOAT), + Feature(name="driver_fs_cost", dtype=ValueType.FLOAT), + ], + entities=[Entity("driver_fs_id", ValueType.INT64)], + max_age=Duration(seconds=3600), + ) + client.apply(driver_fs) + + N_ROWS = 2 + time_offset = datetime.utcnow().replace(tzinfo=pytz.utc) + driver_df = pd.DataFrame( + { + "datetime": [time_offset] * N_ROWS, + "driver_fs_id": [i for i in range(N_ROWS)], + "driver_fs_rating": [float(i) for i in range(N_ROWS)], + "driver_fs_cost": [float(i) + 0.5 for i in range(N_ROWS)], + } + ) + client.ingest(driver_fs, driver_df, timeout=600) + time.sleep(15) + + online_request_entity = [{"driver_fs_id": 0}, {"driver_fs_id": 1}] + online_request_features = ["driver_fs_rating", "driver_fs_cost"] + + def try_get_features(): + response = client.get_online_features( + entity_rows=online_request_entity, feature_refs=online_request_features + ) + return response, True + + online_features_actual = wait_retry_backoff( + retry_fn=try_get_features, + timeout_secs=90, + timeout_msg="Timed out trying to get online feature values", + ) + + online_features_expected = { + "driver_fs_id": [0, 1], + "driver_fs_rating": [0.0, 1.0], + "driver_fs_cost": [0.5, 1.5], + } + + assert online_features_actual.to_dict() == online_features_expected + + +@pytest.mark.timeout(600) +@pytest.mark.run(order=17) +def test_basic_ingest_retrieval_str(client): + # Set to another project to test ingestion based on current project context + client.set_project(PROJECT_NAME + "_NS1") + customer_fs = FeatureSet( + name="cust_fs", + features=[ + Feature(name="cust_rating", dtype=ValueType.INT64), + Feature(name="cust_cost", dtype=ValueType.FLOAT), + ], + entities=[Entity("cust_id", ValueType.INT64)], + max_age=Duration(seconds=3600), + ) + client.apply(customer_fs) + + N_ROWS = 2 + time_offset = datetime.utcnow().replace(tzinfo=pytz.utc) + cust_df = pd.DataFrame( + { + "datetime": [time_offset] * N_ROWS, + "cust_id": [i for i in range(N_ROWS)], + "cust_rating": [i for i in range(N_ROWS)], + "cust_cost": [float(i) + 0.5 for i in range(N_ROWS)], + } + ) + client.ingest("cust_fs", cust_df, timeout=600) + time.sleep(15) + + online_request_entity = [{"cust_id": 0}, {"cust_id": 1}] + online_request_features = ["cust_rating", "cust_cost"] + + def try_get_features(): + response = client.get_online_features( + entity_rows=online_request_entity, feature_refs=online_request_features + ) + return response, True + + online_features_actual = wait_retry_backoff( + retry_fn=try_get_features, + timeout_secs=90, + timeout_msg="Timed out trying to get online feature values", + ) + + online_features_expected = { + "cust_id": [0, 1], + "cust_rating": [0, 1], + "cust_cost": [0.5, 1.5], + } + + assert online_features_actual.to_dict() == online_features_expected + + @pytest.fixture(scope="module") def all_types_dataframe(): return pd.DataFrame( From a466f64cda0f75dbd251eeea9195dc836df08bcd Mon Sep 17 00:00:00 2001 From: Zhu Zhan Yan Date: Thu, 30 Jul 2020 16:56:13 +0800 Subject: [PATCH 11/31] Add Structured Audit Logging (#891) --- common/pom.xml | 53 ++++++- .../interceptors/GrpcMessageInterceptor.java | 92 ++++++++++++ .../feast/common/logging/AuditLogger.java | 140 ++++++++++++++++++ .../logging/config/LoggingProperties.java | 32 ++-- .../logging/entry/ActionAuditLogEntry.java | 43 ++++++ .../common/logging/entry/AuditLogEntry.java | 45 ++++++ .../logging/entry/AuditLogEntryKind.java | 14 +- .../common/logging/entry/LogResource.java | 31 ++-- .../logging/entry/MessageAuditLogEntry.java | 120 +++++++++++++++ .../entry/TransitionAuditLogEntry.java | 44 ++++++ common/src/main/resources/log4j2.xml | 48 ++++++ .../logging/entry/AuditLogEntryTest.java | 93 ++++++++++++ core/pom.xml | 7 + .../feast/core/config/FeastProperties.java | 15 +- .../java/feast/core/grpc/CoreServiceImpl.java | 3 +- .../java/feast/core/job/CreateJobTask.java | 68 --------- .../main/java/feast/core/job/JobManager.java | 10 +- .../java/feast/core/job/UpgradeJobTask.java | 44 ------ .../core/job/dataflow/DataflowJobManager.java | 2 - .../job/direct/DirectRunnerJobManager.java | 2 - .../feast/core/job/task/CreateJobTask.java | 64 ++++++++ .../java/feast/core/job/task/JobTask.java | 68 +++++++++ .../Action.java => job/task/JobTasks.java} | 22 +-- .../feast/core/job/task/RestartJobTask.java | 48 ++++++ .../feast/core/job/task/TerminateJobTask.java | 50 +++++++ .../job/{ => task}/UpdateJobStatusTask.java | 32 ++-- .../main/java/feast/core/log/AuditLogger.java | 52 ------- .../core/service/JobCoordinatorService.java | 13 +- .../java/feast/core/service/JobService.java | 71 ++------- core/src/main/resources/application.yml | 9 ++ core/src/main/resources/log4j2.xml | 22 +-- .../job/dataflow/DataflowJobManagerTest.java | 1 - .../direct/DirectRunnerJobManagerTest.java | 2 - .../core/job/{ => task}/JobTasksTest.java | 15 +- .../service/JobCoordinatorServiceTest.java | 14 +- .../feast/core/service/JobServiceTest.java | 30 +--- .../test/java/feast/core/util/TestUtil.java | 21 +++ ingestion/pom.xml | 4 - pom.xml | 22 ++- serving/pom.xml | 1 - .../feast/serving/config/FeastProperties.java | 28 +++- .../ServingServiceGRpcController.java | 3 +- serving/src/main/resources/application.yml | 9 ++ serving/src/main/resources/log4j2.xml | 27 ++-- storage/connectors/redis/pom.xml | 8 +- 45 files changed, 1153 insertions(+), 389 deletions(-) create mode 100644 common/src/main/java/feast/common/interceptors/GrpcMessageInterceptor.java create mode 100644 common/src/main/java/feast/common/logging/AuditLogger.java rename core/src/main/java/feast/core/job/TerminateJobTask.java => common/src/main/java/feast/common/logging/config/LoggingProperties.java (53%) create mode 100644 common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java create mode 100644 common/src/main/java/feast/common/logging/entry/AuditLogEntry.java rename core/src/main/java/feast/core/log/Resource.java => common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java (78%) rename core/src/main/java/feast/core/job/JobTask.java => common/src/main/java/feast/common/logging/entry/LogResource.java (53%) create mode 100644 common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java create mode 100644 common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java create mode 100644 common/src/main/resources/log4j2.xml create mode 100644 common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java delete mode 100644 core/src/main/java/feast/core/job/CreateJobTask.java delete mode 100644 core/src/main/java/feast/core/job/UpgradeJobTask.java create mode 100644 core/src/main/java/feast/core/job/task/CreateJobTask.java create mode 100644 core/src/main/java/feast/core/job/task/JobTask.java rename core/src/main/java/feast/core/{log/Action.java => job/task/JobTasks.java} (69%) create mode 100644 core/src/main/java/feast/core/job/task/RestartJobTask.java create mode 100644 core/src/main/java/feast/core/job/task/TerminateJobTask.java rename core/src/main/java/feast/core/job/{ => task}/UpdateJobStatusTask.java (58%) delete mode 100644 core/src/main/java/feast/core/log/AuditLogger.java rename core/src/test/java/feast/core/job/{ => task}/JobTasksTest.java (91%) diff --git a/common/pom.xml b/common/pom.xml index db681090fe5..a8d652c0c6c 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -44,11 +44,15 @@ - + dev.feast datatypes-java ${project.version} compile + + + com.google.protobuf + protobuf-java-util @@ -58,13 +62,54 @@ javax.validation validation-api - 2.0.0.Final + + com.google.auto.value + auto-value-annotations + + + com.google.auto.value + auto-value + + + com.google.code.gson + gson + + + net.devh + grpc-server-spring-boot-starter + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.security + spring-security-core + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.slf4j + slf4j-api + + + junit junit - 4.12 + test + + + org.hamcrest + hamcrest-library test - \ No newline at end of file + diff --git a/common/src/main/java/feast/common/interceptors/GrpcMessageInterceptor.java b/common/src/main/java/feast/common/interceptors/GrpcMessageInterceptor.java new file mode 100644 index 00000000000..53dec6a0294 --- /dev/null +++ b/common/src/main/java/feast/common/interceptors/GrpcMessageInterceptor.java @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * 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 + * + * https://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 feast.common.interceptors; + +import com.google.protobuf.Empty; +import com.google.protobuf.Message; +import feast.common.logging.AuditLogger; +import feast.common.logging.entry.MessageAuditLogEntry; +import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import org.slf4j.event.Level; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * GrpcMessageInterceptor intercepts a GRPC calls to log handling of GRPC messages to the Audit Log. + * Intercepts the incoming and outgoing messages logs them to the audit log, together with method + * name and assumed authenticated identity (if authentication is enabled). NOTE: + * GrpcMessageInterceptor assumes that all service calls are unary (ie single request/response). + */ +public class GrpcMessageInterceptor implements ServerInterceptor { + @Override + public Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + MessageAuditLogEntry.Builder entryBuilder = MessageAuditLogEntry.newBuilder(); + // default response message to empty proto in log entry. + entryBuilder.setResponse(Empty.newBuilder().build()); + + // Unpack service & method name from call + // full method name is in format ./ + String fullMethodName = call.getMethodDescriptor().getFullMethodName(); + entryBuilder.setService( + fullMethodName.substring(fullMethodName.lastIndexOf(".") + 1, fullMethodName.indexOf("/"))); + entryBuilder.setMethod(fullMethodName.substring(fullMethodName.indexOf("/") + 1)); + + // Attempt Extract current authenticated identity. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String identity = (authentication == null) ? "" : authentication.getName(); + entryBuilder.setIdentity(identity); + + // Register forwarding call to intercept outgoing response and log to audit log + call = + new SimpleForwardingServerCall(call) { + @Override + public void sendMessage(RespT message) { + // 2. Track the response & Log entry to audit logger + super.sendMessage(message); + entryBuilder.setResponse((Message) message); + } + + @Override + public void close(Status status, Metadata trailers) { + super.close(status, trailers); + // 3. Log the message log entry to the audit log + Level logLevel = (status.isOk()) ? Level.INFO : Level.ERROR; + entryBuilder.setStatusCode(status.getCode()); + AuditLogger.logMessage(logLevel, entryBuilder); + } + }; + + ServerCall.Listener listener = next.startCall(call, headers); + return new SimpleForwardingServerCallListener(listener) { + @Override + // Register listener to intercept incoming request messages and log to audit log + public void onMessage(ReqT message) { + super.onMessage(message); + // 1. Track the request. + entryBuilder.setRequest((Message) message); + } + }; + } +} diff --git a/common/src/main/java/feast/common/logging/AuditLogger.java b/common/src/main/java/feast/common/logging/AuditLogger.java new file mode 100644 index 00000000000..4e779a75aaf --- /dev/null +++ b/common/src/main/java/feast/common/logging/AuditLogger.java @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.common.logging; + +import feast.common.logging.config.LoggingProperties; +import feast.common.logging.config.LoggingProperties.AuditLogProperties; +import feast.common.logging.entry.ActionAuditLogEntry; +import feast.common.logging.entry.AuditLogEntry; +import feast.common.logging.entry.AuditLogEntryKind; +import feast.common.logging.entry.LogResource; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.common.logging.entry.MessageAuditLogEntry; +import feast.common.logging.entry.TransitionAuditLogEntry; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; +import org.slf4j.event.Level; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class AuditLogger { + private static final Marker AUDIT_MARKER = MarkerFactory.getMarker("AUDIT_MARK"); + private static AuditLogProperties properties; + private static BuildProperties buildProperties; + + @Autowired + public AuditLogger(LoggingProperties loggingProperties, BuildProperties buildProperties) { + // Spring runs this constructor when creating the AuditLogger bean, + // which allows us to populate the AuditLogger class with dependencies. + // This allows us to use the dependencies in the AuditLogger's static methods + AuditLogger.properties = loggingProperties.getAudit(); + AuditLogger.buildProperties = buildProperties; + } + + /** + * Log the handling of a Protobuf message by a service call. + * + * @param entryBuilder with all fields set except instance. + */ + public static void logMessage(Level level, MessageAuditLogEntry.Builder entryBuilder) { + log( + level, + entryBuilder + .setComponent(buildProperties.getArtifact()) + .setVersion(buildProperties.getVersion()) + .build()); + } + + /** + * Log an action being taken on a specific resource + * + * @param level describing the severity of the log. + * @param action name of the action being taken on specific resource. + * @param resourceType the type of resource being logged. + * @param resourceId resource specific identifier identifing the instance of the resource. + */ + public static void logAction( + Level level, String action, ResourceType resourceType, String resourceId) { + log( + level, + ActionAuditLogEntry.of( + buildProperties.getArtifact(), + buildProperties.getArtifact(), + LogResource.of(resourceType, resourceId), + action)); + } + + /** + * Log a transition in state/status in a specific resource. + * + * @param level describing the severity of the log. + * @param status name of end status which the resource transition to. + * @param resourceType the type of resource being logged. + * @param resourceId resource specific identifier identifing the instance of the resource. + */ + public static void logTransition( + Level level, String status, ResourceType resourceType, String resourceId) { + log( + level, + TransitionAuditLogEntry.of( + buildProperties.getArtifact(), + buildProperties.getArtifact(), + LogResource.of(resourceType, resourceId), + status)); + } + + /** + * Log given {@link AuditLogEntry} at the given logging {@link Level} to the Audit log. + * + * @param level describing the severity of the log. + * @param entry the {@link AuditLogEntry} to push to the audit log. + */ + private static void log(Level level, AuditLogEntry entry) { + // Check if audit logging is of this specific log entry enabled. + if (!properties.isEnabled()) { + return; + } + if (entry.getKind().equals(AuditLogEntryKind.MESSAGE) + && !properties.isMessageLoggingEnabled()) { + return; + } + + // Log event to audit log through enabled formats + String entryJSON = entry.toJSON(); + switch (level) { + case TRACE: + log.trace(AUDIT_MARKER, entryJSON); + break; + case DEBUG: + log.debug(AUDIT_MARKER, entryJSON); + break; + case INFO: + log.info(AUDIT_MARKER, entryJSON); + break; + case WARN: + log.warn(AUDIT_MARKER, entryJSON); + break; + case ERROR: + log.error(AUDIT_MARKER, entryJSON); + break; + } + } +} diff --git a/core/src/main/java/feast/core/job/TerminateJobTask.java b/common/src/main/java/feast/common/logging/config/LoggingProperties.java similarity index 53% rename from core/src/main/java/feast/core/job/TerminateJobTask.java rename to common/src/main/java/feast/common/logging/config/LoggingProperties.java index c408578a3bd..54932c9ca8f 100644 --- a/core/src/main/java/feast/core/job/TerminateJobTask.java +++ b/common/src/main/java/feast/common/logging/config/LoggingProperties.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2020 The Feast Authors + * Copyright 2018-2019 The Feast Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,30 +14,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feast.core.job; +package feast.common.logging.config; -import feast.core.log.Action; -import feast.core.model.Job; -import lombok.Builder; +import javax.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; -/** Task to terminate given {@link Job} by using {@link JobManager} */ @Getter @Setter -@Builder(setterPrefix = "set") -public class TerminateJobTask implements JobTask { - private Job job; - private JobManager jobManager; +public class LoggingProperties { + @NotNull private AuditLogProperties audit; - @Override - public Job call() { - JobTask.logAudit( - Action.ABORT, - job, - "Aborting job %s for runner %s", - job.getId(), - jobManager.getRunnerType().toString()); - return jobManager.abortJob(job); + @Getter + @Setter + public static class AuditLogProperties { + // Whether to enable/disable audit logging entirely. + private boolean enabled; + + // Whether to enable/disable message level (ie request/response) audit logging. + private boolean messageLoggingEnabled; } } diff --git a/common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java b/common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java new file mode 100644 index 00000000000..cec85b736a6 --- /dev/null +++ b/common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; + +/** ActionAuditLogEntry records an action being taken on a specific resource */ +@AutoValue +public abstract class ActionAuditLogEntry extends AuditLogEntry { + /** The name of the action taken on the resource. */ + public abstract String getAction(); + + /** The target resource of which the action was taken on. */ + public abstract LogResource getResource(); + + /** + * Create an {@link AuditLogEntry} that records an action being taken on a specific resource. + * + * @param component The name of th Feast component producing this {@link AuditLogEntry}. + * @param version The version of Feast producing this {@link AuditLogEntry}. + * @param resource The target resource of which the action was taken on. + * @param action The name of the action being taken on the given resource. + */ + public static ActionAuditLogEntry of( + String component, String version, LogResource resource, String action) { + return new AutoValue_ActionAuditLogEntry( + component, version, AuditLogEntryKind.ACTION, action, resource); + } +} diff --git a/common/src/main/java/feast/common/logging/entry/AuditLogEntry.java b/common/src/main/java/feast/common/logging/entry/AuditLogEntry.java new file mode 100644 index 00000000000..9aa8fcb8c5c --- /dev/null +++ b/common/src/main/java/feast/common/logging/entry/AuditLogEntry.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * 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 + * + * https://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 feast.common.logging.entry; + +import com.google.gson.Gson; + +/** + * AuditLogEntry represents a single audit Log Entry. Audit log entry can converted into string with + * {{@link #toString()} for human readable representation. Or structured JSON with {{@link + * #toJSON()} for a machine parsable representation. + */ +public abstract class AuditLogEntry { + /** Declare Log Type to allow external Logging systems to filter out {@link AuditLogEntry} */ + public final String logType = "FeastAuditLogEntry"; + + public final String application = "Feast"; + + /** The name of the Feast component producing this {@link AuditLogEntry} */ + public abstract String getComponent(); + + /** The version of Feast producing this {@link AuditLogEntry} */ + public abstract String getVersion(); + + public abstract AuditLogEntryKind getKind(); + + /** Return a structured JSON representation of this {@link AuditLogEntry} */ + public String toJSON() { + Gson gson = new Gson(); + return gson.toJson(this); + } +} diff --git a/core/src/main/java/feast/core/log/Resource.java b/common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java similarity index 78% rename from core/src/main/java/feast/core/log/Resource.java rename to common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java index d8e484b3885..d673f6bdb30 100644 --- a/core/src/main/java/feast/core/log/Resource.java +++ b/common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java @@ -14,13 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feast.core.log; +package feast.common.logging.entry; -/** Resources interacted with, for audit logging purposes */ -public enum Resource { - FEATURE, - FEATURE_GROUP, - ENTITY, - STORAGE, - JOB +/** AuditLogEntryKind lists the various kinds of {@link AuditLogEntry} */ +public enum AuditLogEntryKind { + MESSAGE, + ACTION, + TRANSITION, } diff --git a/core/src/main/java/feast/core/job/JobTask.java b/common/src/main/java/feast/common/logging/entry/LogResource.java similarity index 53% rename from core/src/main/java/feast/core/job/JobTask.java rename to common/src/main/java/feast/common/logging/entry/LogResource.java index c7809c4ab82..02e7589f976 100644 --- a/core/src/main/java/feast/core/job/JobTask.java +++ b/common/src/main/java/feast/common/logging/entry/LogResource.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2020 The Feast Authors + * Copyright 2018-2019 The Feast Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,19 +14,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feast.core.job; +package feast.common.logging.entry; -import feast.core.log.Action; -import feast.core.log.AuditLogger; -import feast.core.log.Resource; -import feast.core.model.Job; -import java.util.concurrent.Callable; +import com.google.auto.value.AutoValue; -public interface JobTask extends Callable { - static void logAudit(Action action, Job job, String detail, Object... args) { - AuditLogger.log(Resource.JOB, job.getId(), action, detail, args); +@AutoValue +/** + * LogResource is used in {@link AuditLogEntry} to reference a specific resource as the subject of + * the log + */ +public abstract class LogResource { + public enum ResourceType { + JOB, + FEATURE_SET, } - @Override - Job call() throws RuntimeException; + public abstract ResourceType getType(); + + public abstract String getId(); + + public static LogResource of(ResourceType type, String id) { + return new AutoValue_LogResource(type, id); + } } diff --git a/common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java b/common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java new file mode 100644 index 00000000000..745cc1283ae --- /dev/null +++ b/common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.protobuf.Empty; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.grpc.Status.Code; +import java.lang.reflect.Type; +import java.util.UUID; + +/** MessageAuditLogEntry records the handling of a Protobuf message by a service call. */ +@AutoValue +public abstract class MessageAuditLogEntry extends AuditLogEntry { + /** Id used to identify the service call that the log entry is recording */ + public abstract UUID getId(); + + /** The name of the service that was used to handle the service call. */ + public abstract String getService(); + + /** The name of the method that was used to handle the service call. */ + public abstract String getMethod(); + + /** The request Protobuf {@link Message} that was passed to the Service in the service call. */ + public abstract Message getRequest(); + + /** + * The response Protobuf {@link Message} that was passed to the Service in the service call. May + * be an {@link Empty} protobuf no request could be collected due to an error. + */ + public abstract Message getResponse(); + + /** + * The authenticated identity that was assumed during the handling of the service call. For + * example, the user id or email that identifies the user making the call. Empty if the service + * call is not authenticated. + */ + public abstract String getIdentity(); + + /** The result status code of the service call. */ + public abstract Code getStatusCode(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setId(UUID id); + + public abstract Builder setComponent(String component); + + public abstract Builder setVersion(String component); + + public abstract Builder setKind(AuditLogEntryKind kind); + + public abstract Builder setService(String name); + + public abstract Builder setMethod(String name); + + public abstract Builder setRequest(Message request); + + public abstract Builder setResponse(Message response); + + public abstract Builder setIdentity(String identity); + + public abstract Builder setStatusCode(Code statusCode); + + public abstract MessageAuditLogEntry build(); + } + + public static MessageAuditLogEntry.Builder newBuilder() { + return new AutoValue_MessageAuditLogEntry.Builder() + .setKind(AuditLogEntryKind.MESSAGE) + .setId(UUID.randomUUID()); + } + + @Override + public String toJSON() { + // GSON requires custom typeadapter (serializer) to convert Protobuf messages to JSON properly + Gson gson = + new GsonBuilder() + .registerTypeAdapter( + Message.class, + new JsonSerializer() { + @Override + public JsonElement serialize( + Message message, Type type, JsonSerializationContext context) { + try { + String messageJSON = JsonFormat.printer().print(message); + return new JsonParser().parse(messageJSON); + } catch (InvalidProtocolBufferException e) { + + throw new RuntimeException( + "Unexpected exception converting Protobuf to JSON", e); + } + } + }) + .create(); + return gson.toJson(this); + } +} diff --git a/common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java b/common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java new file mode 100644 index 00000000000..0f139b7bdbd --- /dev/null +++ b/common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; + +/** TransitionAuditLogEntry records a transition in state/status in a specific resource. */ +@AutoValue +public abstract class TransitionAuditLogEntry extends AuditLogEntry { + /** The resource which the state/status transition occured. */ + public abstract LogResource getResource(); + + /** The end status with the resource transition to. */ + public abstract String getStatus(); + + /** + * Construct a new {@link AuditLogEntry} to record a transition in state/status in a specific + * resource. + * + * @param component The name of th Feast component producing this {@link AuditLogEntry}. + * @param version The version of Feast producing this {@link AuditLogEntry}. + * @param resource the resource which the transtion occured + * @param status the end status which the resource transitioned to. + */ + public static TransitionAuditLogEntry of( + String component, String version, LogResource resource, String status) { + return new AutoValue_TransitionAuditLogEntry( + component, version, AuditLogEntryKind.TRANSITION, resource, status); + } +} diff --git a/common/src/main/resources/log4j2.xml b/common/src/main/resources/log4j2.xml new file mode 100644 index 00000000000..c75c2db13cc --- /dev/null +++ b/common/src/main/resources/log4j2.xml @@ -0,0 +1,48 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex + + + {"time":"%d{yyyy-MM-dd'T'HH:mm:ssXXX}","hostname":"${hostName}","severity":"%p","message":%m}%n%ex + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java b/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java new file mode 100644 index 00000000000..a332e0be799 --- /dev/null +++ b/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.common.logging.entry; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.proto.serving.ServingAPIProto.FeatureReference; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequest; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.types.ValueProto.Value; +import io.grpc.Status; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +public class AuditLogEntryTest { + public List getTestAuditLogs() { + GetOnlineFeaturesRequest requestSpec = + GetOnlineFeaturesRequest.newBuilder() + .setOmitEntitiesInResponse(false) + .addAllFeatures( + Arrays.asList( + FeatureReference.newBuilder().setName("feature1").build(), + FeatureReference.newBuilder().setName("feature2").build())) + .build(); + + GetOnlineFeaturesResponse responseSpec = + GetOnlineFeaturesResponse.newBuilder() + .addAllFieldValues( + Arrays.asList( + FieldValues.newBuilder() + .putFields("feature", Value.newBuilder().setInt32Val(32).build()) + .build(), + FieldValues.newBuilder() + .putFields("feature2", Value.newBuilder().setInt32Val(64).build()) + .build())) + .build(); + + return Arrays.asList( + MessageAuditLogEntry.newBuilder() + .setComponent("feast-serving") + .setVersion("0.6") + .setService("ServingService") + .setMethod("getOnlineFeatures") + .setRequest(requestSpec) + .setResponse(responseSpec) + .setStatusCode(Status.OK.getCode()) + .setIdentity("adam@no.such.email") + .build(), + ActionAuditLogEntry.of( + "core", "0.6", LogResource.of(ResourceType.JOB, "kafka-to-redis"), "CREATE"), + TransitionAuditLogEntry.of( + "core", + "0.6", + LogResource.of(ResourceType.FEATURE_SET, "project/feature_set"), + "READY")); + } + + @Test + public void shouldReturnJSONRepresentationOfAuditLog() { + for (AuditLogEntry auditLog : getTestAuditLogs()) { + // Check that auditLog's toJSON() returns valid JSON + String logJSON = auditLog.toJSON(); + System.out.println(logJSON); + JsonParser parser = new JsonParser(); + + // check basic fields are present in JSON representation. + JsonObject logObject = parser.parse(logJSON).getAsJsonObject(); + assertThat(logObject.getAsJsonPrimitive("logType").getAsString(), equalTo(auditLog.logType)); + assertThat( + logObject.getAsJsonPrimitive("kind").getAsString(), equalTo(auditLog.getKind().name())); + } + } +} diff --git a/core/pom.xml b/core/pom.xml index d2f881bb5a2..a7ec3374737 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -75,6 +75,13 @@ dev.feast feast-ingestion ${project.version} + + + + org.slf4j + slf4j-simple + + dev.feast diff --git a/core/src/main/java/feast/core/config/FeastProperties.java b/core/src/main/java/feast/core/config/FeastProperties.java index c50b32b1749..5beb18d7377 100644 --- a/core/src/main/java/feast/core/config/FeastProperties.java +++ b/core/src/main/java/feast/core/config/FeastProperties.java @@ -19,6 +19,7 @@ import feast.auth.config.SecurityProperties; import feast.auth.config.SecurityProperties.AuthenticationProperties; import feast.auth.config.SecurityProperties.AuthorizationProperties; +import feast.common.logging.config.LoggingProperties; import feast.common.validators.OneOfStrings; import feast.core.config.FeastProperties.StreamProperties.FeatureStreamOptions; import java.net.InetAddress; @@ -43,11 +44,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @Getter @Setter @Configuration +@ComponentScan("feast.common.logging") @ConfigurationProperties(prefix = "feast", ignoreInvalidFields = true) public class FeastProperties { @@ -74,11 +77,19 @@ public FeastProperties() {} /* Feast Kafka stream properties */ private StreamProperties stream; - private SecurityProperties security; + @NotNull private SecurityProperties security; @Bean SecurityProperties securityProperties() { - return this.getSecurity(); + return getSecurity(); + } + + /* Feast Audit Logging properties */ + @NotNull private LoggingProperties logging; + + @Bean + LoggingProperties loggingProperties() { + return getLogging(); } /** Feast job properties. These properties are used for ingestion jobs. */ diff --git a/core/src/main/java/feast/core/grpc/CoreServiceImpl.java b/core/src/main/java/feast/core/grpc/CoreServiceImpl.java index e920464a50c..19d73f8f8f8 100644 --- a/core/src/main/java/feast/core/grpc/CoreServiceImpl.java +++ b/core/src/main/java/feast/core/grpc/CoreServiceImpl.java @@ -19,6 +19,7 @@ import com.google.api.gax.rpc.InvalidArgumentException; import com.google.protobuf.InvalidProtocolBufferException; import feast.auth.service.AuthorizationService; +import feast.common.interceptors.GrpcMessageInterceptor; import feast.core.config.FeastProperties; import feast.core.exception.RetrievalException; import feast.core.grpc.interceptors.MonitoringInterceptor; @@ -43,7 +44,7 @@ /** Implementation of the feast core GRPC service. */ @Slf4j -@GrpcService(interceptors = {MonitoringInterceptor.class}) +@GrpcService(interceptors = {GrpcMessageInterceptor.class, MonitoringInterceptor.class}) public class CoreServiceImpl extends CoreServiceImplBase { private final FeastProperties feastProperties; diff --git a/core/src/main/java/feast/core/job/CreateJobTask.java b/core/src/main/java/feast/core/job/CreateJobTask.java deleted file mode 100644 index a0d8d3a1d52..00000000000 --- a/core/src/main/java/feast/core/job/CreateJobTask.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2020 The Feast Authors - * - * 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 - * - * https://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 feast.core.job; - -import feast.core.log.Action; -import feast.core.model.Job; -import feast.core.model.JobStatus; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Task that starts recently created {@link Job} by using {@link JobManager}. */ -@Getter -@Setter -@Builder(setterPrefix = "set") -public class CreateJobTask implements JobTask { - final Logger log = LoggerFactory.getLogger(CreateJobTask.class); - - private Job job; - private JobManager jobManager; - - @Override - public Job call() { - String runnerName = jobManager.getRunnerType().toString(); - - job.setRunner(jobManager.getRunnerType()); - job.setStatus(JobStatus.PENDING); - - try { - JobTask.logAudit(Action.SUBMIT, job, "Building graph and submitting to %s", runnerName); - - job = jobManager.startJob(job); - var extId = job.getExtId(); - if (extId.isEmpty()) { - throw new RuntimeException( - String.format("Could not submit job: \n%s", "unable to retrieve job external id")); - } - - var auditMessage = "Job submitted to runner %s with ext id %s."; - JobTask.logAudit(Action.STATUS_CHANGE, job, auditMessage, runnerName, extId); - - return job; - } catch (Exception e) { - log.error(e.getMessage()); - var auditMessage = "Job failed to be submitted to runner %s. Job status changed to ERROR."; - JobTask.logAudit(Action.STATUS_CHANGE, job, auditMessage, runnerName); - - job.setStatus(JobStatus.ERROR); - return job; - } - } -} diff --git a/core/src/main/java/feast/core/job/JobManager.java b/core/src/main/java/feast/core/job/JobManager.java index 20b5a861084..90f5f873033 100644 --- a/core/src/main/java/feast/core/job/JobManager.java +++ b/core/src/main/java/feast/core/job/JobManager.java @@ -29,10 +29,11 @@ public interface JobManager { Runner getRunnerType(); /** - * Start an import job. Start should change the status of the Job from PENDING to RUNNING. + * Start an import job. The JobManager should also attach external id that is specific to + * JobManager implementation * * @param job job to start - * @return Job + * @return Running Job with extId set. */ Job startJob(Job job); @@ -45,11 +46,10 @@ public interface JobManager { Job updateJob(Job job); /** - * Abort a job given runner-specific job ID. Abort should change the status of the Job from - * RUNNING to ABORTING. + * Abort a job given runner-specific job ID. * * @param job to abort. - * @return The aborted Job + * @return The Aborting Job */ Job abortJob(Job job); diff --git a/core/src/main/java/feast/core/job/UpgradeJobTask.java b/core/src/main/java/feast/core/job/UpgradeJobTask.java deleted file mode 100644 index e7de8f5e275..00000000000 --- a/core/src/main/java/feast/core/job/UpgradeJobTask.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2020 The Feast Authors - * - * 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 - * - * https://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 feast.core.job; - -import feast.core.log.Action; -import feast.core.model.Job; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; - -/** Task that upgrade given {@link Job} by restarting it in {@link JobManager} */ -@Getter -@Setter -@Builder(setterPrefix = "set") -public class UpgradeJobTask implements JobTask { - private JobManager jobManager; - private Job job; - - @Override - public Job call() { - JobTask.logAudit( - Action.UPDATE, - job, - "Updating job %s for runner %s", - job.getId(), - jobManager.getRunnerType().toString()); - - return jobManager.updateJob(job); - } -} diff --git a/core/src/main/java/feast/core/job/dataflow/DataflowJobManager.java b/core/src/main/java/feast/core/job/dataflow/DataflowJobManager.java index a2937ee621e..5a1cdadadc5 100644 --- a/core/src/main/java/feast/core/job/dataflow/DataflowJobManager.java +++ b/core/src/main/java/feast/core/job/dataflow/DataflowJobManager.java @@ -123,7 +123,6 @@ public Job startJob(Job job) { .collect(Collectors.toSet()), false); job.setExtId(extId); - job.setStatus(JobStatus.RUNNING); return job; } catch (RuntimeException e) { @@ -183,7 +182,6 @@ public Job abortJob(Job job) { Strings.lenientFormat("Unable to drain job with id: %s", dataflowJobId), e); } - job.setStatus(JobStatus.ABORTING); return job; } diff --git a/core/src/main/java/feast/core/job/direct/DirectRunnerJobManager.java b/core/src/main/java/feast/core/job/direct/DirectRunnerJobManager.java index 8ee326affa1..4d952a1b10e 100644 --- a/core/src/main/java/feast/core/job/direct/DirectRunnerJobManager.java +++ b/core/src/main/java/feast/core/job/direct/DirectRunnerJobManager.java @@ -84,7 +84,6 @@ public Job startJob(Job job) { DirectJob directJob = new DirectJob(job.getId(), pipelineResult); jobs.add(directJob); job.setExtId(job.getId()); - job.setStatus(JobStatus.RUNNING); return job; } catch (Exception e) { log.error("Error submitting job", e); @@ -156,7 +155,6 @@ public Job abortJob(Job job) { jobs.remove(job.getExtId()); } - job.setStatus(JobStatus.ABORTING); return job; } diff --git a/core/src/main/java/feast/core/job/task/CreateJobTask.java b/core/src/main/java/feast/core/job/task/CreateJobTask.java new file mode 100644 index 00000000000..fa63cc67aa9 --- /dev/null +++ b/core/src/main/java/feast/core/job/task/CreateJobTask.java @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.core.job.task; + +import feast.common.logging.AuditLogger; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.core.job.JobManager; +import feast.core.model.Job; +import feast.core.model.JobStatus; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.event.Level; + +/** Task that starts recently created {@link Job} by using {@link feast.core.job.JobManager}. */ +@Slf4j +public class CreateJobTask extends JobTask { + + public CreateJobTask(Job job, JobManager jobManager) { + super(job, jobManager); + } + + @Override + public Job call() { + try { + String runnerName = jobManager.getRunnerType().toString(); + changeJobStatus(JobStatus.PENDING); + + // Start job with jobManager. + job.setRunner(jobManager.getRunnerType()); + job = jobManager.startJob(job); + + log.info(String.format("Build graph and submitting to %s", runnerName)); + AuditLogger.logAction(Level.INFO, JobTasks.CREATE.name(), ResourceType.JOB, job.getId()); + + // Check for expected external job id + if (job.getExtId().isEmpty()) { + throw new RuntimeException( + String.format( + "Could not submit job %s: unable to retrieve job external id", job.getId())); + } + + log.info( + String.format("Job submitted to runner %s with ext id %s.", runnerName, job.getExtId())); + changeJobStatus(JobStatus.RUNNING); + return job; + } catch (Exception e) { + handleException(e); + return job; + } + } +} diff --git a/core/src/main/java/feast/core/job/task/JobTask.java b/core/src/main/java/feast/core/job/task/JobTask.java new file mode 100644 index 00000000000..60a51809d67 --- /dev/null +++ b/core/src/main/java/feast/core/job/task/JobTask.java @@ -0,0 +1,68 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.core.job.task; + +import feast.common.logging.AuditLogger; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.core.job.JobManager; +import feast.core.model.Job; +import feast.core.model.JobStatus; +import java.util.concurrent.Callable; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.event.Level; + +@Getter +@Setter +@Slf4j +public abstract class JobTask implements Callable { + protected Job job; + protected JobManager jobManager; + + public JobTask(Job job, JobManager jobManager) { + this.job = job; + this.jobManager = jobManager; + } + + @Override + public abstract Job call() throws RuntimeException; + + /** + * Change Job Status to the given status and logs changes in Job Status to audit and normal log. + */ + protected void changeJobStatus(JobStatus newStatus) { + JobStatus currentStatus = job.getStatus(); + if (currentStatus != newStatus) { + job.setStatus(newStatus); + log.info( + String.format("Job status updated: changed from %s to %s", currentStatus, newStatus)); + + AuditLogger.logTransition(Level.INFO, newStatus.name(), ResourceType.JOB, job.getId()); + log.info("test"); + } + } + + /** + * Handle Exception when executing JobTask by transition Job to ERROR status and logging exception + */ + protected void handleException(Exception e) { + log.error("Unexpected exception performing JobTask: %s", e.getMessage()); + e.printStackTrace(); + changeJobStatus(JobStatus.ERROR); + } +} diff --git a/core/src/main/java/feast/core/log/Action.java b/core/src/main/java/feast/core/job/task/JobTasks.java similarity index 69% rename from core/src/main/java/feast/core/log/Action.java rename to core/src/main/java/feast/core/job/task/JobTasks.java index 3eb24c080a4..85b52bdfdeb 100644 --- a/core/src/main/java/feast/core/log/Action.java +++ b/core/src/main/java/feast/core/job/task/JobTasks.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2019 The Feast Authors + * Copyright 2018-2020 The Feast Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,20 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feast.core.log; +package feast.core.job.task; -/** Actions taken for audit logging purposes */ -public enum Action { - // Job-related actions - SUBMIT, - STATUS_CHANGE, +/** Enum listing of the available Job Tasks to perform on Jobs */ +public enum JobTasks { + CREATE, + UPDATE_STATUS, + RESTART, ABORT, - - // Spec-related - UPDATE, - REGISTER, - - // Storage-related - ADD, - SCHEMA_UPDATE, } diff --git a/core/src/main/java/feast/core/job/task/RestartJobTask.java b/core/src/main/java/feast/core/job/task/RestartJobTask.java new file mode 100644 index 00000000000..990e9191ddf --- /dev/null +++ b/core/src/main/java/feast/core/job/task/RestartJobTask.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.core.job.task; + +import feast.common.logging.AuditLogger; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.core.job.JobManager; +import feast.core.model.Job; +import feast.core.model.JobStatus; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.event.Level; + +/** Task that restarts given {@link Job} by restarting it in {@link JobManager} */ +@Slf4j +public class RestartJobTask extends JobTask { + public RestartJobTask(Job job, JobManager jobManager) { + super(job, jobManager); + } + + @Override + public Job call() { + try { + job = jobManager.restartJob(job); + log.info("Restart job %s for runner %s", job.getId(), job.getRunner().toString()); + AuditLogger.logAction(Level.INFO, JobTasks.RESTART.name(), ResourceType.JOB, job.getId()); + + changeJobStatus(JobStatus.RUNNING); + return job; + } catch (Exception e) { + handleException(e); + return job; + } + } +} diff --git a/core/src/main/java/feast/core/job/task/TerminateJobTask.java b/core/src/main/java/feast/core/job/task/TerminateJobTask.java new file mode 100644 index 00000000000..5099d2d2049 --- /dev/null +++ b/core/src/main/java/feast/core/job/task/TerminateJobTask.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.core.job.task; + +import feast.common.logging.AuditLogger; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.core.job.JobManager; +import feast.core.model.Job; +import feast.core.model.JobStatus; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.event.Level; + +/** Task to terminate given {@link Job} by using {@link JobManager} */ +@Slf4j +public class TerminateJobTask extends JobTask { + public TerminateJobTask(Job job, JobManager jobManager) { + super(job, jobManager); + } + + @Override + public Job call() { + try { + job = jobManager.abortJob(job); + log.info( + String.format( + "Aborted job %s for runner %s", job.getId(), jobManager.getRunnerType().toString())); + AuditLogger.logAction(Level.INFO, JobTasks.ABORT.name(), ResourceType.JOB, job.getId()); + + changeJobStatus(JobStatus.ABORTING); + return job; + } catch (Exception e) { + handleException(e); + return job; + } + } +} diff --git a/core/src/main/java/feast/core/job/UpdateJobStatusTask.java b/core/src/main/java/feast/core/job/task/UpdateJobStatusTask.java similarity index 58% rename from core/src/main/java/feast/core/job/UpdateJobStatusTask.java rename to core/src/main/java/feast/core/job/task/UpdateJobStatusTask.java index 9ee4d2f1eec..c793ab6a815 100644 --- a/core/src/main/java/feast/core/job/UpdateJobStatusTask.java +++ b/core/src/main/java/feast/core/job/task/UpdateJobStatusTask.java @@ -14,37 +14,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feast.core.job; +package feast.core.job.task; -import feast.core.log.Action; +import feast.core.job.JobManager; import feast.core.model.Job; import feast.core.model.JobStatus; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; /** * Task that retrieves status from {@link JobManager} on given {@link Job} and update the job * accordingly in-place */ -@Getter -@Setter -@Builder(setterPrefix = "set") -public class UpdateJobStatusTask implements JobTask { - private Job job; - private JobManager jobManager; +public class UpdateJobStatusTask extends JobTask { + public UpdateJobStatusTask(Job job, JobManager jobManager) { + super(job, jobManager); + } @Override public Job call() { - JobStatus currentStatus = job.getStatus(); - JobStatus newStatus = jobManager.getJobStatus(job); + try { + JobStatus newStatus = jobManager.getJobStatus(job); + changeJobStatus(newStatus); - if (newStatus != currentStatus) { - var auditMessage = "Job status updated: changed from %s to %s"; - JobTask.logAudit(Action.STATUS_CHANGE, job, auditMessage, currentStatus, newStatus); + return job; + } catch (Exception e) { + handleException(e); + return job; } - - job.setStatus(newStatus); - return job; } } diff --git a/core/src/main/java/feast/core/log/AuditLogger.java b/core/src/main/java/feast/core/log/AuditLogger.java deleted file mode 100644 index 5349b5548b0..00000000000 --- a/core/src/main/java/feast/core/log/AuditLogger.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2019 The Feast Authors - * - * 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 - * - * https://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 feast.core.log; - -import com.google.common.base.Strings; -import java.util.Date; -import java.util.Map; -import java.util.TreeMap; -import lombok.extern.log4j.Log4j2; -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.message.ObjectMessage; - -@Log4j2 -public class AuditLogger { - private static final Level AUDIT_LEVEL = Level.getLevel("AUDIT"); - - /** - * Log to stdout a json formatted audit log. - * - * @param resource type of resource - * @param id id of resource, if any - * @param action action taken - * @param detail additional detail. Supports string formatting. - * @param args arguments to the detail string - */ - public static void log( - Resource resource, String id, Action action, String detail, Object... args) { - Map map = new TreeMap<>(); - map.put("timestamp", new Date().toString()); - map.put("resource", resource.toString()); - map.put("id", id); - map.put("action", action.toString()); - map.put("detail", Strings.lenientFormat(detail, args)); - ObjectMessage msg = new ObjectMessage(map); - - log.log(AUDIT_LEVEL, msg); - } -} diff --git a/core/src/main/java/feast/core/service/JobCoordinatorService.java b/core/src/main/java/feast/core/service/JobCoordinatorService.java index 59cc619fa65..7cfdd342105 100644 --- a/core/src/main/java/feast/core/service/JobCoordinatorService.java +++ b/core/src/main/java/feast/core/service/JobCoordinatorService.java @@ -26,6 +26,7 @@ import feast.core.dao.FeatureSetRepository; import feast.core.dao.JobRepository; import feast.core.job.*; +import feast.core.job.task.*; import feast.core.model.*; import feast.core.model.FeatureSet; import feast.core.model.Job; @@ -129,6 +130,7 @@ void startOrUpdateJobs(List tasks) { } } catch (ExecutionException | InterruptedException | TimeoutException e) { log.warn("Unable to start or update job: {}", e.getMessage()); + e.printStackTrace(); } completedTasks++; } @@ -162,7 +164,7 @@ List makeJobUpdateTasks(Iterable>> sourceToStor if (job.isDeployed()) { if (!job.isRunning()) { - jobTasks.add(UpdateJobStatusTask.builder().setJob(job).setJobManager(jobManager).build()); + jobTasks.add(new UpdateJobStatusTask(job, jobManager)); // Mark that it is not safe to stop jobs without disrupting ingestion isSafeToStopJobs = false; @@ -180,9 +182,9 @@ List makeJobUpdateTasks(Iterable>> sourceToStor isSafeToStopJobs = false; - jobTasks.add(CreateJobTask.builder().setJob(job).setJobManager(jobManager).build()); + jobTasks.add(new CreateJobTask(job, jobManager)); } else { - jobTasks.add(UpdateJobStatusTask.builder().setJob(job).setJobManager(jobManager).build()); + jobTasks.add(new UpdateJobStatusTask(job, jobManager)); } } else { job.setId(groupingStrategy.createJobId(job)); @@ -192,7 +194,7 @@ List makeJobUpdateTasks(Iterable>> sourceToStor .filter(fs -> fs.getSource().equals(source)) .collect(Collectors.toSet())); - jobTasks.add(CreateJobTask.builder().setJob(job).setJobManager(jobManager).build()); + jobTasks.add(new CreateJobTask(job, jobManager)); } // Record the job as required to safeguard it from getting stopped @@ -203,8 +205,7 @@ List makeJobUpdateTasks(Iterable>> sourceToStor getExtraJobs(activeJobs) .forEach( extraJob -> { - jobTasks.add( - TerminateJobTask.builder().setJob(extraJob).setJobManager(jobManager).build()); + jobTasks.add(new TerminateJobTask(extraJob, jobManager)); }); } diff --git a/core/src/main/java/feast/core/service/JobService.java b/core/src/main/java/feast/core/service/JobService.java index e4c2ea255ab..812f5c17200 100644 --- a/core/src/main/java/feast/core/service/JobService.java +++ b/core/src/main/java/feast/core/service/JobService.java @@ -20,12 +20,10 @@ import feast.core.dao.JobRepository; import feast.core.job.JobManager; import feast.core.job.Runner; -import feast.core.log.Action; -import feast.core.log.AuditLogger; -import feast.core.log.Resource; +import feast.core.job.task.RestartJobTask; +import feast.core.job.task.TerminateJobTask; import feast.core.model.Job; import feast.core.model.JobStatus; -import feast.proto.core.CoreServiceProto.ListFeatureSetsRequest; import feast.proto.core.CoreServiceProto.ListIngestionJobsRequest; import feast.proto.core.CoreServiceProto.ListIngestionJobsResponse; import feast.proto.core.CoreServiceProto.RestartIngestionJobRequest; @@ -54,14 +52,11 @@ @Service public class JobService { private final JobRepository jobRepository; - private final SpecService specService; private final Map jobManagers; @Autowired - public JobService( - JobRepository jobRepository, SpecService specService, List jobManagerList) { + public JobService(JobRepository jobRepository, List jobManagerList) { this.jobRepository = jobRepository; - this.specService = specService; this.jobManagers = new HashMap<>(); for (JobManager manager : jobManagerList) { @@ -77,12 +72,10 @@ public JobService( * * @param request list ingestion jobs request specifying which jobs to include * @throws IllegalArgumentException when given filter in a unsupported configuration - * @throws InvalidProtocolBufferException on error when constructing response protobuf * @return list ingestion jobs response */ @Transactional(readOnly = true) - public ListIngestionJobsResponse listJobs(ListIngestionJobsRequest request) - throws InvalidProtocolBufferException { + public ListIngestionJobsResponse listJobs(ListIngestionJobsRequest request) { Set matchingJobIds = new HashSet<>(); // check that filter specified and not empty @@ -142,7 +135,11 @@ public ListIngestionJobsResponse listJobs(ListIngestionJobsRequest request) if (job.getStatus() == JobStatus.ERROR) { continue; } - ingestJobs.add(job.toProto()); + try { + ingestJobs.add(job.toProto()); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException("Unexpected failure to construct Protobuf", e); + } } // pack jobs into response @@ -176,14 +173,9 @@ public RestartIngestionJobResponse restartJob(RestartIngestionJobRequest request "Restarting a job with a transitional, terminal or unknown status is unsupported"); } - // restart job with job manager - JobManager jobManager = this.jobManagers.get(job.getRunner()); - job = jobManager.restartJob(job); - log.info( - String.format( - "Restarted job (id: %s, extId: %s runner: %s)", - job.getId(), job.getExtId(), job.getRunner())); - this.logStatusChange(job, status, job.getStatus()); + // restart job by running job task + new RestartJobTask(job, jobManagers.get(job.getRunner())).call(); + // update job model in job repository this.jobRepository.saveAndFlush(job); @@ -219,16 +211,10 @@ public StopIngestionJobResponse stopJob(StopIngestionJobRequest request) { throw new UnsupportedOperationException( "Stopping a job with a transitional or unknown status is unsupported"); } - this.logStatusChange(job, status, job.getStatus()); - // stop job with job manager - JobManager jobManager = this.jobManagers.get(job.getRunner()); - job = jobManager.abortJob(job); - log.info( - String.format( - "Aborted job (id: %s, extId: %s runner: %s)", - job.getId(), job.getExtId(), job.getRunner())); - this.logStatusChange(job, status, job.getStatus()); + // stop job with job task + new TerminateJobTask(job, jobManagers.get(job.getRunner())).call(); + // update job model in job repository this.jobRepository.saveAndFlush(job); @@ -248,31 +234,4 @@ private Set mergeResults(Set results, Collection newResults) { } return results; } - - /** converts feature set reference to a list feature set filter */ - private ListFeatureSetsRequest.Filter toListFeatureSetFilter(FeatureSetReference fsReference) { - // match featuresets using contents of featureset reference - String fsName = fsReference.getName(); - String fsProject = fsReference.getProject(); - - // construct list featureset request filter using feature set reference - // for proto3, default value for missing values: - // - numeric values (ie int) is zero - // - strings is empty string - return ListFeatureSetsRequest.Filter.newBuilder() - .setFeatureSetName(fsName.isEmpty() ? "*" : fsName) - .setProject(fsProject.isEmpty() ? "*" : fsProject) - .build(); - } - - /** log job status using job manager */ - private void logStatusChange(Job job, JobStatus oldStatus, JobStatus newStatus) { - AuditLogger.log( - Resource.JOB, - job.getId(), - Action.STATUS_CHANGE, - "Job status transition: changed from %s to %s", - oldStatus, - newStatus); - } } diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index 669192f3367..d3d5b909522 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -95,6 +95,15 @@ feast: options: authorizationUrl: http://localhost:8082 subjectClaim: email + + logging: + # Audit logging provides a machine readable structured JSON log that can give better + # insight into what is happening in Feast. + audit: + # Whether audit logging is enabled. + enabled: true + # Whether to enable message level (ie request/response) audit logging + messageLoggingEnabled: false grpc: server: diff --git a/core/src/main/resources/log4j2.xml b/core/src/main/resources/log4j2.xml index efbf7d1f624..8781d668a84 100644 --- a/core/src/main/resources/log4j2.xml +++ b/core/src/main/resources/log4j2.xml @@ -21,26 +21,28 @@ %d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex - ${env:LOG_TYPE:-Console} - ${env:LOG_LEVEL:-info} + + {"time":"%d{yyyy-MM-dd'T'HH:mm:ssXXX}","hostname":"${hostName}","severity":"%p","message":%m}%n%ex + - - - + - + + - - + + + - - + + + diff --git a/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java b/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java index 5b5c6a6340e..e110bf6ee22 100644 --- a/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java +++ b/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java @@ -213,7 +213,6 @@ public void shouldStartJobWithCorrectPipelineOptions() throws IOException { actualPipelineOptions.getSpecsStreamingUpdateConfigJson(), equalTo(printer.print(specsStreamingUpdateConfig))); assertThat(actual.getExtId(), equalTo(expectedExtJobId)); - assertThat(actual.getStatus(), equalTo(JobStatus.RUNNING)); } @Test diff --git a/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java b/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java index 5846cf54906..c2f0f2a4e1f 100644 --- a/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java +++ b/core/src/test/java/feast/core/job/direct/DirectRunnerJobManagerTest.java @@ -158,7 +158,6 @@ public void shouldStartDirectJobAndRegisterPipelineResult() throws IOException { verify(drJobManager, times(1)).runPipeline(pipelineOptionsCaptor.capture()); verify(directJobRegistry, times(1)).add(directJobCaptor.capture()); - assertThat(actual.getStatus(), equalTo(JobStatus.RUNNING)); ImportOptions actualPipelineOptions = pipelineOptionsCaptor.getValue(); DirectJob jobStarted = directJobCaptor.getValue(); @@ -201,6 +200,5 @@ public void shouldAbortJobThenRemoveFromRegistry() throws IOException { job = drJobManager.abortJob(job); verify(directJob, times(1)).abort(); verify(directJobRegistry, times(1)).remove("ext1"); - assertThat(job.getStatus(), equalTo(JobStatus.ABORTING)); } } diff --git a/core/src/test/java/feast/core/job/JobTasksTest.java b/core/src/test/java/feast/core/job/task/JobTasksTest.java similarity index 91% rename from core/src/test/java/feast/core/job/JobTasksTest.java rename to core/src/test/java/feast/core/job/task/JobTasksTest.java index d1e1b651c19..d463def0669 100644 --- a/core/src/test/java/feast/core/job/JobTasksTest.java +++ b/core/src/test/java/feast/core/job/task/JobTasksTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package feast.core.job; +package feast.core.job.task; import static org.hamcrest.core.IsEqual.equalTo; import static org.junit.Assert.assertThat; @@ -24,6 +24,7 @@ import static org.mockito.MockitoAnnotations.initMocks; import com.google.common.collect.ImmutableSet; +import feast.core.job.*; import feast.core.model.*; import feast.core.util.TestUtil; import feast.proto.core.SourceProto; @@ -73,6 +74,8 @@ public void setUp() { .setBootstrapServers("servers:9092") .build()) .build()); + + TestUtil.setupAuditLogger(); } Job makeJob(String extId, List featureSets, JobStatus status) { @@ -90,19 +93,15 @@ Job makeJob(String extId, List featureSets, JobStatus status) { } CreateJobTask makeCreateTask(Job currentJob) { - return CreateJobTask.builder().setJob(currentJob).setJobManager(jobManager).build(); - } - - UpgradeJobTask makeUpgradeTask(Job currentJob) { - return UpgradeJobTask.builder().setJob(currentJob).setJobManager(jobManager).build(); + return new CreateJobTask(currentJob, jobManager); } UpdateJobStatusTask makeCheckStatusTask(Job currentJob) { - return UpdateJobStatusTask.builder().setJob(currentJob).setJobManager(jobManager).build(); + return new UpdateJobStatusTask(currentJob, jobManager); } TerminateJobTask makeTerminateTask(Job currentJob) { - return TerminateJobTask.builder().setJob(currentJob).setJobManager(jobManager).build(); + return new TerminateJobTask(currentJob, jobManager); } @Test diff --git a/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java b/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java index 621590bd064..54a8482daa0 100644 --- a/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java +++ b/core/src/test/java/feast/core/service/JobCoordinatorServiceTest.java @@ -41,6 +41,7 @@ import feast.core.dao.JobRepository; import feast.core.dao.SourceRepository; import feast.core.job.*; +import feast.core.job.task.*; import feast.core.model.*; import feast.core.util.TestUtil; import feast.proto.core.CoreServiceProto.ListFeatureSetsRequest.Filter; @@ -91,6 +92,7 @@ public void setUp() { JobProperties jobProperties = new JobProperties(); jobProperties.setJobUpdateTimeoutSeconds(5); feastProperties.setJobs(jobProperties); + TestUtil.setupAuditLogger(); jcsWithConsolidation = new JobCoordinatorService( @@ -214,7 +216,7 @@ private Job newJob(String id, Store store, Source source, FeatureSet... featureS Job job = Job.builder() .setId(id) - .setExtId("") + .setExtId("extId") .setRunner(Runner.DATAFLOW) .setSource(source) .setFeatureSetJobStatuses(TestUtil.makeFeatureSetJobStatus(featureSets)) @@ -752,7 +754,8 @@ public void shouldCreateJobPerStore() throws InvalidProtocolBufferException { Job expected1 = newJob("", store1, source); Job expected2 = newJob("", store2, source); - when(jobManager.startJob(any())).thenReturn(new Job()); + when(jobManager.startJob(expected1)).thenReturn(expected1); + when(jobManager.startJob(expected2)).thenReturn(expected2); when(jobManager.getRunnerType()).thenReturn(Runner.DATAFLOW); jcsWithJobPerStore.Poll(); @@ -805,6 +808,11 @@ public void shouldCloneRunningJobOnUpgrade() throws InvalidProtocolBufferExcepti existingJob.setFeatureSetJobStatuses(new HashSet<>()); existingJob.setStatus(JobStatus.RUNNING); + Job spawnJob = newJob("some-other-id", store1, source); + existingJob.setExtId("extId2"); + existingJob.setFeatureSetJobStatuses(new HashSet<>()); + existingJob.setStatus(JobStatus.RUNNING); + when(jobRepository .findFirstBySourceTypeAndSourceConfigAndStoreNameAndStatusNotInOrderByLastUpdatedDesc( eq(source.getType()), @@ -814,6 +822,7 @@ public void shouldCloneRunningJobOnUpgrade() throws InvalidProtocolBufferExcepti .thenReturn(Optional.of(existingJob)); when(jobManager.getRunnerType()).thenReturn(Runner.DATAFLOW); + when(jobManager.startJob(any())).thenReturn(spawnJob); jcsWithConsolidation.Poll(); @@ -821,7 +830,6 @@ public void shouldCloneRunningJobOnUpgrade() throws InvalidProtocolBufferExcepti // not stopped yet verify(jobManager, never()).abortJob(any()); - verify(jobManager, times(1)).startJob(jobCaptor.capture()); Job actual = jobCaptor.getValue(); diff --git a/core/src/test/java/feast/core/service/JobServiceTest.java b/core/src/test/java/feast/core/service/JobServiceTest.java index 1e0bec76da3..ec09820b363 100644 --- a/core/src/test/java/feast/core/service/JobServiceTest.java +++ b/core/src/test/java/feast/core/service/JobServiceTest.java @@ -35,7 +35,6 @@ import feast.core.model.*; import feast.core.util.TestUtil; import feast.proto.core.CoreServiceProto.ListFeatureSetsRequest; -import feast.proto.core.CoreServiceProto.ListFeatureSetsResponse; import feast.proto.core.CoreServiceProto.ListIngestionJobsRequest; import feast.proto.core.CoreServiceProto.ListIngestionJobsResponse; import feast.proto.core.CoreServiceProto.RestartIngestionJobRequest; @@ -57,7 +56,6 @@ public class JobServiceTest { // mocks @Mock private JobRepository jobRepository; @Mock private JobManager jobManager; - @Mock private SpecService specService; // fake models private Source dataSource; private Store dataStore; @@ -97,28 +95,12 @@ public void setup() { this.listFilters = this.newDummyListRequestFilters(); // setup mock objects - this.setupSpecService(); this.setupJobRepository(); this.setupJobManager(); + TestUtil.setupAuditLogger(); // create test target - this.jobService = - new JobService(this.jobRepository, this.specService, Arrays.asList(this.jobManager)); - } - - public void setupSpecService() { - try { - ListFeatureSetsResponse response = - ListFeatureSetsResponse.newBuilder().addFeatureSets(this.featureSet.toProto()).build(); - - when(this.specService.listFeatureSets(this.listFilters.get(0))).thenReturn(response); - - when(this.specService.listFeatureSets(this.listFilters.get(1))).thenReturn(response); - - } catch (InvalidProtocolBufferException e) { - e.printStackTrace(); - fail("Unexpected exception"); - } + this.jobService = new JobService(this.jobRepository, Arrays.asList(this.jobManager)); } public void setupJobRepository() { @@ -190,13 +172,7 @@ private List newDummyListRequestFilters() { private ListIngestionJobsResponse tryListJobs(ListIngestionJobsRequest request) { ListIngestionJobsResponse response = null; - try { - response = this.jobService.listJobs(request); - } catch (InvalidProtocolBufferException e) { - e.printStackTrace(); - fail("Caught Unexpected exception"); - } - + response = this.jobService.listJobs(request); return response; } diff --git a/core/src/test/java/feast/core/util/TestUtil.java b/core/src/test/java/feast/core/util/TestUtil.java index b93aaa8cd42..0199d21d708 100644 --- a/core/src/test/java/feast/core/util/TestUtil.java +++ b/core/src/test/java/feast/core/util/TestUtil.java @@ -16,6 +16,12 @@ */ package feast.core.util; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import feast.common.logging.AuditLogger; +import feast.common.logging.config.LoggingProperties; +import feast.common.logging.config.LoggingProperties.AuditLogProperties; import feast.core.model.Entity; import feast.core.model.Feature; import feast.core.model.FeatureSet; @@ -41,6 +47,7 @@ import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.springframework.boot.info.BuildProperties; public class TestUtil { public static Set makeFeatureSetJobStatus(FeatureSet... featureSets) { @@ -145,4 +152,18 @@ public static FeatureSetJobStatus CreateFeatureSetJobStatusWithJob( return featureSetJobStatus; } + + /** Setup the audit logger. This call is required to use the audit logger when testing. */ + public static void setupAuditLogger() { + AuditLogProperties properties = new AuditLogProperties(); + properties.setEnabled(true); + LoggingProperties loggingProperties = new LoggingProperties(); + loggingProperties.setAudit(properties); + + BuildProperties buildProperties = mock(BuildProperties.class); + when(buildProperties.getArtifact()).thenReturn("feast-core"); + when(buildProperties.getVersion()).thenReturn("0.6"); + + new AuditLogger(loggingProperties, buildProperties); + } } diff --git a/ingestion/pom.xml b/ingestion/pom.xml index de50789a67b..d1fbf1d45b5 100644 --- a/ingestion/pom.xml +++ b/ingestion/pom.xml @@ -57,10 +57,6 @@ - - org.springframework - org.springframework.vendor - io.opencensus io.opencensus.vendor diff --git a/pom.xml b/pom.xml index b42edab7b67..826bfa02ebe 100644 --- a/pom.xml +++ b/pom.xml @@ -71,6 +71,7 @@ 2.5.0.RELEASE false + 1.6.6 @@ -231,9 +232,9 @@ - io.github.lognet - grpc-spring-boot-starter - 3.5.5 + net.devh + grpc-server-spring-boot-starter + ${grpc.spring.boot.starter.version} @@ -280,6 +281,21 @@ 1.18.12 provided + + com.google.auto.value + auto-value-annotations + ${auto.value.version} + + + com.google.auto.value + auto-value + ${auto.value.version} + + + com.google.code.gson + gson + 2.8.5 + diff --git a/serving/pom.xml b/serving/pom.xml index e881d63966e..35b919968c5 100644 --- a/serving/pom.xml +++ b/serving/pom.xml @@ -384,7 +384,6 @@ true - diff --git a/serving/src/main/java/feast/serving/config/FeastProperties.java b/serving/src/main/java/feast/serving/config/FeastProperties.java index 18d9ad7221e..6a1d1a55171 100644 --- a/serving/src/main/java/feast/serving/config/FeastProperties.java +++ b/serving/src/main/java/feast/serving/config/FeastProperties.java @@ -29,6 +29,7 @@ import feast.auth.config.SecurityProperties.AuthenticationProperties; import feast.auth.config.SecurityProperties.AuthorizationProperties; import feast.auth.credentials.CoreAuthenticationProperties; +import feast.common.logging.config.LoggingProperties; import feast.proto.core.StoreProto; import java.util.*; import java.util.stream.Collectors; @@ -39,16 +40,17 @@ import javax.validation.Validator; import javax.validation.ValidatorFactory; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; import javax.validation.constraints.Positive; import org.apache.logging.log4j.core.config.plugins.validation.constraints.ValidHost; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ComponentScan; /** Feast Serving properties. */ -@Configuration +@ComponentScan("feast.common.logging") @ConfigurationProperties(prefix = "feast", ignoreInvalidFields = true) public class FeastProperties { @@ -147,6 +149,14 @@ public void setActiveStore(String activeStore) { /* Metric tracing properties. */ private TracingProperties tracing; + /* Feast Audit Logging properties */ + @NotNull private LoggingProperties logging; + + @Bean + LoggingProperties loggingProperties() { + return getLogging(); + } + /** * Gets Serving store configuration as a list of {@link Store}. * @@ -475,6 +485,20 @@ public void setTracing(TracingProperties tracing) { this.tracing = tracing; } + /** + * Gets logging properties + * + * @return logging properties + */ + public LoggingProperties getLogging() { + return logging; + } + + /** Sets logging properties @@param logging the logging properties */ + public void setLogging(LoggingProperties logging) { + this.logging = logging; + } + /** The type Job store properties. */ public static class JobStoreProperties { diff --git a/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java b/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java index ad97747ab15..e888f523164 100644 --- a/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java +++ b/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java @@ -17,6 +17,7 @@ package feast.serving.controller; import feast.auth.service.AuthorizationService; +import feast.common.interceptors.GrpcMessageInterceptor; import feast.proto.serving.ServingAPIProto.FeatureReference; import feast.proto.serving.ServingAPIProto.GetBatchFeaturesRequest; import feast.proto.serving.ServingAPIProto.GetBatchFeaturesResponse; @@ -46,7 +47,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.context.SecurityContextHolder; -@GrpcService(interceptors = {GrpcMonitoringInterceptor.class}) +@GrpcService(interceptors = {GrpcMessageInterceptor.class, GrpcMonitoringInterceptor.class}) public class ServingServiceGRpcController extends ServingServiceImplBase { private static final Logger log = diff --git a/serving/src/main/resources/application.yml b/serving/src/main/resources/application.yml index 08fcdf28747..7e14cb58a89 100644 --- a/serving/src/main/resources/application.yml +++ b/serving/src/main/resources/application.yml @@ -97,6 +97,15 @@ feast: # Redis port to connect to redis_port: 6379 + logging: + # Audit logging provides a machine readable structured JSON log that can give better + # insight into what is happening in Feast. + audit: + # Whether audit logging is enabled. + enabled: true + # Whether to enable message level (ie request/response) audit logging + messageLoggingEnabled: false + grpc: server: # The port number Feast Serving GRPC service should listen on diff --git a/serving/src/main/resources/log4j2.xml b/serving/src/main/resources/log4j2.xml index 661c8e5061c..c75c2db13cc 100644 --- a/serving/src/main/resources/log4j2.xml +++ b/serving/src/main/resources/log4j2.xml @@ -16,28 +16,33 @@ ~ --> - + %d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex - ${env:LOG_TYPE:-Console} - ${env:LOG_LEVEL:-info} + + {"time":"%d{yyyy-MM-dd'T'HH:mm:ssXXX}","hostname":"${hostName}","severity":"%p","message":%m}%n%ex + + - + + - - - - - - + + + + + + + + - \ No newline at end of file + diff --git a/storage/connectors/redis/pom.xml b/storage/connectors/redis/pom.xml index 807ee86fe5e..d0e127cde86 100644 --- a/storage/connectors/redis/pom.xml +++ b/storage/connectors/redis/pom.xml @@ -89,6 +89,12 @@ 4.12 test - + + org.slf4j + slf4j-simple + 1.7.30 + test + + From ae29ba97cca5585d728c5e91cba7cb888b87be4a Mon Sep 17 00:00:00 2001 From: Zhu Zhan Yan Date: Sat, 1 Aug 2020 14:06:10 +0800 Subject: [PATCH 12/31] Fix Online Serving unable to retrieve feature data after Feature Set update. (#908) * Update RedisCustomIO to write FeatureRows with field's name set to hash of field. * Update FeatureRowDecoder to decode by name hash instead of order * Bump pytest order numbers by 2 to make space for new tests * Revert "Bump pytest order numbers by 2 to make space for new tests" This reverts commit aecc9a6e9a70be3fd84d04f81442b518be01a4c6. * Added e2e to check that feature rows with missing or extra fields can be retrieved * Clarify docs about Feature Row v1 encoding and Feature Row v2 encoding * Fix python lint * Update FeatureRowDecoder's isEncodedV2 check to use anyMatch() * Make missing field/extra field e2e tests independent of other tests. * Update FeatureRowDecoder if/else statement into 2 ifs * Fix python and java lint * Fix java unit test failures * Fix ImportJobTest java unit test * Sync github workflows with master * Sync .github folder with master for fix * Replace v1/v2 encoding with v1/v2 decoder in docs --- .../java/feast/ingestion/ImportJobTest.java | 4 +- .../src/test/java/feast/test/TestUtil.java | 5 + .../storage/common/testing/TestUtil.java | 6 + .../redis/retriever/FeatureRowDecoder.java | 115 ++++++++++++------ .../RedisClusterOnlineRetriever.java | 16 +-- .../redis/retriever/RedisOnlineRetriever.java | 10 +- .../redis/writer/RedisCustomIO.java | 38 ++++-- .../retriever/FeatureRowDecoderTest.java | 103 ++++++++++++++-- .../writer/RedisClusterFeatureSinkTest.java | 54 ++++++-- .../redis/writer/RedisFeatureSinkTest.java | 59 +++++++-- tests/e2e/redis/basic-ingest-redis-serving.py | 90 ++++++++++++++ 11 files changed, 404 insertions(+), 96 deletions(-) diff --git a/ingestion/src/test/java/feast/ingestion/ImportJobTest.java b/ingestion/src/test/java/feast/ingestion/ImportJobTest.java index 1cfd29b5415..f775bc31bbb 100644 --- a/ingestion/src/test/java/feast/ingestion/ImportJobTest.java +++ b/ingestion/src/test/java/feast/ingestion/ImportJobTest.java @@ -217,7 +217,9 @@ public void runPipeline_ShouldWriteToRedisCorrectlyGivenValidSpecAndFeatureRow() .map(FeatureSpec::getName) .collect(Collectors.toList()) .contains(field.getName())) - .map(field -> field.toBuilder().clearName().build()) + .map( + field -> + field.toBuilder().setName(TestUtil.hash(field.getName())).build()) .collect(Collectors.toList()); randomRow = randomRow diff --git a/ingestion/src/test/java/feast/test/TestUtil.java b/ingestion/src/test/java/feast/test/TestUtil.java index b003137846b..1fb8ea89ea3 100644 --- a/ingestion/src/test/java/feast/test/TestUtil.java +++ b/ingestion/src/test/java/feast/test/TestUtil.java @@ -19,6 +19,7 @@ import static feast.common.models.FeatureSet.getFeatureSetStringRef; import com.google.common.collect.ImmutableList; +import com.google.common.hash.Hashing; import com.google.common.io.Files; import com.google.protobuf.ByteString; import com.google.protobuf.Message; @@ -517,4 +518,8 @@ public static void waitUntilAllElementsAreWrittenToStore( } } } + + public static String hash(String input) { + return Hashing.murmur3_32().hashString(input, StandardCharsets.UTF_8).toString(); + } } diff --git a/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java b/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java index 5f191d276ce..773abd57d61 100644 --- a/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java +++ b/storage/api/src/main/java/feast/storage/common/testing/TestUtil.java @@ -16,6 +16,7 @@ */ package feast.storage.common.testing; +import com.google.common.hash.Hashing; import com.google.protobuf.ByteString; import com.google.protobuf.Timestamp; import feast.proto.core.FeatureSetProto.FeatureSet; @@ -24,6 +25,7 @@ import feast.proto.types.FeatureRowProto.FeatureRow.Builder; import feast.proto.types.FieldProto.Field; import feast.proto.types.ValueProto.*; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; import org.apache.commons.lang3.RandomStringUtils; @@ -191,4 +193,8 @@ public static Field field(String name, Object value, ValueType.Enum valueType) { throw new IllegalStateException("Unexpected valueType: " + value.getClass()); } } + + public static String hash(String input) { + return Hashing.murmur3_32().hashString(input, StandardCharsets.UTF_8).toString(); + } } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/FeatureRowDecoder.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/FeatureRowDecoder.java index aad3147f710..d89e5373669 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/FeatureRowDecoder.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/FeatureRowDecoder.java @@ -16,12 +16,17 @@ */ package feast.storage.connectors.redis.retriever; +import com.google.common.hash.Hashing; import feast.proto.core.FeatureSetProto.FeatureSetSpec; import feast.proto.core.FeatureSetProto.FeatureSpec; import feast.proto.types.FeatureRowProto.FeatureRow; import feast.proto.types.FieldProto.Field; +import feast.proto.types.ValueProto.Value; +import feast.storage.connectors.redis.writer.RedisCustomIO; +import java.nio.charset.StandardCharsets; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -36,60 +41,102 @@ public FeatureRowDecoder(String featureSetRef, FeatureSetSpec spec) { } /** - * A feature row is considered encoded if the feature set and field names are not set. This method - * is required for backward compatibility purposes, to allow Feast serving to continue serving non - * encoded Feature Row ingested by an older version of Feast. + * Check if encoded feature row can be decoded by v1 Decoder. The v1 Decoder requires that the + * Feature Row to have both it's feature set reference and fields names are not set. The no. of + * fields in the feature row should also match up with the number of fields in the Feature Set + * spec. NOTE: This method is deprecated and will be removed in Feast v0.7. * * @param featureRow Feature row * @return boolean */ - public boolean isEncoded(FeatureRow featureRow) { + @Deprecated + private boolean isEncodedV1(FeatureRow featureRow) { return featureRow.getFeatureSet().isEmpty() - && featureRow.getFieldsList().stream().allMatch(field -> field.getName().isEmpty()); + && featureRow.getFieldsList().stream().allMatch(field -> field.getName().isEmpty()) + && featureRow.getFieldsList().size() == spec.getFeaturesList().size(); } /** - * Validates if an encoded feature row can be decoded without exception. + * Check if encoded feature row can be decoded by Decoder. The v2 Decoder requires that a Feature + * Row to have both it feature set reference and fields names are set. * * @param featureRow Feature row * @return boolean */ - public boolean isEncodingValid(FeatureRow featureRow) { - return featureRow.getFieldsList().size() == spec.getFeaturesList().size(); + private boolean isEncodedV2(FeatureRow featureRow) { + return !featureRow.getFieldsList().stream().anyMatch(field -> field.getName().isEmpty()); } /** - * Decoding feature row by repopulating the field names based on the corresponding feature set - * spec. + * Decode feature row encoded by {@link RedisCustomIO}. NOTE: The v1 Decoder will be removed in + * Feast 0.7 * + * @throws IllegalArgumentException if unable to the decode the given feature row * @param encodedFeatureRow Feature row * @return boolean */ public FeatureRow decode(FeatureRow encodedFeatureRow) { - final List fieldsWithoutName = encodedFeatureRow.getFieldsList(); + if (isEncodedV1(encodedFeatureRow)) { + // TODO: remove v1 feature row decoder in Feast 0.7 + // Decode Feature Rows using the v1 Decoder. + final List fieldsWithoutName = encodedFeatureRow.getFieldsList(); + List featureNames = + spec.getFeaturesList().stream() + .sorted(Comparator.comparing(FeatureSpec::getName)) + .map(FeatureSpec::getName) + .collect(Collectors.toList()); - List featureNames = - spec.getFeaturesList().stream() - .sorted(Comparator.comparing(FeatureSpec::getName)) - .map(FeatureSpec::getName) - .collect(Collectors.toList()); - List fields = - IntStream.range(0, featureNames.size()) - .mapToObj( - featureNameIndex -> { - String featureName = featureNames.get(featureNameIndex); - return fieldsWithoutName - .get(featureNameIndex) - .toBuilder() - .setName(featureName) - .build(); - }) - .collect(Collectors.toList()); - return encodedFeatureRow - .toBuilder() - .clearFields() - .setFeatureSet(featureSetRef) - .addAllFields(fields) - .build(); + List fields = + IntStream.range(0, featureNames.size()) + .mapToObj( + featureNameIndex -> { + String featureName = featureNames.get(featureNameIndex); + return fieldsWithoutName + .get(featureNameIndex) + .toBuilder() + .setName(featureName) + .build(); + }) + .collect(Collectors.toList()); + + return encodedFeatureRow + .toBuilder() + .clearFields() + .setFeatureSet(featureSetRef) + .addAllFields(fields) + .build(); + } + if (isEncodedV2(encodedFeatureRow)) { + // Decode Feature Rows using the v2 Decoder. + // v2 Decoder input Feature Rows should use a hashed name as the field name and + // should not have feature set reference set. + // Decoding reverts the field name to a unhashed string and set feature set reference. + Map nameHashValueMap = + encodedFeatureRow.getFieldsList().stream() + .collect(Collectors.toMap(field -> field.getName(), field -> field.getValue())); + + List featureNames = + spec.getFeaturesList().stream().map(FeatureSpec::getName).collect(Collectors.toList()); + + List fields = + featureNames.stream() + .map( + name -> { + String nameHash = + Hashing.murmur3_32().hashString(name, StandardCharsets.UTF_8).toString(); + Value value = + nameHashValueMap.getOrDefault(nameHash, Value.newBuilder().build()); + return Field.newBuilder().setName(name).setValue(value).build(); + }) + .collect(Collectors.toList()); + + return encodedFeatureRow + .toBuilder() + .clearFields() + .setFeatureSet(featureSetRef) + .addAllFields(fields) + .build(); + } + throw new IllegalArgumentException("Failed to decode FeatureRow row: Possible data corruption"); } } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterOnlineRetriever.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterOnlineRetriever.java index c006149cd51..2146ec2f87b 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterOnlineRetriever.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterOnlineRetriever.java @@ -158,17 +158,11 @@ private List> getFeaturesFromRedis( // decode feature rows from data bytes using decoder. FeatureRow featureRow = FeatureRow.parseFrom(featureRowBytes); - if (decoder.isEncoded(featureRow)) { - if (decoder.isEncodingValid(featureRow)) { - featureRow = decoder.decode(featureRow); - } else { - // decoding feature row failed: data corruption could have occurred - throw Status.DATA_LOSS - .withDescription( - "Failed to decode FeatureRow from bytes retrieved from redis" - + ": Possible data corruption") - .asRuntimeException(); - } + try { + featureRow = decoder.decode(featureRow); + } catch (IllegalArgumentException e) { + // decoding feature row failed: data corruption could have occurred + throw Status.DATA_LOSS.withCause(e).withDescription(e.getMessage()).asRuntimeException(); } featureRows.add(Optional.of(featureRow)); } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisOnlineRetriever.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisOnlineRetriever.java index ef80b06799b..049175879de 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisOnlineRetriever.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisOnlineRetriever.java @@ -151,15 +151,11 @@ private List> getFeaturesFromRedis( // decode feature rows from data bytes using decoder. FeatureRow featureRow = FeatureRow.parseFrom(featureRowBytes); - if (decoder.isEncoded(featureRow) && decoder.isEncodingValid(featureRow)) { + try { featureRow = decoder.decode(featureRow); - } else { + } catch (IllegalArgumentException e) { // decoding feature row failed: data corruption could have occurred - throw Status.DATA_LOSS - .withDescription( - "Failed to decode FeatureRow from bytes retrieved from redis" - + ": Possible data corruption") - .asRuntimeException(); + throw Status.DATA_LOSS.withCause(e).withDescription(e.getMessage()).asRuntimeException(); } featureRows.add(Optional.of(featureRow)); } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java index dcd2e5bfda1..f73c458d788 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java @@ -18,6 +18,7 @@ import com.google.common.collect.Iterators; import com.google.common.collect.Lists; +import com.google.common.hash.Hashing; import feast.proto.core.FeatureSetProto.EntitySpec; import feast.proto.core.FeatureSetProto.FeatureSetSpec; import feast.proto.core.FeatureSetProto.FeatureSpec; @@ -29,7 +30,9 @@ import feast.storage.api.writer.FailedElement; import feast.storage.api.writer.WriteResult; import feast.storage.common.retry.Retriable; +import feast.storage.connectors.redis.retriever.FeatureRowDecoder; import io.lettuce.core.RedisException; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -203,28 +206,45 @@ private byte[] getKey(FeatureRow featureRow, FeatureSetSpec spec) { return redisKeyBuilder.build().toByteArray(); } + /** + * Encode the Feature Row as bytes to store in Redis in encoded Feature Row encoding. To + * reduce storage space consumption in redis, feature rows are "encoded" by hashing the fields + * names and not unsetting the feature set reference. {@link FeatureRowDecoder} is + * rensponsible for reversing this "encoding" step. + */ private byte[] getValue(FeatureRow featureRow, FeatureSetSpec spec) { List featureNames = spec.getFeaturesList().stream().map(FeatureSpec::getName).collect(Collectors.toList()); - Map fieldValueOnlyMap = + + Map fieldValueOnlyMap = featureRow.getFieldsList().stream() .filter(field -> featureNames.contains(field.getName())) .distinct() .collect( Collectors.toMap( - Field::getName, - field -> Field.newBuilder().setValue(field.getValue()).build())); + Field::getName, field -> Field.newBuilder().setValue(field.getValue()))); List values = featureNames.stream() .sorted() .map( - featureName -> - fieldValueOnlyMap.getOrDefault( - featureName, - Field.newBuilder() - .setValue(ValueProto.Value.getDefaultInstance()) - .build())) + featureName -> { + Field.Builder field = + fieldValueOnlyMap.getOrDefault( + featureName, + Field.newBuilder().setValue(ValueProto.Value.getDefaultInstance())); + + // Encode the name of the as the hash of the field name. + // Use hash of name instead of the name of to reduce redis storage consumption + // per feature row stored. + String nameHash = + Hashing.murmur3_32() + .hashString(featureName, StandardCharsets.UTF_8) + .toString(); + field.setName(nameHash); + + return field.build(); + }) .collect(Collectors.toList()); return FeatureRow.newBuilder() diff --git a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/retriever/FeatureRowDecoderTest.java b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/retriever/FeatureRowDecoderTest.java index 63ad7aa26de..c843d311274 100644 --- a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/retriever/FeatureRowDecoderTest.java +++ b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/retriever/FeatureRowDecoderTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.*; +import com.google.common.hash.Hashing; import com.google.protobuf.Timestamp; import feast.proto.core.FeatureSetProto; import feast.proto.core.FeatureSetProto.FeatureSetSpec; @@ -25,6 +26,7 @@ import feast.proto.types.FieldProto.Field; import feast.proto.types.ValueProto.Value; import feast.proto.types.ValueProto.ValueType; +import java.nio.charset.StandardCharsets; import java.util.Collections; import org.junit.Test; @@ -48,10 +50,29 @@ public class FeatureRowDecoderTest { .build(); @Test - public void featureRowWithFieldNamesIsNotConsideredAsEncoded() { - + public void shouldDecodeValidEncodedFeatureRowV2() { FeatureRowDecoder decoder = new FeatureRowDecoder("feature_set_ref", spec); - FeatureRowProto.FeatureRow nonEncodedFeatureRow = + + FeatureRowProto.FeatureRow encodedFeatureRow = + FeatureRowProto.FeatureRow.newBuilder() + .setEventTimestamp(Timestamp.newBuilder().setNanos(1000)) + .addFields( + Field.newBuilder() + .setName( + Hashing.murmur3_32() + .hashString("feature1", StandardCharsets.UTF_8) + .toString()) + .setValue(Value.newBuilder().setInt32Val(2))) + .addFields( + Field.newBuilder() + .setName( + Hashing.murmur3_32() + .hashString("feature2", StandardCharsets.UTF_8) + .toString()) + .setValue(Value.newBuilder().setFloatVal(1.0f))) + .build(); + + FeatureRowProto.FeatureRow expectedFeatureRow = FeatureRowProto.FeatureRow.newBuilder() .setFeatureSet("feature_set_ref") .setEventTimestamp(Timestamp.newBuilder().setNanos(1000)) @@ -62,26 +83,88 @@ public void featureRowWithFieldNamesIsNotConsideredAsEncoded() { .setName("feature2") .setValue(Value.newBuilder().setFloatVal(1.0f))) .build(); - assertFalse(decoder.isEncoded(nonEncodedFeatureRow)); + + assertEquals(expectedFeatureRow, decoder.decode(encodedFeatureRow)); } @Test - public void encodingIsInvalidIfNumberOfFeaturesInSpecDiffersFromFeatureRow() { - + public void shouldDecodeValidFeatureRowV2WithIncompleteFields() { FeatureRowDecoder decoder = new FeatureRowDecoder("feature_set_ref", spec); FeatureRowProto.FeatureRow encodedFeatureRow = FeatureRowProto.FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setNanos(1000)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setInt32Val(2))) + .addFields( + Field.newBuilder() + .setName( + Hashing.murmur3_32() + .hashString("feature1", StandardCharsets.UTF_8) + .toString()) + .setValue(Value.newBuilder().setInt32Val(2))) + .build(); + + // should decode missing fields as fields with unset value. + FeatureRowProto.FeatureRow expectedFeatureRow = + FeatureRowProto.FeatureRow.newBuilder() + .setFeatureSet("feature_set_ref") + .setEventTimestamp(Timestamp.newBuilder().setNanos(1000)) + .addFields( + Field.newBuilder().setName("feature1").setValue(Value.newBuilder().setInt32Val(2))) + .addFields(Field.newBuilder().setName("feature2").setValue(Value.newBuilder().build())) .build(); - assertFalse(decoder.isEncodingValid(encodedFeatureRow)); + assertEquals(expectedFeatureRow, decoder.decode(encodedFeatureRow)); } @Test - public void shouldDecodeValidEncodedFeatureRow() { + public void shouldDecodeValidFeatureRowV2AndIgnoreExtraFields() { + FeatureRowDecoder decoder = new FeatureRowDecoder("feature_set_ref", spec); + FeatureRowProto.FeatureRow encodedFeatureRow = + FeatureRowProto.FeatureRow.newBuilder() + .setEventTimestamp(Timestamp.newBuilder().setNanos(1000)) + .addFields( + Field.newBuilder() + .setName( + Hashing.murmur3_32() + .hashString("feature1", StandardCharsets.UTF_8) + .toString()) + .setValue(Value.newBuilder().setInt32Val(2))) + .addFields( + Field.newBuilder() + .setName( + Hashing.murmur3_32() + .hashString("feature2", StandardCharsets.UTF_8) + .toString()) + .setValue(Value.newBuilder().setFloatVal(1.0f))) + .addFields( + Field.newBuilder() + .setName( + Hashing.murmur3_32() + .hashString("feature3", StandardCharsets.UTF_8) + .toString()) + .setValue(Value.newBuilder().setStringVal("data"))) + .build(); + + // should decode missing fields as fields with unset value. + FeatureRowProto.FeatureRow expectedFeatureRow = + FeatureRowProto.FeatureRow.newBuilder() + .setFeatureSet("feature_set_ref") + .setEventTimestamp(Timestamp.newBuilder().setNanos(1000)) + .addFields( + Field.newBuilder().setName("feature1").setValue(Value.newBuilder().setInt32Val(2))) + .addFields( + Field.newBuilder() + .setName("feature2") + .setValue(Value.newBuilder().setFloatVal(1.0f))) + .build(); + + assertEquals(expectedFeatureRow, decoder.decode(encodedFeatureRow)); + } + + // TODO: remove this test in Feast 0.7 when support for Feature Row v1 encoding is removed + @Test + public void shouldDecodeValidEncodedFeatureRowV1() { FeatureRowDecoder decoder = new FeatureRowDecoder("feature_set_ref", spec); FeatureRowProto.FeatureRow encodedFeatureRow = @@ -103,8 +186,6 @@ public void shouldDecodeValidEncodedFeatureRow() { .setValue(Value.newBuilder().setFloatVal(1.0f))) .build(); - assertTrue(decoder.isEncoded(encodedFeatureRow)); - assertTrue(decoder.isEncodingValid(encodedFeatureRow)); assertEquals(expectedFeatureRow, decoder.decode(encodedFeatureRow)); } } diff --git a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java index 2adf0cec47f..62ddfff3a7c 100644 --- a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java +++ b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java @@ -17,6 +17,7 @@ package feast.storage.connectors.redis.writer; import static feast.storage.common.testing.TestUtil.field; +import static feast.storage.common.testing.TestUtil.hash; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; @@ -160,7 +161,10 @@ public void shouldWriteToRedis() { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("one"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("one"))) .build()); kvs.put( RedisKey.newBuilder() @@ -169,7 +173,10 @@ public void shouldWriteToRedis() { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("two"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("two"))) .build()); List featureRows = @@ -205,7 +212,10 @@ public void shouldRetryFailConnection() throws InterruptedException { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("one"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("one"))) .build()); List featureRows = @@ -332,8 +342,14 @@ public void shouldConvertRowWithDuplicateEntitiesToValidKey() { FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setInt64Val(1001))) + .addFields( + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1"))) + .addFields( + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.newBuilder().setInt64Val(1001))) .build(); p.apply(Create.of(offendingRow)).apply(redisClusterFeatureSink.writer()); @@ -383,8 +399,14 @@ public void shouldConvertRowWithOutOfOrderFieldsToValidKey() { List expectedFields = Arrays.asList( - Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1")).build(), - Field.newBuilder().setValue(Value.newBuilder().setInt64Val(1001)).build()); + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1")) + .build(), + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.newBuilder().setInt64Val(1001)) + .build()); FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) @@ -443,8 +465,14 @@ public void shouldMergeDuplicateFeatureFields() { FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setInt64Val(1001))) + .addFields( + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1"))) + .addFields( + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.newBuilder().setInt64Val(1001))) .build(); p.apply(Create.of(featureRowWithDuplicatedFeatureFields)) @@ -492,8 +520,12 @@ public void shouldPopulateMissingFeatureValuesWithDefaultInstance() { FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields(Field.newBuilder().setValue(Value.getDefaultInstance())) + .addFields( + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1"))) + .addFields( + Field.newBuilder().setName(hash("feature_2")).setValue(Value.getDefaultInstance())) .build(); p.apply(Create.of(featureRowWithDuplicatedFeatureFields)) diff --git a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java index 63ec136c5d0..948b8d0fda4 100644 --- a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java +++ b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java @@ -17,6 +17,7 @@ package feast.storage.connectors.redis.writer; import static feast.storage.common.testing.TestUtil.field; +import static feast.storage.common.testing.TestUtil.hash; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; @@ -134,7 +135,10 @@ public void shouldWriteToRedis() { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("one"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("one"))) .build()); kvs.put( RedisKey.newBuilder() @@ -143,7 +147,10 @@ public void shouldWriteToRedis() { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("two"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("two"))) .build()); List featureRows = @@ -193,7 +200,10 @@ public void shouldRetryFailConnection() throws InterruptedException { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("one"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("one"))) .build()); List featureRows = @@ -251,7 +261,10 @@ public void shouldProduceFailedElementIfRetryExceeded() { .build(), FeatureRow.newBuilder() .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("one"))) + .addFields( + Field.newBuilder() + .setName(hash("feature")) + .setValue(Value.newBuilder().setStringVal("one"))) .build()); List featureRows = @@ -318,8 +331,14 @@ public void shouldConvertRowWithDuplicateEntitiesToValidKey() { FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setInt64Val(1001))) + .addFields( + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1"))) + .addFields( + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.newBuilder().setInt64Val(1001))) .build(); p.apply(Create.of(offendingRow)).apply(redisFeatureSink.writer()); @@ -369,8 +388,14 @@ public void shouldConvertRowWithOutOfOrderFieldsToValidKey() { List expectedFields = Arrays.asList( - Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1")).build(), - Field.newBuilder().setValue(Value.newBuilder().setInt64Val(1001)).build()); + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1")) + .build(), + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.newBuilder().setInt64Val(1001)) + .build()); FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) @@ -429,8 +454,14 @@ public void shouldMergeDuplicateFeatureFields() { FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setInt64Val(1001))) + .addFields( + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1"))) + .addFields( + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.newBuilder().setInt64Val(1001))) .build(); p.apply(Create.of(featureRowWithDuplicatedFeatureFields)).apply(redisFeatureSink.writer()); @@ -477,8 +508,12 @@ public void shouldPopulateMissingFeatureValuesWithDefaultInstance() { FeatureRow expectedValue = FeatureRow.newBuilder() .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields(Field.newBuilder().setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields(Field.newBuilder().setValue(Value.getDefaultInstance())) + .addFields( + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.newBuilder().setStringVal("strValue1"))) + .addFields( + Field.newBuilder().setName(hash("feature_2")).setValue(Value.getDefaultInstance())) .build(); p.apply(Create.of(featureRowWithDuplicatedFeatureFields)).apply(redisFeatureSink.writer()); diff --git a/tests/e2e/redis/basic-ingest-redis-serving.py b/tests/e2e/redis/basic-ingest-redis-serving.py index 341c789f765..1fcae69ed3b 100644 --- a/tests/e2e/redis/basic-ingest-redis-serving.py +++ b/tests/e2e/redis/basic-ingest-redis-serving.py @@ -675,6 +675,96 @@ def try_get_features(): assert online_features_actual.to_dict() == online_features_expected +@pytest.mark.timeout(600) +@pytest.mark.run(order=18) +def test_basic_retrieve_feature_row_missing_fields(client, cust_trans_df): + feature_refs = ["daily_transactions", "total_transactions", "null_values"] + + # apply cust_trans_fs and ingest dataframe + client.set_project(PROJECT_NAME + "_basic_retrieve_missing_fields") + old_cust_trans_fs = FeatureSet.from_yaml(f"{DIR_PATH}/basic/cust_trans_fs.yaml") + client.apply(old_cust_trans_fs) + client.ingest(old_cust_trans_fs, cust_trans_df) + + # update cust_trans_fs with one additional feature. + # feature rows ingested before the feature set update will be missing a field. + new_cust_trans_fs = client.get_feature_set(name="customer_transactions") + new_cust_trans_fs.add(Feature("n_trips", ValueType.INT64)) + client.apply(new_cust_trans_fs) + # sleep to ensure feature set update is propagated + time.sleep(15) + + # attempt to retrieve features from feature rows with missing fields + def try_get_features(): + response = client.get_online_features( + entity_rows=[ + {"customer_id": np.int64(cust_trans_df.iloc[0]["customer_id"])} + ], + feature_refs=feature_refs + ["n_trips"], + ) # type: GetOnlineFeaturesResponse + # check if the ingested fields can be correctly retrieved. + is_ok = all( + [ + check_online_response(ref, cust_trans_df, response) + for ref in feature_refs + ] + ) + # should return null_value status for missing field n_trips + is_missing_ok = ( + response.field_values[0].statuses["n_trips"] + == GetOnlineFeaturesResponse.FieldStatus.NULL_VALUE + ) + return response, is_ok and is_missing_ok + + wait_retry_backoff( + retry_fn=try_get_features, + timeout_secs=90, + timeout_msg="Timed out trying to get online feature values", + ) + + +@pytest.mark.timeout(600) +@pytest.mark.run(order=19) +def test_basic_retrieve_feature_row_extra_fields(client, cust_trans_df): + feature_refs = ["daily_transactions", "total_transactions"] + # apply cust_trans_fs and ingest dataframe + client.set_project(PROJECT_NAME + "_basic_retrieve_missing_fields") + old_cust_trans_fs = FeatureSet.from_yaml(f"{DIR_PATH}/basic/cust_trans_fs.yaml") + client.apply(old_cust_trans_fs) + client.ingest(old_cust_trans_fs, cust_trans_df) + + # update cust_trans_fs with the null_values feature dropped. + # feature rows ingested before the feature set update will have an extra field. + new_cust_trans_fs = client.get_feature_set(name="customer_transactions") + new_cust_trans_fs.drop("null_values") + client.apply(new_cust_trans_fs) + # sleep to ensure feature set update is propagated + time.sleep(15) + + # attempt to retrieve features from feature rows with extra fields + def try_get_features(): + response = client.get_online_features( + entity_rows=[ + {"customer_id": np.int64(cust_trans_df.iloc[0]["customer_id"])} + ], + feature_refs=feature_refs, + ) # type: GetOnlineFeaturesResponse + # check if the non dropped fields can be correctly retrieved. + is_ok = all( + [ + check_online_response(ref, cust_trans_df, response) + for ref in feature_refs + ] + ) + return response, is_ok + + wait_retry_backoff( + retry_fn=try_get_features, + timeout_secs=90, + timeout_msg="Timed out trying to get online feature values", + ) + + @pytest.fixture(scope="module") def all_types_dataframe(): return pd.DataFrame( From eb995b0ef60339a45997b8dc63b1dc36575302e1 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Sat, 1 Aug 2020 12:21:10 +0300 Subject: [PATCH 13/31] Throw more informative exception when write_triggering_frequency_seconds is missing (#917) * add checkArgument * add default write_triggering_frequency_seconds --- infra/charts/feast/README.md | 2 ++ infra/charts/feast/README.md.gotmpl | 1 + infra/charts/feast/values-batch-serving.yaml | 1 + infra/docker-compose/serving/batch-serving.yml | 1 + protos/feast/core/Store.proto | 5 +++-- .../connectors/bigquery/writer/BigQueryFeatureSink.java | 8 ++++++++ 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 6b0325884e7..0ec2e455d60 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -164,6 +164,7 @@ feast-batch-serving: staging_location: gs:///feast-staging-location initial_retry_delay_seconds: 3 total_timeout_seconds: 21600 + write_triggering_frequency_seconds: 600 subscriptions: - name: "*" project: "*" @@ -280,6 +281,7 @@ feast-batch-serving: staging_location: gs:///feast-staging-location initial_retry_delay_seconds: 3 total_timeout_seconds: 21600 + write_triggering_frequency_seconds: 600 subscriptions: - name: "*" project: "*" diff --git a/infra/charts/feast/README.md.gotmpl b/infra/charts/feast/README.md.gotmpl index 75fc6661c80..d412aa01415 100644 --- a/infra/charts/feast/README.md.gotmpl +++ b/infra/charts/feast/README.md.gotmpl @@ -137,6 +137,7 @@ feast-batch-serving: staging_location: gs:///feast-staging-location initial_retry_delay_seconds: 3 total_timeout_seconds: 21600 + write_triggering_frequency_seconds: 600 subscriptions: - name: "*" project: "*" diff --git a/infra/charts/feast/values-batch-serving.yaml b/infra/charts/feast/values-batch-serving.yaml index 3ee35be1061..afa99f6d69b 100644 --- a/infra/charts/feast/values-batch-serving.yaml +++ b/infra/charts/feast/values-batch-serving.yaml @@ -20,6 +20,7 @@ feast-batch-serving: staging_location: gs:///feast-staging-location initial_retry_delay_seconds: 3 total_timeout_seconds: 21600 + write_triggering_frequency_seconds: 600 subscriptions: - name: "*" project: "*" diff --git a/infra/docker-compose/serving/batch-serving.yml b/infra/docker-compose/serving/batch-serving.yml index c34aba277c7..3feb81c84e1 100644 --- a/infra/docker-compose/serving/batch-serving.yml +++ b/infra/docker-compose/serving/batch-serving.yml @@ -12,6 +12,7 @@ feast: staging_location: gs://gcs_bucket/prefix initial_retry_delay_seconds: 1 total_timeout_seconds: 21600 + write_triggering_frequency_seconds: 600 subscriptions: - name: "*" project: "*" diff --git a/protos/feast/core/Store.proto b/protos/feast/core/Store.proto index 3b4394150db..780d7a7db8b 100644 --- a/protos/feast/core/Store.proto +++ b/protos/feast/core/Store.proto @@ -108,7 +108,7 @@ message Store { int32 initial_backoff_ms = 3; // Optional. Maximum total number of retries for connecting to Redis. Default to zero retries. int32 max_retries = 4; - // Optional. how often flush data to redis + // Optional. How often flush data to redis int32 flush_frequency_seconds = 5; } @@ -118,6 +118,7 @@ message Store { string staging_location = 3; int32 initial_retry_delay_seconds = 4; int32 total_timeout_seconds = 5; + // Required. Frequency of running BQ load job and flushing all collected rows to BQ table int32 write_triggering_frequency_seconds = 6; } @@ -131,7 +132,7 @@ message Store { string connection_string = 1; int32 initial_backoff_ms = 2; int32 max_retries = 3; - // Optional. how often flush data to redis + // Optional. How often flush data to redis int32 flush_frequency_seconds = 4; } diff --git a/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/writer/BigQueryFeatureSink.java b/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/writer/BigQueryFeatureSink.java index ed5a7b020ee..2d55b308dd0 100644 --- a/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/writer/BigQueryFeatureSink.java +++ b/storage/connectors/bigquery/src/main/java/feast/storage/connectors/bigquery/writer/BigQueryFeatureSink.java @@ -16,6 +16,8 @@ */ package feast.storage.connectors.bigquery.writer; +import static com.google.common.base.Preconditions.checkArgument; + import com.google.api.services.bigquery.model.TableSchema; import com.google.auto.value.AutoValue; import com.google.cloud.bigquery.*; @@ -62,6 +64,12 @@ public abstract class BigQueryFeatureSink implements FeatureSink { * @return {@link BigQueryFeatureSink.Builder} */ public static FeatureSink fromConfig(BigQueryConfig config) { + checkArgument( + config.getWriteTriggeringFrequencySeconds() > 0, + "Invalid configuration: " + + "write_triggering_frequency_seconds in BigQueryConfig must be positive integer. " + + "Please fix that in your serving configuration."); + return BigQueryFeatureSink.builder() .setDatasetId(config.getDatasetId()) .setProjectId(config.getProjectId()) From 1bf2faf59eebc0acf7e9838c95282669e924a983 Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Sat, 1 Aug 2020 13:07:10 +0300 Subject: [PATCH 14/31] Dataflow runner options: disk type & streaming engine (#906) * diskType & streamingEnginer * edit infra docs --- .../core/job/dataflow/DataflowRunnerConfig.java | 12 ++++++++++-- core/src/main/resources/application.yml | 4 +++- .../job/dataflow/DataflowJobManagerTest.java | 2 +- .../job/dataflow/DataflowRunnerConfigTest.java | 17 +++++++++++------ infra/charts/feast/README.md | 2 +- infra/charts/feast/README.md.gotmpl | 2 +- infra/charts/feast/values-dataflow-runner.yaml | 4 +++- .../values-end-to-end-batch-dataflow.yaml | 2 +- protos/feast/core/Runner.proto | 7 ++++++- 9 files changed, 37 insertions(+), 15 deletions(-) diff --git a/core/src/main/java/feast/core/job/dataflow/DataflowRunnerConfig.java b/core/src/main/java/feast/core/job/dataflow/DataflowRunnerConfig.java index 804d258f46d..a87afa1bcf6 100644 --- a/core/src/main/java/feast/core/job/dataflow/DataflowRunnerConfig.java +++ b/core/src/main/java/feast/core/job/dataflow/DataflowRunnerConfig.java @@ -32,7 +32,7 @@ public class DataflowRunnerConfig extends RunnerConfig { public DataflowRunnerConfig(DataflowRunnerConfigOptions runnerConfigOptions) { this.project = runnerConfigOptions.getProject(); this.region = runnerConfigOptions.getRegion(); - this.zone = runnerConfigOptions.getZone(); + this.workerZone = runnerConfigOptions.getWorkerZone(); this.serviceAccount = runnerConfigOptions.getServiceAccount(); this.network = runnerConfigOptions.getNetwork(); this.subnetwork = runnerConfigOptions.getSubnetwork(); @@ -44,6 +44,8 @@ public DataflowRunnerConfig(DataflowRunnerConfigOptions runnerConfigOptions) { this.deadLetterTableSpec = runnerConfigOptions.getDeadLetterTableSpec(); this.diskSizeGb = runnerConfigOptions.getDiskSizeGb(); this.labels = runnerConfigOptions.getLabelsMap(); + this.enableStreamingEngine = runnerConfigOptions.getEnableStreamingEngine(); + this.workerDiskType = runnerConfigOptions.getWorkerDiskType(); validate(); } @@ -54,7 +56,7 @@ public DataflowRunnerConfig(DataflowRunnerConfigOptions runnerConfigOptions) { @NotBlank public String region; /* GCP availability zone for operations. */ - @NotBlank public String zone; + @NotBlank public String workerZone; /* Run the job as a specific service account, instead of the default GCE robot. */ public String serviceAccount; @@ -91,6 +93,12 @@ public DataflowRunnerConfig(DataflowRunnerConfigOptions runnerConfigOptions) { public Map labels; + /* If true job will be run on StreamingEngine instead of VMs */ + public Boolean enableStreamingEngine; + + /* Type of persistent disk to be used by workers */ + public String workerDiskType; + /** Validates Dataflow runner configuration options */ public void validate() { ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index d3d5b909522..69ed090a0f7 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -42,11 +42,13 @@ feast: options: project: my_gcp_project region: asia-east1 - zone: asia-east1-a + workerZone: asia-east1-a tempLocation: gs://bucket/tempLocation network: default subnetwork: regions/asia-east1/subnetworks/mysubnetwork maxNumWorkers: 1 + enableStreamingEngine: false + workerDiskType: compute.googleapis.com/projects/asia-east1-a/diskTypes/pd-ssd autoscalingAlgorithm: THROUGHPUT_BASED usePublicIps: false workerMachineType: n1-standard-1 diff --git a/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java b/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java index e110bf6ee22..3250c1d42be 100644 --- a/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java +++ b/core/src/test/java/feast/core/job/dataflow/DataflowJobManagerTest.java @@ -81,7 +81,7 @@ public void setUp() { Builder optionsBuilder = DataflowRunnerConfigOptions.newBuilder(); optionsBuilder.setProject("project"); optionsBuilder.setRegion("region"); - optionsBuilder.setZone("zone"); + optionsBuilder.setWorkerZone("zone"); optionsBuilder.setTempLocation("tempLocation"); optionsBuilder.setNetwork("network"); optionsBuilder.setSubnetwork("subnetwork"); diff --git a/core/src/test/java/feast/core/job/dataflow/DataflowRunnerConfigTest.java b/core/src/test/java/feast/core/job/dataflow/DataflowRunnerConfigTest.java index 925e48aec11..9c6b5a085c8 100644 --- a/core/src/test/java/feast/core/job/dataflow/DataflowRunnerConfigTest.java +++ b/core/src/test/java/feast/core/job/dataflow/DataflowRunnerConfigTest.java @@ -33,7 +33,9 @@ public void shouldConvertToPipelineArgs() throws IllegalAccessException { DataflowRunnerConfigOptions.newBuilder() .setProject("my-project") .setRegion("asia-east1") - .setZone("asia-east1-a") + .setWorkerZone("asia-east1-a") + .setEnableStreamingEngine(true) + .setWorkerDiskType("pd-ssd") .setTempLocation("gs://bucket/tempLocation") .setNetwork("default") .setSubnetwork("regions/asia-east1/subnetworks/mysubnetwork") @@ -52,7 +54,7 @@ public void shouldConvertToPipelineArgs() throws IllegalAccessException { Arrays.asList( "--project=my-project", "--region=asia-east1", - "--zone=asia-east1-a", + "--workerZone=asia-east1-a", "--tempLocation=gs://bucket/tempLocation", "--network=default", "--subnetwork=regions/asia-east1/subnetworks/mysubnetwork", @@ -62,7 +64,9 @@ public void shouldConvertToPipelineArgs() throws IllegalAccessException { "--workerMachineType=n1-standard-1", "--deadLetterTableSpec=project_id:dataset_id.table_id", "--diskSizeGb=100", - "--labels={\"key\":\"value\"}") + "--labels={\"key\":\"value\"}", + "--enableStreamingEngine=true", + "--workerDiskType=pd-ssd") .toArray(String[]::new); assertThat(args.size(), equalTo(expectedArgs.length)); assertThat(args, containsInAnyOrder(expectedArgs)); @@ -74,7 +78,7 @@ public void shouldIgnoreOptionalArguments() throws IllegalAccessException { DataflowRunnerConfigOptions.newBuilder() .setProject("my-project") .setRegion("asia-east1") - .setZone("asia-east1-a") + .setWorkerZone("asia-east1-a") .setTempLocation("gs://bucket/tempLocation") .setNetwork("default") .setSubnetwork("regions/asia-east1/subnetworks/mysubnetwork") @@ -90,7 +94,7 @@ public void shouldIgnoreOptionalArguments() throws IllegalAccessException { Arrays.asList( "--project=my-project", "--region=asia-east1", - "--zone=asia-east1-a", + "--workerZone=asia-east1-a", "--tempLocation=gs://bucket/tempLocation", "--network=default", "--subnetwork=regions/asia-east1/subnetworks/mysubnetwork", @@ -98,7 +102,8 @@ public void shouldIgnoreOptionalArguments() throws IllegalAccessException { "--autoscalingAlgorithm=THROUGHPUT_BASED", "--usePublicIps=false", "--workerMachineType=n1-standard-1", - "--labels={}") + "--labels={}", + "--enableStreamingEngine=false") .toArray(String[]::new); assertThat(args.size(), equalTo(expectedArgs.length)); assertThat(args, containsInAnyOrder(expectedArgs)); diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 0ec2e455d60..1a11ce1e294 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -241,7 +241,7 @@ feast-core: options: project: region: - zone: + workerZone: tempLocation: network: subnetwork: diff --git a/infra/charts/feast/README.md.gotmpl b/infra/charts/feast/README.md.gotmpl index d412aa01415..56023730fde 100644 --- a/infra/charts/feast/README.md.gotmpl +++ b/infra/charts/feast/README.md.gotmpl @@ -214,7 +214,7 @@ feast-core: options: project: region: - zone: + workerZone: tempLocation: network: subnetwork: diff --git a/infra/charts/feast/values-dataflow-runner.yaml b/infra/charts/feast/values-dataflow-runner.yaml index 0469a6349e2..56e51551970 100644 --- a/infra/charts/feast/values-dataflow-runner.yaml +++ b/infra/charts/feast/values-dataflow-runner.yaml @@ -19,10 +19,12 @@ feast-core: options: project: region: - zone: + workerZone: tempLocation: network: subnetwork: + enableStreamingEngine: false + workerDiskType: maxNumWorkers: 1 autoscalingAlgorithm: THROUGHPUT_BASED usePublicIps: false diff --git a/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml b/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml index 48231face69..377fa7a0aee 100644 --- a/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml +++ b/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml @@ -27,7 +27,7 @@ feast-core: options: project: $GCLOUD_PROJECT region: $GCLOUD_REGION - zone: $GCLOUD_REGION-a + workerZone: $GCLOUD_REGION-a tempLocation: gs://$TEMP_BUCKET/tempLocation network: $GCLOUD_NETWORK subnetwork: regions/$GCLOUD_REGION/subnetworks/$GCLOUD_SUBNET diff --git a/protos/feast/core/Runner.proto b/protos/feast/core/Runner.proto index 0684356f8d2..9bb4457d4f7 100644 --- a/protos/feast/core/Runner.proto +++ b/protos/feast/core/Runner.proto @@ -45,7 +45,7 @@ message DataflowRunnerConfigOptions { string region = 2; /* GCP availability zone for operations. */ - string zone = 3; + string workerZone = 3; /* Run the job as a specific service account, instead of the default GCE robot. */ string serviceAccount = 4; @@ -81,4 +81,9 @@ message DataflowRunnerConfigOptions { /* Disk size to use on each remote Compute Engine worker instance */ int32 diskSizeGb = 14; + /* Run job on Dataflow Streaming Engine instead of creating worker VMs */ + bool enableStreamingEngine = 15; + + /* Type of persistent disk to be used by workers */ + string workerDiskType = 16; } \ No newline at end of file From 8a750f4069069bcfdf8887df1ef5e3813188bdbd Mon Sep 17 00:00:00 2001 From: Oleksii Moskalenko Date: Sat, 1 Aug 2020 17:02:10 +0300 Subject: [PATCH 15/31] Redis sink flushes only rows that have more recent eventTimestamp (#913) * redis sink read then write * fix load tests * e2e * fix * specify feature ref * move test up in order * set project default * some docs * reorder e2e tests * reorder e2e tests * reorder e2e tests --- storage/connectors/redis/pom.xml | 7 +- .../redis/writer/BatchDoFnWithRedis.java | 88 +++ .../writer/RedisClusterIngestionClient.java | 51 +- .../redis/writer/RedisCustomIO.java | 175 +++-- .../redis/writer/RedisIngestionClient.java | 18 +- .../RedisStandaloneIngestionClient.java | 52 +- .../writer/RedisClusterFeatureSinkTest.java | 539 -------------- .../redis/writer/RedisFeatureSinkTest.java | 661 +++++++++++------- tests/e2e/redis/basic-ingest-redis-serving.py | 74 +- 9 files changed, 692 insertions(+), 973 deletions(-) create mode 100644 storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/BatchDoFnWithRedis.java delete mode 100644 storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java diff --git a/storage/connectors/redis/pom.xml b/storage/connectors/redis/pom.xml index d0e127cde86..ca6e8d42ad6 100644 --- a/storage/connectors/redis/pom.xml +++ b/storage/connectors/redis/pom.xml @@ -89,7 +89,12 @@ 4.12 test - + + org.apache.beam + beam-sdks-java-extensions-protobuf + ${org.apache.beam.version} + test + org.slf4j slf4j-simple diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/BatchDoFnWithRedis.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/BatchDoFnWithRedis.java new file mode 100644 index 00000000000..d6c83c3a540 --- /dev/null +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/BatchDoFnWithRedis.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.storage.connectors.redis.writer; + +import feast.storage.common.retry.Retriable; +import io.lettuce.core.RedisException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.function.Function; +import org.apache.beam.sdk.transforms.DoFn; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for redis-related DoFns. Assumes that operations will be batched. Prepares redisClient + * on DoFn.Setup stage and close it on DoFn.Teardown stage. + * + * @param + * @param + */ +public class BatchDoFnWithRedis extends DoFn { + private static final Logger log = LoggerFactory.getLogger(BatchDoFnWithRedis.class); + + private final RedisIngestionClient redisIngestionClient; + + BatchDoFnWithRedis(RedisIngestionClient redisIngestionClient) { + this.redisIngestionClient = redisIngestionClient; + } + + @Setup + public void setup() { + this.redisIngestionClient.setup(); + } + + @StartBundle + public void startBundle() { + try { + redisIngestionClient.connect(); + } catch (RedisException e) { + log.error("Connection to redis cannot be established: %s", e); + } + } + + void executeBatch(Function>> executor) + throws Exception { + this.redisIngestionClient + .getBackOffExecutor() + .execute( + new Retriable() { + @Override + public void execute() throws ExecutionException, InterruptedException { + if (!redisIngestionClient.isConnected()) { + redisIngestionClient.connect(); + } + + Iterable> futures = executor.apply(redisIngestionClient); + redisIngestionClient.sync(futures); + } + + @Override + public Boolean isExceptionRetriable(Exception e) { + return e instanceof RedisException; + } + + @Override + public void cleanUpAfterFailure() {} + }); + } + + @Teardown + public void teardown() { + redisIngestionClient.shutdown(); + } +} diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisClusterIngestionClient.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisClusterIngestionClient.java index 389db4be3ad..f36d70563e1 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisClusterIngestionClient.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisClusterIngestionClient.java @@ -20,7 +20,6 @@ import feast.proto.core.StoreProto; import feast.storage.common.retry.BackOffExecutor; import io.lettuce.core.LettuceFutures; -import io.lettuce.core.RedisFuture; import io.lettuce.core.RedisURI; import io.lettuce.core.cluster.RedisClusterClient; import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; @@ -28,6 +27,8 @@ import io.lettuce.core.codec.ByteArrayCodec; import java.util.Arrays; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.joda.time.Duration; @@ -39,7 +40,6 @@ public class RedisClusterIngestionClient implements RedisIngestionClient { private transient RedisClusterClient clusterClient; private StatefulRedisClusterConnection connection; private RedisAdvancedClusterAsyncCommands commands; - private List futures = Lists.newArrayList(); public RedisClusterIngestionClient(StoreProto.Store.RedisClusterConfig redisClusterConfig) { this.uriList = @@ -55,7 +55,6 @@ public RedisClusterIngestionClient(StoreProto.Store.RedisClusterConfig redisClus redisClusterConfig.getInitialBackoffMs() > 0 ? redisClusterConfig.getInitialBackoffMs() : 1; this.backOffExecutor = new BackOffExecutor(redisClusterConfig.getMaxRetries(), Duration.millis(backoffMs)); - this.clusterClient = RedisClusterClient.create(uriList); } @Override @@ -78,6 +77,10 @@ public void connect() { if (!isConnected()) { this.connection = clusterClient.connect(new ByteArrayCodec()); this.commands = connection.async(); + + // despite we're using async API client still flushes after each command by default + // which we don't want since we produce all commands in batches + this.commands.setAutoFlushCommands(false); } } @@ -87,46 +90,20 @@ public boolean isConnected() { } @Override - public void sync() { - try { - LettuceFutures.awaitAll(60, TimeUnit.SECONDS, futures.toArray(new RedisFuture[0])); - } finally { - futures.clear(); - } - } - - @Override - public void pexpire(byte[] key, Long expiryMillis) { - futures.add(commands.pexpire(key, expiryMillis)); - } - - @Override - public void append(byte[] key, byte[] value) { - futures.add(commands.append(key, value)); - } - - @Override - public void set(byte[] key, byte[] value) { - futures.add(commands.set(key, value)); - } + public void sync(Iterable> futures) { + this.connection.flushCommands(); - @Override - public void lpush(byte[] key, byte[] value) { - futures.add(commands.lpush(key, value)); - } - - @Override - public void rpush(byte[] key, byte[] value) { - futures.add(commands.rpush(key, value)); + LettuceFutures.awaitAll( + 60, TimeUnit.SECONDS, Lists.newArrayList(futures).toArray(new Future[0])); } @Override - public void sadd(byte[] key, byte[] value) { - futures.add(commands.sadd(key, value)); + public CompletableFuture set(byte[] key, byte[] value) { + return commands.set(key, value).toCompletableFuture(); } @Override - public void zadd(byte[] key, Long score, byte[] value) { - futures.add(commands.zadd(key, score, value)); + public CompletableFuture get(byte[] key) { + return commands.get(key).toCompletableFuture(); } } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java index f73c458d788..c42cff7bd0f 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisCustomIO.java @@ -17,8 +17,9 @@ package feast.storage.connectors.redis.writer; import com.google.common.collect.Iterators; -import com.google.common.collect.Lists; +import com.google.common.collect.Streams; import com.google.common.hash.Hashing; +import com.google.protobuf.InvalidProtocolBufferException; import feast.proto.core.FeatureSetProto.EntitySpec; import feast.proto.core.FeatureSetProto.FeatureSetSpec; import feast.proto.core.FeatureSetProto.FeatureSpec; @@ -29,20 +30,20 @@ import feast.proto.types.ValueProto; import feast.storage.api.writer.FailedElement; import feast.storage.api.writer.WriteResult; -import feast.storage.common.retry.Retriable; import feast.storage.connectors.redis.retriever.FeatureRowDecoder; -import io.lettuce.core.RedisException; import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ExecutionException; +import java.util.function.BinaryOperator; import java.util.stream.Collectors; import org.apache.beam.sdk.transforms.*; import org.apache.beam.sdk.transforms.windowing.*; import org.apache.beam.sdk.values.*; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.tuple.ImmutablePair; +import org.joda.time.DateTime; import org.joda.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -116,63 +117,23 @@ public void process(ProcessContext c) { redisWrite.get(failedInsertsTupleTag)); } - public static class WriteDoFn extends DoFn, FeatureRow> { - private PCollectionView>> featureSetSpecsView; - private RedisIngestionClient redisIngestionClient; + /** + * Writes batch of {@link FeatureRow} to Redis. Only latest values should be written. In order + * to guarantee that we first fetch all existing values (first batch operation), compare with + * current batch by eventTimestamp, and send to redis values (second batch operation) that were + * confirmed to be most recent. + */ + public static class WriteDoFn extends BatchDoFnWithRedis, FeatureRow> { + private final PCollectionView>> featureSetSpecsView; WriteDoFn( RedisIngestionClient redisIngestionClient, PCollectionView>> featureSetSpecsView) { - this.redisIngestionClient = redisIngestionClient; + super(redisIngestionClient); this.featureSetSpecsView = featureSetSpecsView; } - @Setup - public void setup() { - this.redisIngestionClient.setup(); - } - - @StartBundle - public void startBundle() { - try { - redisIngestionClient.connect(); - } catch (RedisException e) { - log.error("Connection to redis cannot be established ", e); - } - } - - private void executeBatch( - Iterable featureRows, Map featureSetSpecs) - throws Exception { - this.redisIngestionClient - .getBackOffExecutor() - .execute( - new Retriable() { - @Override - public void execute() throws ExecutionException, InterruptedException { - if (!redisIngestionClient.isConnected()) { - redisIngestionClient.connect(); - } - featureRows.forEach( - row -> { - redisIngestionClient.set( - getKey(row, featureSetSpecs.get(row.getFeatureSet())), - getValue(row, featureSetSpecs.get(row.getFeatureSet()))); - }); - redisIngestionClient.sync(); - } - - @Override - public Boolean isExceptionRetriable(Exception e) { - return e instanceof RedisException; - } - - @Override - public void cleanUpAfterFailure() {} - }); - } - private FailedElement toFailedElement( FeatureRow featureRow, Exception exception, String jobName) { return FailedElement.newBuilder() @@ -184,7 +145,7 @@ private FailedElement toFailedElement( .build(); } - private byte[] getKey(FeatureRow featureRow, FeatureSetSpec spec) { + private RedisKey getKey(FeatureRow featureRow, FeatureSetSpec spec) { List entityNames = spec.getEntitiesList().stream() .map(EntitySpec::getName) @@ -203,7 +164,7 @@ private byte[] getKey(FeatureRow featureRow, FeatureSetSpec spec) { for (String entityName : entityNames) { redisKeyBuilder.addEntities(entityFields.get(entityName)); } - return redisKeyBuilder.build().toByteArray(); + return redisKeyBuilder.build(); } /** @@ -212,7 +173,7 @@ private byte[] getKey(FeatureRow featureRow, FeatureSetSpec spec) { * names and not unsetting the feature set reference. {@link FeatureRowDecoder} is * rensponsible for reversing this "encoding" step. */ - private byte[] getValue(FeatureRow featureRow, FeatureSetSpec spec) { + private FeatureRow getValue(FeatureRow featureRow, FeatureSetSpec spec) { List featureNames = spec.getFeaturesList().stream().map(FeatureSpec::getName).collect(Collectors.toList()); @@ -250,35 +211,101 @@ private byte[] getValue(FeatureRow featureRow, FeatureSetSpec spec) { return FeatureRow.newBuilder() .setEventTimestamp(featureRow.getEventTimestamp()) .addAllFields(values) - .build() - .toByteArray(); + .build(); } @ProcessElement public void processElement(ProcessContext context) { - List featureRows = Lists.newArrayList(context.element().iterator()); - + List filteredFeatureRows = Collections.synchronizedList(new ArrayList<>()); Map latestSpecs = - context.sideInput(featureSetSpecsView).entrySet().stream() - .map(e -> ImmutablePair.of(e.getKey(), Iterators.getLast(e.getValue().iterator()))) - .collect(Collectors.toMap(ImmutablePair::getLeft, ImmutablePair::getRight)); + getLatestSpecs(context.sideInput(featureSetSpecsView)); + + Map deduplicatedRows = + deduplicateRows(context.element(), latestSpecs); try { - executeBatch(featureRows, latestSpecs); - featureRows.forEach(row -> context.output(successfulInsertsTag, row)); + executeBatch( + (redisIngestionClient) -> + deduplicatedRows.entrySet().stream() + .map( + entry -> + redisIngestionClient + .get(entry.getKey().toByteArray()) + .thenAccept( + currentValue -> { + FeatureRow newRow = entry.getValue(); + if (rowShouldBeWritten(newRow, currentValue)) { + filteredFeatureRows.add(newRow); + } + })) + .collect(Collectors.toList())); + + executeBatch( + redisIngestionClient -> + filteredFeatureRows.stream() + .map( + row -> + redisIngestionClient.set( + getKey(row, latestSpecs.get(row.getFeatureSet())).toByteArray(), + getValue(row, latestSpecs.get(row.getFeatureSet())) + .toByteArray())) + .collect(Collectors.toList())); + + filteredFeatureRows.forEach(row -> context.output(successfulInsertsTag, row)); } catch (Exception e) { - featureRows.forEach( - failedMutation -> { - FailedElement failedElement = - toFailedElement(failedMutation, e, context.getPipelineOptions().getJobName()); - context.output(failedInsertsTupleTag, failedElement); - }); + deduplicatedRows + .values() + .forEach( + failedMutation -> { + FailedElement failedElement = + toFailedElement( + failedMutation, e, context.getPipelineOptions().getJobName()); + context.output(failedInsertsTupleTag, failedElement); + }); } } - @Teardown - public void teardown() { - redisIngestionClient.shutdown(); + boolean rowShouldBeWritten(FeatureRow newRow, byte[] currentValue) { + if (currentValue == null) { + // nothing to compare with + return true; + } + FeatureRow currentRow; + try { + currentRow = FeatureRow.parseFrom(currentValue); + } catch (InvalidProtocolBufferException e) { + // definitely need to replace current value + return true; + } + + // check whether new row has later eventTimestamp + return new DateTime(currentRow.getEventTimestamp().getSeconds() * 1000L) + .isBefore(new DateTime(newRow.getEventTimestamp().getSeconds() * 1000L)); + } + + /** Deduplicate rows by key within batch. Keep only latest eventTimestamp */ + Map deduplicateRows( + Iterable rows, Map latestSpecs) { + Comparator byEventTimestamp = + Comparator.comparing(r -> r.getEventTimestamp().getSeconds()); + + FeatureRow identity = + FeatureRow.newBuilder() + .setEventTimestamp( + com.google.protobuf.Timestamp.newBuilder().setSeconds(-1).build()) + .build(); + + return Streams.stream(rows) + .collect( + Collectors.groupingBy( + row -> getKey(row, latestSpecs.get(row.getFeatureSet())), + Collectors.reducing(identity, BinaryOperator.maxBy(byEventTimestamp)))); + } + + Map getLatestSpecs(Map> specs) { + return specs.entrySet().stream() + .map(e -> ImmutablePair.of(e.getKey(), Iterators.getLast(e.getValue().iterator()))) + .collect(Collectors.toMap(ImmutablePair::getLeft, ImmutablePair::getRight)); } } } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisIngestionClient.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisIngestionClient.java index 6616a79aaca..e9b1a5dc445 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisIngestionClient.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisIngestionClient.java @@ -18,6 +18,8 @@ import feast.storage.common.retry.BackOffExecutor; import java.io.Serializable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; public interface RedisIngestionClient extends Serializable { @@ -31,19 +33,9 @@ public interface RedisIngestionClient extends Serializable { boolean isConnected(); - void sync(); + void sync(Iterable> futures); - void pexpire(byte[] key, Long expiryMillis); + CompletableFuture set(byte[] key, byte[] value); - void append(byte[] key, byte[] value); - - void set(byte[] key, byte[] value); - - void lpush(byte[] key, byte[] value); - - void rpush(byte[] key, byte[] value); - - void sadd(byte[] key, byte[] value); - - void zadd(byte[] key, Long score, byte[] value); + CompletableFuture get(byte[] key); } diff --git a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisStandaloneIngestionClient.java b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisStandaloneIngestionClient.java index 24591a1dc0f..f0a2054b9bd 100644 --- a/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisStandaloneIngestionClient.java +++ b/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/writer/RedisStandaloneIngestionClient.java @@ -21,12 +21,12 @@ import feast.storage.common.retry.BackOffExecutor; import io.lettuce.core.LettuceFutures; import io.lettuce.core.RedisClient; -import io.lettuce.core.RedisFuture; import io.lettuce.core.RedisURI; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.async.RedisAsyncCommands; import io.lettuce.core.codec.ByteArrayCodec; -import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import org.joda.time.Duration; @@ -38,7 +38,6 @@ public class RedisStandaloneIngestionClient implements RedisIngestionClient { private static final int DEFAULT_TIMEOUT = 2000; private StatefulRedisConnection connection; private RedisAsyncCommands commands; - private List futures = Lists.newArrayList(); public RedisStandaloneIngestionClient(StoreProto.Store.RedisConfig redisConfig) { this.host = redisConfig.getHost(); @@ -69,6 +68,9 @@ public void connect() { if (!isConnected()) { this.connection = this.redisclient.connect(new ByteArrayCodec()); this.commands = connection.async(); + + // enable pipelining of commands + this.commands.setAutoFlushCommands(false); } } @@ -78,48 +80,20 @@ public boolean isConnected() { } @Override - public void sync() { - // Wait for some time for futures to complete - // TODO: should this be configurable? - try { - LettuceFutures.awaitAll(60, TimeUnit.SECONDS, futures.toArray(new RedisFuture[0])); - } finally { - futures.clear(); - } - } - - @Override - public void pexpire(byte[] key, Long expiryMillis) { - commands.pexpire(key, expiryMillis); - } - - @Override - public void append(byte[] key, byte[] value) { - futures.add(commands.append(key, value)); - } - - @Override - public void set(byte[] key, byte[] value) { - futures.add(commands.set(key, value)); - } + public void sync(Iterable> futures) { + this.connection.flushCommands(); - @Override - public void lpush(byte[] key, byte[] value) { - futures.add(commands.lpush(key, value)); - } - - @Override - public void rpush(byte[] key, byte[] value) { - futures.add(commands.rpush(key, value)); + LettuceFutures.awaitAll( + 60, TimeUnit.SECONDS, Lists.newArrayList(futures).toArray(new Future[0])); } @Override - public void sadd(byte[] key, byte[] value) { - futures.add(commands.sadd(key, value)); + public CompletableFuture set(byte[] key, byte[] value) { + return commands.set(key, value).toCompletableFuture(); } @Override - public void zadd(byte[] key, Long score, byte[] value) { - futures.add(commands.zadd(key, score, value)); + public CompletableFuture get(byte[] key) { + return commands.get(key).toCompletableFuture(); } } diff --git a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java deleted file mode 100644 index 62ddfff3a7c..00000000000 --- a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisClusterFeatureSinkTest.java +++ /dev/null @@ -1,539 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * Copyright 2018-2019 The Feast Authors - * - * 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 - * - * https://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 feast.storage.connectors.redis.writer; - -import static feast.storage.common.testing.TestUtil.field; -import static feast.storage.common.testing.TestUtil.hash; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.protobuf.Timestamp; -import feast.common.models.FeatureSetReference; -import feast.proto.core.FeatureSetProto.EntitySpec; -import feast.proto.core.FeatureSetProto.FeatureSetSpec; -import feast.proto.core.FeatureSetProto.FeatureSpec; -import feast.proto.core.StoreProto.Store.RedisClusterConfig; -import feast.proto.storage.RedisProto.RedisKey; -import feast.proto.types.FeatureRowProto.FeatureRow; -import feast.proto.types.FieldProto.Field; -import feast.proto.types.ValueProto.Value; -import feast.proto.types.ValueProto.ValueType.Enum; -import io.lettuce.core.RedisURI; -import io.lettuce.core.cluster.RedisClusterClient; -import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; -import io.lettuce.core.cluster.api.sync.RedisClusterCommands; -import io.lettuce.core.codec.ByteArrayCodec; -import java.io.File; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.*; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import net.ishiis.redis.unit.RedisCluster; -import org.apache.beam.sdk.testing.PAssert; -import org.apache.beam.sdk.testing.TestPipeline; -import org.apache.beam.sdk.transforms.Count; -import org.apache.beam.sdk.transforms.Create; -import org.apache.beam.sdk.transforms.View; -import org.apache.beam.sdk.transforms.windowing.*; -import org.apache.beam.sdk.values.PCollection; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -public class RedisClusterFeatureSinkTest { - @Rule public transient TestPipeline p = TestPipeline.create(); - - private static String REDIS_CLUSTER_HOST = "localhost"; - private static int REDIS_CLUSTER_PORT1 = 6380; - private static int REDIS_CLUSTER_PORT2 = 6381; - private static int REDIS_CLUSTER_PORT3 = 6382; - private static String CONNECTION_STRING = "localhost:6380,localhost:6381,localhost:6382"; - private RedisCluster redisCluster; - private RedisClusterClient redisClusterClient; - private RedisClusterCommands redisClusterCommands; - - private RedisFeatureSink redisClusterFeatureSink; - - @Before - public void setUp() throws IOException { - redisCluster = new RedisCluster(REDIS_CLUSTER_PORT1, REDIS_CLUSTER_PORT2, REDIS_CLUSTER_PORT3); - redisCluster.start(); - redisClusterClient = - RedisClusterClient.create( - Arrays.asList( - RedisURI.create(REDIS_CLUSTER_HOST, REDIS_CLUSTER_PORT1), - RedisURI.create(REDIS_CLUSTER_HOST, REDIS_CLUSTER_PORT2), - RedisURI.create(REDIS_CLUSTER_HOST, REDIS_CLUSTER_PORT3))); - StatefulRedisClusterConnection connection = - redisClusterClient.connect(new ByteArrayCodec()); - redisClusterCommands = connection.sync(); - redisClusterCommands.setTimeout(java.time.Duration.ofMillis(600000)); - - FeatureSetSpec spec1 = - FeatureSetSpec.newBuilder() - .setName("fs") - .setProject("myproject") - .addEntities(EntitySpec.newBuilder().setName("entity").setValueType(Enum.INT64).build()) - .addFeatures( - FeatureSpec.newBuilder().setName("feature").setValueType(Enum.STRING).build()) - .build(); - - FeatureSetSpec spec2 = - FeatureSetSpec.newBuilder() - .setName("feature_set") - .setProject("myproject") - .addEntities( - EntitySpec.newBuilder() - .setName("entity_id_primary") - .setValueType(Enum.INT32) - .build()) - .addEntities( - EntitySpec.newBuilder() - .setName("entity_id_secondary") - .setValueType(Enum.STRING) - .build()) - .addFeatures( - FeatureSpec.newBuilder().setName("feature_1").setValueType(Enum.STRING).build()) - .addFeatures( - FeatureSpec.newBuilder().setName("feature_2").setValueType(Enum.INT64).build()) - .build(); - - Map specMap = - ImmutableMap.of( - FeatureSetReference.of("myproject", "fs", 1), spec1, - FeatureSetReference.of("myproject", "feature_set", 1), spec2); - RedisClusterConfig redisClusterConfig = - RedisClusterConfig.newBuilder() - .setConnectionString(CONNECTION_STRING) - .setInitialBackoffMs(2000) - .setMaxRetries(4) - .build(); - - redisClusterFeatureSink = - RedisFeatureSink.builder().setRedisClusterConfig(redisClusterConfig).build(); - redisClusterFeatureSink.prepareWrite(p.apply("Specs-1", Create.of(specMap))); - } - - static boolean deleteDirectory(File directoryToBeDeleted) { - File[] allContents = directoryToBeDeleted.listFiles(); - if (allContents != null) { - for (File file : allContents) { - deleteDirectory(file); - } - } - return directoryToBeDeleted.delete(); - } - - @After - public void teardown() { - redisCluster.stop(); - redisClusterClient.shutdown(); - deleteDirectory(new File(String.valueOf(Paths.get(System.getProperty("user.dir"), ".redis")))); - } - - @Test - public void shouldWriteToRedis() { - - HashMap kvs = new LinkedHashMap<>(); - kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 1, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("one"))) - .build()); - kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 2, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("two"))) - .build()); - - List featureRows = - ImmutableList.of( - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 1, Enum.INT64)) - .addFields(field("feature", "one", Enum.STRING)) - .build(), - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 2, Enum.INT64)) - .addFields(field("feature", "two", Enum.STRING)) - .build()); - - p.apply(Create.of(featureRows)).apply(redisClusterFeatureSink.writer()); - p.run(); - - kvs.forEach( - (key, value) -> { - byte[] actual = redisClusterCommands.get(key.toByteArray()); - assertThat(actual, equalTo(value.toByteArray())); - }); - } - - @Test(timeout = 15000) - public void shouldRetryFailConnection() throws InterruptedException { - HashMap kvs = new LinkedHashMap<>(); - kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 1, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("one"))) - .build()); - - List featureRows = - ImmutableList.of( - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 1, Enum.INT64)) - .addFields(field("feature", "one", Enum.STRING)) - .build()); - - PCollection failedElementCount = - p.apply(Create.of(featureRows)) - .apply(redisClusterFeatureSink.writer()) - .getFailedInserts() - .apply(Count.globally()); - - redisCluster.stop(); - final ScheduledThreadPoolExecutor redisRestartExecutor = new ScheduledThreadPoolExecutor(1); - ScheduledFuture scheduledRedisRestart = - redisRestartExecutor.schedule( - () -> { - redisCluster.start(); - }, - 3, - TimeUnit.SECONDS); - - PAssert.that(failedElementCount).containsInAnyOrder(0L); - p.run(); - scheduledRedisRestart.cancel(true); - - kvs.forEach( - (key, value) -> { - byte[] actual = redisClusterCommands.get(key.toByteArray()); - assertThat(actual, equalTo(value.toByteArray())); - }); - } - - @Test - public void shouldProduceFailedElementIfRetryExceeded() { - RedisClusterConfig redisClusterConfig = - RedisClusterConfig.newBuilder() - .setConnectionString(CONNECTION_STRING) - .setInitialBackoffMs(2000) - .setMaxRetries(1) - .build(); - - FeatureSetSpec spec1 = - FeatureSetSpec.newBuilder() - .setName("fs") - .setProject("myproject") - .addEntities(EntitySpec.newBuilder().setName("entity").setValueType(Enum.INT64).build()) - .addFeatures( - FeatureSpec.newBuilder().setName("feature").setValueType(Enum.STRING).build()) - .build(); - Map specMap = ImmutableMap.of("myproject/fs", spec1); - redisClusterFeatureSink = - RedisFeatureSink.builder() - .setRedisClusterConfig(redisClusterConfig) - .build() - .withSpecsView(p.apply("Specs-2", Create.of(specMap)).apply("View", View.asMultimap())); - - redisCluster.stop(); - - List featureRows = - ImmutableList.of( - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 1, Enum.INT64)) - .addFields(field("feature", "one", Enum.STRING)) - .build()); - - PCollection failedElementCount = - p.apply(Create.of(featureRows)) - .apply("modifiedSink", redisClusterFeatureSink.writer()) - .getFailedInserts() - .apply(Count.globally()); - - PAssert.that(failedElementCount).containsInAnyOrder(1L); - p.run(); - } - - @Test - public void shouldConvertRowWithDuplicateEntitiesToValidKey() { - - FeatureRow offendingRow = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(2))) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName("feature_2") - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); - - RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); - - FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName(hash("feature_2")) - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); - - p.apply(Create.of(offendingRow)).apply(redisClusterFeatureSink.writer()); - - p.run(); - - byte[] actual = redisClusterCommands.get(expectedKey.toByteArray()); - assertThat(actual, equalTo(expectedValue.toByteArray())); - } - - @Test - public void shouldConvertRowWithOutOfOrderFieldsToValidKey() { - FeatureRow offendingRow = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("feature_2") - .setValue(Value.newBuilder().setInt64Val(1001))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .build(); - - RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); - - List expectedFields = - Arrays.asList( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1")) - .build(), - Field.newBuilder() - .setName(hash("feature_2")) - .setValue(Value.newBuilder().setInt64Val(1001)) - .build()); - FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addAllFields(expectedFields) - .build(); - - p.apply(Create.of(offendingRow)).apply(redisClusterFeatureSink.writer()); - - p.run(); - - byte[] actual = redisClusterCommands.get(expectedKey.toByteArray()); - assertThat(actual, equalTo(expectedValue.toByteArray())); - } - - @Test - public void shouldMergeDuplicateFeatureFields() { - FeatureRow featureRowWithDuplicatedFeatureFields = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName("feature_2") - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); - - RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); - - FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName(hash("feature_2")) - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); - - p.apply(Create.of(featureRowWithDuplicatedFeatureFields)) - .apply(redisClusterFeatureSink.writer()); - - p.run(); - - byte[] actual = redisClusterCommands.get(expectedKey.toByteArray()); - assertThat(actual, equalTo(expectedValue.toByteArray())); - } - - @Test - public void shouldPopulateMissingFeatureValuesWithDefaultInstance() { - FeatureRow featureRowWithDuplicatedFeatureFields = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .build(); - - RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); - - FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder().setName(hash("feature_2")).setValue(Value.getDefaultInstance())) - .build(); - - p.apply(Create.of(featureRowWithDuplicatedFeatureFields)) - .apply(redisClusterFeatureSink.writer()); - - p.run(); - - byte[] actual = redisClusterCommands.get(expectedKey.toByteArray()); - assertThat(actual, equalTo(expectedValue.toByteArray())); - } -} diff --git a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java index 948b8d0fda4..12377fd1d1b 100644 --- a/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java +++ b/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/writer/RedisFeatureSinkTest.java @@ -20,63 +20,112 @@ import static feast.storage.common.testing.TestUtil.hash; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsCollectionWithSize.hasSize; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.protobuf.Message; import com.google.protobuf.Timestamp; import feast.common.models.FeatureSetReference; import feast.proto.core.FeatureSetProto.EntitySpec; import feast.proto.core.FeatureSetProto.FeatureSetSpec; import feast.proto.core.FeatureSetProto.FeatureSpec; import feast.proto.core.StoreProto; +import feast.proto.core.StoreProto.Store.RedisClusterConfig; import feast.proto.core.StoreProto.Store.RedisConfig; import feast.proto.storage.RedisProto.RedisKey; import feast.proto.types.FeatureRowProto.FeatureRow; import feast.proto.types.FieldProto.Field; import feast.proto.types.ValueProto.Value; import feast.proto.types.ValueProto.ValueType.Enum; +import io.lettuce.core.AbstractRedisClient; import io.lettuce.core.RedisClient; import io.lettuce.core.RedisURI; -import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisStringCommands; +import io.lettuce.core.cluster.RedisClusterClient; import io.lettuce.core.codec.ByteArrayCodec; -import java.io.IOException; import java.util.*; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import net.ishiis.redis.unit.Redis; +import net.ishiis.redis.unit.RedisCluster; +import net.ishiis.redis.unit.RedisServer; +import org.apache.beam.sdk.extensions.protobuf.ProtoCoder; import org.apache.beam.sdk.testing.PAssert; import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.sdk.testing.TestStream; import org.apache.beam.sdk.transforms.Count; import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.values.PCollection; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import redis.embedded.Redis; -import redis.embedded.RedisServer; +import org.junit.*; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +@RunWith(Parameterized.class) public class RedisFeatureSinkTest { @Rule public transient TestPipeline p = TestPipeline.create(); private static String REDIS_HOST = "localhost"; - private static int REDIS_PORT = 51234; - private Redis redis; - private RedisClient redisClient; - private RedisStringCommands sync; + private static int REDIS_PORT = 51233; + private static Integer[] REDIS_CLUSTER_PORTS = {6380, 6381, 6382}; + private RedisStringCommands sync; private RedisFeatureSink redisFeatureSink; private Map specMap; - @Before - public void setUp() throws IOException { - redis = new RedisServer(REDIS_PORT); - redis.start(); - redisClient = + @Parameterized.Parameters + public static Iterable backends() { + Redis redis = new RedisServer(REDIS_PORT); + RedisClient client = RedisClient.create(new RedisURI(REDIS_HOST, REDIS_PORT, java.time.Duration.ofMillis(2000))); - StatefulRedisConnection connection = redisClient.connect(new ByteArrayCodec()); - sync = connection.sync(); + + Redis redisCluster = new RedisCluster(REDIS_CLUSTER_PORTS); + RedisClusterClient clientCluster = + RedisClusterClient.create( + Lists.newArrayList(REDIS_CLUSTER_PORTS).stream() + .map(port -> RedisURI.create(REDIS_HOST, port)) + .collect(Collectors.toList())); + + StoreProto.Store.RedisConfig redisConfig = + StoreProto.Store.RedisConfig.newBuilder().setHost(REDIS_HOST).setPort(REDIS_PORT).build(); + + StoreProto.Store.RedisClusterConfig redisClusterConfig = + StoreProto.Store.RedisClusterConfig.newBuilder() + .setConnectionString( + Lists.newArrayList(REDIS_CLUSTER_PORTS).stream() + .map(port -> String.format("%s:%d", REDIS_HOST, port)) + .collect(Collectors.joining(","))) + .setInitialBackoffMs(2000) + .setMaxRetries(4) + .build(); + + return Arrays.asList( + new Object[] {redis, client, redisConfig}, + new Object[] {redisCluster, clientCluster, redisClusterConfig}); + } + + @Parameterized.Parameter(0) + public Redis redisServer; + + @Parameterized.Parameter(1) + public AbstractRedisClient redisClient; + + @Parameterized.Parameter(2) + public Message redisConfig; + + @Before + public void setUp() { + redisServer.start(); + + if (redisClient instanceof RedisClient) { + sync = ((RedisClient) redisClient).connect(new ByteArrayCodec()).sync(); + } else { + sync = ((RedisClusterClient) redisClient).connect(new ByteArrayCodec()).sync(); + } FeatureSetSpec spec1 = FeatureSetSpec.newBuilder() @@ -111,17 +160,42 @@ public void setUp() throws IOException { ImmutableMap.of( FeatureSetReference.of("myproject", "fs", 1), spec1, FeatureSetReference.of("myproject", "feature_set", 1), spec2); - StoreProto.Store.RedisConfig redisConfig = - StoreProto.Store.RedisConfig.newBuilder().setHost(REDIS_HOST).setPort(REDIS_PORT).build(); - redisFeatureSink = RedisFeatureSink.builder().setRedisConfig(redisConfig).build(); + RedisFeatureSink.Builder builder = RedisFeatureSink.builder(); + if (redisConfig instanceof RedisConfig) { + builder = builder.setRedisConfig((RedisConfig) redisConfig); + } else { + builder = builder.setRedisClusterConfig((RedisClusterConfig) redisConfig); + } + redisFeatureSink = builder.build(); redisFeatureSink.prepareWrite(p.apply("Specs-1", Create.of(specMap))); } @After - public void teardown() { - redisClient.shutdown(); - redis.stop(); + public void tearDown() { + if (redisServer.isActive()) { + redisServer.stop(); + } + } + + private RedisKey createRedisKey(String featureSetRef, Field... fields) { + return RedisKey.newBuilder() + .setFeatureSet(featureSetRef) + .addAllEntities(Lists.newArrayList(fields)) + .build(); + } + + private FeatureRow createFeatureRow(String featureSetRef, Timestamp timestamp, Field... fields) { + FeatureRow.Builder builder = FeatureRow.newBuilder(); + if (featureSetRef != null) { + builder.setFeatureSet(featureSetRef); + } + + if (timestamp != null) { + builder.setEventTimestamp(timestamp); + } + + return builder.addAllFields(Lists.newArrayList(fields)).build(); } @Test @@ -129,42 +203,26 @@ public void shouldWriteToRedis() { HashMap kvs = new LinkedHashMap<>(); kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 1, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("one"))) - .build()); + createRedisKey("myproject/fs", field("entity", 1, Enum.INT64)), + createFeatureRow( + null, Timestamp.getDefaultInstance(), field(hash("feature"), "one", Enum.STRING))); kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 2, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("two"))) - .build()); + createRedisKey("myproject/fs", field("entity", 2, Enum.INT64)), + createFeatureRow( + null, Timestamp.getDefaultInstance(), field(hash("feature"), "two", Enum.STRING))); List featureRows = ImmutableList.of( - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 1, Enum.INT64)) - .addFields(field("feature", "one", Enum.STRING)) - .build(), - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 2, Enum.INT64)) - .addFields(field("feature", "two", Enum.STRING)) - .build()); + createFeatureRow( + "myproject/fs", + null, + field("entity", 1, Enum.INT64), + field("feature", "one", Enum.STRING)), + createFeatureRow( + "myproject/fs", + null, + field("entity", 2, Enum.INT64), + field("feature", "two", Enum.STRING))); p.apply(Create.of(featureRows)).apply(redisFeatureSink.writer()); p.run(); @@ -176,7 +234,7 @@ public void shouldWriteToRedis() { }); } - @Test(timeout = 10000) + @Test(timeout = 30000) public void shouldRetryFailConnection() throws InterruptedException { RedisConfig redisConfig = RedisConfig.newBuilder() @@ -194,25 +252,17 @@ public void shouldRetryFailConnection() throws InterruptedException { HashMap kvs = new LinkedHashMap<>(); kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 1, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("one"))) - .build()); + createRedisKey("myproject/fs", field("entity", 1, Enum.INT64)), + createFeatureRow( + "", Timestamp.getDefaultInstance(), field(hash("feature"), "one", Enum.STRING))); List featureRows = ImmutableList.of( - FeatureRow.newBuilder() - .setFeatureSet("myproject/fs") - .addFields(field("entity", 1, Enum.INT64)) - .addFields(field("feature", "one", Enum.STRING)) - .build()); + createFeatureRow( + "myproject/fs", + null, + field("entity", 1, Enum.INT64), + field("feature", "one", Enum.STRING))); PCollection failedElementCount = p.apply(Create.of(featureRows)) @@ -220,12 +270,12 @@ public void shouldRetryFailConnection() throws InterruptedException { .getFailedInserts() .apply(Count.globally()); - redis.stop(); + redisServer.stop(); final ScheduledThreadPoolExecutor redisRestartExecutor = new ScheduledThreadPoolExecutor(1); ScheduledFuture scheduledRedisRestart = redisRestartExecutor.schedule( () -> { - redis.start(); + redisServer.start(); }, 3, TimeUnit.SECONDS); @@ -255,17 +305,9 @@ public void shouldProduceFailedElementIfRetryExceeded() { HashMap kvs = new LinkedHashMap<>(); kvs.put( - RedisKey.newBuilder() - .setFeatureSet("myproject/fs") - .addEntities(field("entity", 1, Enum.INT64)) - .build(), - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.getDefaultInstance()) - .addFields( - Field.newBuilder() - .setName(hash("feature")) - .setValue(Value.newBuilder().setStringVal("one"))) - .build()); + createRedisKey("myproject/fs", field("entity", 1, Enum.INT64)), + createFeatureRow( + "", Timestamp.getDefaultInstance(), field(hash("feature"), "one", Enum.STRING))); List featureRows = ImmutableList.of( @@ -281,7 +323,7 @@ public void shouldProduceFailedElementIfRetryExceeded() { .getFailedInserts() .apply(Count.globally()); - redis.stop(); + redisServer.stop(); PAssert.that(failedElementCount).containsInAnyOrder(1L); p.run(); } @@ -290,56 +332,27 @@ public void shouldProduceFailedElementIfRetryExceeded() { public void shouldConvertRowWithDuplicateEntitiesToValidKey() { FeatureRow offendingRow = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(2))) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName("feature_2") - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(10).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_primary", 2, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_1", "strValue1", Enum.STRING), + field("feature_2", 1001, Enum.INT64)); RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName(hash("feature_2")) - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(10).build(), + field(hash("feature_1"), "strValue1", Enum.STRING), + field(hash("feature_2"), 1001, Enum.INT64)); p.apply(Create.of(offendingRow)).apply(redisFeatureSink.writer()); @@ -352,55 +365,26 @@ public void shouldConvertRowWithDuplicateEntitiesToValidKey() { @Test public void shouldConvertRowWithOutOfOrderFieldsToValidKey() { FeatureRow offendingRow = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("feature_2") - .setValue(Value.newBuilder().setInt64Val(1001))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .build(); + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(10).build(), + field("entity_id_secondary", "a", Enum.STRING), + field("entity_id_primary", 1, Enum.INT32), + field("feature_2", 1001, Enum.INT64), + field("feature_1", "strValue1", Enum.STRING)); RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); - List expectedFields = - Arrays.asList( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1")) - .build(), - Field.newBuilder() - .setName(hash("feature_2")) - .setValue(Value.newBuilder().setInt64Val(1001)) - .build()); FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addAllFields(expectedFields) - .build(); + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(10).build(), + field(hash("feature_1"), "strValue1", Enum.STRING), + field(hash("feature_2"), 1001, Enum.INT64)); p.apply(Create.of(offendingRow)).apply(redisFeatureSink.writer()); @@ -413,56 +397,27 @@ public void shouldConvertRowWithOutOfOrderFieldsToValidKey() { @Test public void shouldMergeDuplicateFeatureFields() { FeatureRow featureRowWithDuplicatedFeatureFields = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName("feature_2") - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(10).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_2", 1001, Enum.INT64), + field("feature_1", "strValue1", Enum.STRING), + field("feature_1", "strValue1", Enum.STRING)); RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder() - .setName(hash("feature_2")) - .setValue(Value.newBuilder().setInt64Val(1001))) - .build(); + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(10).build(), + field(hash("feature_1"), "strValue1", Enum.STRING), + field(hash("feature_2"), 1001, Enum.INT64)); p.apply(Create.of(featureRowWithDuplicatedFeatureFields)).apply(redisFeatureSink.writer()); @@ -475,46 +430,28 @@ public void shouldMergeDuplicateFeatureFields() { @Test public void shouldPopulateMissingFeatureValuesWithDefaultInstance() { FeatureRow featureRowWithDuplicatedFeatureFields = - FeatureRow.newBuilder() - .setFeatureSet("myproject/feature_set") - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addFields( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .addFields( - Field.newBuilder() - .setName("feature_1") - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .build(); + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(10).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_1", "strValue1", Enum.STRING)); RedisKey expectedKey = - RedisKey.newBuilder() - .setFeatureSet("myproject/feature_set") - .addEntities( - Field.newBuilder() - .setName("entity_id_primary") - .setValue(Value.newBuilder().setInt32Val(1))) - .addEntities( - Field.newBuilder() - .setName("entity_id_secondary") - .setValue(Value.newBuilder().setStringVal("a"))) - .build(); + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); FeatureRow expectedValue = - FeatureRow.newBuilder() - .setEventTimestamp(Timestamp.newBuilder().setSeconds(10)) - .addFields( - Field.newBuilder() - .setName(hash("feature_1")) - .setValue(Value.newBuilder().setStringVal("strValue1"))) - .addFields( - Field.newBuilder().setName(hash("feature_2")).setValue(Value.getDefaultInstance())) - .build(); + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(10).build(), + field(hash("feature_1"), "strValue1", Enum.STRING), + Field.newBuilder() + .setName(hash("feature_2")) + .setValue(Value.getDefaultInstance()) + .build()); p.apply(Create.of(featureRowWithDuplicatedFeatureFields)).apply(redisFeatureSink.writer()); @@ -523,4 +460,206 @@ public void shouldPopulateMissingFeatureValuesWithDefaultInstance() { byte[] actual = sync.get(expectedKey.toByteArray()); assertThat(actual, equalTo(expectedValue.toByteArray())); } + + @Test + public void shouldDeduplicateRowsWithinBatch() { + TestStream featureRowTestStream = + TestStream.create(ProtoCoder.of(FeatureRow.class)) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(20).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_2", 111, Enum.INT32))) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(10).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_2", 222, Enum.INT32))) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.getDefaultInstance(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_2", 333, Enum.INT32))) + .advanceWatermarkToInfinity(); + + p.apply(featureRowTestStream).apply(redisFeatureSink.writer()); + p.run(); + + RedisKey expectedKey = + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); + + FeatureRow expectedValue = + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(20).build(), + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.getDefaultInstance()) + .build(), + field(hash("feature_2"), 111, Enum.INT32)); + + byte[] actual = sync.get(expectedKey.toByteArray()); + assertThat(actual, equalTo(expectedValue.toByteArray())); + } + + @Test + public void shouldWriteWithLatterTimestamp() { + TestStream featureRowTestStream = + TestStream.create(ProtoCoder.of(FeatureRow.class)) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(20).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_2", 111, Enum.INT32))) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(20).build(), + field("entity_id_primary", 2, Enum.INT32), + field("entity_id_secondary", "b", Enum.STRING), + field("feature_2", 222, Enum.INT32))) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(10).build(), + field("entity_id_primary", 3, Enum.INT32), + field("entity_id_secondary", "c", Enum.STRING), + field("feature_2", 333, Enum.INT32))) + .advanceWatermarkToInfinity(); + + RedisKey keyA = + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); + + RedisKey keyB = + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 2, Enum.INT32), + field("entity_id_secondary", "b", Enum.STRING)); + + RedisKey keyC = + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 3, Enum.INT32), + field("entity_id_secondary", "c", Enum.STRING)); + + sync.set( + keyA.toByteArray(), + createFeatureRow("", Timestamp.newBuilder().setSeconds(30).build()).toByteArray()); + + sync.set( + keyB.toByteArray(), + createFeatureRow("", Timestamp.newBuilder().setSeconds(10).build()).toByteArray()); + + sync.set( + keyC.toByteArray(), + createFeatureRow("", Timestamp.newBuilder().setSeconds(10).build()).toByteArray()); + + p.apply(featureRowTestStream).apply(redisFeatureSink.writer()); + p.run(); + + assertThat( + sync.get(keyA.toByteArray()), + equalTo(createFeatureRow("", Timestamp.newBuilder().setSeconds(30).build()).toByteArray())); + + assertThat( + sync.get(keyB.toByteArray()), + equalTo( + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(20).build(), + Field.newBuilder() + .setName(hash("feature_1")) + .setValue(Value.getDefaultInstance()) + .build(), + field(hash("feature_2"), 222, Enum.INT32)) + .toByteArray())); + + assertThat( + sync.get(keyC.toByteArray()), + equalTo(createFeatureRow("", Timestamp.newBuilder().setSeconds(10).build()).toByteArray())); + } + + @Test + public void shouldOverwriteInvalidRows() { + TestStream featureRowTestStream = + TestStream.create(ProtoCoder.of(FeatureRow.class)) + .addElements( + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(20).build(), + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_1", "text", Enum.STRING), + field("feature_2", 111, Enum.INT32))) + .advanceWatermarkToInfinity(); + + RedisKey expectedKey = + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", 1, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)); + + sync.set(expectedKey.toByteArray(), "some-invalid-data".getBytes()); + + p.apply(featureRowTestStream).apply(redisFeatureSink.writer()); + p.run(); + + FeatureRow expectedValue = + createFeatureRow( + "", + Timestamp.newBuilder().setSeconds(20).build(), + field(hash("feature_1"), "text", Enum.STRING), + field(hash("feature_2"), 111, Enum.INT32)); + + byte[] actual = sync.get(expectedKey.toByteArray()); + assertThat(actual, equalTo(expectedValue.toByteArray())); + } + + @Test + public void loadTest() { + List rows = + IntStream.range(0, 10000) + .mapToObj( + i -> + createFeatureRow( + "myproject/feature_set", + Timestamp.newBuilder().setSeconds(20).build(), + field("entity_id_primary", i, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING), + field("feature_1", "text", Enum.STRING), + field("feature_2", 111, Enum.INT32))) + .collect(Collectors.toList()); + + p.apply(Create.of(rows)).apply(redisFeatureSink.writer()); + p.run(); + + List outcome = + IntStream.range(0, 10000) + .mapToObj( + i -> + createRedisKey( + "myproject/feature_set", + field("entity_id_primary", i, Enum.INT32), + field("entity_id_secondary", "a", Enum.STRING)) + .toByteArray()) + .map(sync::get) + .collect(Collectors.toList()); + + assertThat(outcome, hasSize(10000)); + assertThat("All rows were stored", outcome.stream().allMatch(Objects::nonNull)); + } } diff --git a/tests/e2e/redis/basic-ingest-redis-serving.py b/tests/e2e/redis/basic-ingest-redis-serving.py index 1fcae69ed3b..c1e25508d44 100644 --- a/tests/e2e/redis/basic-ingest-redis-serving.py +++ b/tests/e2e/redis/basic-ingest-redis-serving.py @@ -4,7 +4,7 @@ import tempfile import time import uuid -from datetime import datetime +from datetime import datetime, timedelta import grpc import numpy as np @@ -821,6 +821,8 @@ def all_types_dataframe(): @pytest.mark.timeout(45) @pytest.mark.run(order=20) def test_all_types_register_feature_set_success(client): + client.set_project(PROJECT_NAME) + all_types_fs_expected = FeatureSet( name="all_types", entities=[Entity(name="user_id", dtype=ValueType.INT64)], @@ -930,9 +932,11 @@ def try_get_features(): @pytest.mark.timeout(300) -@pytest.mark.run(order=29) +@pytest.mark.run(order=35) def test_all_types_ingest_jobs(client, all_types_dataframe): # list ingestion jobs given featureset + client.set_project(PROJECT_NAME) + all_types_fs = client.get_feature_set(name="all_types") ingest_jobs = client.list_ingest_jobs( feature_set_ref=FeatureSetRef.from_feature_set(all_types_fs) @@ -990,7 +994,7 @@ def large_volume_dataframe(): @pytest.mark.timeout(45) -@pytest.mark.run(order=30) +@pytest.mark.run(order=40) def test_large_volume_register_feature_set_success(client): cust_trans_fs_expected = FeatureSet.from_yaml( f"{DIR_PATH}/large_volume/cust_trans_large_fs.yaml" @@ -1016,7 +1020,7 @@ def test_large_volume_register_feature_set_success(client): @pytest.mark.timeout(300) -@pytest.mark.run(order=31) +@pytest.mark.run(order=41) def test_large_volume_ingest_success(client, large_volume_dataframe): # Get large volume feature set cust_trans_fs = client.get_feature_set(name="customer_transactions_large") @@ -1026,7 +1030,7 @@ def test_large_volume_ingest_success(client, large_volume_dataframe): @pytest.mark.timeout(90) -@pytest.mark.run(order=32) +@pytest.mark.run(order=42) def test_large_volume_retrieve_online_success(client, large_volume_dataframe): # Poll serving for feature values until the correct values are returned feature_refs = [ @@ -1112,7 +1116,7 @@ def all_types_parquet_file(): @pytest.mark.timeout(300) -@pytest.mark.run(order=40) +@pytest.mark.run(order=50) def test_all_types_parquet_register_feature_set_success(client): # Load feature set from file all_types_parquet_expected = FeatureSet.from_yaml( @@ -1140,7 +1144,7 @@ def test_all_types_parquet_register_feature_set_success(client): @pytest.mark.timeout(600) -@pytest.mark.run(order=41) +@pytest.mark.run(order=51) def test_all_types_infer_register_ingest_file_success(client, all_types_parquet_file): # Get feature set all_types_fs = client.get_feature_set(name="all_types_parquet") @@ -1150,7 +1154,7 @@ def test_all_types_infer_register_ingest_file_success(client, all_types_parquet_ @pytest.mark.timeout(200) -@pytest.mark.run(order=50) +@pytest.mark.run(order=60) def test_list_entities_and_features(client): customer_entity = Entity("customer_id", ValueType.INT64) driver_entity = Entity("driver_id", ValueType.INT64) @@ -1225,7 +1229,7 @@ def test_list_entities_and_features(client): @pytest.mark.timeout(900) -@pytest.mark.run(order=60) +@pytest.mark.run(order=70) def test_sources_deduplicate_ingest_jobs(client): source = KafkaSource("localhost:9092", "feast-features") alt_source = KafkaSource("localhost:9092", "feast-data") @@ -1273,6 +1277,58 @@ def get_running_jobs(): time.sleep(1) +@pytest.mark.run(order=30) +def test_sink_writes_only_recent_rows(client): + client.set_project("default") + + feature_refs = ["driver:rating", "driver:cost"] + + later_df = basic_dataframe( + entities=["driver_id"], + features=["rating", "cost"], + ingest_time=datetime.utcnow(), + n_size=5, + ) + + earlier_df = basic_dataframe( + entities=["driver_id"], + features=["rating", "cost"], + ingest_time=datetime.utcnow() - timedelta(minutes=5), + n_size=5, + ) + + def try_get_features(): + response = client.get_online_features( + entity_rows=[ + GetOnlineFeaturesRequest.EntityRow( + fields={"driver_id": Value(int64_val=later_df.iloc[0]["driver_id"])} + ) + ], + feature_refs=feature_refs, + ) # type: GetOnlineFeaturesResponse + is_ok = all( + [check_online_response(ref, later_df, response) for ref in feature_refs] + ) + return response, is_ok + + # test compaction within batch + client.ingest("driver", pd.concat([earlier_df, later_df])) + wait_retry_backoff( + retry_fn=try_get_features, + timeout_secs=90, + timeout_msg="Timed out trying to get online feature values", + ) + + # test read before write + client.ingest("driver", earlier_df) + time.sleep(10) + wait_retry_backoff( + retry_fn=try_get_features, + timeout_secs=90, + timeout_msg="Timed out trying to get online feature values", + ) + + # TODO: rewrite these using python SDK once the labels are implemented there class TestsBasedOnGrpc: GRPC_CONNECTION_TIMEOUT = 3 From 5893cd7028b219ef1218f2e57183e138b6d34cff Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Sun, 2 Aug 2020 12:16:31 +0800 Subject: [PATCH 16/31] Update CHANGELOG for release v0.6.2 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d86eac1cb8..a59404c2c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [v0.6.2](https://github.com/feast-dev/feast/tree/v0.6.2) (2020-08-02) +[Full Changelog](https://github.com/feast-dev/feast/compare/v0.6.1...v0.6.2) + +**Implemented enhancements:** + +- Redis sink flushes only rows that have more recent eventTimestamp [\#913](https://github.com/feast-dev/feast/pull/913) ([pyalex](https://github.com/pyalex)) +- Dataflow runner options: disk type & streaming engine [\#906](https://github.com/feast-dev/feast/pull/906) ([pyalex](https://github.com/pyalex)) +- Add Structured Audit Logging [\#891](https://github.com/feast-dev/feast/pull/891) ([mrzzy](https://github.com/mrzzy)) +- Add Authentication and Authorization for feast serving [\#865](https://github.com/feast-dev/feast/pull/865) ([jmelinav](https://github.com/jmelinav)) +- Throw more informative exception when write\_triggering\_frequency\_seconds is missing [\#917](https://github.com/feast-dev/feast/pull/917) ([pyalex](https://github.com/pyalex)) +- Add caching to authorization [\#884](https://github.com/feast-dev/feast/pull/884) ([jmelinav](https://github.com/jmelinav)) +- Add Auth header [\#885](https://github.com/feast-dev/feast/pull/885) ([AnujaVane](https://github.com/AnujaVane)) + +**Fixed bugs:** + +- Fix Online Serving unable to retrieve feature data after Feature Set update. [\#908](https://github.com/feast-dev/feast/pull/908) ([mrzzy](https://github.com/mrzzy)) +- Exclude dependencies signatures from IngestionJob package [\#879](https://github.com/feast-dev/feast/pull/879) ([pyalex](https://github.com/pyalex)) +- Fix Python SDK ingestion for featureset name that exist in multiple projects [\#868](https://github.com/feast-dev/feast/pull/868) ([terryyylim](https://github.com/terryyylim)) +- Backport delay in Redis acknowledgement of spec [\#915](https://github.com/feast-dev/feast/pull/915) ([woop](https://github.com/woop)) + +**Merged pull requests:** + +- Upgrade Feast dependencies [\#876](https://github.com/feast-dev/feast/pull/876) ([pyalex](https://github.com/pyalex)) + ## [v0.6.1](https://github.com/feast-dev/feast/tree/v0.6.1) (2020-07-17) [Full Changelog](https://github.com/feast-dev/feast/compare/v0.6.0...v0.6.1) From 80d054fed87f8b83956b41be28eec9d602a94859 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Sun, 2 Aug 2020 12:21:35 +0800 Subject: [PATCH 17/31] Remove -SNAPSHOT from release version for v0.6.2 --- datatypes/java/README.md | 2 +- docs/contributing/development-guide.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 10 +++++----- infra/charts/feast/charts/feast-core/Chart.yaml | 2 +- infra/charts/feast/charts/feast-core/README.md | 2 +- infra/charts/feast/charts/feast-jupyter/Chart.yaml | 2 +- infra/charts/feast/charts/feast-jupyter/README.md | 2 +- infra/charts/feast/charts/feast-serving/Chart.yaml | 2 +- infra/charts/feast/charts/feast-serving/README.md | 2 +- infra/charts/feast/requirements.yaml | 8 ++++---- pom.xml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/datatypes/java/README.md b/datatypes/java/README.md index f87a50f1bd1..ca793f09d6b 100644 --- a/datatypes/java/README.md +++ b/datatypes/java/README.md @@ -16,7 +16,7 @@ Dependency Coordinates dev.feast datatypes-java - 0.4.0-SNAPSHOT + 0.6.2 ``` diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md index 964e667009a..095dbf2c4e9 100644 --- a/docs/contributing/development-guide.md +++ b/docs/contributing/development-guide.md @@ -181,7 +181,7 @@ grpc_cli call localhost:6566 GetFeastServingInfo '' ```text connecting to localhost:6566 -version: "0.4.2-SNAPSHOT" +version: "0.6.2" type: FEAST_SERVING_TYPE_ONLINE Rpc succeeded with OK status diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index 58379789127..a4d8f163d88 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feature store for machine learning. name: feast -version: 0.6.2-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 1a11ce1e294..5971e2ad430 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -1,7 +1,7 @@ feast ===== -Feature store for machine learning. Current chart version is `0.6.2-SNAPSHOT` +Feature store for machine learning. Current chart version is `0.6.2` ## TL;DR; @@ -32,10 +32,10 @@ This chart install Feast deployment on a Kubernetes cluster using the [Helm](htt | Repository | Name | Version | |------------|------|---------| -| | feast-core | 0.6.2-SNAPSHOT | -| | feast-jupyter | 0.6.2-SNAPSHOT | -| | feast-serving | 0.6.2-SNAPSHOT | -| | feast-serving | 0.6.2-SNAPSHOT | +| | feast-core | 0.6.2 | +| | feast-jupyter | 0.6.2 | +| | feast-serving | 0.6.2 | +| | feast-serving | 0.6.2 | | | prometheus-statsd-exporter | 0.1.2 | | https://kubernetes-charts-incubator.storage.googleapis.com/ | kafka | 0.20.8 | | https://kubernetes-charts.storage.googleapis.com/ | grafana | 5.0.5 | diff --git a/infra/charts/feast/charts/feast-core/Chart.yaml b/infra/charts/feast/charts/feast-core/Chart.yaml index 40517cfa233..821c5e5dab4 100644 --- a/infra/charts/feast/charts/feast-core/Chart.yaml +++ b/infra/charts/feast/charts/feast-core/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Core registers feature specifications and manage ingestion jobs. name: feast-core -version: 0.6.2-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-core/README.md b/infra/charts/feast/charts/feast-core/README.md index cbf4d33e8af..40b0b155e9c 100644 --- a/infra/charts/feast/charts/feast-core/README.md +++ b/infra/charts/feast/charts/feast-core/README.md @@ -2,7 +2,7 @@ feast-core ========== Feast Core registers feature specifications and manage ingestion jobs. -Current chart version is `0.6.2-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/charts/feast-jupyter/Chart.yaml b/infra/charts/feast/charts/feast-jupyter/Chart.yaml index d284302ee28..3dac9846ee6 100644 --- a/infra/charts/feast/charts/feast-jupyter/Chart.yaml +++ b/infra/charts/feast/charts/feast-jupyter/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Jupyter provides a Jupyter server with pre-installed Feast SDK name: feast-jupyter -version: 0.6.2-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-jupyter/README.md b/infra/charts/feast/charts/feast-jupyter/README.md index ee89fbec871..df356b28502 100644 --- a/infra/charts/feast/charts/feast-jupyter/README.md +++ b/infra/charts/feast/charts/feast-jupyter/README.md @@ -2,7 +2,7 @@ feast-jupyter ============= Feast Jupyter provides a Jupyter server with pre-installed Feast SDK -Current chart version is `0.6.2-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/charts/feast-serving/Chart.yaml b/infra/charts/feast/charts/feast-serving/Chart.yaml index 78f81b5e531..2a4ff2b82c6 100644 --- a/infra/charts/feast/charts/feast-serving/Chart.yaml +++ b/infra/charts/feast/charts/feast-serving/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Serving serves low-latency latest features and historical batch features. name: feast-serving -version: 0.6.2-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-serving/README.md b/infra/charts/feast/charts/feast-serving/README.md index 3d045f0ae87..7e14e2ebe02 100644 --- a/infra/charts/feast/charts/feast-serving/README.md +++ b/infra/charts/feast/charts/feast-serving/README.md @@ -2,7 +2,7 @@ feast-serving ============= Feast Serving serves low-latency latest features and historical batch features. -Current chart version is `0.6.2-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index b46025b31f5..835b281cd33 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,17 +1,17 @@ dependencies: - name: feast-core - version: 0.6.2-SNAPSHOT + version: 0.6.2 condition: feast-core.enabled - name: feast-serving alias: feast-online-serving - version: 0.6.2-SNAPSHOT + version: 0.6.2 condition: feast-online-serving.enabled - name: feast-serving alias: feast-batch-serving - version: 0.6.2-SNAPSHOT + version: 0.6.2 condition: feast-batch-serving.enabled - name: feast-jupyter - version: 0.6.2-SNAPSHOT + version: 0.6.2 condition: feast-jupyter.enabled - name: postgresql version: 8.6.1 diff --git a/pom.xml b/pom.xml index 826bfa02ebe..b2624657e48 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - 0.6.2-SNAPSHOT + 0.6.2 https://github.com/feast-dev/feast UTF-8 From d7f90ae122405ecbd9844c42930198a6f57b62f2 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Sun, 2 Aug 2020 12:29:15 +0800 Subject: [PATCH 18/31] Remove entry in changelog that is not part of v0.6.2 --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a59404c2c1e..09d0d2cb1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ **Fixed bugs:** - Fix Online Serving unable to retrieve feature data after Feature Set update. [\#908](https://github.com/feast-dev/feast/pull/908) ([mrzzy](https://github.com/mrzzy)) -- Exclude dependencies signatures from IngestionJob package [\#879](https://github.com/feast-dev/feast/pull/879) ([pyalex](https://github.com/pyalex)) - Fix Python SDK ingestion for featureset name that exist in multiple projects [\#868](https://github.com/feast-dev/feast/pull/868) ([terryyylim](https://github.com/terryyylim)) - Backport delay in Redis acknowledgement of spec [\#915](https://github.com/feast-dev/feast/pull/915) ([woop](https://github.com/woop)) From 9f2f5da218597ebab2b499a8b1e7b49d2481f31a Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Sun, 2 Aug 2020 12:42:08 +0800 Subject: [PATCH 19/31] Change versions to 0.6.3-SNAPSHOT --- datatypes/java/README.md | 2 +- docs/contributing/development-guide.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 10 +++++----- infra/charts/feast/charts/feast-core/Chart.yaml | 2 +- infra/charts/feast/charts/feast-core/README.md | 2 +- infra/charts/feast/charts/feast-jupyter/Chart.yaml | 2 +- infra/charts/feast/charts/feast-jupyter/README.md | 2 +- infra/charts/feast/charts/feast-serving/Chart.yaml | 2 +- infra/charts/feast/charts/feast-serving/README.md | 2 +- infra/charts/feast/requirements.yaml | 8 ++++---- pom.xml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/datatypes/java/README.md b/datatypes/java/README.md index ca793f09d6b..7a0fffda491 100644 --- a/datatypes/java/README.md +++ b/datatypes/java/README.md @@ -16,7 +16,7 @@ Dependency Coordinates dev.feast datatypes-java - 0.6.2 + 0.6.3-SNAPSHOT ``` diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md index 095dbf2c4e9..28e5ca42e7a 100644 --- a/docs/contributing/development-guide.md +++ b/docs/contributing/development-guide.md @@ -181,7 +181,7 @@ grpc_cli call localhost:6566 GetFeastServingInfo '' ```text connecting to localhost:6566 -version: "0.6.2" +version: "0.6.3-SNAPSHOT" type: FEAST_SERVING_TYPE_ONLINE Rpc succeeded with OK status diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index a4d8f163d88..e7921416350 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feature store for machine learning. name: feast -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 5971e2ad430..c33ea0fedc6 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -1,7 +1,7 @@ feast ===== -Feature store for machine learning. Current chart version is `0.6.2` +Feature store for machine learning. Current chart version is `0.6.3-SNAPSHOT` ## TL;DR; @@ -32,10 +32,10 @@ This chart install Feast deployment on a Kubernetes cluster using the [Helm](htt | Repository | Name | Version | |------------|------|---------| -| | feast-core | 0.6.2 | -| | feast-jupyter | 0.6.2 | -| | feast-serving | 0.6.2 | -| | feast-serving | 0.6.2 | +| | feast-core | 0.6.3-SNAPSHOT | +| | feast-jupyter | 0.6.3-SNAPSHOT | +| | feast-serving | 0.6.3-SNAPSHOT | +| | feast-serving | 0.6.3-SNAPSHOT | | | prometheus-statsd-exporter | 0.1.2 | | https://kubernetes-charts-incubator.storage.googleapis.com/ | kafka | 0.20.8 | | https://kubernetes-charts.storage.googleapis.com/ | grafana | 5.0.5 | diff --git a/infra/charts/feast/charts/feast-core/Chart.yaml b/infra/charts/feast/charts/feast-core/Chart.yaml index 821c5e5dab4..c7c22d099cd 100644 --- a/infra/charts/feast/charts/feast-core/Chart.yaml +++ b/infra/charts/feast/charts/feast-core/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Core registers feature specifications and manage ingestion jobs. name: feast-core -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-core/README.md b/infra/charts/feast/charts/feast-core/README.md index 40b0b155e9c..6067e0176a4 100644 --- a/infra/charts/feast/charts/feast-core/README.md +++ b/infra/charts/feast/charts/feast-core/README.md @@ -2,7 +2,7 @@ feast-core ========== Feast Core registers feature specifications and manage ingestion jobs. -Current chart version is `0.6.2` +Current chart version is `0.6.3-SNAPSHOT` diff --git a/infra/charts/feast/charts/feast-jupyter/Chart.yaml b/infra/charts/feast/charts/feast-jupyter/Chart.yaml index 3dac9846ee6..5f1589a42c2 100644 --- a/infra/charts/feast/charts/feast-jupyter/Chart.yaml +++ b/infra/charts/feast/charts/feast-jupyter/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Jupyter provides a Jupyter server with pre-installed Feast SDK name: feast-jupyter -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-jupyter/README.md b/infra/charts/feast/charts/feast-jupyter/README.md index df356b28502..18e262eb368 100644 --- a/infra/charts/feast/charts/feast-jupyter/README.md +++ b/infra/charts/feast/charts/feast-jupyter/README.md @@ -2,7 +2,7 @@ feast-jupyter ============= Feast Jupyter provides a Jupyter server with pre-installed Feast SDK -Current chart version is `0.6.2` +Current chart version is `0.6.3-SNAPSHOT` diff --git a/infra/charts/feast/charts/feast-serving/Chart.yaml b/infra/charts/feast/charts/feast-serving/Chart.yaml index 2a4ff2b82c6..5100c13b42c 100644 --- a/infra/charts/feast/charts/feast-serving/Chart.yaml +++ b/infra/charts/feast/charts/feast-serving/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Serving serves low-latency latest features and historical batch features. name: feast-serving -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-serving/README.md b/infra/charts/feast/charts/feast-serving/README.md index 7e14e2ebe02..edeab143899 100644 --- a/infra/charts/feast/charts/feast-serving/README.md +++ b/infra/charts/feast/charts/feast-serving/README.md @@ -2,7 +2,7 @@ feast-serving ============= Feast Serving serves low-latency latest features and historical batch features. -Current chart version is `0.6.2` +Current chart version is `0.6.3-SNAPSHOT` diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index 835b281cd33..4a76af2fb16 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,17 +1,17 @@ dependencies: - name: feast-core - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-core.enabled - name: feast-serving alias: feast-online-serving - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-online-serving.enabled - name: feast-serving alias: feast-batch-serving - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-batch-serving.enabled - name: feast-jupyter - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-jupyter.enabled - name: postgresql version: 8.6.1 diff --git a/pom.xml b/pom.xml index b2624657e48..9a3f1e4b4e3 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - 0.6.2 + 0.6.3-SNAPSHOT https://github.com/feast-dev/feast UTF-8 From 9728b7408aa1cbb2634c5aef94e7748bccaac159 Mon Sep 17 00:00:00 2001 From: Willem Pienaar <6728866+woop@users.noreply.github.com> Date: Mon, 3 Aug 2020 00:21:09 +0800 Subject: [PATCH 20/31] Backport CI housekeeping to 0.6 (#923) * Backport housekeeping of Feast CI, docker compose, and notebooks (#916) * Add caching to core and serving builds * Fix flaky e2e tests * Simplify docker-compose setup * Add kafka broker to docker compose test * Rename batch to historical * Update troubleshooting.md * Fix casing for FEAST_HISTORICAL_SERVING_URL * Remove f-string prefix * Fix missing key in docker compose setup * Fix documentation typos * Add jupyter to load test * Add docker compose back to load-test * Clean up FEAST_VERSION in load test * Fix artifact output for load test * Revert e2e test order * Add missing makefile commands from backport * Remove missing tests --- .github/workflows/code_standards.yaml | 42 ----- .github/workflows/complete.yml | 111 ++++++++++++++ .github/workflows/docker_compose_tests.yml | 15 -- .github/workflows/master_only.yml | 60 ++++++++ .github/workflows/unit_tests.yml | 45 ------ Makefile | 11 +- docs/administration/troubleshooting.md | 18 +-- .../deploying-feast/docker-compose.md | 4 +- docs/installation/docker-compose.md | 2 +- docs/installation/gke.md | 2 +- docs/user-guide/feature-retrieval.md | 2 +- examples/basic/basic.ipynb | 14 +- ... Prediction (with Feast and XGBoost).ipynb | 4 +- .../feast-jupyter/templates/deployment.yaml | 2 +- .../tests/test-feast-batch-serving.yaml | 4 +- infra/docker-compose/.env.sample | 25 +-- infra/docker-compose/core/core.yml | 5 +- infra/docker-compose/docker-compose.batch.yml | 27 ---- infra/docker-compose/docker-compose.dev.yml | 11 +- .../docker-compose/docker-compose.online.yml | 27 ---- infra/docker-compose/docker-compose.yml | 55 +++++-- ...tch-serving.yml => historical-serving.yml} | 5 +- infra/docker/core/Dockerfile | 27 +++- infra/docker/jupyter/Dockerfile | 4 +- infra/docker/serving/Dockerfile | 27 +++- infra/scripts/test-docker-compose.sh | 11 +- infra/scripts/test-load.sh | 109 +++++++++++++ tests/e2e/conftest.py | 1 + tests/e2e/redis/basic-ingest-redis-serving.py | 143 +++++++++++++----- 29 files changed, 530 insertions(+), 283 deletions(-) delete mode 100644 .github/workflows/code_standards.yaml create mode 100644 .github/workflows/complete.yml delete mode 100644 .github/workflows/docker_compose_tests.yml create mode 100644 .github/workflows/master_only.yml delete mode 100644 .github/workflows/unit_tests.yml delete mode 100644 infra/docker-compose/docker-compose.batch.yml delete mode 100644 infra/docker-compose/docker-compose.online.yml rename infra/docker-compose/serving/{batch-serving.yml => historical-serving.yml} (87%) create mode 100755 infra/scripts/test-load.sh diff --git a/.github/workflows/code_standards.yaml b/.github/workflows/code_standards.yaml deleted file mode 100644 index 92ff8effeee..00000000000 --- a/.github/workflows/code_standards.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: code standards - -on: [push, pull_request] - -jobs: - lint-java: - container: gcr.io/kf-feast/feast-ci:latest - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v2 - - name: lint java - run: make lint-java - - lint-python: - container: gcr.io/kf-feast/feast-ci:latest - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v2 - - name: install dependencies - run: make install-python-ci-dependencies - - name: compile protos - run: make compile-protos-python - - name: lint python - run: make lint-python - - lint-go: - container: gcr.io/kf-feast/feast-ci:latest - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v2 - - name: install dependencies - run: make install-go-ci-dependencies - - name: lint go - run: make lint-go - - lint-versions: - container: gcr.io/kf-feast/feast-ci:latest - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v2 - - name: install dependencies - run: make lint-versions \ No newline at end of file diff --git a/.github/workflows/complete.yml b/.github/workflows/complete.yml new file mode 100644 index 00000000000..4cf6fdbbf41 --- /dev/null +++ b/.github/workflows/complete.yml @@ -0,0 +1,111 @@ +name: complete + +on: [push, pull_request] + +jobs: + build-push-docker-images: + runs-on: [self-hosted] + strategy: + matrix: + component: [core, serving, jupyter] + env: + GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha }} + REGISTRY: gcr.io/kf-feast + MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2019-10-24.tar + steps: + - uses: actions/checkout@v2 + - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master + with: + version: '290.0.1' + export_default_credentials: true + - run: gcloud auth configure-docker --quiet + - name: Get m2 cache + run: | + infra/scripts/download-maven-cache.sh \ + --archive-uri ${MAVEN_CACHE} \ + --output-dir . + - name: Build image + run: make build-${{ matrix.component }}-docker REGISTRY=${REGISTRY} VERSION=${GITHUB_SHA} + - name: Push image + run: | + docker push ${REGISTRY}/feast-${{ matrix.component }}:${GITHUB_SHA} + if [ -n "${GITHUB_PR_SHA}" ]; then + docker tag ${REGISTRY}/feast-${{ matrix.component }}:${GITHUB_SHA} gcr.io/kf-feast/feast-${{ matrix.component }}:${GITHUB_PR_SHA} + docker push ${REGISTRY}/feast-${{ matrix.component }}:${GITHUB_PR_SHA} + fi + + lint-java: + container: gcr.io/kf-feast/feast-ci:latest + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v2 + - name: Lint java + run: make lint-java + + lint-python: + container: gcr.io/kf-feast/feast-ci:latest + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: make install-python-ci-dependencies + - name: Compile protos + run: make compile-protos-python + - name: Lint python + run: make lint-python + + lint-go: + container: gcr.io/kf-feast/feast-ci:latest + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: make install-go-ci-dependencies + - name: Lint go + run: make lint-go + + lint-versions: + container: gcr.io/kf-feast/feast-ci:latest + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v2 + - name: install dependencies + run: make lint-versions + + unit-test-java: + runs-on: ubuntu-latest + container: gcr.io/kf-feast/feast-ci:latest + steps: + - uses: actions/checkout@v2 + - uses: actions/cache@v1 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Test java + run: make test-java-with-coverage + - uses: actions/upload-artifact@v2 + with: + name: java-coverage-report + path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ + + unit-test-python: + runs-on: ubuntu-latest + container: gcr.io/kf-feast/feast-ci:latest + steps: + - uses: actions/checkout@v2 + - name: Install python + run: make install-python + - name: Test python + run: make test-python + + unit-test-go: + runs-on: ubuntu-latest + container: gcr.io/kf-feast/feast-ci:latest + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: make compile-protos-go + - name: Test go + run: make test-go \ No newline at end of file diff --git a/.github/workflows/docker_compose_tests.yml b/.github/workflows/docker_compose_tests.yml deleted file mode 100644 index 2984ebc37ba..00000000000 --- a/.github/workflows/docker_compose_tests.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: docker compose tests - -on: - push: - branches: - - master - -jobs: - basic-redis-e2e-tests-docker-compose: - runs-on: ubuntu-latest - name: basic redis e2e tests on docker compose - steps: - - uses: actions/checkout@v2 - - name: test docker compose - run: ./infra/scripts/test-docker-compose.sh diff --git a/.github/workflows/master_only.yml b/.github/workflows/master_only.yml new file mode 100644 index 00000000000..b24b5d85ac7 --- /dev/null +++ b/.github/workflows/master_only.yml @@ -0,0 +1,60 @@ +name: master only + +on: + push: + branches: master + tags: + - 'v*.*.*' + +jobs: + build-docker-images: + runs-on: [self-hosted] + strategy: + matrix: + component: [core, serving, jupyter, ci] + env: + MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2019-10-24.tar + steps: + - uses: actions/checkout@v2 + - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master + with: + version: '290.0.1' + export_default_credentials: true + - run: gcloud auth configure-docker --quiet + - name: Get m2 cache + run: | + infra/scripts/download-maven-cache.sh \ + --archive-uri ${MAVEN_CACHE} \ + --output-dir . + - name: Build image + run: make build-${{ matrix.component }}-docker REGISTRY=gcr.io/kf-feast VERSION=${GITHUB_SHA} + - name: Push image + run: make push-${{ matrix.component }}-docker REGISTRY=gcr.io/kf-feast VERSION=${GITHUB_SHA} + - name: Push image to feast dev + run: | + if [ ${GITHUB_REF#refs/*/} == "master" ]; then + docker tag gcr.io/kf-feast/feast-${{ matrix.component }}:${GITHUB_SHA} gcr.io/kf-feast/feast-${{ matrix.component }}:dev + docker push gcr.io/kf-feast/feast-${{ matrix.component }}:dev + fi + - name: Get version + run: echo ::set-env name=RELEASE_VERSION::${GITHUB_REF#refs/*/} + - name: Push versioned release + run: | + # Build and push semver tagged commits + rx='^v[0-9]+?\.[0-9]+?\.[0-9]+?$' + if [[ "${RELEASE_VERSION}" =~ $rx ]]; then + VERSION_WITHOUT_PREFIX=${RELEASE_VERSION:1} + + docker tag gcr.io/kf-feast/feast-${{ matrix.component }}:${GITHUB_SHA} gcr.io/kf-feast/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} + docker push gcr.io/kf-feast/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} + + # Also update "latest" image if tagged commit is pushed to stable branch + HIGHEST_SEMVER_TAG=$(git tag -l --sort -version:refname | head -n 1) + 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 gcr.io/kf-feast/feast-${{ matrix.component }}:${GITHUB_SHA} gcr.io/kf-feast/feast-${{ matrix.component }}:latest + docker push gcr.io/kf-feast/feast-${{ matrix.component }}:latest + fi + fi diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml deleted file mode 100644 index f71788a90bb..00000000000 --- a/.github/workflows/unit_tests.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: unit tests - -on: [push, pull_request] - -jobs: - unit-test-java: - runs-on: ubuntu-latest - container: gcr.io/kf-feast/feast-ci:latest - name: unit test java - steps: - - uses: actions/checkout@v2 - - uses: actions/cache@v1 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- - - name: test java - run: make test-java-with-coverage - - uses: actions/upload-artifact@v2 - with: - name: java-coverage-report - path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ - - unit-test-python: - runs-on: ubuntu-latest - container: gcr.io/kf-feast/feast-ci:latest - name: unit test python - steps: - - uses: actions/checkout@v2 - - name: install python - run: make install-python - - name: test python - run: make test-python - - unit-test-go: - runs-on: ubuntu-latest - container: gcr.io/kf-feast/feast-ci:latest - name: unit test go - steps: - - uses: actions/checkout@v2 - - name: install dependencies - run: make compile-protos-go - - name: test go - run: make test-go diff --git a/Makefile b/Makefile index 27c5c9954f9..76430bb38f6 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,9 @@ push-serving-docker: push-ci-docker: docker push $(REGISTRY)/feast-ci:latest +push-jupyter-docker: + docker push $(REGISTRY)/feast-jupyter:$(VERSION) + build-core-docker: docker build -t $(REGISTRY)/feast-core:$(VERSION) -f infra/docker/core/Dockerfile . @@ -135,6 +138,9 @@ build-serving-docker: build-ci-docker: docker build -t $(REGISTRY)/feast-ci:latest -f infra/docker/ci/Dockerfile . +build-jupyter-docker: + docker build -t $(REGISTRY)/feast-jupyter:$(VERSION) -f infra/docker/jupyter/Dockerfile . + # Documentation install-dependencies-proto-docs: @@ -175,4 +181,7 @@ build-html: clean-html # Versions lint-versions: - ./infra/scripts/validate-version-consistency.sh \ No newline at end of file + ./infra/scripts/validate-version-consistency.sh + +test-load: + ./infra/scripts/test-load.sh $(GIT_SHA) \ No newline at end of file diff --git a/docs/administration/troubleshooting.md b/docs/administration/troubleshooting.md index e293945acce..e4c946fa0a0 100644 --- a/docs/administration/troubleshooting.md +++ b/docs/administration/troubleshooting.md @@ -15,8 +15,8 @@ docker ps ```text CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES d7447205bced jupyter/datascience-notebook:latest "tini -g -- start-no…" 2 minutes ago Up 2 minutes 0.0.0.0:8888->8888/tcp feast_jupyter_1 -8e49dbe81b92 gcr.io/kf-feast/feast-serving:latest "java -Xms1024m -Xmx…" 2 minutes ago Up 5 seconds 0.0.0.0:6567->6567/tcp feast_batch-serving_1 -b859494bd33a gcr.io/kf-feast/feast-serving:latest "java -jar /opt/feas…" 2 minutes ago Up About a minute 0.0.0.0:6566->6566/tcp feast_online-serving_1 +8e49dbe81b92 gcr.io/kf-feast/feast-serving:latest "java -Xms1024m -Xmx…" 2 minutes ago Up 5 seconds 0.0.0.0:6567->6567/tcp feast_historical_serving_1 +b859494bd33a gcr.io/kf-feast/feast-serving:latest "java -jar /opt/feas…" 2 minutes ago Up About a minute 0.0.0.0:6566->6566/tcp feast_online_serving_1 5c4962811767 gcr.io/kf-feast/feast-core:latest "java -jar /opt/feas…" 2 minutes ago Up 2 minutes 0.0.0.0:6565->6565/tcp feast_core_1 1ba7239e0ae0 confluentinc/cp-kafka:5.2.1 "/etc/confluent/dock…" 2 minutes ago Up 2 minutes 0.0.0.0:9092->9092/tcp, 0.0.0.0:9094->9094/tcp feast_kafka_1 e2779672735c confluentinc/cp-zookeeper:5.2.1 "/etc/confluent/dock…" 2 minutes ago Up 2 minutes 2181/tcp, 2888/tcp, 3888/tcp feast_zookeeper_1 @@ -59,8 +59,8 @@ You will probably need to connect using the hostnames of services and standard F ```bash export FEAST_CORE_URL=core:6565 -export FEAST_ONLINE_SERVING_URL=online-serving:6566 -export FEAST_BATCH_SERVING_URL=batch-serving:6567 +export FEAST_ONLINE_SERVING_URL=online_serving:6566 +export FEAST_HISTORICAL_SERVING_URL=historical_serving:6567 ``` ### **Docker Compose \(from outside the docker cluster\)** @@ -70,7 +70,7 @@ You will probably need to connect using `localhost` and standard ports: ```bash export FEAST_CORE_URL=localhost:6565 export FEAST_ONLINE_SERVING_URL=localhost:6566 -export FEAST_BATCH_SERVING_URL=localhost:6567 +export FEAST_HISTORICAL_SERVING_URL=localhost:6567 ``` ### **Google Kubernetes Engine \(GKE\)** @@ -81,7 +81,7 @@ You will need to find the external IP of one of the nodes as well as the NodePor export FEAST_IP=$(kubectl describe nodes | grep ExternalIP | awk '{print $2}' | head -n 1) export FEAST_CORE_URL=${FEAST_IP}:32090 export FEAST_ONLINE_SERVING_URL=${FEAST_IP}:32091 -export FEAST_BATCH_SERVING_URL=${FEAST_IP}:32092 +export FEAST_HISTORICAL_SERVING_URL=${FEAST_IP}:32092 ``` `netcat`, `telnet`, or even `curl` can be used to test whether all services are available and ports are open, but `grpc_cli` is the most powerful. It can be installed from [here](https://github.com/grpc/grpc/blob/master/doc/command_line_tool.md). @@ -107,7 +107,7 @@ ListProjects ### Testing Feast Batch Serving and Online Serving ```bash -grpc_cli ls ${FEAST_BATCH_SERVING_URL} feast.serving.ServingService +grpc_cli ls ${FEAST_HISTORICAL_SERVING_URL} feast.serving.ServingService ``` ```text @@ -145,11 +145,11 @@ In order to print the logs from these services, please run the commands below. ``` ```text -docker logs -f feast_batch-serving_1 +docker logs -f feast_historical_serving_1 ``` ```text -docker logs -f feast_online-serving_1 +docker logs -f feast_online_serving_1 ``` ### Google Kubernetes Engine diff --git a/docs/getting-started/deploying-feast/docker-compose.md b/docs/getting-started/deploying-feast/docker-compose.md index ca0c747a820..7fca5976c6f 100644 --- a/docs/getting-started/deploying-feast/docker-compose.md +++ b/docs/getting-started/deploying-feast/docker-compose.md @@ -80,12 +80,12 @@ Configure the `.env` file based on your environment. At the very least you have | Parameter | Description | | :--- | :--- | | `FEAST_CORE_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json`. | -| `FEAST_BATCH_SERVING_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json` | +| `FEAST_HISTORICAL_SERVING_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json` | | `FEAST_JUPYTER_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json` | ### 3.3 Configure Historical Serving -We will also need to configure the `batch-serving.yml` file inside `infra/docker-compose/serving/`. This configuration is used to retrieve training datasets from Feast. At a minimum you will need to set: +We will also need to configure the `historical-serving.yml` file inside `infra/docker-compose/serving/`. This configuration is used to retrieve training datasets from Feast. At a minimum you will need to set: | Parameter | Description | | :--- | :--- | diff --git a/docs/installation/docker-compose.md b/docs/installation/docker-compose.md index a8895a02529..3c8c50862e3 100644 --- a/docs/installation/docker-compose.md +++ b/docs/installation/docker-compose.md @@ -85,7 +85,7 @@ Configure the `.env` file based on your environment. At the very least you have | Parameter | Description | | :--- | :--- | | FEAST\_CORE\_GCP\_SERVICE\_ACCOUNT\_KEY | This should be your service account file name, for example `key.json`. | -| FEAST\_BATCH\_SERVING\_GCP\_SERVICE\_ACCOUNT\_KEY | This should be your service account file name, for example `key.json` | +| FEAST\_HISTORICAL\_SERVING\_GCP\_SERVICE\_ACCOUNT\_KEY | This should be your service account file name, for example `key.json` | | FEAST\_JUPYTER\_GCP\_SERVICE\_ACCOUNT\_KEY | This should be your service account file name, for example `key.json` | | FEAST\_JOB\_STAGING\_LOCATION | Google Cloud Storage bucket that Feast will use to stage data exports and batch retrieval requests, for example `gs://your-gcs-bucket/staging` | diff --git a/docs/installation/gke.md b/docs/installation/gke.md index c2becc91cd2..66041887786 100644 --- a/docs/installation/gke.md +++ b/docs/installation/gke.md @@ -80,7 +80,7 @@ For this guide we will use `NodePort` for exposing Feast services. In order to d export FEAST_IP=$(kubectl describe nodes | grep ExternalIP | awk '{print $2}' | head -n 1) export FEAST_CORE_URL=${FEAST_IP}:32090 export FEAST_ONLINE_SERVING_URL=${FEAST_IP}:32091 -export FEAST_BATCH_SERVING_URL=${FEAST_IP}:32092 +export FEAST_HISTORICAL_SERVING_URL=${FEAST_IP}:32092 ``` Add firewall rules to open up ports on your Google Cloud Platform project: diff --git a/docs/user-guide/feature-retrieval.md b/docs/user-guide/feature-retrieval.md index 8ef9d3a5dfc..1cdad70920e 100644 --- a/docs/user-guide/feature-retrieval.md +++ b/docs/user-guide/feature-retrieval.md @@ -116,7 +116,7 @@ The computation of statistics is not enabled by default. To indicate to Feast th ```python dataset = client.get_historical_features( feature_refs=features, - entity_rows=entity_df + entity_rows=entity_df, compute_statistics=True ) diff --git a/examples/basic/basic.ipynb b/examples/basic/basic.ipynb index 28bc456ef97..318ccfd3bd6 100644 --- a/examples/basic/basic.ipynb +++ b/examples/basic/basic.ipynb @@ -43,7 +43,7 @@ "FEAST_ONLINE_SERVING_URL = os.getenv('FEAST_ONLINE_SERVING_URL', 'localhost:6566')\n", "\n", "# Feast Batch Serving allows for the retrieval of historical feature data\n", - "FEAST_BATCH_SERVING_URL = os.getenv('FEAST_BATCH_SERVING_URL', 'localhost:6567')" + "FEAST_HISTORICAL_SERVING_URL = os.getenv('FEAST_HISTORICAL_SERVING_URL', 'localhost:6567')" ] }, { @@ -466,8 +466,8 @@ "source": [ "online_features = client.get_online_features(\n", " feature_refs=[\n", - " f\"daily_transactions\",\n", - " f\"total_transactions\",\n", + " \"daily_transactions\",\n", + " \"total_transactions\",\n", " ],\n", " entity_rows=[\n", " {\n", @@ -592,7 +592,7 @@ "metadata": {}, "outputs": [], "source": [ - "batch_client = Client(core_url=FEAST_CORE_URL, serving_url=FEAST_BATCH_SERVING_URL)" + "batch_client = Client(core_url=FEAST_CORE_URL, serving_url=FEAST_HISTORICAL_SERVING_URL)" ] }, { @@ -614,8 +614,8 @@ "source": [ "job = batch_client.get_historical_features(\n", " feature_refs=[\n", - " f\"daily_transactions\", \n", - " f\"total_transactions\", \n", + " \"daily_transactions\",\n", + " \"total_transactions\",\n", " ],\n", " entity_rows=entity_rows\n", " )" @@ -754,4 +754,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/examples/feast-xgboost-churn-prediction-tutorial/Telecom Customer Churn Prediction (with Feast and XGBoost).ipynb b/examples/feast-xgboost-churn-prediction-tutorial/Telecom Customer Churn Prediction (with Feast and XGBoost).ipynb index f7adc80d8d5..58097ca0b45 100644 --- a/examples/feast-xgboost-churn-prediction-tutorial/Telecom Customer Churn Prediction (with Feast and XGBoost).ipynb +++ b/examples/feast-xgboost-churn-prediction-tutorial/Telecom Customer Churn Prediction (with Feast and XGBoost).ipynb @@ -6752,7 +6752,7 @@ "FEAST_ONLINE_SERVING_URL = os.getenv('FEAST_ONLINE_SERVING_URL', 'localhost:6566')\n", "\n", "# Feast Batch Serving allows for the retrieval of historical feature data\n", - "FEAST_BATCH_SERVING_URL = os.getenv('FEAST_BATCH_SERVING_URL', 'localhost:6567')" + "FEAST_HISTORICAL_SERVING_URL = os.getenv('FEAST_HISTORICAL_SERVING_URL', 'localhost:6567')" ] }, { @@ -7187,7 +7187,7 @@ " def __init__(self, features, target, model_path=None):\n", " # Set up Feast clients to retrieve training and online serving data\n", " self._feast_online_client = Client(serving_url=os.environ['FEAST_ONLINE_SERVING_URL'])\n", - " self._feast_batch_client = Client(serving_url=os.environ['FEAST_BATCH_SERVING_URL'],\n", + " self._feast_batch_client = Client(serving_url=os.environ['FEAST_HISTORICAL_SERVING_URL'],\n", " core_url=os.environ['FEAST_CORE_URL'])\n", " \n", " # Path to either save models after training or load models for serving\n", diff --git a/infra/charts/feast/charts/feast-jupyter/templates/deployment.yaml b/infra/charts/feast/charts/feast-jupyter/templates/deployment.yaml index 8e6aea01736..858ac5eed00 100644 --- a/infra/charts/feast/charts/feast-jupyter/templates/deployment.yaml +++ b/infra/charts/feast/charts/feast-jupyter/templates/deployment.yaml @@ -38,7 +38,7 @@ spec: value: "{{ .Release.Name }}-feast-core:6565" - name: FEAST_ONLINE_SERVING_URL value: "{{ .Release.Name }}-feast-online-serving:6566" - - name: FEAST_BATCH_SERVING_URL + - name: FEAST_HISTORICAL_SERVING_URL value: "{{ .Release.Name }}-feast-batch-serving:6566" {{- if .Values.gcpServiceAccount.enabled }} - name: GOOGLE_APPLICATION_CREDENTIALS diff --git a/infra/charts/feast/templates/tests/test-feast-batch-serving.yaml b/infra/charts/feast/templates/tests/test-feast-batch-serving.yaml index 181982d7e81..f2a76e37b21 100644 --- a/infra/charts/feast/templates/tests/test-feast-batch-serving.yaml +++ b/infra/charts/feast/templates/tests/test-feast-batch-serving.yaml @@ -70,14 +70,14 @@ spec: entity_rows_df = df.copy(deep=True).rename(columns={"datetime": "event_timestamp"})[["event_timestamp", "customer_id"]] pandavro.to_avro("entity_rows.avro", entity_rows_df) - batch_serving_job = client.get_historical_features( + historical_serving_job = client.get_historical_features( entity_rows="file://entity_rows.avro", feature_refs=[ f"{project}/daily_transactions:1", f"{project}/total_transactions:1", ] ) - result_df = batch_serving_job.to_dataframe() + result_df = historical_serving_job.to_dataframe() print("Retrieved dataframe: ") print(result_df) diff --git a/infra/docker-compose/.env.sample b/infra/docker-compose/.env.sample index be071be994c..5c1f5af819b 100644 --- a/infra/docker-compose/.env.sample +++ b/infra/docker-compose/.env.sample @@ -1,20 +1,7 @@ -# General COMPOSE_PROJECT_NAME=feast -FEAST_VERSION=latest -FEAST_REPOSITORY_VERSION=v0.5-branch - -# Feast Core -FEAST_CORE_IMAGE=gcr.io/kf-feast/feast-core -FEAST_CORE_CONFIG=core.yml -FEAST_CORE_GCP_SERVICE_ACCOUNT_KEY=placeholder.json - -# Feast Serving -FEAST_SERVING_IMAGE=gcr.io/kf-feast/feast-serving -# Feast Serving - Batch (BigQuery) -FEAST_BATCH_SERVING_CONFIG=batch-serving.yml -FEAST_BATCH_SERVING_GCP_SERVICE_ACCOUNT_KEY=placeholder.json -# Feast Serving - Online (Redis) -FEAST_ONLINE_SERVING_CONFIG=online-serving.yml - -# Jupyter -FEAST_JUPYTER_GCP_SERVICE_ACCOUNT_KEY=placeholder.json +FEAST_VERSION=0.6.2 +GCP_SERVICE_ACCOUNT=./gcp-service-accounts/key.json +FEAST_CORE_CONFIG=./core/core.yml +FEAST_HISTORICAL_SERVING_CONFIG=./serving/historical-serving.yml +FEAST_HISTORICAL_SERVING_ENABLED=false +FEAST_ONLINE_SERVING_CONFIG=./serving/online-serving.yml \ No newline at end of file diff --git a/infra/docker-compose/core/core.yml b/infra/docker-compose/core/core.yml index f54d05a36b2..7506a2dbe61 100644 --- a/infra/docker-compose/core/core.yml +++ b/infra/docker-compose/core/core.yml @@ -1,12 +1,13 @@ feast: jobs: - polling_interval_milliseconds: 30000 + polling_interval_milliseconds: 20000 job_update_timeout_seconds: 240 active_runner: direct runners: - name: direct type: DirectRunner - options: {} + options: + tempLocation: gs://bucket/tempLocation stream: type: kafka options: diff --git a/infra/docker-compose/docker-compose.batch.yml b/infra/docker-compose/docker-compose.batch.yml deleted file mode 100644 index 15b5e71875d..00000000000 --- a/infra/docker-compose/docker-compose.batch.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: "3.7" - -services: - batch-serving: - image: ${FEAST_SERVING_IMAGE}:${FEAST_VERSION} - volumes: - - ./serving/${FEAST_BATCH_SERVING_CONFIG}:/etc/feast/application.yml - - ./gcp-service-accounts/${FEAST_BATCH_SERVING_GCP_SERVICE_ACCOUNT_KEY}:/etc/gcloud/service-accounts/key.json - depends_on: - - redis - ports: - - 6567:6567 - restart: on-failure - environment: - GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json - command: - - "java" - - "-Xms1024m" - - "-Xmx1024m" - - "-jar" - - "/opt/feast/feast-serving.jar" - - "--spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml" - - redis: - image: redis:5-alpine - ports: - - "6379:6379" diff --git a/infra/docker-compose/docker-compose.dev.yml b/infra/docker-compose/docker-compose.dev.yml index 840c6dd2673..a7058c5bf96 100644 --- a/infra/docker-compose/docker-compose.dev.yml +++ b/infra/docker-compose/docker-compose.dev.yml @@ -25,21 +25,18 @@ services: - spring-boot:run jupyter: - image: jupyter/minimal-notebook:619e9cc2fc07 + image: gcr.io/kf-feast/feast-jupyter:${FEAST_VERSION} volumes: - - ./gcp-service-accounts/${FEAST_JUPYTER_GCP_SERVICE_ACCOUNT_KEY}:/etc/gcloud/service-accounts/key.json - - ./jupyter/startup.sh:/etc/startup.sh + - ${GCP_SERVICE_ACCOUNT}:/etc/gcloud/service-accounts/key.json depends_on: - core environment: FEAST_CORE_URL: core:6565 - FEAST_ONLINE_SERVING_URL: online-serving:6566 - FEAST_BATCH_SERVING_URL: batch-serving:6567 + FEAST_ONLINE_SERVING_URL: online_serving:6566 + FEAST_HISTORICAL_SERVING_URL: historical_serving:6567 GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json - FEAST_REPOSITORY_VERSION: ${FEAST_REPOSITORY_VERSION} ports: - 8888:8888 - command: ["/etc/startup.sh"] kafka: image: confluentinc/cp-kafka:5.2.1 diff --git a/infra/docker-compose/docker-compose.online.yml b/infra/docker-compose/docker-compose.online.yml deleted file mode 100644 index 0e5a3cfaec6..00000000000 --- a/infra/docker-compose/docker-compose.online.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: "3.7" - -services: - online-serving: - image: ${FEAST_SERVING_IMAGE}:${FEAST_VERSION} - volumes: - - ./serving/${FEAST_ONLINE_SERVING_CONFIG}:/etc/feast/application.yml - # Required if authentication is enabled on core and - # provider is 'google'. GOOGLE_APPLICATION_CREDENTIALS is used for connecting to core. - - ./gcp-service-accounts/${FEAST_BATCH_SERVING_GCP_SERVICE_ACCOUNT_KEY}:/etc/gcloud/service-accounts/key.json - depends_on: - - redis - ports: - - 6566:6566 - restart: on-failure - environment: - GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json - command: - - java - - -jar - - /opt/feast/feast-serving.jar - - --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml - - redis: - image: redis:5-alpine - ports: - - "6379:6379" diff --git a/infra/docker-compose/docker-compose.yml b/infra/docker-compose/docker-compose.yml index 5239531c8a0..61fc873f41f 100644 --- a/infra/docker-compose/docker-compose.yml +++ b/infra/docker-compose/docker-compose.yml @@ -2,10 +2,10 @@ version: "3.7" services: core: - image: ${FEAST_CORE_IMAGE}:${FEAST_VERSION} + image: gcr.io/kf-feast/feast-core:${FEAST_VERSION} volumes: - - ./core/${FEAST_CORE_CONFIG}:/etc/feast/application.yml - - ./gcp-service-accounts/${FEAST_CORE_GCP_SERVICE_ACCOUNT_KEY}:/etc/gcloud/service-accounts/key.json + - ${FEAST_CORE_CONFIG}:/etc/feast/application.yml + - ${GCP_SERVICE_ACCOUNT}:/etc/gcloud/service-accounts/key.json environment: DB_HOST: db GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json @@ -22,23 +22,18 @@ services: - --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml jupyter: - image: gcr.io/kf-feast/feast-jupyter:latest + image: gcr.io/kf-feast/feast-jupyter:${FEAST_VERSION} volumes: - - ./gcp-service-accounts/${FEAST_JUPYTER_GCP_SERVICE_ACCOUNT_KEY}:/etc/gcloud/service-accounts/key.json + - ${GCP_SERVICE_ACCOUNT}:/etc/gcloud/service-accounts/key.json depends_on: - core environment: FEAST_CORE_URL: core:6565 - FEAST_ONLINE_SERVING_URL: online-serving:6566 - FEAST_BATCH_SERVING_URL: batch-serving:6567 + FEAST_ONLINE_SERVING_URL: online_serving:6566 + FEAST_HISTORICAL_SERVING_URL: historical_serving:6567 GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json ports: - 8888:8888 - command: > - bash -c " - git clone https://github.com/feast-dev/feast.git || true - && cd feast && git checkout $$(git tag | sort -r --version-sort | head -n1 | cut -c1-4)-branch && cd .. - && start-notebook.sh --NotebookApp.token=''" kafka: image: confluentinc/cp-kafka:5.2.1 @@ -67,3 +62,39 @@ services: POSTGRES_PASSWORD: password ports: - "5432:5432" + + online_serving: + image: gcr.io/kf-feast/feast-serving:${FEAST_VERSION} + volumes: + - ${FEAST_ONLINE_SERVING_CONFIG}:/etc/feast/application.yml + depends_on: + - redis + ports: + - 6566:6566 + restart: on-failure + command: + - java + - -jar + - /opt/feast/feast-serving.jar + - --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml + + historical_serving: + image: gcr.io/kf-feast/feast-serving:${FEAST_VERSION} + volumes: + - ${FEAST_HISTORICAL_SERVING_CONFIG}:/etc/feast/application.yml + - ${GCP_SERVICE_ACCOUNT}:/etc/gcloud/service-accounts/key.json + depends_on: + - redis + ports: + - 6567:6567 + restart: on-failure + environment: + GOOGLE_APPLICATION_CREDENTIALS: /etc/gcloud/service-accounts/key.json + command: > + bash -c "if [ $FEAST_HISTORICAL_SERVING_ENABLED != "true" ]; then echo \"Feast historical serving is disabled\"; sleep 10; exit 1; fi + && java -Xms1024m -Xmx1024m -jar /opt/feast/feast-serving.jar --spring.config.location=classpath:/application.yml,file:/etc/feast/application.yml" + + redis: + image: redis:5-alpine + ports: + - "6379:6379" \ No newline at end of file diff --git a/infra/docker-compose/serving/batch-serving.yml b/infra/docker-compose/serving/historical-serving.yml similarity index 87% rename from infra/docker-compose/serving/batch-serving.yml rename to infra/docker-compose/serving/historical-serving.yml index 3feb81c84e1..892673bcd0d 100644 --- a/infra/docker-compose/serving/batch-serving.yml +++ b/infra/docker-compose/serving/historical-serving.yml @@ -4,7 +4,7 @@ feast: stores: - name: historical type: BIGQUERY - # Changes required for batch serving to work + # Changes required for historical serving to work # Please see https://api.docs.feast.dev/grpc/feast.core.pb.html#Store for configuration options config: project_id: project @@ -21,4 +21,5 @@ feast: redis_port: 6379 grpc: - port: 6567 + server: + port: 6567 diff --git a/infra/docker/core/Dockerfile b/infra/docker/core/Dockerfile index f210e1c40cc..2c6b4e57236 100644 --- a/infra/docker/core/Dockerfile +++ b/infra/docker/core/Dockerfile @@ -3,15 +3,34 @@ # ============================================================ FROM maven:3.6-jdk-11 as builder -ARG REVISION=dev -COPY . /build + WORKDIR /build -# + +COPY pom.xml . +COPY datatypes/java/pom.xml datatypes/java/pom.xml +COPY common/pom.xml common/pom.xml +COPY auth/pom.xml auth/pom.xml +COPY ingestion/pom.xml ingestion/pom.xml +COPY core/pom.xml core/pom.xml +COPY serving/pom.xml serving/pom.xml +COPY storage/api/pom.xml storage/api/pom.xml +COPY storage/connectors/pom.xml storage/connectors/pom.xml +COPY storage/connectors/redis/pom.xml storage/connectors/redis/pom.xml +COPY storage/connectors/bigquery/pom.xml storage/connectors/bigquery/pom.xml +COPY sdk/java/pom.xml sdk/java/pom.xml +COPY docs/coverage/java/pom.xml docs/coverage/java/pom.xml +COPY protos/ protos/ + # Setting Maven repository .m2 directory relative to /build folder gives the # user to optionally use cached repository when building the image by copying # the existing .m2 directory to $FEAST_REPO_ROOT/.m2 -# ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository -DdependencyLocationsEnabled=false" +COPY pom.xml .m2/* .m2/ +RUN mvn dependency:go-offline -DexcludeGroupIds:dev.feast 2>/dev/null || true + +COPY . . + +ARG REVISION=dev RUN mvn --also-make --projects core,ingestion -Drevision=$REVISION \ -DskipTests=true --batch-mode clean package # diff --git a/infra/docker/jupyter/Dockerfile b/infra/docker/jupyter/Dockerfile index 5aba9eaa333..a29b19d464d 100644 --- a/infra/docker/jupyter/Dockerfile +++ b/infra/docker/jupyter/Dockerfile @@ -23,4 +23,6 @@ RUN pip install -e sdk/python -U USER $NB_UID WORKDIR $HOME -CMD start-notebook.sh --NotebookApp.token='' \ No newline at end of file +COPY examples . + +CMD ["start-notebook.sh", "--NotebookApp.token=''"] \ No newline at end of file diff --git a/infra/docker/serving/Dockerfile b/infra/docker/serving/Dockerfile index 8f2abf5b75c..2e724e9a4ce 100644 --- a/infra/docker/serving/Dockerfile +++ b/infra/docker/serving/Dockerfile @@ -3,15 +3,34 @@ # ============================================================ FROM maven:3.6-jdk-11 as builder -ARG REVISION=dev -COPY . /build + WORKDIR /build -# + +COPY pom.xml . +COPY datatypes/java/pom.xml datatypes/java/pom.xml +COPY common/pom.xml common/pom.xml +COPY auth/pom.xml auth/pom.xml +COPY ingestion/pom.xml ingestion/pom.xml +COPY core/pom.xml core/pom.xml +COPY serving/pom.xml serving/pom.xml +COPY storage/api/pom.xml storage/api/pom.xml +COPY storage/connectors/pom.xml storage/connectors/pom.xml +COPY storage/connectors/redis/pom.xml storage/connectors/redis/pom.xml +COPY storage/connectors/bigquery/pom.xml storage/connectors/bigquery/pom.xml +COPY sdk/java/pom.xml sdk/java/pom.xml +COPY docs/coverage/java/pom.xml docs/coverage/java/pom.xml +COPY protos/ protos/ + # Setting Maven repository .m2 directory relative to /build folder gives the # user to optionally use cached repository when building the image by copying # the existing .m2 directory to $FEAST_REPO_ROOT/.m2 -# ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository -DdependencyLocationsEnabled=false" +COPY pom.xml .m2/* .m2/ +RUN mvn dependency:go-offline -DexcludeGroupIds:dev.feast 2>/dev/null || true + +COPY . . + +ARG REVISION=dev RUN mvn --also-make --projects serving -Drevision=$REVISION \ -DskipTests=true --batch-mode clean package # diff --git a/infra/scripts/test-docker-compose.sh b/infra/scripts/test-docker-compose.sh index 3d92513c770..0d6e99f8607 100755 --- a/infra/scripts/test-docker-compose.sh +++ b/infra/scripts/test-docker-compose.sh @@ -12,10 +12,7 @@ clean_up () { ARG=$? # Shut down docker-compose images - docker-compose -f docker-compose.yml -f docker-compose.online.yml down - - # Remove configuration file - rm .env + docker-compose down exit $ARG } @@ -30,7 +27,7 @@ cd ${PROJECT_ROOT_DIR}/infra/docker-compose/ cp .env.sample .env # Start Docker Compose containers -docker-compose -f docker-compose.yml -f docker-compose.online.yml up -d +docker-compose up -d # Get Jupyter container IP address export JUPYTER_DOCKER_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feast_jupyter_1) @@ -49,10 +46,10 @@ export FEAST_CORE_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSett ${PROJECT_ROOT_DIR}/infra/scripts/wait-for-it.sh ${FEAST_CORE_CONTAINER_IP_ADDRESS}:6565 --timeout=120 # Get Feast Online Serving container IP address -export FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feast_online-serving_1) +export FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feast_online_serving_1) # Wait for Feast Online Serving to be ready ${PROJECT_ROOT_DIR}/infra/scripts/wait-for-it.sh ${FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS}:6566 --timeout=120 # Run e2e tests for Redis -docker exec feast_jupyter_1 bash -c 'cd feast/tests/e2e/redis && pytest -s basic-ingest-redis-serving.py --core_url core:6565 --serving_url=online-serving:6566' +docker exec feast_jupyter_1 bash -c 'cd /feast/tests/e2e/redis && pytest --verbose -rs basic-ingest-redis-serving.py --core_url core:6565 --serving_url=online_serving:6566 --kafka_brokers=kafka:9092' diff --git a/infra/scripts/test-load.sh b/infra/scripts/test-load.sh new file mode 100755 index 00000000000..6ff6d86231a --- /dev/null +++ b/infra/scripts/test-load.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +set -e + +echo " +============================================================ +Running Load Tests +============================================================ +" + +clean_up() { + ARG=$? + + # Shut down docker-compose images + cd "${PROJECT_ROOT_DIR}"/infra/docker-compose + + docker-compose down + + exit $ARG +} + +# Get Feast project repository root and scripts directory +export PROJECT_ROOT_DIR=$(git rev-parse --show-toplevel) +export SCRIPTS_DIR=${PROJECT_ROOT_DIR}/infra/scripts +export COMPOSE_INTERACTIVE_NO_CLI=1 +source ${SCRIPTS_DIR}/setup-common-functions.sh + +if [ -z "$1" ] ; then + echo "No SHA/FEAST_VERSION provided as argument, using local HEAD"; + FEAST_VERSION=$(git rev-parse HEAD); + export FEAST_VERSION +else + echo "Using ${1} as SHA/FEAST_VERSION to test"; + FEAST_VERSION=${1} + export FEAST_VERSION +fi + +wait_for_docker_image gcr.io/kf-feast/feast-core:"${FEAST_VERSION}" +wait_for_docker_image gcr.io/kf-feast/feast-serving:"${FEAST_VERSION}" +wait_for_docker_image gcr.io/kf-feast/feast-jupyter:"${FEAST_VERSION}" + +# Clean up Docker Compose if failure +trap clean_up EXIT + +# Create Docker Compose configuration file +cd "${PROJECT_ROOT_DIR}"/infra/docker-compose/ +cp .env.sample .env + +# Start Docker Compose containers +FEAST_VERSION=${FEAST_VERSION} docker-compose up -d + +# Get Jupyter container IP address +export JUPYTER_DOCKER_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feast_jupyter_1) + +# Print Jupyter container information +docker inspect feast_jupyter_1 +docker logs feast_jupyter_1 + +# Wait for Jupyter Notebook Container to come online +"${PROJECT_ROOT_DIR}"/infra/scripts/wait-for-it.sh ${JUPYTER_DOCKER_CONTAINER_IP_ADDRESS}:8888 --timeout=60 + +# Get Feast Core container IP address +export FEAST_CORE_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feast_core_1) + +# Wait for Feast Core to be ready +"${PROJECT_ROOT_DIR}"/infra/scripts/wait-for-it.sh ${FEAST_CORE_CONTAINER_IP_ADDRESS}:6565 --timeout=120 + +# Get Feast Online Serving container IP address +export FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' feast_online_serving_1) + +# Wait for Feast Online Serving to be ready +"${PROJECT_ROOT_DIR}"/infra/scripts/wait-for-it.sh ${FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS}:6566 --timeout=120 + +# Ingest data into Feast +pip install --user matplotlib feast pytz --upgrade +python "${PROJECT_ROOT_DIR}"/tests/load/ingest.py "${FEAST_CORE_CONTAINER_IP_ADDRESS}":6565 "${FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS}":6566 + +# Download load test tool and proxy +cd $(mktemp -d) +wget -c https://github.com/feast-dev/feast-load-test-proxy/releases/download/v0.1.1/feast-load-test-proxy_0.1.1_Linux_x86_64.tar.gz -O - | tar -xz +git clone https://github.com/giltene/wrk2.git +cd wrk2 +make +cd .. +cp wrk2/wrk . + +# Start load test server +LOAD_FEAST_SERVING_HOST=${FEAST_ONLINE_SERVING_CONTAINER_IP_ADDRESS} LOAD_FEAST_SERVING_PORT=6566 ./feast-load-test-proxy & +sleep 5 + +# Run load tests +./wrk -t2 -c10 -d30s -R20 --latency http://localhost:8080/echo +./wrk -t2 -c10 -d30s -R20 --latency http://localhost:8080/send?entity_count=10 > load_test_results_1fs_13f_10e_20rps +./wrk -t2 -c10 -d30s -R50 --latency http://localhost:8080/send?entity_count=10 > load_test_results_1fs_13f_10e_50rps +./wrk -t2 -c10 -d30s -R250 --latency http://localhost:8080/send?entity_count=10 > load_test_results_1fs_13f_10e_250rps +./wrk -t2 -c10 -d30s -R20 --latency http://localhost:8080/send?entity_count=50 > load_test_results_1fs_13f_50e_20rps +./wrk -t2 -c10 -d30s -R50 --latency http://localhost:8080/send?entity_count=50 > load_test_results_1fs_13f_50e_50rps +./wrk -t2 -c10 -d30s -R250 --latency http://localhost:8080/send?entity_count=50 > load_test_results_1fs_13f_50e_250rps + +# Print load test results +cat $(ls -lah | grep load_test_results | awk '{print $9}' | tr '\n' ' ') + +# Create hdr-plot of load tests +export PLOT_FILE_NAME="load_test_graph_${FEAST_VERSION}"_$(date "+%Y%m%d-%H%M%S").png +python $PROJECT_ROOT_DIR/tests/load/hdr_plot.py --output "$PLOT_FILE_NAME" --title "Load test: ${FEAST_VERSION}" $(ls -lah | grep load_test_results | awk '{print $9}' | tr '\n' ' ') + +# Persist artifact +mkdir -p "${PROJECT_ROOT_DIR}"/load-test-output/ +cp -r load_test_* "${PROJECT_ROOT_DIR}"/load-test-output/ \ No newline at end of file diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 392f6501d9b..61140699f93 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,3 +6,4 @@ def pytest_addoption(parser): "--gcs_path", action="store", default="gs://feast-templocation-kf-feast/" ) parser.addoption("--enable_auth", action="store", default="False") + parser.addoption("--kafka_brokers", action="store", default="localhost:9092") diff --git a/tests/e2e/redis/basic-ingest-redis-serving.py b/tests/e2e/redis/basic-ingest-redis-serving.py index c1e25508d44..e6d3834b4d8 100644 --- a/tests/e2e/redis/basic-ingest-redis-serving.py +++ b/tests/e2e/redis/basic-ingest-redis-serving.py @@ -4,6 +4,7 @@ import tempfile import time import uuid +from copy import copy from datetime import datetime, timedelta import grpc @@ -110,6 +111,11 @@ def enable_auth(pytestconfig): return True if pytestconfig.getoption("enable_auth").lower() == "true" else False +@pytest.fixture(scope="module") +def kafka_brokers(pytestconfig): + return pytestconfig.getoption("kafka_brokers") + + @pytest.fixture(scope="module") def client(core_url, serving_url, allow_dirty, enable_auth): # Get client for core and serving @@ -422,13 +428,19 @@ def try_get_features1(): response = client.get_online_features( entity_rows=online_request_entity, feature_refs=online_request_features ) - return response, True + is_ok = check_online_response( + "customer2_rating", nonlist_entity_dataframe, response + ) + return response, is_ok def try_get_features2(): response = client.get_online_features( entity_rows=online_request_entity2, feature_refs=online_request_features ) - return response, True + is_ok = check_online_response( + "customer2_rating", nonlist_entity_dataframe, response + ) + return response, is_ok online_features_actual1 = wait_retry_backoff( retry_fn=try_get_features1, @@ -522,13 +534,19 @@ def try_get_features1(): response = client.get_online_features( entity_rows=online_request_entity, feature_refs=online_request_features ) - return response, True + is_ok = check_online_response( + "district_rating", list_entity_dataframe, response + ) + return response, is_ok def try_get_features2(): response = client.get_online_features( entity_rows=online_request_entity2, feature_refs=online_request_features ) - return response, True + is_ok = check_online_response( + "district_rating", list_entity_dataframe, response + ) + return response, is_ok online_features_actual = wait_retry_backoff( retry_fn=try_get_features1, @@ -605,7 +623,8 @@ def try_get_features(): response = client.get_online_features( entity_rows=online_request_entity, feature_refs=online_request_features ) - return response, True + is_ok = check_online_response("driver_fs_rating", driver_df, response) + return response, is_ok online_features_actual = wait_retry_backoff( retry_fn=try_get_features, @@ -658,7 +677,8 @@ def try_get_features(): response = client.get_online_features( entity_rows=online_request_entity, feature_refs=online_request_features ) - return response, True + is_ok = check_online_response("cust_rating", cust_df, response) + return response, is_ok online_features_actual = wait_retry_backoff( retry_fn=try_get_features, @@ -1228,52 +1248,91 @@ def test_list_entities_and_features(client): ) -@pytest.mark.timeout(900) +@pytest.mark.timeout(500) @pytest.mark.run(order=70) -def test_sources_deduplicate_ingest_jobs(client): - source = KafkaSource("localhost:9092", "feast-features") - alt_source = KafkaSource("localhost:9092", "feast-data") - - def get_running_jobs(): - return [ - job - for job in client.list_ingest_jobs() - if job.status == IngestionJobStatus.RUNNING - ] - - # stop all ingest jobs - ingest_jobs = client.list_ingest_jobs() - for ingest_job in ingest_jobs: - client.stop_ingest_job(ingest_job) - for ingest_job in ingest_jobs: - ingest_job.wait(IngestionJobStatus.ABORTED) +def test_sources_deduplicate_ingest_jobs(client, kafka_brokers): + shared_source = KafkaSource(kafka_brokers, "dup_shared") + dup_source_fs_1 = FeatureSet( + name="duplicate_source_fs_1", + features=[Feature("fs1", ValueType.FLOAT), Feature("fs2", ValueType.FLOAT)], + entities=[Entity("e2", ValueType.INT64)], + source=shared_source, + ) + dup_source_fs_2 = copy(dup_source_fs_1) + dup_source_fs_2.name = "duplicate_source_fs_2" - # register multiple featuresets with the same source + def is_same_jobs(): + fs_1_jobs = client.list_ingest_jobs( + feature_set_ref=FeatureSetRef( + name=dup_source_fs_1.name, project=dup_source_fs_1.project + ) + ) + fs_2_jobs = client.list_ingest_jobs( + feature_set_ref=FeatureSetRef( + name=dup_source_fs_2.name, project=dup_source_fs_2.project + ) + ) + same = True + if not (len(fs_1_jobs) > 0 and len(fs_1_jobs) == len(fs_2_jobs)): + same = False + for fs_1_job in fs_1_jobs: + for fs_2_job in fs_2_jobs: + if ( + not fs_1_job.source.to_proto() == fs_2_job.source.to_proto() + and fs_1_job.source.to_proto() == shared_source.to_proto() + ): + same = False + if fs_1_job.id != fs_2_job.id: + same = False + return same + + def is_different_jobs(): + fs_1_jobs = client.list_ingest_jobs( + feature_set_ref=FeatureSetRef( + name=dup_source_fs_1.name, project=dup_source_fs_1.project + ) + ) + fs_2_jobs = client.list_ingest_jobs( + feature_set_ref=FeatureSetRef( + name=dup_source_fs_2.name, project=dup_source_fs_2.project + ) + ) + different = True + if not (len(fs_1_jobs) > 0 and len(fs_2_jobs) > 0): + different = False + for fs_1_job in fs_1_jobs: + if fs_1_job.source.to_proto() == alt_source.to_proto(): + different = False + for fs_2_job in fs_2_jobs: + if fs_2_job.source.to_proto() == shared_source.to_proto(): + different = False + for fs_1_job in fs_1_jobs: + for fs_2_job in fs_2_jobs: + if fs_1_job.id == fs_2_job.id: + different = False + return different + + # register multiple feature sets with the same source # only one ingest job should spawned due to test ingest job deduplication - cust_trans_fs = FeatureSet.from_yaml(f"{DIR_PATH}/basic/cust_trans_fs.yaml") - driver_fs = FeatureSet.from_yaml(f"{DIR_PATH}/basic/driver_fs.yaml") - cust_trans_fs.source, driver_fs.source = source, source - client.apply(cust_trans_fs) - client.apply(driver_fs) + client.apply(dup_source_fs_1) + client.apply(dup_source_fs_2) - while len(get_running_jobs()) != 1: - assert 0 <= len(get_running_jobs()) <= 1 + while not is_same_jobs(): time.sleep(1) - # update feature sets with different sources, should spawn 2 ingest jobs - driver_fs.source = alt_source - client.apply(driver_fs) + # update feature sets with different sources, should have different jobs + alt_source = KafkaSource(kafka_brokers, "alt_source") + dup_source_fs_2.source = alt_source + client.apply(dup_source_fs_2) - while len(get_running_jobs()) != 2: - assert 1 <= len(get_running_jobs()) <= 2 + while not is_different_jobs(): time.sleep(1) - # update feature sets with same source again, should spawn only 1 ingest job - driver_fs.source = source - client.apply(driver_fs) + # update feature sets with same source again, should have the same job + dup_source_fs_2.source = shared_source + client.apply(dup_source_fs_2) - while len(get_running_jobs()) != 1: - assert 1 <= len(get_running_jobs()) <= 2 + while not is_same_jobs(): time.sleep(1) From d7037008b2b231f0b057a5718a7d4f4eac7ec9f8 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Mon, 3 Aug 2020 02:49:58 +0000 Subject: [PATCH 21/31] Backport documentation updates --- .../deploying-feast/docker-compose.md | 83 +++++++++---------- .../deploying-feast/kubernetes.md | 18 +++- docs/user-guide/feature-retrieval.md | 2 +- docs/user-guide/statistics.md | 12 +-- 4 files changed, 63 insertions(+), 52 deletions(-) diff --git a/docs/getting-started/deploying-feast/docker-compose.md b/docs/getting-started/deploying-feast/docker-compose.md index 7fca5976c6f..ad8e4285993 100644 --- a/docs/getting-started/deploying-feast/docker-compose.md +++ b/docs/getting-started/deploying-feast/docker-compose.md @@ -7,12 +7,12 @@ This guide will bring Feast up using Docker Compose. This will allow you to: * Create, register, and manage [feature sets](../../user-guide/feature-sets.md) * Ingest feature data into Feast * Retrieve features for online serving -* Retrieve features for batch serving \(only if using Google Cloud Platform\) +* Retrieve features for historical serving \(training\) ### 0. Requirements -* [Docker Compose](https://docs.docker.com/compose/install/) should be installed -* [GCP service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) that has access to [Google Cloud Storage](https://cloud.google.com/storage) and [BigQuery](https://cloud.google.com/bigquery) \(for historical serving only\). +* [Docker Compose](https://docs.docker.com/compose/install/) should be installed. +* a [GCP service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) that has access to [Google Cloud Storage](https://cloud.google.com/storage) and [BigQuery](https://cloud.google.com/bigquery) \(for historical serving only\). * [Google Cloud SDK ](https://cloud.google.com/sdk/install)installed, authenticated, and configured to the GCP project you want to use \(for historical serving only\). ## 1. Set up environment @@ -30,82 +30,81 @@ cp .env.sample .env Run the following command if you would like to start both Feast Core and Feast Serving at the same time. The Feast Serving online deployment will use Redis as its database. ```javascript -docker-compose \ --f docker-compose.yml \ --f docker-compose.online.yml \ -up -d +docker-compose up -d ``` -Your Feast deployment should now be starting up. Have a look at the Jupyter docker logs to know when your notebook is ready to be used: +Once Feast comes up you should be able to connect to a local Jupyter notebook that contains Feast examples. This may take a few minutes. -```text -docker logs feast_jupyter_1 -``` - -Once it is ready you should be able to connect to a local notebook that contains Feast examples. This may take a few minutes. - -```text -[I 05:50:22.991 NotebookApp] The Jupyter Notebook is running at: -[I 05:50:22.991 NotebookApp] http://localhost:8888/ -``` - -Navigate to the Jupyter Notebook container at the following URL when the container is ready. - -{% embed url="http://localhost:8888/tree/feast/examples" caption="" %} +{% embed url="http://localhost:8888/tree?" %} ## 3. Start Feast for Training and Online Serving {% hint style="info" %} -Batch serving requires Google Cloud Platform to function, specifically Google Cloud Storage \(GCP\) and BigQuery. +Historical serving requires Google Cloud Platform to function, specifically Google Cloud Storage \(GCS\) and BigQuery. {% endhint %} ### 3.1 Set up Google Cloud Platform -Create a [service account ](https://cloud.google.com/iam/docs/creating-managing-service-accounts)and copy it to the `infra/docker-compose/gcp-service-accounts` folder: +Create a [service account ](https://cloud.google.com/iam/docs/creating-managing-service-accounts)and add it to your docker compose configuration: ```javascript -cp my-service-account.json ${FEAST_HOME_DIR}/infra/docker-compose/gcp-service-accounts +cp key.json ${FEAST_HOME_DIR}/infra/docker-compose/gcp-service-accounts ``` -Create a Google Cloud Storage bucket. Make sure that your service account above has read/write permissions to this bucket: +Temporarily activate the service account to setup your GCS bucket and BigQuery dataset, or alternatively you will need to grant access to these if you use a different account for provisioning the resources. + +```text +gcloud auth activate-service-account --key-file=key.json +``` + +Create a Google Cloud Storage bucket that Feast will use to load data into and out of BigQuery ```bash gsutil mb gs://my-feast-staging-bucket ``` -### 3.2 Configure .env +Create a BigQuery dataset for Feast to store historical data: + +```bash +bq --location=US mk --dataset my_project:feast +``` + +### 3.2 Configure Docker Compose Configure the `.env` file based on your environment. At the very least you have to modify: | Parameter | Description | | :--- | :--- | -| `FEAST_CORE_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json`. | -| `FEAST_HISTORICAL_SERVING_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json` | -| `FEAST_JUPYTER_GCP_SERVICE_ACCOUNT_KEY` | This should be your service account file name, for example `key.json` | +| `GCP_SERVICE_ACCOUNT` | This should be your service account file path, for example `./gcp-service-accounts/key.json`. | +| `FEAST_HISTORICAL_SERVING_ENABLED` | Set this to `true` to enable historical serving \(BigQuery\) | -### 3.3 Configure Historical Serving +### 3.3 Configure Services -We will also need to configure the `historical-serving.yml` file inside `infra/docker-compose/serving/`. This configuration is used to retrieve training datasets from Feast. At a minimum you will need to set: +The following configuration has to be set in `serving/historical-serving.yml` | Parameter | Description | | :--- | :--- | | `feast.stores.config.project_id` | This is your [GCP project Id](https://cloud.google.com/resource-manager/docs/creating-managing-projects). | -| `feast.stores.config.dataset_id` | This is the name of the BigQuery dataset that feature data will be stored in. | -| `feast.stores.config.staging_location` | This is the staging location on Google Cloud Storage for retrieval of training datasets | +| `feast.stores.config.dataset_id` | This is the name of the BigQuery dataset that you created above | +| `feast.stores.config.staging_location` | This is the staging location on Google Cloud Storage for retrieval of training datasets, created above. Keep a suffix. | -### 3.4 Start Feast \(with batch retrieval support\) +The following configuration has to be set in `core/core.yml` + +| Parameter | Description | +| :--- | :--- | +| `feast.jobs.runners.options.tempLocation` | Beam ingestion jobs will persist data here before loading it into BigQuery. Use the same bucket as above and keep a suffix. | + +### 3.4 Start Feast Start Feast: ```javascript -docker-compose \ --f docker-compose.yml \ --f docker-compose.online.yml \ --f docker-compose.batch.yml \ -up -d +docker-compose up -d ``` -A Jupyter Notebook should become available within a few minutes: +Once Feast comes up you should be able to connect to a local Jupyter notebook that contains Feast examples. This may take a few minutes. + +{% embed url="http://localhost:8888/tree?" %} -{% embed url="http://localhost:8888/tree/feast/examples" caption="" %} +## diff --git a/docs/getting-started/deploying-feast/kubernetes.md b/docs/getting-started/deploying-feast/kubernetes.md index b1976b18c2b..65f9dc44b6b 100644 --- a/docs/getting-started/deploying-feast/kubernetes.md +++ b/docs/getting-started/deploying-feast/kubernetes.md @@ -46,6 +46,12 @@ gcloud iam service-accounts keys create credentials.json --iam-account \ feast-service-account@my-gcp-project.iam.gserviceaccount.com ``` +Create a BigQuery dataset for Feast to store historical data: + +```bash +bq --location=US mk --dataset my_project:feast +``` + ## 2. Set up a Kubernetes \(GKE\) cluster Create a Kubernetes cluster: @@ -123,8 +129,14 @@ curl https://raw.githubusercontent.com/feast-dev/feast/master/infra/charts/feast Update `values.yaml` based on your GCP and GKE environment. Minimally the following values must be set * `project_id` is your GCP project id -* `dataset_id` is your BigQuery dataset id. This dataset will be created by Feast if it does not exist. -* `staging_location` is the GCS bucket used for staging that you created in this guide. +* `dataset_id` is your BigQuery dataset id. +* `staging_location` is the GCS bucket used for staging data being loaded into BigQuery. +* `tempLocation` should also be set to the bucket that you want ingestion jobs to load data into BigQuery. + +For more details on configuration parameters, please see the following two files + +* [Core](https://github.com/feast-dev/feast/blob/master/core/src/main/resources/application.yml) +* [Serving](https://github.com/feast-dev/feast/blob/master/serving/src/main/resources/application.yml) Install the Feast Helm chart: @@ -142,7 +154,7 @@ This may take a few minutes ```bash NAME READY STATUS RESTARTS AGE -myrelease-feast-batch-serving-5674f6fb4c-bzsk8 1/1 Running 2 14m +myrelease-feast-historical-serving-5674f6fb4c-bzsk8 1/1 Running 2 14m myrelease-feast-core-7547b455f4-fvppz 1/1 Running 1 14m myrelease-feast-jupyter-9b7c4b8fd-bdcbv 1/1 Running 0 14m myrelease-feast-online-serving-7884578db5-57xwv 1/1 Running 1 14m diff --git a/docs/user-guide/feature-retrieval.md b/docs/user-guide/feature-retrieval.md index 1cdad70920e..f4a952bd930 100644 --- a/docs/user-guide/feature-retrieval.md +++ b/docs/user-guide/feature-retrieval.md @@ -173,7 +173,7 @@ Online Serving also returns Online Field Statuses when retrieving features. Thes for feature in features: # field statuses can be obtained from the response's field values status = response.field_values.statuses[feature] - + if status == GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND: # handle case where feature value has not been ingested elif status == GetOnlineFeaturesResponse.FieldStatus.PRESENT: diff --git a/docs/user-guide/statistics.md b/docs/user-guide/statistics.md index 71ce58422fa..debfcbf958e 100644 --- a/docs/user-guide/statistics.md +++ b/docs/user-guide/statistics.md @@ -16,7 +16,7 @@ Statistics can be retrieved from Feast using the python SDK's `get_statistics` m Feature statistics can be retrieved for a single feature set, from a single valid warehouse store. Users can opt to either retrieve feature statistics for a discrete subset of data by providing an `ingestion_id` , a unique id generated for a dataset when it is ingested into feast: -```text +```python # A unique ingestion id is returned for each batch ingestion ingestion_id=client.ingest(feature_set,df) @@ -29,7 +29,7 @@ stats = client.get_statistics( Or by selecting data within a time range by providing a `start_date` and `end_date` \(the start date is inclusive, the end date is not\): -```text +```python start_date=datetime(2020,10,1,0,0,0) end_date=datetime(2020,10,2,0,0,0) @@ -49,7 +49,7 @@ Note that when providing a time range, Feast will NOT filter out duplicated rows Feast returns the statistics in the form of the protobuf [DatasetFeatureStatisticsList](https://github.com/tensorflow/metadata/blob/master/tensorflow_metadata/proto/v0/statistics.proto#L36), which can be subsequently passed to TFDV methods to [validate the dataset](https://www.tensorflow.org/tfx/data_validation/get_started#checking_the_data_for_errors)... -```text +```python anomalies = tfdv.validate_statistics( statistics=stats_2, schema=feature_set.export_tfx_schema()) tfdv.display_anomalies(anomalies) @@ -57,7 +57,7 @@ tfdv.display_anomalies(anomalies) Or [visualise the statistics](https://www.tensorflow.org/tfx/data_validation/get_started#computing_descriptive_data_statistics) in [facets](https://github.com/PAIR-code/facets). -```text +```python tfdv.visualize_statistics(stats) ``` @@ -67,7 +67,7 @@ Refer to the [example notebook](https://github.com/feast-dev/feast/blob/master/e Feast supports retrieval of feature statistics across multiple datasets or days. -```text +```python stats = client.get_statistics( feature_set_id='project/feature_set', store='warehouse', @@ -83,7 +83,7 @@ Refer to the table below for the list of statistics that will be dropped. Feast caches the results of all feature statistics requests, and will, by default, retrieve and return the cached results. To recompute previously computed feature statistics, set `force_refresh` to `true` when retrieving the statistics: -```text +```python stats=client.get_statistics( feature_set_id='project/feature_set', store='warehouse', From f814dc151e5b7b630cd5c407edf051ee01ffba26 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Wed, 5 Aug 2020 00:03:43 +0800 Subject: [PATCH 22/31] Revert "Change versions to 0.6.3-SNAPSHOT" This reverts commit 9f2f5da218597ebab2b499a8b1e7b49d2481f31a. --- datatypes/java/README.md | 2 +- docs/contributing/development-guide.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 10 +++++----- infra/charts/feast/charts/feast-core/Chart.yaml | 2 +- infra/charts/feast/charts/feast-core/README.md | 2 +- infra/charts/feast/charts/feast-jupyter/Chart.yaml | 2 +- infra/charts/feast/charts/feast-jupyter/README.md | 2 +- infra/charts/feast/charts/feast-serving/Chart.yaml | 2 +- infra/charts/feast/charts/feast-serving/README.md | 2 +- infra/charts/feast/requirements.yaml | 8 ++++---- pom.xml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/datatypes/java/README.md b/datatypes/java/README.md index 7a0fffda491..ca793f09d6b 100644 --- a/datatypes/java/README.md +++ b/datatypes/java/README.md @@ -16,7 +16,7 @@ Dependency Coordinates dev.feast datatypes-java - 0.6.3-SNAPSHOT + 0.6.2 ``` diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md index 28e5ca42e7a..095dbf2c4e9 100644 --- a/docs/contributing/development-guide.md +++ b/docs/contributing/development-guide.md @@ -181,7 +181,7 @@ grpc_cli call localhost:6566 GetFeastServingInfo '' ```text connecting to localhost:6566 -version: "0.6.3-SNAPSHOT" +version: "0.6.2" type: FEAST_SERVING_TYPE_ONLINE Rpc succeeded with OK status diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index e7921416350..a4d8f163d88 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feature store for machine learning. name: feast -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index c33ea0fedc6..5971e2ad430 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -1,7 +1,7 @@ feast ===== -Feature store for machine learning. Current chart version is `0.6.3-SNAPSHOT` +Feature store for machine learning. Current chart version is `0.6.2` ## TL;DR; @@ -32,10 +32,10 @@ This chart install Feast deployment on a Kubernetes cluster using the [Helm](htt | Repository | Name | Version | |------------|------|---------| -| | feast-core | 0.6.3-SNAPSHOT | -| | feast-jupyter | 0.6.3-SNAPSHOT | -| | feast-serving | 0.6.3-SNAPSHOT | -| | feast-serving | 0.6.3-SNAPSHOT | +| | feast-core | 0.6.2 | +| | feast-jupyter | 0.6.2 | +| | feast-serving | 0.6.2 | +| | feast-serving | 0.6.2 | | | prometheus-statsd-exporter | 0.1.2 | | https://kubernetes-charts-incubator.storage.googleapis.com/ | kafka | 0.20.8 | | https://kubernetes-charts.storage.googleapis.com/ | grafana | 5.0.5 | diff --git a/infra/charts/feast/charts/feast-core/Chart.yaml b/infra/charts/feast/charts/feast-core/Chart.yaml index c7c22d099cd..821c5e5dab4 100644 --- a/infra/charts/feast/charts/feast-core/Chart.yaml +++ b/infra/charts/feast/charts/feast-core/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Core registers feature specifications and manage ingestion jobs. name: feast-core -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-core/README.md b/infra/charts/feast/charts/feast-core/README.md index 6067e0176a4..40b0b155e9c 100644 --- a/infra/charts/feast/charts/feast-core/README.md +++ b/infra/charts/feast/charts/feast-core/README.md @@ -2,7 +2,7 @@ feast-core ========== Feast Core registers feature specifications and manage ingestion jobs. -Current chart version is `0.6.3-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/charts/feast-jupyter/Chart.yaml b/infra/charts/feast/charts/feast-jupyter/Chart.yaml index 5f1589a42c2..3dac9846ee6 100644 --- a/infra/charts/feast/charts/feast-jupyter/Chart.yaml +++ b/infra/charts/feast/charts/feast-jupyter/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Jupyter provides a Jupyter server with pre-installed Feast SDK name: feast-jupyter -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-jupyter/README.md b/infra/charts/feast/charts/feast-jupyter/README.md index 18e262eb368..df356b28502 100644 --- a/infra/charts/feast/charts/feast-jupyter/README.md +++ b/infra/charts/feast/charts/feast-jupyter/README.md @@ -2,7 +2,7 @@ feast-jupyter ============= Feast Jupyter provides a Jupyter server with pre-installed Feast SDK -Current chart version is `0.6.3-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/charts/feast-serving/Chart.yaml b/infra/charts/feast/charts/feast-serving/Chart.yaml index 5100c13b42c..2a4ff2b82c6 100644 --- a/infra/charts/feast/charts/feast-serving/Chart.yaml +++ b/infra/charts/feast/charts/feast-serving/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Serving serves low-latency latest features and historical batch features. name: feast-serving -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-serving/README.md b/infra/charts/feast/charts/feast-serving/README.md index edeab143899..7e14e2ebe02 100644 --- a/infra/charts/feast/charts/feast-serving/README.md +++ b/infra/charts/feast/charts/feast-serving/README.md @@ -2,7 +2,7 @@ feast-serving ============= Feast Serving serves low-latency latest features and historical batch features. -Current chart version is `0.6.3-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index 4a76af2fb16..835b281cd33 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,17 +1,17 @@ dependencies: - name: feast-core - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-core.enabled - name: feast-serving alias: feast-online-serving - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-online-serving.enabled - name: feast-serving alias: feast-batch-serving - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-batch-serving.enabled - name: feast-jupyter - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-jupyter.enabled - name: postgresql version: 8.6.1 diff --git a/pom.xml b/pom.xml index 9a3f1e4b4e3..b2624657e48 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - 0.6.3-SNAPSHOT + 0.6.2 https://github.com/feast-dev/feast UTF-8 From 74226189f90b6cc3bdf5fe05465ef845ab998e90 Mon Sep 17 00:00:00 2001 From: Zhu Zhan Yan Date: Tue, 4 Aug 2020 21:32:46 +0800 Subject: [PATCH 23/31] Allow unauthenticated access when Authorization is disabled and to Health Probe (#927) * Config Core/Serving authentication to allow unauthenticated access to health probe. * Allow unauthenticated requests when only authentication but not authorization is enabled. * Fix ServingServiceOauthAuthenticationIT * Add missing applyFeatureSet call to ServingServiceOauthAuthenticationIT --- .../feast/auth/config/SecurityConfig.java | 6 ++--- .../feast/core/config/CoreSecurityConfig.java | 2 ++ .../serving/config/ServingSecurityConfig.java | 6 +++++ .../ServingServiceOauthAuthenticationIT.java | 24 +++++++++---------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/auth/src/main/java/feast/auth/config/SecurityConfig.java b/auth/src/main/java/feast/auth/config/SecurityConfig.java index 8229702b3ed..f377c76a874 100644 --- a/auth/src/main/java/feast/auth/config/SecurityConfig.java +++ b/auth/src/main/java/feast/auth/config/SecurityConfig.java @@ -83,13 +83,13 @@ GrpcAuthenticationReader authenticationReader() { } /** - * Creates an AccessDecisionManager if authentication is enabled. This object determines the - * policy used to make authentication decisions. + * Creates an AccessDecisionManager if authorization is enabled. This object determines the policy + * used to make authorization decisions. * * @return AccessDecisionManager */ @Bean - @ConditionalOnProperty(prefix = "feast.security.authentication", name = "enabled") + @ConditionalOnProperty(prefix = "feast.security.authorization", name = "enabled") AccessDecisionManager accessDecisionManager() { final List> voters = new ArrayList<>(); voters.add(new AccessPredicateVoter()); diff --git a/core/src/main/java/feast/core/config/CoreSecurityConfig.java b/core/src/main/java/feast/core/config/CoreSecurityConfig.java index 3e4c2baa9eb..ead6bcb18bc 100644 --- a/core/src/main/java/feast/core/config/CoreSecurityConfig.java +++ b/core/src/main/java/feast/core/config/CoreSecurityConfig.java @@ -17,6 +17,7 @@ package feast.core.config; import feast.proto.core.CoreServiceGrpc; +import io.grpc.health.v1.HealthGrpc; import lombok.extern.slf4j.Slf4j; import net.devh.boot.grpc.server.security.check.AccessPredicate; import net.devh.boot.grpc.server.security.check.GrpcSecurityMetadataSource; @@ -48,6 +49,7 @@ GrpcSecurityMetadataSource grpcSecurityMetadataSource() { // The following endpoints allow unauthenticated access source.set(CoreServiceGrpc.getGetFeastCoreVersionMethod(), AccessPredicate.permitAll()); source.set(CoreServiceGrpc.getUpdateStoreMethod(), AccessPredicate.permitAll()); + source.set(HealthGrpc.getCheckMethod(), AccessPredicate.permitAll()); return source; } } diff --git a/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java b/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java index 2d0a46763a7..839c133387d 100644 --- a/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java +++ b/serving/src/main/java/feast/serving/config/ServingSecurityConfig.java @@ -18,7 +18,9 @@ import feast.auth.credentials.GoogleAuthCredentials; import feast.auth.credentials.OAuthCredentials; +import feast.proto.serving.ServingServiceGrpc; import io.grpc.CallCredentials; +import io.grpc.health.v1.HealthGrpc; import java.io.IOException; import net.devh.boot.grpc.server.security.check.AccessPredicate; import net.devh.boot.grpc.server.security.check.GrpcSecurityMetadataSource; @@ -67,6 +69,10 @@ GrpcSecurityMetadataSource grpcSecurityMetadataSource() { // Authentication is enabled for all gRPC endpoints source.setDefault(AccessPredicate.authenticated()); + + // The following endpoints allow unauthenticated access + source.set(ServingServiceGrpc.getGetFeastServingInfoMethod(), AccessPredicate.permitAll()); + source.set(HealthGrpc.getCheckMethod(), AccessPredicate.permitAll()); return source; } diff --git a/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java b/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java index edd16c24a87..f1289adc737 100644 --- a/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java +++ b/serving/src/test/java/feast/serving/it/ServingServiceOauthAuthenticationIT.java @@ -17,7 +17,6 @@ package feast.serving.it; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.testcontainers.containers.wait.strategy.Wait.forHttp; @@ -26,7 +25,6 @@ import feast.proto.serving.ServingServiceGrpc.ServingServiceBlockingStub; import feast.proto.types.ValueProto.Value; import io.grpc.ManagedChannel; -import io.grpc.StatusRuntimeException; import java.io.File; import java.io.IOException; import java.time.Duration; @@ -87,21 +85,21 @@ static void globalSetup() throws IOException, InitializationError, InterruptedEx } @Test - public void shouldNotAllowUnauthenticatedGetOnlineFeatures() { + public void shouldAllowUnauthenticatedGetOnlineFeatures() { + // apply feature set + CoreSimpleAPIClient coreClient = + AuthTestUtils.getSecureApiClientForCore(FEAST_CORE_PORT, options); + AuthTestUtils.applyFeatureSet(coreClient, PROJECT_NAME, ENTITY_ID, FEATURE_NAME); ServingServiceBlockingStub servingStub = AuthTestUtils.getServingServiceStub(false, FEAST_SERVING_PORT, null); GetOnlineFeaturesRequest onlineFeatureRequest = AuthTestUtils.createOnlineFeatureRequest(PROJECT_NAME, FEATURE_NAME, ENTITY_ID, 1); - Exception exception = - assertThrows( - StatusRuntimeException.class, - () -> { - servingStub.getOnlineFeatures(onlineFeatureRequest); - }); - - String expectedMessage = "UNAUTHENTICATED: Authentication failed"; - String actualMessage = exception.getMessage(); - assertEquals(actualMessage, expectedMessage); + GetOnlineFeaturesResponse featureResponse = servingStub.getOnlineFeatures(onlineFeatureRequest); + assertEquals(1, featureResponse.getFieldValuesCount()); + Map fieldsMap = featureResponse.getFieldValues(0).getFieldsMap(); + assertTrue(fieldsMap.containsKey(ENTITY_ID)); + assertTrue(fieldsMap.containsKey(FEATURE_NAME)); + ((ManagedChannel) servingStub.getChannel()).shutdown(); } @Test From 10045b69a25d18dd77e1d7d843db55a8fbe6e6cb Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Sun, 2 Aug 2020 12:42:08 +0800 Subject: [PATCH 24/31] Change versions to 0.6.3-SNAPSHOT --- datatypes/java/README.md | 2 +- docs/contributing/development-guide.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 10 +++++----- infra/charts/feast/charts/feast-core/Chart.yaml | 2 +- infra/charts/feast/charts/feast-core/README.md | 2 +- infra/charts/feast/charts/feast-jupyter/Chart.yaml | 2 +- infra/charts/feast/charts/feast-jupyter/README.md | 2 +- infra/charts/feast/charts/feast-serving/Chart.yaml | 2 +- infra/charts/feast/charts/feast-serving/README.md | 2 +- infra/charts/feast/requirements.yaml | 8 ++++---- pom.xml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/datatypes/java/README.md b/datatypes/java/README.md index ca793f09d6b..7a0fffda491 100644 --- a/datatypes/java/README.md +++ b/datatypes/java/README.md @@ -16,7 +16,7 @@ Dependency Coordinates dev.feast datatypes-java - 0.6.2 + 0.6.3-SNAPSHOT ``` diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md index 095dbf2c4e9..28e5ca42e7a 100644 --- a/docs/contributing/development-guide.md +++ b/docs/contributing/development-guide.md @@ -181,7 +181,7 @@ grpc_cli call localhost:6566 GetFeastServingInfo '' ```text connecting to localhost:6566 -version: "0.6.2" +version: "0.6.3-SNAPSHOT" type: FEAST_SERVING_TYPE_ONLINE Rpc succeeded with OK status diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index a4d8f163d88..e7921416350 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feature store for machine learning. name: feast -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index 5971e2ad430..c33ea0fedc6 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -1,7 +1,7 @@ feast ===== -Feature store for machine learning. Current chart version is `0.6.2` +Feature store for machine learning. Current chart version is `0.6.3-SNAPSHOT` ## TL;DR; @@ -32,10 +32,10 @@ This chart install Feast deployment on a Kubernetes cluster using the [Helm](htt | Repository | Name | Version | |------------|------|---------| -| | feast-core | 0.6.2 | -| | feast-jupyter | 0.6.2 | -| | feast-serving | 0.6.2 | -| | feast-serving | 0.6.2 | +| | feast-core | 0.6.3-SNAPSHOT | +| | feast-jupyter | 0.6.3-SNAPSHOT | +| | feast-serving | 0.6.3-SNAPSHOT | +| | feast-serving | 0.6.3-SNAPSHOT | | | prometheus-statsd-exporter | 0.1.2 | | https://kubernetes-charts-incubator.storage.googleapis.com/ | kafka | 0.20.8 | | https://kubernetes-charts.storage.googleapis.com/ | grafana | 5.0.5 | diff --git a/infra/charts/feast/charts/feast-core/Chart.yaml b/infra/charts/feast/charts/feast-core/Chart.yaml index 821c5e5dab4..c7c22d099cd 100644 --- a/infra/charts/feast/charts/feast-core/Chart.yaml +++ b/infra/charts/feast/charts/feast-core/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Core registers feature specifications and manage ingestion jobs. name: feast-core -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-core/README.md b/infra/charts/feast/charts/feast-core/README.md index 40b0b155e9c..6067e0176a4 100644 --- a/infra/charts/feast/charts/feast-core/README.md +++ b/infra/charts/feast/charts/feast-core/README.md @@ -2,7 +2,7 @@ feast-core ========== Feast Core registers feature specifications and manage ingestion jobs. -Current chart version is `0.6.2` +Current chart version is `0.6.3-SNAPSHOT` diff --git a/infra/charts/feast/charts/feast-jupyter/Chart.yaml b/infra/charts/feast/charts/feast-jupyter/Chart.yaml index 3dac9846ee6..5f1589a42c2 100644 --- a/infra/charts/feast/charts/feast-jupyter/Chart.yaml +++ b/infra/charts/feast/charts/feast-jupyter/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Jupyter provides a Jupyter server with pre-installed Feast SDK name: feast-jupyter -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-jupyter/README.md b/infra/charts/feast/charts/feast-jupyter/README.md index df356b28502..18e262eb368 100644 --- a/infra/charts/feast/charts/feast-jupyter/README.md +++ b/infra/charts/feast/charts/feast-jupyter/README.md @@ -2,7 +2,7 @@ feast-jupyter ============= Feast Jupyter provides a Jupyter server with pre-installed Feast SDK -Current chart version is `0.6.2` +Current chart version is `0.6.3-SNAPSHOT` diff --git a/infra/charts/feast/charts/feast-serving/Chart.yaml b/infra/charts/feast/charts/feast-serving/Chart.yaml index 2a4ff2b82c6..5100c13b42c 100644 --- a/infra/charts/feast/charts/feast-serving/Chart.yaml +++ b/infra/charts/feast/charts/feast-serving/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Serving serves low-latency latest features and historical batch features. name: feast-serving -version: 0.6.2 +version: 0.6.3-SNAPSHOT diff --git a/infra/charts/feast/charts/feast-serving/README.md b/infra/charts/feast/charts/feast-serving/README.md index 7e14e2ebe02..edeab143899 100644 --- a/infra/charts/feast/charts/feast-serving/README.md +++ b/infra/charts/feast/charts/feast-serving/README.md @@ -2,7 +2,7 @@ feast-serving ============= Feast Serving serves low-latency latest features and historical batch features. -Current chart version is `0.6.2` +Current chart version is `0.6.3-SNAPSHOT` diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index 835b281cd33..4a76af2fb16 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,17 +1,17 @@ dependencies: - name: feast-core - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-core.enabled - name: feast-serving alias: feast-online-serving - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-online-serving.enabled - name: feast-serving alias: feast-batch-serving - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-batch-serving.enabled - name: feast-jupyter - version: 0.6.2 + version: 0.6.3-SNAPSHOT condition: feast-jupyter.enabled - name: postgresql version: 8.6.1 diff --git a/pom.xml b/pom.xml index b2624657e48..9a3f1e4b4e3 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - 0.6.2 + 0.6.3-SNAPSHOT https://github.com/feast-dev/feast UTF-8 From 970f6815f658aecde95cebc0bbe05a953354788f Mon Sep 17 00:00:00 2001 From: Zhu Zhan Yan Date: Wed, 5 Aug 2020 15:01:30 +0800 Subject: [PATCH 25/31] Update v0.6.2 changelog with patch PR #927 (#931) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d0d2cb1a4..02cf0080a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fix Online Serving unable to retrieve feature data after Feature Set update. [\#908](https://github.com/feast-dev/feast/pull/908) ([mrzzy](https://github.com/mrzzy)) - Fix Python SDK ingestion for featureset name that exist in multiple projects [\#868](https://github.com/feast-dev/feast/pull/868) ([terryyylim](https://github.com/terryyylim)) - Backport delay in Redis acknowledgement of spec [\#915](https://github.com/feast-dev/feast/pull/915) ([woop](https://github.com/woop)) +- Allow unauthenticated access when Authorization is disabled and to Health Probe [\#927](https://github.com/feast-dev/feast/pull/927) ([mrzzy](https://github.com/mrzzy)) **Merged pull requests:** From 2e14b2f4f8b5f9fde4f13f1be6335730c9ae506c Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Wed, 5 Aug 2020 15:22:38 +0800 Subject: [PATCH 26/31] Fix Feast Core dataflow dependency version in v0.6 backport --- core/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/pom.xml b/core/pom.xml index a7ec3374737..adc5bb557fd 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -221,7 +221,7 @@ com.google.apis google-api-services-dataflow - v1b3-rev266-1.25.0 + v1b3-rev20200305-1.30.9 org.hibernate From a04da03c9566322d8afb5841ffa52508b20feb73 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Wed, 5 Aug 2020 15:24:44 +0800 Subject: [PATCH 27/31] Revert "Change versions to 0.6.3-SNAPSHOT" This reverts commit 10045b69a25d18dd77e1d7d843db55a8fbe6e6cb. --- datatypes/java/README.md | 2 +- docs/contributing/development-guide.md | 2 +- infra/charts/feast/Chart.yaml | 2 +- infra/charts/feast/README.md | 10 +++++----- infra/charts/feast/charts/feast-core/Chart.yaml | 2 +- infra/charts/feast/charts/feast-core/README.md | 2 +- infra/charts/feast/charts/feast-jupyter/Chart.yaml | 2 +- infra/charts/feast/charts/feast-jupyter/README.md | 2 +- infra/charts/feast/charts/feast-serving/Chart.yaml | 2 +- infra/charts/feast/charts/feast-serving/README.md | 2 +- infra/charts/feast/requirements.yaml | 8 ++++---- pom.xml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/datatypes/java/README.md b/datatypes/java/README.md index 7a0fffda491..ca793f09d6b 100644 --- a/datatypes/java/README.md +++ b/datatypes/java/README.md @@ -16,7 +16,7 @@ Dependency Coordinates dev.feast datatypes-java - 0.6.3-SNAPSHOT + 0.6.2 ``` diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md index 28e5ca42e7a..095dbf2c4e9 100644 --- a/docs/contributing/development-guide.md +++ b/docs/contributing/development-guide.md @@ -181,7 +181,7 @@ grpc_cli call localhost:6566 GetFeastServingInfo '' ```text connecting to localhost:6566 -version: "0.6.3-SNAPSHOT" +version: "0.6.2" type: FEAST_SERVING_TYPE_ONLINE Rpc succeeded with OK status diff --git a/infra/charts/feast/Chart.yaml b/infra/charts/feast/Chart.yaml index e7921416350..a4d8f163d88 100644 --- a/infra/charts/feast/Chart.yaml +++ b/infra/charts/feast/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feature store for machine learning. name: feast -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/README.md b/infra/charts/feast/README.md index c33ea0fedc6..5971e2ad430 100644 --- a/infra/charts/feast/README.md +++ b/infra/charts/feast/README.md @@ -1,7 +1,7 @@ feast ===== -Feature store for machine learning. Current chart version is `0.6.3-SNAPSHOT` +Feature store for machine learning. Current chart version is `0.6.2` ## TL;DR; @@ -32,10 +32,10 @@ This chart install Feast deployment on a Kubernetes cluster using the [Helm](htt | Repository | Name | Version | |------------|------|---------| -| | feast-core | 0.6.3-SNAPSHOT | -| | feast-jupyter | 0.6.3-SNAPSHOT | -| | feast-serving | 0.6.3-SNAPSHOT | -| | feast-serving | 0.6.3-SNAPSHOT | +| | feast-core | 0.6.2 | +| | feast-jupyter | 0.6.2 | +| | feast-serving | 0.6.2 | +| | feast-serving | 0.6.2 | | | prometheus-statsd-exporter | 0.1.2 | | https://kubernetes-charts-incubator.storage.googleapis.com/ | kafka | 0.20.8 | | https://kubernetes-charts.storage.googleapis.com/ | grafana | 5.0.5 | diff --git a/infra/charts/feast/charts/feast-core/Chart.yaml b/infra/charts/feast/charts/feast-core/Chart.yaml index c7c22d099cd..821c5e5dab4 100644 --- a/infra/charts/feast/charts/feast-core/Chart.yaml +++ b/infra/charts/feast/charts/feast-core/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Core registers feature specifications and manage ingestion jobs. name: feast-core -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-core/README.md b/infra/charts/feast/charts/feast-core/README.md index 6067e0176a4..40b0b155e9c 100644 --- a/infra/charts/feast/charts/feast-core/README.md +++ b/infra/charts/feast/charts/feast-core/README.md @@ -2,7 +2,7 @@ feast-core ========== Feast Core registers feature specifications and manage ingestion jobs. -Current chart version is `0.6.3-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/charts/feast-jupyter/Chart.yaml b/infra/charts/feast/charts/feast-jupyter/Chart.yaml index 5f1589a42c2..3dac9846ee6 100644 --- a/infra/charts/feast/charts/feast-jupyter/Chart.yaml +++ b/infra/charts/feast/charts/feast-jupyter/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Jupyter provides a Jupyter server with pre-installed Feast SDK name: feast-jupyter -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-jupyter/README.md b/infra/charts/feast/charts/feast-jupyter/README.md index 18e262eb368..df356b28502 100644 --- a/infra/charts/feast/charts/feast-jupyter/README.md +++ b/infra/charts/feast/charts/feast-jupyter/README.md @@ -2,7 +2,7 @@ feast-jupyter ============= Feast Jupyter provides a Jupyter server with pre-installed Feast SDK -Current chart version is `0.6.3-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/charts/feast-serving/Chart.yaml b/infra/charts/feast/charts/feast-serving/Chart.yaml index 5100c13b42c..2a4ff2b82c6 100644 --- a/infra/charts/feast/charts/feast-serving/Chart.yaml +++ b/infra/charts/feast/charts/feast-serving/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Feast Serving serves low-latency latest features and historical batch features. name: feast-serving -version: 0.6.3-SNAPSHOT +version: 0.6.2 diff --git a/infra/charts/feast/charts/feast-serving/README.md b/infra/charts/feast/charts/feast-serving/README.md index edeab143899..7e14e2ebe02 100644 --- a/infra/charts/feast/charts/feast-serving/README.md +++ b/infra/charts/feast/charts/feast-serving/README.md @@ -2,7 +2,7 @@ feast-serving ============= Feast Serving serves low-latency latest features and historical batch features. -Current chart version is `0.6.3-SNAPSHOT` +Current chart version is `0.6.2` diff --git a/infra/charts/feast/requirements.yaml b/infra/charts/feast/requirements.yaml index 4a76af2fb16..835b281cd33 100644 --- a/infra/charts/feast/requirements.yaml +++ b/infra/charts/feast/requirements.yaml @@ -1,17 +1,17 @@ dependencies: - name: feast-core - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-core.enabled - name: feast-serving alias: feast-online-serving - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-online-serving.enabled - name: feast-serving alias: feast-batch-serving - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-batch-serving.enabled - name: feast-jupyter - version: 0.6.3-SNAPSHOT + version: 0.6.2 condition: feast-jupyter.enabled - name: postgresql version: 8.6.1 diff --git a/pom.xml b/pom.xml index 9a3f1e4b4e3..b2624657e48 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,7 @@ - 0.6.3-SNAPSHOT + 0.6.2 https://github.com/feast-dev/feast UTF-8 From 1d4f4a6f97768da29ffe5647e74dd8da0098b155 Mon Sep 17 00:00:00 2001 From: Willem Pienaar Date: Sun, 26 Jul 2020 14:52:39 +0800 Subject: [PATCH 28/31] Fix trimmed version in image tag for e2e dataflow test --- infra/scripts/test-end-to-end-batch-dataflow.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/scripts/test-end-to-end-batch-dataflow.sh b/infra/scripts/test-end-to-end-batch-dataflow.sh index e138a75bdea..a0a7a482e46 100755 --- a/infra/scripts/test-end-to-end-batch-dataflow.sh +++ b/infra/scripts/test-end-to-end-batch-dataflow.sh @@ -210,7 +210,7 @@ export GCLOUD_NETWORK=$GCLOUD_NETWORK export GCLOUD_SUBNET=$GCLOUD_SUBNET export GCLOUD_REGION=$GCLOUD_REGION export HELM_COMMON_NAME=$HELM_COMMON_NAME -export IMAGE_TAG=${PULL_PULL_SHA:1} +export IMAGE_TAG=$PULL_PULL_SHA export SPECS_TOPIC=$SPECS_TOPIC envsubst $'$TEMP_BUCKET $DATASET_NAME $GCLOUD_PROJECT $GCLOUD_NETWORK $SPECS_TOPIC \ From c05764cf3882170b9e5b699c03bb2d5a377405f0 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Wed, 5 Aug 2020 17:55:11 +0800 Subject: [PATCH 29/31] Backport missing filter in pom.xml --- ingestion/pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ingestion/pom.xml b/ingestion/pom.xml index d1fbf1d45b5..5ca445c7b45 100644 --- a/ingestion/pom.xml +++ b/ingestion/pom.xml @@ -66,6 +66,16 @@ com.google.cloud.bigquery.vendor + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + From de8308711915aae55e1eada63581fa26881a9c53 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Wed, 5 Aug 2020 18:38:12 +0800 Subject: [PATCH 30/31] Backport consolidate jobs option in dataflow e2e tests --- .../test-templates/values-end-to-end-batch-dataflow.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml b/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml index 377fa7a0aee..dfb76a3dfdb 100644 --- a/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml +++ b/infra/scripts/test-templates/values-end-to-end-batch-dataflow.yaml @@ -20,7 +20,7 @@ feast-core: specsAckTopic: $SPECS_TOPIC-ack jobs: active_runner: dataflow - + consolidate-jobs-per-source: true runners: - name: dataflow type: DataflowRunner From 9d87c0245f94efde59af8426fbb2ce03599bd592 Mon Sep 17 00:00:00 2001 From: Zhu Zhanyan Date: Thu, 6 Aug 2020 12:15:41 +0800 Subject: [PATCH 31/31] Add WebSecurityConfig to Serving to disable for /metrics and /actuator endpoints --- .../serving/config/WebSecurityConfig.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 serving/src/main/java/feast/serving/config/WebSecurityConfig.java diff --git a/serving/src/main/java/feast/serving/config/WebSecurityConfig.java b/serving/src/main/java/feast/serving/config/WebSecurityConfig.java new file mode 100644 index 00000000000..f7b24a77405 --- /dev/null +++ b/serving/src/main/java/feast/serving/config/WebSecurityConfig.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * 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 + * + * https://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 feast.serving.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * WebSecurityConfig disables auto configuration of Spring HTTP Security and allows security methods + * to be overridden + */ +@Configuration +@EnableWebSecurity +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + /** + * Allows for custom web security rules to be applied. + * + * @param http {@link HttpSecurity} for configuring web based security + * @throws Exception + */ + @Override + protected void configure(HttpSecurity http) throws Exception { + + // Bypasses security/authentication for the following paths + http.authorizeRequests() + .antMatchers("/actuator/**", "/metrics/**") + .permitAll() + .anyRequest() + .authenticated() + .and() + .csrf() + .disable(); + } +}