Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a813273
feat: fastly compute service to perform assignments (FF-3552)
leoromanovsky Nov 16, 2024
e985863
convert from reqwest blocking to non-blocking to conform to wasm target
leoromanovsky Nov 5, 2024
c8717ee
remove vscode settings
leoromanovsky Nov 16, 2024
5956e65
makefile for local development
leoromanovsky Nov 16, 2024
a3f37c7
split into multiple handlers
leoromanovsky Nov 17, 2024
48d47aa
its a thing of beauty
leoromanovsky Nov 17, 2024
56e5c96
coming along nicely
leoromanovsky Nov 17, 2024
3a4b644
good
leoromanovsky Nov 17, 2024
59cb251
local test kv-store!
leoromanovsky Nov 17, 2024
a159065
feat: add `get_subject_assignments` method to eppo_core to compute al…
leoromanovsky Nov 17, 2024
6e704ef
simplify unnecessary ownership transfer
leoromanovsky Nov 17, 2024
bccce04
e2e evaluation
leoromanovsky Nov 17, 2024
c338d31
remove space end of filename
leoromanovsky Nov 18, 2024
ff73c47
Revert "feat: add `get_subject_assignments` method to eppo_core to co…
leoromanovsky Nov 18, 2024
48d2ebc
tidy up error logging
leoromanovsky Nov 18, 2024
3b55f6d
Merge branch 'main' into lr/ff-3552/edge-function
leoromanovsky Nov 20, 2024
3219a03
edge function dep on 4.1.1
leoromanovsky Nov 20, 2024
d288696
generate token hash
leoromanovsky Nov 20, 2024
be2d5ac
fastly local
leoromanovsky Nov 20, 2024
9f99865
conform to desired API
leoromanovsky Nov 21, 2024
78fe3b5
add CORS support
leoromanovsky Nov 22, 2024
f156caa
Merge branch 'main' into lr/ff-3552/edge-function
leoromanovsky Nov 22, 2024
f2f27af
version from cargo
leoromanovsky Nov 23, 2024
f1bcf3d
use Datetime
leoromanovsky Nov 23, 2024
4ada237
String
leoromanovsky Nov 23, 2024
8101988
no copy
leoromanovsky Nov 23, 2024
50ed0ec
use VariationType
leoromanovsky Nov 23, 2024
38f71eb
move structs to core
leoromanovsky Nov 23, 2024
266de8a
extra logging
leoromanovsky Nov 23, 2024
c3c90f8
refactor FlagAssignment to model
leoromanovsky Nov 23, 2024
b9ca60a
use Str
leoromanovsky Nov 23, 2024
26300db
build responses in core
leoromanovsky Nov 23, 2024
26c4466
tidy
leoromanovsky Nov 23, 2024
7b4eb91
add format
leoromanovsky Nov 23, 2024
95219d4
str
leoromanovsky Nov 23, 2024
81d1f55
format
leoromanovsky Nov 23, 2024
9622a21
one more format
leoromanovsky Nov 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ jobs:
submodules: true
- run: npm ci
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
- run: cargo build --verbose --all-targets
- run: cargo test --verbose
# Add WASM target
- run: rustup target add wasm32-wasi
# Build non-WASM targets
- run: cargo build --verbose --all-targets --workspace --exclude fastly-edge-assignments
# Build WASM target separately
- run: cargo build --verbose -p fastly-edge-assignments --target wasm32-wasi
# Run tests (excluding WASM package)
- run: cargo test --verbose --workspace --exclude fastly-edge-assignments
- run: cargo doc --verbose
9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
[workspace]
resolver = "2"
members = [
"eppo_core",
"rust-sdk",
"python-sdk",
"ruby-sdk/ext/eppo_client",
"eppo_core",
"rust-sdk",
"python-sdk",
"ruby-sdk/ext/eppo_client",
"fastly-edge-assignments",
]

[patch.crates-io]
Expand Down
49 changes: 34 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
# Make settings - @see https://tech.davis-hansson.com/p/make/
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules

# Log levels
DEBUG := $(shell printf "\e[2D\e[35m")
INFO := $(shell printf "\e[2D\e[36m🔵 ")
OK := $(shell printf "\e[2D\e[32m🟢 ")
WARN := $(shell printf "\e[2D\e[33m🟡 ")
ERROR := $(shell printf "\e[2D\e[31m🔴 ")
END := $(shell printf "\e[0m")
WASM_TARGET=wasm32-wasi
FASTLY_PACKAGE=fastly-edge-assignments
BUILD_DIR=target/$(WASM_TARGET)/release
WASM_FILE=$(BUILD_DIR)/$(FASTLY_PACKAGE).wasm

.PHONY: default
default: help

## help - Print help message.
# Help target for easy documentation
.PHONY: help
help: Makefile
@echo "usage: make <target>"
@sed -n 's/^##//p' $<
help:
@echo "Available targets:"
@echo " all - Default target (build workspace)"
@echo " workspace-build - Build the entire workspace excluding the Fastly package"
@echo " workspace-test - Test the entire workspace excluding the Fastly package"
@echo " fastly-edge-assignments-build - Build only the Fastly package for WASM"
@echo " fastly-edge-assignments-test - Test only the Fastly package"
@echo " clean - Clean all build artifacts"

.PHONY: test
test: ${testDataDir}
npm test

# Build the entire workspace excluding the `fastly-edge-assignments` package
.PHONY: workspace-build
workspace-build:
cargo build --workspace --exclude $(FASTLY_PACKAGE)

# Run tests for the entire workspace excluding the `fastly-edge-assignments` package
.PHONY: workspace-test
workspace-test:
cargo test --workspace --exclude $(FASTLY_PACKAGE)

# Build only the `fastly-edge-assignments` package for WASM
.PHONY: fastly-edge-assignments-build
fastly-edge-assignments-build:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these changes helpful? I got tired of putting the various parameters in the right place.

rustup target add $(WASM_TARGET)
cargo build --release --target $(WASM_TARGET) --package $(FASTLY_PACKAGE)

# Test only the `fastly-edge-assignments` package
.PHONY: fastly-edge-assignments-test
fastly-edge-assignments-test:
cargo test --target $(WASM_TARGET) --package $(FASTLY_PACKAGE)
2 changes: 2 additions & 0 deletions fastly-edge-assignments/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasi"
2 changes: 2 additions & 0 deletions fastly-edge-assignments/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pkg/
bin/
16 changes: 16 additions & 0 deletions fastly-edge-assignments/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "fastly-edge-assignments"
version = "0.1.0"
edition = "2021"
# Remove this line if you want to be able to publish this crate as open source on crates.io.
# Otherwise, `publish = false` prevents an accidental `cargo publish` from revealing private source.
publish = false

[dependencies]
base64-url = "2.0.0"
chrono = "0.4.19"
eppo_core = { version = "=4.1.1", path = "../eppo_core" }
fastly = "0.11.0"
serde_json = "1.0.132"
serde = "1.0.192"
sha2 = "0.10.0"
3 changes: 3 additions & 0 deletions fastly-edge-assignments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Eppo Assignments on Fastly Compute@Edge

TODO: Add a description
17 changes: 17 additions & 0 deletions fastly-edge-assignments/fastly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# This file describes a Fastly Compute package. To learn more visit:
# https://www.fastly.com/documentation/reference/compute/fastly-toml

authors = ["[email protected]"]
name = "Eppo Assignments on Fastly Compute@Edge"
description = "Edge compute service for pre-computed Eppo flag assignments"
language = "rust"
manifest_version = 3

[scripts]
build = "cargo build --bin fastly-edge-assignments --release --target wasm32-wasi --color always"

[local_server]
[local_server.kv_stores]
[[local_server.kv_stores.edge-assignment-kv-store]]
key = "ufc-by-sdk-key-token-hash-V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM"
file = "../sdk-test-data/ufc/flags-v1.json"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this instructs the fastly serve process to populate the local in-memory KV store with one key, a UFC configuration at a precomputed hash. See the unit test in this repo for the associate SDK key to use.

3 changes: 3 additions & 0 deletions fastly-edge-assignments/rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
targets = [ "wasm32-wasi" ]
187 changes: 187 additions & 0 deletions fastly-edge-assignments/src/handlers/assignments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
use eppo_core::configuration_store::ConfigurationStore;
use eppo_core::eval::{Evaluator, EvaluatorConfig};
use eppo_core::ufc::{AssignmentValue, UniversalFlagConfig};
use eppo_core::{Attributes, Configuration, SdkMetadata};
use fastly::http::StatusCode;
use fastly::kv_store::KVStoreError;
use fastly::{Error, KVStore, Request, Response};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;

#[derive(Debug, Deserialize)]
struct RequestBody {
subject_key: String,
subject_attributes: Arc<Attributes>,
// TODO: Add bandit actions
// #[serde(rename = "banditActions")]
// #[serde(skip_serializing_if = "Option::is_none")]
// bandit_actions: Option<HashMap<String, serde_json::Value>>,
}

#[derive(Debug, Serialize)]
struct AssignmentsResponse {
assignments: HashMap<String, AssignmentValue>,
timestamp: i64,
}

const KV_STORE_NAME: &str = "edge-assignment-kv-store";
const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: given this is a completely new service, I think we can go with sdkKey as there are no legacy clients?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to do this as well - @sameerank used our existing javascript client to access this service to setup a connection for our eppo.cloud QA environment so we can start collect telemetry on real-world use. Eppo-exp/js-sdk-common#137

I have a couple of goals to evolve the network architecture of this service:

  • Serve it behind our existing CDN (with no caching) to be able to share the same domain name and SSL certificate
  • Since Sameeran developed the new client separately from the old we have a lot of control over the query parameters and should make this change; or even better, pass the auth information in a Authorization header.


const SDK_NAME: &str = "fastly-edge-assignments";
const SDK_VERSION: &str = "0.1.0";

fn kv_store_key(token_hash: &str) -> String {
format!("ufc-by-sdk-key-token-hash-{}", token_hash)
}

fn token_hash(sdk_key: String) -> String {
let mut hasher = Sha256::new();
hasher.update(sdk_key.as_bytes());
base64_url::encode(&hasher.finalize())
}

pub fn handle_assignments(mut req: Request) -> Result<Response, Error> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: this function has potential to be split into multiple helper functions, which is currently hindered by the fact that error responses are constructed inline. If we have an explicit error type, that would make splitting much easier and the main body more pleasant and straightforward

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I see what you mean - ideally we return explicit errors and this handle just is responsible to rendering those into various http codes and messages?

// Extract the SDK key and generate a token hash matching the pre-defined encoding.
let token_hash = match req.get_query_parameter(SDK_KEY_QUERY_PARAM) {
Some(key) if !key.is_empty() => token_hash(key.to_string()),
_ => {
return Ok(
Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!(
"Missing required query parameter: {}",
SDK_KEY_QUERY_PARAM
)),
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in Rust we like modeling errors explicitly. In this case, I would create our own error enum (look at thiserror crate to make that easy), so we can move error handling out of the main flow.

That would help us simplify this statement even further:

let token = req.get_query_parameter(SDK_KEY_QUERY_PARAM)
  .filter(|it| !it.is_empty())
  .ok_or(Error::MissingQueryParam { param: SDK_KEY_QUERY_PARAM })?;

}
};

// Deserialize the request body into a struct
let (subject_key, subject_attributes): (eppo_core::Str, Arc<Attributes>) =
match serde_json::from_slice::<RequestBody>(&req.take_body_bytes()) {
Ok(body) => {
if body.subject_key.is_empty() {
return Ok(Response::from_status(StatusCode::BAD_REQUEST)
.with_body_text_plain("subject_key is required and cannot be empty"));
}
(
eppo_core::Str::from(body.subject_key),
body.subject_attributes,
)
}
Err(e) => {
let error_message = if e.to_string().contains("subject_key") {
"subject_key is required in the request body"
} else {
"Invalid request body format"
};
Comment on lines +59 to +63
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: special-casing here seems error-prone and not extensible. Could we return the error message from serde directly?

Suggested change
let error_message = if e.to_string().contains("subject_key") {
"subject_key is required in the request body"
} else {
"Invalid request body format"
};
let error_message = format!("Invalid request body: {e}");

return Ok(Response::from_status(StatusCode::BAD_REQUEST)
.with_body_text_plain(error_message));
}
};

// Open the KV store
let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could move ? closer inside to simplify:

Suggested change
let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?;
let kv_store = KVStore::open(KV_STORE_NAME)?.expect("KVStore exists");

minor: expect/unwrap panic if condition doesn't hold. In this service, that's less critical than inside SDK but might be worth handling nonetheless


let mut kv_store_item = match kv_store.lookup(&kv_store_key(&token_hash)) {
Ok(item) => item,
Err(e) => {
let (status, message) = match e {
KVStoreError::ItemNotFound => {
eprintln!("Missing configuration for SDK key: {}", token_hash);

// Return unauthorized if the key does not exist.
// Our protocol lets the client know that the SDK key has not had a UFC
// configuration pre-computed for it in the KV Store.
(StatusCode::UNAUTHORIZED, "Invalid SDK key.".to_string())
}
_ => {
eprintln!("KV Store error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Unexpected KV Store error".to_string(),
)
}
};

return Ok(Response::from_status(status).with_body_text_plain(&message));
}
};

// Parse the response from the KV store
let kv_store_item_body = kv_store_item.take_body();
let ufc_config = match UniversalFlagConfig::from_json(
SdkMetadata {
name: SDK_NAME,
version: SDK_VERSION,
},
kv_store_item_body.into_bytes(),
) {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to parse UFC config: {:?}", e);
return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_body_text_plain("Invalid configuration format in KV store"));
}
};

let configuration = Configuration::from_server_response(ufc_config, None);
let flag_keys = configuration.flag_keys();
let configuration_store = ConfigurationStore::new();
configuration_store.set_configuration(Arc::new(configuration));
let evaluator = Evaluator::new(EvaluatorConfig {
configuration_store: Arc::new(configuration_store),
sdk_metadata: SdkMetadata {
name: SDK_NAME,
version: SDK_VERSION,
},
});

let subject_assignments = flag_keys
.iter()
.filter_map(|key| {
match evaluator.get_assignment(key, &subject_key, &subject_attributes, None) {
Ok(Some(assignment)) => Some((key.clone(), assignment.value)),
Ok(None) => None,
Err(e) => {
eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e);
None
}
}
})
.collect::<HashMap<_, _>>();

// Create the response
let assignments_response = AssignmentsResponse {
assignments: subject_assignments,
timestamp: chrono::Utc::now().timestamp(),
};

// Create an HTTP OK response with the assignments
let response = match Response::from_status(StatusCode::OK).with_body_json(&assignments_response)
{
Ok(response) => response,
Err(e) => {
eprintln!("Failed to serialize response: {:?}", e);
return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_body_text_plain("Failed to serialize response"));
}
};
Ok(response)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_token_hash() {
// Test case with a known SDK key and its expected hash
let sdk_key = "5qCSVzH1lCI11.ZWg9ZDhlYnhsLmV2ZW50cy5lcHBvLmxvY2FsaG9zdA".to_string();
let expected_hash = "V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM";

let result = token_hash(sdk_key);

assert_eq!(result, expected_hash);
}
}
5 changes: 5 additions & 0 deletions fastly-edge-assignments/src/handlers/health.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
use fastly::{http::StatusCode, Error, Request, Response};

pub fn handle_health(_req: Request) -> Result<Response, Error> {
Ok(Response::from_status(StatusCode::OK).with_body_text_plain("OK"))
}
7 changes: 7 additions & 0 deletions fastly-edge-assignments/src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Declare submodules
pub mod assignments;
pub mod health;

// Re-export items to make them more convenient to use
pub use assignments::handle_assignments;
pub use health::handle_health;
13 changes: 13 additions & 0 deletions fastly-edge-assignments/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
mod handlers;

use fastly::http::{Method, StatusCode};
use fastly::{Error, Request, Response};

#[fastly::main]
fn main(req: Request) -> Result<Response, Error> {
match (req.get_method(), req.get_path()) {
(&Method::POST, "/assignments") => handlers::handle_assignments(req),
(&Method::GET, "/health") => handlers::handle_health(req),
_ => Ok(Response::from_status(StatusCode::NOT_FOUND).with_body_text_plain("Not Found")),
}
}
2 changes: 1 addition & 1 deletion sdk-test-data
Submodule sdk-test-data updated 55 files
+7 −0 .gitignore
+8 −0 package-testing/.prettierrc
+16 −0 package-testing/eslint.config.EXAMPLE.mjs
+7 −0 package-testing/php-sdk-relay/.env.EXAMPLE
+2 −0 package-testing/php-sdk-relay/.gitignore
+32 −0 package-testing/php-sdk-relay/Dockerfile
+45 −0 package-testing/php-sdk-relay/README.md
+28 −0 package-testing/php-sdk-relay/build-and-run.sh
+35 −0 package-testing/php-sdk-relay/composer.json
+2,813 −0 package-testing/php-sdk-relay/composer.lock
+18 −0 package-testing/php-sdk-relay/docker-run.sh
+5 −0 package-testing/php-sdk-relay/release.sh
+66 −0 package-testing/php-sdk-relay/src/AssignmentHandler.php
+83 −0 package-testing/php-sdk-relay/src/BanditHandler.php
+17 −0 package-testing/php-sdk-relay/src/Config.php
+36 −0 package-testing/php-sdk-relay/src/RelayLogger.php
+18 −0 package-testing/php-sdk-relay/src/eppo_poller.php
+59 −0 package-testing/php-sdk-relay/src/index.php
+14 −0 package-testing/scenarios.json
+3 −0 package-testing/sdk-test-runner/.env.EXAMPLE
+5 −0 package-testing/sdk-test-runner/.gitignore
+16 −0 package-testing/sdk-test-runner/Dockerfile
+214 −0 package-testing/sdk-test-runner/README.md
+16 −0 package-testing/sdk-test-runner/eslint.config.mjs
+39 −0 package-testing/sdk-test-runner/package.json
+8 −0 package-testing/sdk-test-runner/release.sh
+231 −0 package-testing/sdk-test-runner/src/app.ts
+38 −0 package-testing/sdk-test-runner/src/config.ts
+7 −0 package-testing/sdk-test-runner/src/dto/assignmentRequest.ts
+22 −0 package-testing/sdk-test-runner/src/dto/banditActionRequest.ts
+9 −0 package-testing/sdk-test-runner/src/dto/scenario.ts
+6 −0 package-testing/sdk-test-runner/src/dto/testResponse.ts
+14 −0 package-testing/sdk-test-runner/src/logging.ts
+23 −0 package-testing/sdk-test-runner/src/program.ts
+1 −0 package-testing/sdk-test-runner/src/util.ts
+170 −0 package-testing/sdk-test-runner/test-sdk.sh
+12 −0 package-testing/sdk-test-runner/tsconfig.json
+1,394 −0 package-testing/sdk-test-runner/yarn.lock
+3 −0 package-testing/testing-api/.dockerignore
+4 −0 package-testing/testing-api/.env.EXAMPLE
+1 −0 package-testing/testing-api/.gitignore
+17 −0 package-testing/testing-api/Dockerfile
+126 −0 package-testing/testing-api/README.md
+13 −0 package-testing/testing-api/clone-test-data.sh
+5 −0 package-testing/testing-api/copy-test-data.sh
+13 −0 package-testing/testing-api/eslint.config.mjs
+34 −0 package-testing/testing-api/package.json
+9 −0 package-testing/testing-api/release.sh
+53 −0 package-testing/testing-api/src/app.ts
+22 −0 package-testing/testing-api/src/config.ts
+72 −0 package-testing/testing-api/src/routes.ts
+11 −0 package-testing/testing-api/src/server.ts
+39 −0 package-testing/testing-api/src/ufc/data.ts
+12 −0 package-testing/testing-api/tsconfig.json
+1,780 −0 package-testing/testing-api/yarn.lock
Loading