-
Notifications
You must be signed in to change notification settings - Fork 5
feat: fastly compute service to perform assignments (FF-3552) #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
a813273
e985863
c8717ee
5956e65
a3f37c7
48d47aa
56e5c96
3a4b644
59cb251
a159065
6e704ef
bccce04
c338d31
ff73c47
48d2ebc
3b55f6d
3219a03
d288696
be2d5ac
9f99865
78fe3b5
f156caa
f2f27af
f1bcf3d
4ada237
8101988
50ed0ec
38f71eb
266de8a
c3c90f8
b9ca60a
26300db
26c4466
7b4eb91
95219d4
81d1f55
9622a21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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: | ||
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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
[build] | ||
target = "wasm32-wasi" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
pkg/ | ||
bin/ |
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" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Eppo Assignments on Fastly Compute@Edge | ||
|
||
TODO: Add a description |
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this instructs the |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
[toolchain] | ||
channel = "stable" | ||
targets = [ "wasm32-wasi" ] |
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 { | ||||||||||||||
leoromanovsky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
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` | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||||||||||||||
|
||||||||||||||
const SDK_NAME: &str = "fastly-edge-assignments"; | ||||||||||||||
const SDK_VERSION: &str = "0.1.0"; | ||||||||||||||
leoromanovsky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
|
||||||||||||||
fn kv_store_key(token_hash: &str) -> String { | ||||||||||||||
format!("ufc-by-sdk-key-token-hash-{}", token_hash) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
fn token_hash(sdk_key: String) -> String { | ||||||||||||||
leoromanovsky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
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> { | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||||||||||||||
leoromanovsky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
Some(key) if !key.is_empty() => token_hash(key.to_string()), | ||||||||||||||
leoromanovsky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
_ => { | ||||||||||||||
return Ok( | ||||||||||||||
Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( | ||||||||||||||
"Missing required query parameter: {}", | ||||||||||||||
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), | ||||||||||||||
leoromanovsky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||
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"))?; | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: could move
Suggested change
minor: |
||||||||||||||
|
||||||||||||||
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); | ||||||||||||||
} | ||||||||||||||
} |
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")) | ||
} |
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; |
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")), | ||
} | ||
} | ||
leoromanovsky marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
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.