diff --git a/.devcontainer/00-blind-by-design_02-intermediate/devcontainer.json b/.devcontainer/00-blind-by-design_02-intermediate/devcontainer.json new file mode 100644 index 00000000..cfe7df78 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "๐Ÿงช Adventure 00 | ๐ŸŸก Intermediate (Outcome by cohort)", + "dockerComposeFile": "docker-compose.yml", + "service": "workspace", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/planned/00-blind-by-design/intermediate", + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "vmware.vscode-spring-boot", + "vscjava.vscode-spring-boot-dashboard" + ] + }, + "codespaces": { + "openFiles": [ + "adventures/planned/00-blind-by-design/docs/intermediate.md", + "adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java", + "adventures/planned/00-blind-by-design/intermediate/flags.json" + ] + } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/.devcontainer/00-blind-by-design_02-intermediate/docker-compose.yml b/.devcontainer/00-blind-by-design_02-intermediate/docker-compose.yml new file mode 100644 index 00000000..08aeb46b --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/docker-compose.yml @@ -0,0 +1,38 @@ +# Multi-container devcontainer for Intermediate. The lab itself runs in +# `workspace`; flagd runs as a sibling so participants who finish the +# FILE-mode path early can flip the FlagdProvider to RPC mode and talk +# to flagd:8013 without any Docker-in-Docker dance. +# +# Both services bind-mount the same workspace at the same path. flagd +# watches the participant's flags.json directly โ€” edit it in the IDE, +# the file watcher reloads. + +services: + workspace: + image: mcr.microsoft.com/devcontainers/java:1-21 + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:cached + command: sleep infinity + environment: + # Pre-set FLAGD_HOST so participants who switch to RPC mode in + # OpenFeatureConfig do not have to hard-code the hostname. + - FLAGD_HOST=flagd + - FLAGD_PORT=8013 + # The trial's country of registration. Used by OpenFeatureConfig + # via System.getenv("COUNTRY") to populate the global eval context. + # `de` by default so F5 / Spring Boot Dashboard runs already exercise + # the country-targeting branch. Override with run-austria.sh or + # `COUNTRY=at ./mvnw spring-boot:run` to flip. + - COUNTRY=de + + flagd: + image: ghcr.io/open-feature/flagd:v0.15.4 + container_name: side-effects-intermediate-flagd + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:ro + command: + - start + - --uri + - file:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}/adventures/planned/00-blind-by-design/intermediate/flags.json + # No `ports:` block โ€” the lab reaches flagd on the docker-internal + # network as `flagd:8013`. Only :8080 is forwarded into the Codespace. diff --git a/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh b/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh new file mode 100755 index 00000000..378e1181 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "00-blind-by-design" "intermediate" +track_codespace_created + +"$REPO_ROOT/lib/shared/init.sh" --version v0.17.0 # https://github.com/charmbracelet/gum/releases + +# jq is needed by verify.sh; the Java devcontainer image is debian-based. +if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends jq +fi + +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/intermediate" + +# Make the Maven wrapper executable so the participant can just `./mvnw ...` +if [[ -f "$CHALLENGE_DIR/mvnw" ]]; then + chmod +x "$CHALLENGE_DIR/mvnw" +fi + +echo "โœจ Pre-warming the Maven dependency cache so the first ./mvnw is fast..." +( cd "$CHALLENGE_DIR" && ./mvnw -q -DskipTests dependency:go-offline ) || \ + echo "โš ๏ธ Dependency pre-warm skipped (network or wrapper not ready yet)" + +echo "โœ… Post-create complete." diff --git a/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh b/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh new file mode 100755 index 00000000..862f2053 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/intermediate" + +cat </dev/null 2>&1; then + code "$REPO_ROOT/adventures/planned/00-blind-by-design/docs/intermediate.md" \ + "$CHALLENGE_DIR/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java" \ + "$CHALLENGE_DIR/flags.json" \ + 2>/dev/null || true +fi diff --git a/adventures/planned/00-blind-by-design/docs/intermediate.md b/adventures/planned/00-blind-by-design/docs/intermediate.md new file mode 100644 index 00000000..6dd2b3a9 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/intermediate.md @@ -0,0 +1,241 @@ +# ๐ŸŸก Intermediate: Outcome by cohort + +Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register a custom `Hook`: + +- **Transaction context** (request-scoped) โ€” populated by a Spring `HandlerInterceptor` that reads `?species=`, and clears on `afterCompletion` so values don't leak across pooled threads. +- **Global context** (process-scoped) โ€” set once at startup from the `COUNTRY` environment variable. +- **Invocation context** (call-site) โ€” passed as a third argument to `client.getStringDetails(...)`, carrying the per-evaluation `dose` attribute. +- **Audit `Hook`** โ€” fires after every flag evaluation, writes an `[AUDIT]` log line with a fixed PII-safe attribute allowlist. + +The broken-state lab already has the SDK and flagd provider wired in `Resolver.RPC` mode. The targeting in `flags.json` already carries three branches โ€” `species == zyklop`, improper-`dose` for non-zyklops, `country == de` โ€” but none of those attributes are in the eval context yet, so every request lands on the default variant. Your job is to make the targeting fire by wiring the three context layers and the audit hook. + +## ๐Ÿช The Backstory + +The trial is widening. Subjects from outside the lab's local population are getting the wrong reading on their chart, and the lab director has just walked into the lab holding a stack of complaint forms. She wants the audit log to tell her, after the fact, exactly which `vision_state` the lab recorded for which subject โ€” and she wants the lab to read the chart properly before it records any more bad readings. + +The protocol is the same for every subject; the lab is not varying the trial. What differs is the **observed outcome**, because subjects don't all start from the same place โ€” some have a biology that responds enhancedly to the same serum, some absorb less or more than the protocol's standard dose, and the trial is registered in different jurisdictions with different baselines. + +Your shift: teach the lab to read each subject's species off the request, attach the trial's **country of registration** (set on the JVM via the `COUNTRY` environment variable) to the global context, pass the **dose** as invocation context at the moment of the flag evaluation, and register an audit hook that records every dose with its variant and reason. + +## ๐Ÿ—๏ธ Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Spring Boot lab (this challenge) โ”‚ +โ”‚ โ”‚ +โ”‚ HTTP โ”€โ”€โ–บ SpeciesInterceptor โ”€โ”€โ–บ Trial โ”€โ”€โ–บ OpenFeature client โ”‚ +โ”‚ (transaction ctx: (invocation ctx: (global ctx: โ”‚ +โ”‚ species โ† ?species=) dose โ† computed country โ† โ”‚ +โ”‚ at call site, $COUNTRY env) โ”‚ +โ”‚ overridable โ”‚ +โ”‚ with ?dose=) โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ–ผ โ”‚ +โ”‚ AuditHook โ”‚ +โ”‚ (audit log) โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ–ผ โ”‚ +โ”‚ FlagdProvider โ”‚ +โ”‚ (Resolver.RPC) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ gRPC :8013 + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ flagd (sibling container) โ”‚ + โ”‚ reads + watches flags.json โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +The lab and a flagd sidecar run as siblings in the devcontainer's compose stack. The OpenFeature client uses `Resolver.RPC` to reach `flagd:8013`; flagd is the one watching `flags.json` and serving evaluations from it. The targeting rules live entirely inside `flags.json`; your job is to make sure the attributes the rules reference (`species`, `country`, `dose`) are populated on every evaluation. + +## ๐ŸŽฏ Objective + +By the end of this level, the lab hits each of these observable outcomes: + +- **Targeting by species fires.** `curl /?species=zyklop` returns `"enhanced"` regardless of dose or country. +- **Targeting by country fires.** With `COUNTRY=de`, `curl /?dose=standard` returns `"sharp"`; with `COUNTRY=at` the same call falls through to the default โ€” Austria isn't a country branch in `flags.json`. +- **Targeting by dose fires, and species takes precedence over it.** `curl /?dose=underdose` returns `"clouded"`; `curl /?species=zyklop&dose=underdose` still returns `"enhanced"`. +- **Every evaluation produces an `[AUDIT]` log line** naming the flag, the resolved variant, the reason, and the attributes that drove the outcome (`species`, `country`, `dose`). +- **The response is never `"untreated"`.** That fallback only fires when the SDK can't reach flagd at all โ€” if you see it, the provider isn't registered. + +> ๐Ÿ“‹ **Run with `tee app.log`.** The verifier greps `[AUDIT]` lines from `app.log` next to `pom.xml`. The `./run-germany.sh` / `./run-austria.sh` scripts handle this for you; if you run `./mvnw spring-boot:run` directly, pipe through `| tee app.log` or the verifier has nothing to grep. + +## ๐Ÿง  What You'll Learn + +- How OpenFeature's **transaction-context propagation** works in a thread-per-request server, and why a `ThreadLocalTransactionContextPropagator` is the right primitive for Servlet-based apps +- The difference between **request-scoped context** (the subject's species) and **global evaluation context** (the trial's country) โ€” and when each is the right tool +- How **hooks** let you attach cross-cutting behaviour โ€” audit logging today, OpenTelemetry tracing tomorrow โ€” without modifying every flag evaluation call site + +## ๐Ÿงฐ Toolbox + +Your Codespace comes pre-configured with the following tools: + +- [Java 21](https://adoptium.net/) toolchain (Temurin) +- The Spring Boot Maven Wrapper (`./mvnw`) โ€” no global Maven install required +- `curl` and `jq` for poking at the lab +- `tail -f` for watching the application log live + +The flagd sibling that the Beginner level introduced is still running here โ€” the broken-state `OpenFeatureConfig` already targets it via `Resolver.RPC` (`flagd:8013` from the workspace, `localhost:8013` from your host). + +## โฐ Deadline + +> ๐Ÿšง **Coming Soon** โ€” this level is in the planned bucket. Final deadline will be announced when the adventure goes live. + +## ๐Ÿ’ฌ Join the discussion + +> ๐Ÿšง **Coming Soon** โ€” community thread will be linked here at launch. + +## โœ… How to Play + +### 1. Start Your Challenge + +> ๐Ÿ“– **First time?** Check out the [Getting Started Guide](../../start-a-challenge) for detailed instructions on forking, starting a Codespace, and waiting for infrastructure setup. + +Quick start: + +- Fork the repo +- Create a Codespace +- Select "Adventure 00 | ๐ŸŸก Intermediate (Outcome by cohort)" +- Wait ~2-3 minutes for the Java toolchain to install (`Cmd/Ctrl + Shift + P` โ†’ `View Creation Log` to view progress) + +When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready in `adventures/planned/00-blind-by-design/intermediate/`. + +### 2. Start the Lab + +The lab is a terminal-only level โ€” no port is forwarded to your host, you `curl` it from inside the Codespace. Boot it once so it's actually serving on `localhost:8080`. Either click **Run** on `Laboratory` in the Spring Boot Dashboard panel (or press **F5** with `Laboratory.java` open), or, from the terminal: + +```bash +./mvnw spring-boot:run +``` + +In another terminal, confirm the broken-state symptom: + +```bash +curl 'http://localhost:8080/?species=zyklop' +# => {"value":"blurry", ...} โ† wrong cohort, no targeting fired +``` + +That `"blurry"` is the starting point you want: even when the request shouts `species=zyklop`, the lab has nothing in its evaluation context, so flagd's targeting can't fire and every subject drops to the default variant. + +Stop the app (`Ctrl+C`) and start fixing. + +### 3. Inspect the Starting Point + +The lab already has the OpenFeature SDK and the flagd contrib provider on the classpath, and the `FlagdProvider` is wired in `Resolver.RPC` mode against the flagd sibling. The `flags.json` shipping with this level is the targeting-rich version โ€” all three branches (open `intermediate/flags.json` and you'll see this verbatim): + +```json +"targeting": { + "if": [ + { "===": [{"var": "species"}, "zyklop"] }, "enhanced", + { "in": [{"var": "dose"}, ["underdose", "overdose"]] }, "clouded", + { "===": [{"var": "country"}, "de"] }, "sharp" + ] +} +``` + +The catch: nothing in the application populates `species`, `country`, or `dose` yet. Every request lands with an empty evaluation context, so none of the branches fire and every subject walks out with `"blurry"` (the default variant) โ€” exactly the symptom you just reproduced in step 2. + +### 4. Implement the Objective + +You need four pieces. + +#### 4a. A `SpeciesInterceptor` + +Create a Spring [`HandlerInterceptor`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html) โ€” a per-request hook with `preHandle` running before your controller and `afterCompletion` running after the response โ€” that: + +- In `preHandle`, reads `?species=` from the request and puts it on the **transaction context** for the duration of the request. +- In `afterCompletion`, clears the transaction context. Servlet threads are pooled โ€” if you don't clear, the previous request's species leaks into whichever request lands on the thread next. +- In a static initialiser, registers a `ThreadLocalTransactionContextPropagator` once on the OpenFeature API. Without it the SDK has no way to carry per-request context across the call into the controller, and the transaction context silently stays empty. + +#### 4b. Wire the interceptor + global context + hook in `OpenFeatureConfig` + +Update `OpenFeatureConfig` to: + +- Register your `SpeciesInterceptor` with Spring (`WebMvcConfigurer.addInterceptors`). +- Read `COUNTRY` from the environment and set it as the **global** evaluation context โ€” merged into every flag evaluation regardless of request. +- Register your `AuditHook` (you'll write that next) globally on the OpenFeature API. + +The three context layers โ€” *global* (this `country`), *transaction* (the `species` you set in 4a), and *invocation* (the `dose` your `Trial` controller will pass at each call site) โ€” merge before flagd evaluates the rules. Precedence on conflict is **invocation > transaction > global**. + +#### 4c. Pass the dose as invocation context from `Trial` + +Update `Trial` so each evaluation carries a `dose` on the **invocation context** โ€” the third argument to `client.getStringDetails(flag, fallback, ctx)`, evaluated per call and not stored anywhere afterwards. Two real-world details to think through: + +- The dose should be `"standard"` most of the time but occasionally `"underdose"` or `"overdose"` โ€” that's the lab tech mis-measuring, and it's what makes the improper-dosing branch in `flags.json` fire at all. +- Make it overridable via a `?dose=` query parameter so you can verify each branch by hand without waiting for the random pick to happen. + +The flag rule that depends on this is the second branch in `flags.json`: + +```json +{ "in": [{"var": "dose"}, ["underdose", "overdose"]] } +``` + +If your invocation context doesn't carry `dose`, that rule sees `null` and the branch never fires โ€” every non-zyklop request lands on either the country branch or the default. + +#### 4d. An `AuditHook` + +A [`Hook`](https://openfeature.dev/docs/reference/concepts/hooks) is OpenFeature's interceptor for flag evaluations: `before` / `after` / `error` / `finallyAfter` fire around every `client.getXxxDetails(...)`, and `HookContext.getCtx()` exposes the **merged** context โ€” that's what makes an audit trail useful instead of a "got here" log line. Create one that, on `after(...)`, writes an `[AUDIT]` log line naming the flag, the resolved variant, the reason, and the attributes that drove the outcome. Two design decisions worth thinking about: + +- When the resolved variant is `clouded`, log at **`WARN`** so the safety officer can grep for it; otherwise `INFO`. Also implement `error(...)` โ€” failed evaluations shouldn't disappear silently. +- Use a **fixed allowlist** of attribute keys (`List.of("species", "country", "dose")`) rather than iterating the whole eval context โ€” audit logs outlive app logs and a discipline of "log only what you decided to log" pays off the moment something sensitive lands on the context. + +The order matters less than you'd think โ€” Spring will pick up `OpenFeatureConfig` as a `@Configuration` class on boot, the `@PostConstruct` will run once, and from then on every evaluation the `Trial` performs will see both contexts and trigger your hook. + +### 5. Re-run the Lab with a Cohort + +```bash +./run-germany.sh # COUNTRY=de โ€” exercises the country-targeting branch (or `make lab-germany`) +``` + +`./run-austria.sh` (`COUNTRY=at`) ships alongside it for the no-targeting case. Three named launch configs in `.vscode/launch.json` (Germany / Austria / No country) give you one-click cohort switching from the **Run and Debug** view. + +### 6. Verify Each Cohort by Hand + +In another terminal โ€” exercise all three context layers and the precedence between them: + +```bash +# Transaction context โ€” species wins, regardless of country / dose +curl -s 'http://localhost:8080/?species=zyklop' | jq .value +# => "enhanced" + +# Global context โ€” country=de from the env. Pin ?dose=standard so the +# random dose pick can't trip the improper-dose branch. +curl -s 'http://localhost:8080/?dose=standard' | jq .value +# => "sharp" (when running ./run-germany.sh โ€” COUNTRY=de) +# => "blurry" (when running ./run-austria.sh โ€” COUNTRY=at: no targeting branch fires, default applies) + +# Invocation context โ€” improper dose for a non-zyklop subject +curl -s 'http://localhost:8080/?dose=underdose' | jq .value +# => "clouded" + +# Precedence โ€” species-zyklop is evaluated before improper-dose in flags.json +curl -s 'http://localhost:8080/?species=zyklop&dose=underdose' | jq .value +# => "enhanced" +``` + +Tail the log to see the audit trail: + +```bash +grep '\[AUDIT\]' app.log | head +``` + +You should see one `[AUDIT] flag=vision_state variant=โ€ฆ reason=โ€ฆ species=โ€ฆ country=โ€ฆ dose=โ€ฆ` line per `curl` call. `clouded` outcomes log at `WARN` with the "improper dosing or off-protocol cohort, follow-up required" suffix. + +### 7. Verify Your Solution + +Once you think you've solved the challenge, run the verification script: + +```bash +./verify.sh +``` + +**If the verification fails:** + +The script will tell you which checks failed. Fix the issues and run it again. + +**If the verification passes:** + +1. The script will check if your changes are committed and pushed. +2. Follow the on-screen instructions to commit your changes if needed. +3. Once everything is ready, the script will generate a **Certificate of Completion**. +4. **Copy this certificate** and paste it into the [challenge thread](https://community.open-ecosystem.com/c/open-ecosystem-challenges/) to claim your victory! ๐Ÿ† diff --git a/adventures/planned/00-blind-by-design/intermediate/.mvn/wrapper/maven-wrapper.properties b/adventures/planned/00-blind-by-design/intermediate/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..13b218bf --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/adventures/planned/00-blind-by-design/intermediate/.vscode/launch.json b/adventures/planned/00-blind-by-design/intermediate/.vscode/launch.json new file mode 100644 index 00000000..f5ff5568 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "๐Ÿ‡ฉ๐Ÿ‡ช Run the Lab โ€” Germany (COUNTRY=de)", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { "COUNTRY": "de" } + }, + { + "type": "java", + "name": "๐Ÿ‡ฆ๐Ÿ‡น Run the Lab โ€” Austria (COUNTRY=at)", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { "COUNTRY": "at" } + }, + { + "type": "java", + "name": "๐ŸŒ Run the Lab โ€” No country", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { "COUNTRY": "" } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/intermediate/.vscode/tasks.json b/adventures/planned/00-blind-by-design/intermediate/.vscode/tasks.json new file mode 100644 index 00000000..9de294cd --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/.vscode/tasks.json @@ -0,0 +1,32 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "๐Ÿงช Verify Solution", + "type": "shell", + "command": "./verify.sh", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" }, + "group": { "kind": "test", "isDefault": true } + }, + { + "label": "๐Ÿ‡ฉ๐Ÿ‡ช Run the Lab โ€” Germany", + "type": "shell", + "command": "./run-germany.sh", + "options": { "cwd": "${workspaceFolder}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + }, + { + "label": "๐Ÿ‡ฆ๐Ÿ‡น Run the Lab โ€” Austria", + "type": "shell", + "command": "./run-austria.sh", + "options": { "cwd": "${workspaceFolder}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/intermediate/Makefile b/adventures/planned/00-blind-by-design/intermediate/Makefile new file mode 100644 index 00000000..e44abbb4 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/Makefile @@ -0,0 +1,44 @@ +# ============================================================================ +# Makefile for Blind by Design - Intermediate Level: Outcome by cohort +# ============================================================================ +# This Makefile provides convenient commands for running the Spring Boot lab +# in different country cohorts and verifying your solution. +# ============================================================================ + +.PHONY: help lab lab-germany lab-austria probe verify + +# Default target - show help +help: + @echo "Blind by Design - Intermediate Level: Outcome by cohort" + @echo "" + @echo "Application:" + @echo " make lab - Start the lab with no COUNTRY (no targeting branch fires)" + @echo " make lab-germany - Start the lab with COUNTRY=de (exercises the country branch)" + @echo " make lab-austria - Start the lab with COUNTRY=at (no targeting branch fires)" + @echo " make probe - Hit the lab as a zyklop subject and pretty-print the response" + @echo "" + @echo "Verification:" + @echo " make verify - Run verification checks" + +# ---------------------------------------------------------------------------- +# Application Targets +# ---------------------------------------------------------------------------- + +lab: + @./mvnw spring-boot:run | tee app.log + +lab-germany: + @./run-germany.sh + +lab-austria: + @./run-austria.sh + +probe: + @curl -s 'http://localhost:8080/?species=zyklop' | jq + +# ---------------------------------------------------------------------------- +# Verification Targets +# ---------------------------------------------------------------------------- + +verify: + @./verify.sh diff --git a/adventures/planned/00-blind-by-design/intermediate/flags.json b/adventures/planned/00-blind-by-design/intermediate/flags.json new file mode 100644 index 00000000..5af2d2b0 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/flags.json @@ -0,0 +1,24 @@ +{ + "flags": { + "vision_state": { + "state": "ENABLED", + "variants": { + "enhanced": "enhanced", + "sharp": "sharp", + "blurry": "blurry", + "clouded": "clouded" + }, + "defaultVariant": "blurry", + "targeting": { + "if": [ + { "===": [{ "var": "species" }, "zyklop"] }, + "enhanced", + { "in": [{ "var": "dose" }, ["underdose", "overdose"]] }, + "clouded", + { "===": [{ "var": "country" }, "de"] }, + "sharp" + ] + } + } + } +} diff --git a/adventures/planned/00-blind-by-design/intermediate/mvnw b/adventures/planned/00-blind-by-design/intermediate/mvnw new file mode 100755 index 00000000..19529ddf --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/adventures/planned/00-blind-by-design/intermediate/mvnw.cmd b/adventures/planned/00-blind-by-design/intermediate/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/adventures/planned/00-blind-by-design/intermediate/pom.xml b/adventures/planned/00-blind-by-design/intermediate/pom.xml new file mode 100644 index 00000000..4445bbcf --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + dev.openfeature.demo.java + demo + 0.0.1-SNAPSHOT + demo + Demo project for OpenFeature with Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + + + dev.openfeature + sdk + 1.14.2 + + + dev.openfeature.contrib.providers + flagd + 0.11.8 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/adventures/planned/00-blind-by-design/intermediate/run-austria.sh b/adventures/planned/00-blind-by-design/intermediate/run-austria.sh new file mode 100755 index 00000000..6b4411fd --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/run-austria.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Start the lab as a Phase trial registered in Austria. Same shape as +# run-germany.sh; only the country code differs. The country=at branch is +# not in flags.json by default โ€” every subject without a species override falls +# through to the "blurry" default. Useful for proving the country-targeting +# branch only fires when the country matches. +set -e +cd "$(dirname "${BASH_SOURCE[0]}")" +COUNTRY=at exec ./mvnw spring-boot:run "$@" 2>&1 | tee app.log diff --git a/adventures/planned/00-blind-by-design/intermediate/run-germany.sh b/adventures/planned/00-blind-by-design/intermediate/run-germany.sh new file mode 100755 index 00000000..e6d33b33 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/run-germany.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Start the lab as a Phase trial registered in Germany. Pipes through tee so +# verify.sh can grep the audit-hook lines from app.log. +set -e +cd "$(dirname "${BASH_SOURCE[0]}")" +COUNTRY=de exec ./mvnw spring-boot:run "$@" 2>&1 | tee app.log diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java new file mode 100644 index 00000000..53fb812b --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java @@ -0,0 +1,13 @@ +package dev.openfeature.demo.java.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Laboratory { + + public static void main(String[] args) { + SpringApplication.run(Laboratory.class, args); + } + +} diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java new file mode 100644 index 00000000..b82bc1e9 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java @@ -0,0 +1,22 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenFeatureConfig { + + @PostConstruct + public void initProvider() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .build(); + + api.setProviderAndWait(new FlagdProvider(flagdOptions)); + } +} diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Trial.java b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Trial.java new file mode 100644 index 00000000..744764fe --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Trial.java @@ -0,0 +1,17 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Trial { + + @GetMapping("/") + public FlagEvaluationDetails observeSubject() { + Client client = OpenFeatureAPI.getInstance().getClient(); + return client.getStringDetails("vision_state", "untreated"); + } +} diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/resources/application.properties b/adventures/planned/00-blind-by-design/intermediate/src/main/resources/application.properties new file mode 100644 index 00000000..2109a440 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=demo diff --git a/adventures/planned/00-blind-by-design/intermediate/verify.sh b/adventures/planned/00-blind-by-design/intermediate/verify.sh new file mode 100755 index 00000000..7cd61b69 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/verify.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared libraries +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../../lib/scripts/loader.sh" + +OBJECTIVE="By the end of this level, the lab hits each of these observable outcomes: + +- Targeting by species fires: curl /?species=zyklop returns 'enhanced' regardless of dose or country +- Targeting by country fires: with COUNTRY=de, curl /?dose=standard returns 'sharp' +- Targeting by dose fires, and species takes precedence: curl /?dose=underdose returns 'clouded'; curl /?species=zyklop&dose=underdose still returns 'enhanced' +- Every evaluation produces an [AUDIT] log line carrying species, country, and dose +- The response is never 'untreated' (provider is wired and reaches flagd)" + +DOCS_URL="https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/intermediate" + +print_header \ + 'Challenge 00: Blind by Design' \ + '๐ŸŸก Intermediate: Outcome by cohort' \ + 'Verification' + +# Init test counters +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +check_prerequisites curl jq + +# ----------------------------------------------------------------------------- +# Locate the application log. The participant is instructed (in intermediate.md) +# to start the app with `./mvnw spring-boot:run | tee app.log` so the log lives +# next to this script. Fall back to a couple of other reasonable spots. +# ----------------------------------------------------------------------------- +APP_LOG="" +for candidate in \ + "$SCRIPT_DIR/app.log" \ + "$SCRIPT_DIR/../app.log" \ + "$PWD/app.log"; do + if [[ -f "$candidate" ]]; then + APP_LOG="$candidate" + break + fi +done + +print_sub_header "Running verification checks..." + +# ----------------------------------------------------------------------------- +# 1. App reachable on :8080 and serving an OpenFeature evaluation. Lean on +# test_http_endpoint from lib/scripts/http.sh โ€” it handles the connection +# failure / unexpected-content cases for us. +# ----------------------------------------------------------------------------- +print_test_section "Checking the lab is reachable on :8080..." +if ! test_http_endpoint "http://localhost:8080/" "vision_state" \ + "Start the lab with: ./run-germany.sh (or COUNTRY=de ./mvnw spring-boot:run | tee app.log)"; then + FAILED_CHECKS+=("app_reachable") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 2. Per-subject targeting: ?species=zyklop must return "enhanced" +# ----------------------------------------------------------------------------- +print_test_section "Checking the zyklop subject gets 'enhanced'..." +ZYKLOP_VALUE="$(curl -s --max-time 5 'http://localhost:8080/?species=zyklop' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$ZYKLOP_VALUE" == "enhanced" ]]; then + print_success_indent "GET /?species=zyklop returned 'enhanced'" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "GET /?species=zyklop returned: '$ZYKLOP_VALUE' (expected 'enhanced')" + print_hint "Did you wire SpeciesInterceptor and register a ThreadLocalTransactionContextPropagator?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("species_targeting") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 3. Trial-country targeting: GET /?dose=standard with COUNTRY=de in the env +# should resolve to "sharp". We pin dose=standard explicitly so the random +# dose pick (which the controller does on the call site) cannot trip the +# "improper dose -> clouded" branch. If the global eval context is not +# wired, the targeting falls through to the default variant and "blurry" +# comes back instead. The only response we truly reject is the literal +# fallback "untreated", which means no provider is resolving at all. +# ----------------------------------------------------------------------------- +print_test_section "Checking the trial-country branch fires for COUNTRY=de..." +COUNTRY_VALUE="$(curl -s --max-time 5 'http://localhost:8080/?dose=standard' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$COUNTRY_VALUE" == "sharp" ]]; then + print_success_indent "GET /?dose=standard returned 'sharp' โ€” country targeting is firing" + TESTS_PASSED=$((TESTS_PASSED + 1)) +elif [[ "$COUNTRY_VALUE" == "untreated" || -z "$COUNTRY_VALUE" ]]; then + print_error_indent "GET /?dose=standard returned: '$COUNTRY_VALUE' โ€” provider isn't resolving" + print_hint "Check OpenFeatureConfig โ€” the FlagdProvider should be registered before the first request." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("default_resolves") +else + print_error_indent "GET /?dose=standard returned: '$COUNTRY_VALUE' (expected 'sharp' with COUNTRY=de)" + print_hint "Did you populate the global evaluation context with country=System.getenv(\"COUNTRY\")? Did you start the lab via ./run-germany.sh or with COUNTRY=de set?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("country_targeting") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 4. Invocation context: GET /?dose=underdose must return "clouded" โ€” the +# controller passes the participant-supplied dose at the call site, the +# targeting catches improper doses for non-zyklop subjects. +# ----------------------------------------------------------------------------- +print_test_section "Checking improper-dose targeting fires for ?dose=underdose..." +UNDERDOSE_VALUE="$(curl -s --max-time 5 'http://localhost:8080/?dose=underdose' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$UNDERDOSE_VALUE" == "clouded" ]]; then + print_success_indent "GET /?dose=underdose returned 'clouded' โ€” invocation context is firing" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "GET /?dose=underdose returned: '$UNDERDOSE_VALUE' (expected 'clouded')" + print_hint "Does Trial.observeSubject pass an ImmutableContext with 'dose' to client.getStringDetails(...) at the call site?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("invocation_context") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 5. Zyklop biology overrides bad dosing: even with ?dose=underdose, a zyklop +# subject should still resolve to "enhanced" because the targeting puts +# species-zyklop ahead of the improper-dose branch. +# ----------------------------------------------------------------------------- +print_test_section "Checking zyklop biology survives an improper dose..." +ZYKLOP_BAD_DOSE="$(curl -s --max-time 5 'http://localhost:8080/?species=zyklop&dose=underdose' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$ZYKLOP_BAD_DOSE" == "enhanced" ]]; then + print_success_indent "Zyklop + underdose returned 'enhanced' โ€” species priority is correct" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "Zyklop + underdose returned: '$ZYKLOP_BAD_DOSE' (expected 'enhanced')" + print_hint "Targeting order in flags.json should evaluate species=zyklop before the improper-dose branch." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("priority_species_over_dose") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 6. AuditHook must produce [AUDIT] lines in the application log. +# ----------------------------------------------------------------------------- +print_test_section "Checking AuditHook produced [AUDIT] lines in application log..." +if [[ -z "$APP_LOG" ]]; then + print_error_indent "Couldn't find app.log next to verify.sh" + print_hint "Start the lab with: ./run-germany.sh (or COUNTRY=de ./mvnw spring-boot:run | tee app.log)" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("app_log_missing") +elif grep -q '\[AUDIT\]' "$APP_LOG"; then + print_success_indent "Found [AUDIT] lines in $APP_LOG" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "No '[AUDIT]' lines found in $APP_LOG" + print_hint "Did you implement AuditHook and register it via api.addHooks(...)? The hook should write a line tagged '[AUDIT]'." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("custom_hook_log") +fi +print_new_line + +# ============================================================================= +# Summary +# ============================================================================= +failed_checks_json="[]" +if [[ -n "${FAILED_CHECKS[*]:-}" ]]; then + failed_checks_json=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) +fi + +if [[ $TESTS_FAILED -gt 0 ]]; then + track_verification_completed "failed" "$failed_checks_json" + print_verification_summary "blind by design" "$DOCS_URL" "$OBJECTIVE" + exit 1 +fi + +track_verification_completed "success" "$failed_checks_json" + +print_header "Test Results Summary" +print_success "โœ… PASSED: All $TESTS_PASSED verification checks passed!" +print_new_line + +# Run submission readiness checks (best-effort: the function exists in lib) +if command -v check_submission_readiness >/dev/null 2>&1; then + check_submission_readiness "00-blind-by-design" "intermediate" +fi