diff --git a/adventures/planned/00-blind-by-design/.gitignore b/adventures/planned/00-blind-by-design/.gitignore new file mode 100644 index 00000000..2bbe1f43 --- /dev/null +++ b/adventures/planned/00-blind-by-design/.gitignore @@ -0,0 +1,8 @@ +target/ + +# The repo root .gitignore excludes .vscode/ globally. Re-include the +# launch + task configs that ship as part of this scenario โ€” they are not +# editor preferences, they are the F5 / Run-and-Debug entry points the +# participant uses to start the lab. +!*/.vscode/ +!*/.vscode/** diff --git a/adventures/planned/00-blind-by-design/README.md b/adventures/planned/00-blind-by-design/README.md new file mode 100644 index 00000000..67ee8e41 --- /dev/null +++ b/adventures/planned/00-blind-by-design/README.md @@ -0,0 +1,12 @@ +# ๐Ÿงช Adventure 00: Blind by Design + +A research lab is testing a vision-enhancement serum on volunteers. The serum is supposed to take ordinary eyes and produce sharper, even enhanced sight. The lab is a Spring Boot service; OpenFeature is the chart system; `flags.json` decides what reading the lab records for each subject. The protocol is the same for everyone โ€” what differs is the observed outcome, because subjects come in with different biology, dose adherence, and trial-jurisdiction baseline. The flagship Phase 3 trial โ€” a new amplifier algorithm โ€” has started showing trouble: subjects stabilise slower, and roughly one in ten emerge blind. The dashboard that should be tracking all of this is dark. Your mission across three levels: stand up the lab, read the chart by cohort, then turn on the lights and roll back the trial before more subjects lose their sight. + +**Technologies:** OpenFeature Java SDK, flagd, Spring Boot, Grafana LGTM (Tempo + Prometheus + Loki), Testcontainers + +The entire **infrastructure is pre-provisioned in your Codespace**. +**You don't need to set up anything locally. Just focus on solving the problem.** + +## ๐Ÿš€ Ready to Start? + +[Choose your level](https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/) and begin learning! diff --git a/adventures/planned/00-blind-by-design/beginner/.mvn/wrapper/maven-wrapper.properties b/adventures/planned/00-blind-by-design/beginner/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..13b218bf --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/.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/beginner/.vscode/launch.json b/adventures/planned/00-blind-by-design/beginner/.vscode/launch.json new file mode 100644 index 00000000..9e149819 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "๐Ÿงช Run the Lab", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/adventures/planned/00-blind-by-design/beginner/.vscode/tasks.json b/adventures/planned/00-blind-by-design/beginner/.vscode/tasks.json new file mode 100644 index 00000000..1d483f30 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "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 } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/devcontainer.json b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/devcontainer.json new file mode 100644 index 00000000..f66eafae --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "๐Ÿงช Adventure 00 | ๐ŸŸข Beginner (Stand up the lab)", + "dockerComposeFile": "docker-compose.yml", + "service": "workspace", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/planned/00-blind-by-design/beginner", + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_01-beginner/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_01-beginner/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "vmware.vscode-spring-boot", + "vscjava.vscode-spring-boot-dashboard", + "redhat.vscode-xml" + ] + }, + "codespaces": { + "openFiles": [ + "adventures/planned/00-blind-by-design/docs/beginner.md", + "adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java", + "adventures/planned/00-blind-by-design/beginner/flags.json" + ] + } + }, + "forwardPorts": [8080], + "portsAttributes": { + "8080": { "label": "Lab (Spring Boot)", "onAutoForward": "notify" } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/docker-compose.yml b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/docker-compose.yml new file mode 100644 index 00000000..4d7a44fc --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/docker-compose.yml @@ -0,0 +1,39 @@ +# Multi-container devcontainer for Beginner. The lab itself runs in +# `workspace`; flagd runs as a sibling so participants meet the realistic +# shape ("the SDK talks RPC to a separate flag service") from level 1 +# instead of running file-mode in-process and throwing it away for +# Intermediate. +# +# 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, the next request sees the new variant. + +services: + workspace: + image: mcr.microsoft.com/devcontainers/java:1-21 + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:cached + command: sleep infinity + environment: + # The flagd Java provider reads these env vars by default when + # FlagdOptions.builder() is invoked without an explicit host/port. + # Pre-set so a vanilla `Resolver.RPC` config Just Works inside the + # devcontainer โ€” and a participant who runs the lab from their host + # machine after `docker compose up flagd` will hit `localhost:8013` + # via the published port. + - FLAGD_HOST=flagd + - FLAGD_PORT=8013 + + flagd: + image: ghcr.io/open-feature/flagd:v0.15.4 + container_name: side-effects-beginner-flagd + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:ro + command: + - start + - --uri + - file:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}/adventures/planned/00-blind-by-design/beginner/flags.json + # No `ports:` block โ€” the lab reaches flagd on the docker-internal + # network as `flagd:8013`. We deliberately do not publish to the host: + # only :8080 is forwarded into the Codespace, so participants see one + # port to click in the Ports tab. diff --git a/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/post-create.sh b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/post-create.sh new file mode 100755 index 00000000..f20e6752 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/post-create.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/beginner" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "00-blind-by-design" "beginner" +track_codespace_created + +# Install gum (used by the verify.sh output helpers). +"$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 + +# Java 21 is provided by the devcontainer image (mcr.microsoft.com/devcontainers/java:1-21-bullseye). +# Pre-fetch Maven dependencies so the IDE is responsive immediately. +echo "โœจ Resolving Maven dependencies for the lab..." +cd "$CHALLENGE_DIR" +chmod +x ./mvnw +./mvnw -q -B -DskipTests dependency:go-offline || true + +echo "โœ… Post-create complete." diff --git a/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/post-start.sh b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/post-start.sh new file mode 100755 index 00000000..3121d4dd --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/00-blind-by-design_01-beginner/post-start.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/beginner" + +cat </dev/null 2>&1; then + code "$REPO_ROOT/adventures/planned/00-blind-by-design/docs/beginner.md" \ + "$CHALLENGE_DIR/src/main/java/dev/openfeature/demo/java/demo/Trial.java" \ + 2>/dev/null || true +fi diff --git a/adventures/planned/00-blind-by-design/beginner/Makefile b/adventures/planned/00-blind-by-design/beginner/Makefile new file mode 100644 index 00000000..5359b523 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/Makefile @@ -0,0 +1,36 @@ +# ============================================================================ +# Makefile for Blind by Design - Beginner Level: Stand up the lab +# ============================================================================ +# This Makefile provides convenient commands for running +# the Spring Boot lab and verifying your solution. +# ============================================================================ + +.PHONY: help lab probe verify + +# Default target - show help +help: + @echo "Blind by Design - Beginner Level: Stand up the lab" + @echo "" + @echo "Application:" + @echo " make lab - Start the Spring Boot lab on :8080" + @echo " make probe - Hit the lab endpoint and pretty-print the response" + @echo "" + @echo "Verification:" + @echo " make verify - Run verification checks" + +# ---------------------------------------------------------------------------- +# Application Targets +# ---------------------------------------------------------------------------- + +lab: + @./mvnw spring-boot:run + +probe: + @curl -s http://localhost:8080/ | jq + +# ---------------------------------------------------------------------------- +# Verification Targets +# ---------------------------------------------------------------------------- + +verify: + @./verify.sh diff --git a/adventures/planned/00-blind-by-design/beginner/flags.json b/adventures/planned/00-blind-by-design/beginner/flags.json new file mode 100644 index 00000000..b04d1531 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/flags.json @@ -0,0 +1,3 @@ +{ + "flags": {} +} diff --git a/adventures/planned/00-blind-by-design/beginner/mvnw b/adventures/planned/00-blind-by-design/beginner/mvnw new file mode 100755 index 00000000..19529ddf --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/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/beginner/mvnw.cmd b/adventures/planned/00-blind-by-design/beginner/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/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/beginner/pom.xml b/adventures/planned/00-blind-by-design/beginner/pom.xml new file mode 100644 index 00000000..eb67c50a --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/pom.xml @@ -0,0 +1,59 @@ + + + 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 + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java b/adventures/planned/00-blind-by-design/beginner/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/beginner/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/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java b/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java new file mode 100644 index 00000000..1e20a475 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java @@ -0,0 +1,15 @@ +package dev.openfeature.demo.java.demo; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Trial { + + @GetMapping("/") + public String observeSubject() { + // The lab is reading from a hard-coded label, not from the chart. + // Wire OpenFeature in and resolve the "vision_state" flag from flags.json instead. + return "untreated"; + } +} diff --git a/adventures/planned/00-blind-by-design/beginner/src/main/resources/application.properties b/adventures/planned/00-blind-by-design/beginner/src/main/resources/application.properties new file mode 100644 index 00000000..2109a440 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=demo diff --git a/adventures/planned/00-blind-by-design/beginner/verify.sh b/adventures/planned/00-blind-by-design/beginner/verify.sh new file mode 100755 index 00000000..d80bf413 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/verify.sh @@ -0,0 +1,162 @@ +#!/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, you should: + +- See curl http://localhost:8080/ return a vision_state reading resolved from flags.json (not the hard-coded fallback) +- Confirm the response payload includes the OpenFeature evaluation details (variant, reason, value) +- Edit flags.json to change the defaultVariant, save, and have the next request return the new variant without restarting the app" + +DOCS_URL="https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/beginner" + +APP_URL="http://localhost:8080/" +FLAGS_FILE="$SCRIPT_DIR/flags.json" + +print_header \ + 'Adventure 00: Blind by Design' \ + '๐ŸŸข Beginner: Stand up the lab' \ + 'Verification' + +# Init test counters +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +check_prerequisites curl jq + +print_sub_header "Running verification checks..." + +# 1. The Spring Boot lab is reachable on :8080 and returns OpenFeature +# evaluation details for the vision_state flag. test_http_endpoint +# handles the connection failure / unexpected-content cases for us. +print_test_section "Checking the lab is reachable on $APP_URL..." +if ! test_http_endpoint "$APP_URL" "vision_state" \ + "Start the app with: ./mvnw spring-boot:run, then make sure Trial returns a FlagEvaluationDetails for 'vision_state'."; then + FAILED_CHECKS+=("vision_state_endpoint") + print_verification_summary "stand up the lab" "$DOCS_URL" "$OBJECTIVE" + exit 1 +fi +print_new_line + +# Cache the response once we know it's good โ€” the remaining checks reuse it. +RESPONSE=$(curl -s --max-time 5 "$APP_URL" 2>/dev/null || echo "") + +# 2. Response carries flagKey=vision_state (more precise than the substring check). +print_test_section "Checking the response is an OpenFeature evaluation for 'vision_state'..." +FLAG_KEY=$(echo "$RESPONSE" | jq -r '.flagKey // .flag_key // empty' 2>/dev/null || echo "") +if [[ "$FLAG_KEY" != "vision_state" ]]; then + print_error_indent "Response did not include 'flagKey':'vision_state'" + print_info_indent "Actual response: $RESPONSE" + print_hint "Wire client.getStringDetails(\"vision_state\", ...) in Trial and return the FlagEvaluationDetails." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flag_key_missing") +else + print_success_indent "Response carries OpenFeature evaluation details for 'vision_state'" + TESTS_PASSED=$((TESTS_PASSED + 1)) +fi +print_new_line + +# 3. The resolved value is NOT the literal "untreated" fallback. +print_test_section "Checking the value is resolved from a provider, not the hard-coded fallback..." +VALUE=$(echo "$RESPONSE" | jq -r '.value // empty' 2>/dev/null || echo "") +REASON=$(echo "$RESPONSE" | jq -r '.reason // empty' 2>/dev/null || echo "") + +if [[ "$VALUE" == "untreated" ]]; then + print_error_indent "Value is still the hard-coded fallback 'untreated' (reason=$REASON)" + print_hint "Configure a FlagdProvider in RPC mode (talks to the flagd sidecar on flagd:8013) and add a 'vision_state' flag to flags.json." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("fallback_value") +elif [[ -z "$VALUE" ]]; then + print_error_indent "No 'value' field in the response" + print_info_indent "Actual response: $RESPONSE" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("value_missing") +else + print_success_indent "Resolved value '$VALUE' (reason=$REASON)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +fi +print_new_line + +# 4. flags.json is hot-reloaded: flip defaultVariant and confirm the response changes. +print_test_section "Checking that flags.json drives the response (hot-reload swap)..." +if [[ ! -f "$FLAGS_FILE" ]]; then + print_error_indent "flags.json not found at $FLAGS_FILE" + print_hint "Drop a flags.json next to pom.xml with a 'vision_state' flag (variants: blurry, clouded)." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flags_json_missing") +else + ORIGINAL_VARIANT=$(jq -r '.flags.vision_state.defaultVariant // empty' "$FLAGS_FILE") + if [[ -z "$ORIGINAL_VARIANT" ]]; then + print_error_indent "Could not read .flags.vision_state.defaultVariant from flags.json" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flags_json_invalid") + else + OTHER_VARIANT=$(jq -r --arg cur "$ORIGINAL_VARIANT" '.flags.vision_state.variants | keys[] | select(. != $cur)' "$FLAGS_FILE" | head -n1) + if [[ -z "$OTHER_VARIANT" ]]; then + print_error_indent "flags.json only defines a single variant; need at least two for the swap test." + print_hint "Add a 'clouded' variant alongside 'blurry'." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("single_variant") + else + BACKUP="$(mktemp)" + cp "$FLAGS_FILE" "$BACKUP" + trap 'cp "$BACKUP" "$FLAGS_FILE" 2>/dev/null || true; rm -f "$BACKUP"' EXIT + + BEFORE_VALUE=$(curl -sS --max-time 5 "$APP_URL" | jq -r '.value // empty') + jq --arg v "$OTHER_VARIANT" '.flags.vision_state.defaultVariant = $v' "$FLAGS_FILE" > "$FLAGS_FILE.tmp" && mv "$FLAGS_FILE.tmp" "$FLAGS_FILE" + + AFTER_VALUE="$BEFORE_VALUE" + for _ in 1 2 3 4 5; do + sleep 1 + AFTER_VALUE=$(curl -sS --max-time 5 "$APP_URL" | jq -r '.value // empty') + if [[ "$AFTER_VALUE" != "$BEFORE_VALUE" ]]; then + break + fi + done + + cp "$BACKUP" "$FLAGS_FILE" + rm -f "$BACKUP" + trap - EXIT + + if [[ "$AFTER_VALUE" != "$BEFORE_VALUE" && -n "$AFTER_VALUE" ]]; then + print_success_indent "Hot-reload works: response changed from '$BEFORE_VALUE' to '$AFTER_VALUE' after editing flags.json" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "Editing flags.json did not change the response (still '$AFTER_VALUE')" + print_hint "flagd's file watcher should pick up the edit. Confirm flagd is running (docker compose ps) and that flags.json sits where the compose file mounts it." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("hot_reload_failed") + fi + fi + fi +fi +print_new_line + +# ============================================================================= +# Summary & Next Steps +# ============================================================================= +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 "stand up the lab" "$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 + +if command -v check_submission_readiness >/dev/null 2>&1; then + check_submission_readiness "00-blind-by-design" "beginner" +fi diff --git a/adventures/planned/00-blind-by-design/docs/beginner.md b/adventures/planned/00-blind-by-design/docs/beginner.md new file mode 100644 index 00000000..fe4c5706 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/beginner.md @@ -0,0 +1,152 @@ +# ๐ŸŸข Beginner: Stand up the lab + +Wire the OpenFeature Java SDK and the flagd contrib provider into a Spring Boot service so flag evaluations are resolved by a flagd sidecar against a `flags.json` file. Author your first flag, then prove that editing `flags.json` flips the response on the **next** request โ€” no app restart, no flagd restart, no redeploy. + +The Spring Boot service runs on `:8080`; a flagd container is already running on `:8013`; `flags.json` is an empty skeleton (`{"flags": {}}`). The SDK is **not** wired in yet โ€” that's your job. + +## ๐Ÿช The Backstory + +The lab is on its first shift and it isn't reading the chart. Every subject who walks through the door gets the same hard-coded reading on their record โ€” no matter what the lab director just signed off on. The label coming out of the lab is a literal string baked into the controller, not a reading pulled from the chart. + +Your mission: replace that hard-coded label with an OpenFeature client, point that client at the **flagd sidecar** that already runs next to your Codespace, and let `flags.json` drive what gets recorded as the subject's `vision_state`. While you're at it, prove the lab can change what it records **without restarting anything** โ€” edit `flags.json`, save, and the next subject through the door has the new reading on their chart. + +## ๐Ÿ—๏ธ Architecture + +This level runs as two containers side-by-side in your Codespace โ€” the Spring Boot lab and a flagd sidecar. + +- **Spring Boot service (the lab)** โ€” a Spring Boot 4 service on `http://localhost:8080/` with one endpoint, `GET /`. Today it returns a hard-coded `"untreated"` literal from `Trial`. +- **`flags.json`** โ€” a flag-definition file in the level folder, mounted **read-only** into the flagd sidecar. The participant edits it through the IDE; flagd's file watcher picks up the change. +- **flagd sidecar** โ€” `ghcr.io/open-feature/flagd:v0.15.4`, started by the devcontainer compose stack. It serves flag evaluations over **gRPC on `:8013`**, watches `flags.json` on disk, and reloads when it changes. +- **OpenFeature SDK + flagd contrib provider** โ€” the OpenFeature Java SDK plus the **flagd contrib provider** in `Resolver.RPC` mode. The provider reads `FLAGD_HOST=flagd` / `FLAGD_PORT=8013` from the environment (the compose file pre-sets them), so there is no host or port to hard-code. + +## ๐ŸŽฏ Objective + +By the end of this level, you should: + +- Have `curl http://localhost:8080/` return a `vision_state` reading **resolved from `flags.json`** (not the hard-coded `"untreated"` fallback) +- Confirm the response payload includes the **OpenFeature evaluation details** โ€” flag key, variant, reason, value +- Edit `flags.json` to change the `defaultVariant`, save, and have the **next** request return the new variant **without restarting the app** + +## ๐Ÿง  What You'll Learn + +- How an OpenFeature client and provider work together โ€” the SDK is provider-agnostic and the flagd provider plugs in via dependency only +- What "remote provider" means in practice โ€” the SDK calls a separate flag service (flagd) over gRPC; the SDK does not parse `flags.json` itself +- What `flags.json` looks like for flagd (`state`, `variants`, `defaultVariant`) +- Why hot-reload of the flag file matters operationally โ€” configuration without redeploy + +## ๐Ÿงฐ Toolbox + +Your Codespace comes pre-configured with the following tools to help you solve the challenge: + +- [`./mvnw`](https://maven.apache.org/wrapper/): The Maven wrapper checked in next to `pom.xml`. Builds and runs the Spring Boot lab. +- [`curl`](https://curl.se/): Hits `http://localhost:8080/` and shows you what reading the lab is recording. +- [`jq`](https://jqlang.org/): Pretty-prints and filters the JSON evaluation details that come back from the SDK. +- A **flagd sidecar** โ€” already running in the devcontainer's compose stack on the docker-internal network. The lab reaches it as `flagd:8013`; you don't need to forward its ports to play this challenge. + +## โฐ Deadline + +_TBD โ€” to be announced at challenge launch._ +> โ„น๏ธ You can still complete the challenge after this date, but points will only be awarded for submissions before the +> deadline. + +## ๐Ÿ’ฌ Join the discussion + +Share your solutions and questions in the challenge thread on the Open Ecosystem Community. +_Discussion link will be added when this adventure goes live._ + +## โœ… How to Play + +### 1. Start Your Challenge + +- Click the "Fork" button in the top-right corner of the GitHub repo or use + [this link](https://github.com/dynatrace-oss/open-ecosystem-challenges/fork). +- From your fork, click the green **Code** button โ†’ **Codespaces hamburger menu** โ†’ **New with options**. +- Select the **Adventure 00 | ๐ŸŸข Beginner (Stand up the lab)** configuration. + +> โš ๏ธ **Important:** The challenge will not work if you choose another configuration (or the default). + +The Codespace will install a Java 21 toolchain and resolve the Maven dependencies. Once it is ready you'll have a +terminal in +`adventures/planned/00-blind-by-design/beginner/`. + +### 2. Start the Lab + +Before you open the forwarded port, start the Spring Boot lab so it is actually serving on `:8080`. You have two options: + +- **Click โ–ถ in VS Code.** The Spring Boot Dashboard panel (one of the recommended extensions in this devcontainer) lists `Laboratory` with a **Run** button. Or press **F5** with `Laboratory.java` open and pick **Java** as the debugger โ€” Spring's main class is detected automatically; no launch.json needed. +- **From the terminal** in the level folder: + + ```bash + ./mvnw spring-boot:run + ``` + +The lab boots in the broken state โ€” `Trial` returns the hard-coded `"untreated"` literal โ€” and that is exactly the starting point you want. + +### 3. Access the UI + +Open the **Ports** tab in the bottom panel. You should see: + +- **8080 โ€” Lab (Spring Boot).** Click the forwarded address. You should see the current hard-coded response: `untreated`. + +flagd is also running, but only inside the docker network โ€” you don't need to forward its ports to play this challenge. + +### 4. Implement the Objective + +You are turning a hard-coded label into a real protocol-driven reading. Work through the steps in this order โ€” each +step makes the next one possible. + +#### a. Add the OpenFeature SDK and the flagd provider to `pom.xml` + +The lab needs two dependencies: the OpenFeature Java SDK and the flagd contrib provider. GroupIds, artifactIds, and current versions are in the +[OpenFeature Java SDK docs](https://openfeature.dev/docs/reference/technologies/server/java/) and the +[flagd Java provider readme](https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd). + +#### b. Configure the OpenFeature provider + +Create a Spring `@Configuration` class that, at startup, builds a `FlagdProvider` in **RPC mode** and registers it on the global OpenFeature API. The [flagd Java provider readme](https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd) covers `FlagdOptions` / `Resolver.RPC` usage. + +You don't need to set host or port โ€” the devcontainer pre-sets `FLAGD_HOST=flagd` and `FLAGD_PORT=8013` in the environment, and the provider reads those automatically. + +#### c. Author the `vision_state` flag in `flags.json` + +The level ships an empty `flags.json` next to `pom.xml` (`{"flags": {}}`) so the flagd sidecar has a valid file to mount at boot. Open it and add a flag named `vision_state` with **two string variants** (e.g. `blurry` and `clouded`) so you have something to flip in the verification step. The schema (`state`, `variants`, `defaultVariant`) is in the [flagd flag-definitions reference](https://flagd.dev/reference/flag-definitions/). + +Save โ€” flagd's file watcher picks the change up within about a second; no restart needed. + +#### d. Read the chart from `Trial` + +Replace the hard-coded `return "untreated";` with an OpenFeature evaluation of the `vision_state` flag, using `"untreated"` as the fallback. **Return the full evaluation details** (not just the value) so the response carries the flag key, variant, value, and reason โ€” that's what the verifier checks. + +The Java SDK's evaluation methods are documented in the [OpenFeature Java SDK reference](https://openfeature.dev/docs/reference/technologies/server/java/). + +#### e. Restart the lab, then prove hot-reload + +The lab is already running from step 2. Stop it (`Ctrl+C` in the terminal, or the **Stop** button in the Spring Boot Dashboard) and start it again so the new `OpenFeatureConfig` is picked up. Then, in a second terminal: + +```bash +curl -s http://localhost:8080/ | jq +``` + +You should see `"value": "blurry"` and `"flagKey": "vision_state"`. Now, **without stopping the app or the flagd +sidecar**, edit `flags.json` and change `"defaultVariant": "blurry"` to `"defaultVariant": "clouded"`. Save, then +re-run the `curl`. The value should flip to `"clouded"` โ€” that's flagd's file watcher noticing the change on disk +and serving the new variant on the next gRPC evaluation. Nothing redeployed; nothing restarted. + +### 5. 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/docs/index.md b/adventures/planned/00-blind-by-design/docs/index.md new file mode 100644 index 00000000..1d4a8e84 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/index.md @@ -0,0 +1,46 @@ +# ๐Ÿงช Adventure 00: Blind by Design + +Three levels of OpenFeature with **flagd** as the provider, in a Java + Spring Boot service. Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert) โ€” all without redeploying. + +The entire **infrastructure is pre-provisioned in your Codespace** โ€” no local setup required. + +## ๐Ÿช The Backstory + +The **Aletheia Institute** is running a multi-phase vision-enhancement trial. The **lab** is a Spring Boot service whose one job is to record the **`vision_state`** of every subject who walks through the protocol โ€” `blurry`, `sharp`, `enhanced`, or `clouded` โ€” because subjects don't all arrive with the same biology, the same dose adherence, or the same trial-jurisdiction baseline. The flag definitions that drive those readings live in `flags.json`, watched by a **flagd** sidecar; the **OpenFeature** SDK is supposed to call that sidecar on every evaluation. + +It hasn't been. For the past **eight months**, every subject through the door has been recorded as `"untreated"` โ€” the integration was never finished, and the lab director assumed the system was reading the chart. Worse, **eight weeks ago** the Institute opened its flagship Phase 3 trial: a new amplifier variant rolled out fractionally to a cohort by a targeting rule in `flags.json`. **Four adverse-event reports** have since been filed, each one a subject whose `vision_state` at discharge was worse than at enrollment. + +The monitoring is dark โ€” not by accident, but because no one ever turned the lights on. Your mission across three levels: **stand up the lab** so it reads the chart, **read the chart by cohort** so outcomes can be tracked, then **turn on the lights and roll back the Phase 3 variant** before the director signs off on the next enrollment batch. + +## ๐Ÿง  What you'll be using + +OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native implementation is **flagd** โ€” it serves flag definitions from a JSON file, locally or remotely, and the OpenFeature SDK in your application calls it on every evaluation. + +In this adventure, the lab uses OpenFeature exactly the way a real engineering team would: a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the targeting rules in `flags.json` decide what reading every subject ends up with. By the end, you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled back a misbehaving Phase 3 trial without redeploying. + +## ๐ŸŽฎ Choose Your Level + +Each level is a standalone challenge with its own Codespace, building on the story while staying technically independent. + +### ๐ŸŸข Beginner: Stand up the lab + +- **Status:** โœ… Ready to Play +- **Topics:** OpenFeature Java SDK, flagd as a sidecar (`Resolver.RPC`), Spring Boot + +Wire OpenFeature into a Spring Boot service so the lab's `vision_state` reading is resolved by a flagd sidecar against a `flags.json` instead of a hard-coded literal. + +[**Start the Beginner Challenge**](./beginner.md){ .md-button .md-button--primary } + +### ๐ŸŸก Intermediate: Outcome by cohort + +- **Status:** ๐Ÿšง Coming Soon +- **Topics:** OpenFeature targeting, transaction context, hooks, Spring `HandlerInterceptor` + +Add request-scoped context, a global runtime context, an invocation context at the call site, and an audit hook so the lab records the right reading per subject cohort. + +### ๐Ÿ”ด Expert: Phase 3 โ€” read the chart + +- **Status:** ๐Ÿšง Coming Soon +- **Topics:** OpenTelemetry traces + metrics, custom hooks, Grafana LGTM, fractional rollout, OpenFeature OTel hooks + +Finish wiring OpenTelemetry through to the Grafana LGTM stack, write a `ContextSpanHook` that puts the merged eval context onto Tempo spans, find the misbehaving Phase 3 amplifier in the dashboard, and roll it back without redeploying. diff --git a/adventures/planned/00-blind-by-design/mkdocs.yaml b/adventures/planned/00-blind-by-design/mkdocs.yaml new file mode 100644 index 00000000..3180a33d --- /dev/null +++ b/adventures/planned/00-blind-by-design/mkdocs.yaml @@ -0,0 +1,5 @@ +site_name: '๐Ÿงช 00: Blind by Design' + +nav: + - Introduction: index.md + - '๐ŸŸข Beginner': beginner.md diff --git a/ideas/.implemented/blind-by-design.md b/ideas/.implemented/blind-by-design.md new file mode 100644 index 00000000..e5941953 --- /dev/null +++ b/ideas/.implemented/blind-by-design.md @@ -0,0 +1,130 @@ +# Adventure Idea: ๐Ÿงช Blind by Design + +## Overview + +**Theme:** A research lab is testing a vision-enhancement serum on volunteers. The serum is supposed to take ordinary eyes and produce sharper, even enhanced sight โ€” useful for observation work. The lab is a Spring Boot service; OpenFeature is the chart system; `flags.json` decides what reading the lab records for each subject. The protocol is the same for every subject โ€” what differs is the **observed `vision_state`**, because subjects come in with different biology, dose adherence, and trial-jurisdiction baseline. The flagship Phase 3 trial โ€” a new amplifier algorithm โ€” has started showing trouble: subjects stabilise slower, and roughly one in ten emerge blind. The dashboard that should be tracking all of this is dark, because the lab forgot to wire the metric exporter. Your mission across three levels: stand up the lab, read the chart by cohort, then turn on the lights and roll back the trial before more subjects lose their sight. + +**Skills:** + +- Wire OpenFeature into a real application and resolve flags from a flagd sidecar +- Layer per-request, per-process, and per-evaluation context so the same trial yields the right reading for every cohort, and audit every reading in the logs +- Roll out a risky algorithm in measured phases and roll it back from observability data when it misbehaves + +**Technologies:** OpenFeature Java SDK, flagd, Spring Boot, Grafana LGTM (Tempo + Prometheus + Loki), Testcontainers + +--- + +## Levels + +### ๐ŸŸข Beginner: Stand up the lab + +#### Description + +Wire OpenFeature into a Spring Boot service so the lab's `vision_state` reading is resolved by a flagd sidecar against a `flags.json` instead of a hard-coded literal. + +#### Story + +The lab is on its first shift. Every subject who walks in gets the same hard-coded reading on their chart โ€” no matter what the lab director just signed off on, no matter what the protocol says. A flagd sidecar is already running next to the lab in the Codespace, with an empty `flags.json` mounted into it; the OpenFeature SDK is not in the project at all. The lab director has approved the switch: add the SDK + flagd contrib provider to the project, register the provider against the sidecar, author the first flag definition in `flags.json`, and let the chart drive what gets recorded for each subject. While you are at it, prove the lab can change the reading without restarting anything โ€” flagd's file watcher does the work. + +#### The Problem + +The Spring Boot starter app has a `Trial` controller whose `GET /` returns a string literal. There is no OpenFeature dependency in the `pom.xml`, no provider configured, and `flags.json` ships as `{"flags": {}}` so the flagd sidecar can boot. The participant must add the OpenFeature SDK and the flagd contrib provider, configure a `FlagdProvider` in `Resolver.RPC` mode (the devcontainer pre-sets `FLAGD_HOST=flagd` and `FLAGD_PORT=8013` so no host or port needs to be hard-coded), add the `vision_state` flag to `flags.json`, and switch the controller to call `client.getStringDetails` against it. + +#### Objective + +By the end of this level, the learner should: + +- Have `curl http://localhost:8080/` return a `vision_state` reading **resolved by the flagd sidecar** (not the hard-coded `untreated` fallback) +- Confirm the response payload includes the OpenFeature evaluation details (variant, reason, value) +- Edit `flags.json` to change the `defaultVariant`, save, and have the **next** request return the new variant **without restarting the app or flagd** + +#### What You'll Learn + +- How an OpenFeature client and provider work together โ€” the SDK is provider-agnostic and the flagd provider plugs in via dependency only +- What "remote provider" means in practice โ€” the SDK calls a separate flag service (flagd) over gRPC; the SDK does not parse `flags.json` itself +- What `flags.json` looks like for flagd (state, variants, defaultVariant) +- Why hot-reload of the flag file matters operationally โ€” configuration without redeploy + +#### Tools & Infrastructure + +- **Tools:** `curl`, `./mvnw`, `jq` (optional for prettier output) +- **Infrastructure:** Java 21 toolchain, a `flagd` sidecar service running in the devcontainer's compose stack on `:8013` (gRPC eval), `:8014` (management/metrics), `:8015` (sync), `:8016` (OFREP) + +--- + +### ๐ŸŸก Intermediate: Outcome by cohort + +#### Description + +Add request-scoped context, a global runtime context, an invocation context at the call site, and an audit hook so the lab records the right reading per subject cohort and every reading shows up in the audit log. + +#### Story + +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. The protocol is the same for every subject; what differs is the *observed outcome* because subjects come in with different biology, different dose adherence, and the trial is registered in different jurisdictions. The OpenFeature client never sees what **species** is on the table (each subject brings their own โ€” humans, zyklops, you name it), never sees which **country** this trial is registered in (set once when the lab boots), never sees what **dose** the subject actually absorbed (varies per subject โ€” missed appointments, fast metabolisers, the usual reasons). And there is no audit hook recording who ended up with which reading. + +#### The Problem + +The lab from the Beginner level reads the flag, but the same variant comes back for every request. The flag definition in `flags.json` already has all three targeting branches loaded โ€” `species == zyklop`, improper-`dose` for non-zyklops, and `country == de` โ€” but none of those attributes are in the evaluation context yet, so the targeting has nothing to fire on. The participant must wire a `SpeciesInterceptor` that lifts `?species=` into the OpenFeature **transaction context**, populate the **global** evaluation context with `country` from the `COUNTRY` env var at startup, pass a `dose` attribute as **invocation context** at the call site of `client.getStringDetails(...)`, and register an `AuditHook` that records every evaluation with the cohort attributes that drove it. + +#### Objective + +By the end of this level, the learner should: + +- Have a Spring `HandlerInterceptor` (`SpeciesInterceptor`) that reads `?species=` from the request and sets it on the OpenFeature transaction context, then clears it on `afterCompletion` +- Have a global evaluation context that carries `country` from `System.getenv("COUNTRY")`, set once in `OpenFeatureConfig.@PostConstruct` +- Have the `Trial` controller pass a `dose` attribute as invocation context at the call site +- Have an `AuditHook` registered that emits one `[AUDIT] ...` log line per evaluation, at `WARN` for `clouded` outcomes +- Confirm `curl /?species=zyklop` returns `enhanced` (transaction wins), `curl /?dose=standard` with `COUNTRY=de` returns `sharp` (global), `curl /?dose=underdose` returns `clouded` (invocation), and `curl /?species=zyklop&dose=underdose` returns `enhanced` (precedence: species-zyklop is evaluated before improper-dose in `flags.json`) + +#### What You'll Learn + +- How OpenFeature's three evaluation-context layers compose โ€” global (per-process), transaction (per-request, propagated thread-locally), invocation (per-evaluation, passed at the call site) โ€” and how precedence works on conflict +- How transaction-context propagation works in a thread-per-request server with a `ThreadLocalTransactionContextPropagator` +- How hooks let you attach cross-cutting behaviour (audit today, observability tomorrow) without modifying every call site +- Why an audit log needs a fixed allowlist of context attributes โ€” `targetingKey` and other PII routinely sit in the merged context in real apps + +#### Tools & Infrastructure + +- **Tools:** `curl`, `./mvnw`, `tail -f` against `app.log`, two convenience runners (`./run-germany.sh` and `./run-austria.sh`) that pre-set `COUNTRY` and tee to the log +- **Infrastructure:** Same Java 21 toolchain, the same `flagd` sidecar from Beginner (compose-managed alongside `workspace`) + +--- + +### ๐Ÿ”ด Expert: Phase 3 โ€” read the chart + +#### Description + +Finish wiring OpenTelemetry traces and metrics through to the Grafana LGTM stack, write a `ContextSpanHook` of your own that mirrors the merged evaluation context onto the active span, find the misbehaving Phase 3 amplifier in the dashboard, and roll it back without redeploying. + +#### Story + +The trial just went wide. The same flagd sidecar from earlier levels is now serving two flags that matter โ€” the cohort-targeted `vision_state` from Intermediate, and a new fractional-rollout flag `vision_amplifier_v2` driving the Phase 3 trial. OpenTelemetry is half-wired: a traces exporter is shipping spans to Tempo, but the meter provider is unconfigured, so the rollout dashboard is dark. And the fractional bucket on `vision_amplifier_v2` is inverted โ€” every subject is rolling into the new amplifier. Each evaluation under the new amplifier is 200 milliseconds slower to stabilise, and roughly one in ten subjects emerges blind (HTTP 500). The lab is the lab โ€” it cannot fix what it cannot see. The dashboard is dark. + +The director wants four things, in order: the dashboard lit up, the eval-context attributes that drove each outcome searchable on the spans (so on-call can answer "which dose got which variant?"), the bad fractional bucket identified, and the rollout rolled back to a safe number โ€” all without redeploying the lab. + +#### The Problem + +The level ships a working lab pointed at the same `flagd` sidecar in `Resolver.RPC` mode, plus a Grafana LGTM container with OTLP receivers on the standard ports and a k6 loadgen that drives traffic when the `loadgen_active` flag is on. The OpenTelemetry SDK in the app is wired for traces (the OTel `TracesHook` is registered, the exporter writes to Tempo) but the meter provider's exporter is set to `none`, so the OpenFeature `MetricsHook` has nowhere to record. The `AuditHook` from Intermediate is carried over and continues to write a durable archive view; what the lab is missing is the **real-time correlation** between context attributes and span events. The participant must (1) flip `otel.metrics.exporter` from `none` to `otlp`, (2) register `MetricsHook` on the OpenFeatureAPI, (3) write a small `ContextSpanHook` that copies a fixed allowlist (`species`, `country`, `dose`) from the merged eval context onto the active span as `feature_flag.context.`, (4) flip `loadgen_active` to `on` and observe the latency and 5xx panels, and (5) edit `flags.json` to flip the `vision_amplifier_v2` fractional bucket back to `100% off / 0% on` while the app keeps running. + +#### Objective + +By the end of this level, the learner should: + +- Have `MetricsHook` registered and the OTel meter provider configured to export to the LGTM stack on `localhost:4317` +- Have a `ContextSpanHook` of their own that copies the merged evaluation context (`species`, `country`, `dose`) onto the active span as `feature_flag.context.` โ€” registered alongside `TracesHook` / `MetricsHook` +- Have **at least one trace** for `fun-with-flags-java-spring` visible in the Grafana **Explore โ†’ Tempo** view +- Have spans tagged with `feature_flag.context.dose=underdose` searchable in Tempo and lining up with `feature_flag.variant=clouded` on the same span +- Have the **Fun With Flags โ€” Feature Flag Metrics** dashboard showing live evaluation rate, variant distribution, and latency by variant +- Have `vision_amplifier_v2` rolled back to `0% on`, confirmed by reading the flag from flagd's gRPC-Gateway HTTP route on `:8013`, and the HTTP 5xx rate dropping below threshold afterwards + +#### What You'll Learn + +- How the OpenFeature OTel hooks (`TracesHook` and `MetricsHook`) join flag evaluations to the rest of an app's telemetry without a separate ingestion path +- How to author your own `Hook` โ€” a tiny class that reads `HookContext.getCtx()` (the merged eval context) and emits something useful (here: span attributes) โ€” and why the **PII allowlist** matters when those attributes flow into observability backends with multi-day retention +- How fractional rollout in flagd buckets subjects by `targetingKey` and how to read the bucketing from a dashboard +- How a flag flip is a faster operational lever than a redeploy when a rollout is misbehaving + +#### Tools & Infrastructure + +- **Tools:** `curl`, `./mvnw`, `docker compose`, a browser pointed at Grafana on `:3000` +- **Infrastructure:** Java 21 toolchain, `flagd` sidecar (`:8013` gRPC eval, `:8014` management/metrics, `:8015` sync, `:8016` OFREP), `grafana/otel-lgtm` container on `:3000`/`:4317`/`:4318`/`:9090`/`:3200`, k6 loadgen container driving traffic when `loadgen_active` is on