diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e3fb41a69..ebf093d3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,8 +15,8 @@ jobs: if: startsWith(github.repository, 'spinnaker/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: java-version: 11 distribution: 'zulu' diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 44e0dc102..f5ccf04c7 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -6,5 +6,5 @@ jobs: name: "Gradle wrapper validation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: gradle/wrapper-validation-action@v1 + - uses: actions/checkout@v4 + - uses: gradle/wrapper-validation-action@v2 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a41edec9a..3d4281aa3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,8 +9,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: java-version: 11 distribution: 'zulu' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28939da57..bc3d15a34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: git fetch --prune --unshallow # Given a tag, determine what branch we are on, so we can bump dependencies in the correct branch - name: Get Branch @@ -35,7 +35,7 @@ jobs: fi echo "exactly one branch ($BRANCHES)" echo BRANCH=$BRANCHES >> $GITHUB_ENV - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: java-version: 11 distribution: 'zulu' @@ -74,7 +74,7 @@ jobs: - name: Pause before dependency bump run: sleep 600 - name: Trigger dependency bump workflow - uses: peter-evans/repository-dispatch@v2 + uses: peter-evans/repository-dispatch@v3 with: token: ${{ secrets.SPINNAKER_GITHUB_TOKEN }} event-type: bump-dependencies diff --git a/build.gradle b/build.gradle index 39c639391..045fb39d3 100644 --- a/build.gradle +++ b/build.gradle @@ -31,14 +31,12 @@ subprojects { if (it.name != "kork-bom" && it.name != "spinnaker-dependencies") { apply plugin: 'java-library' test { - useJUnitPlatform { - includeEngines "spek2", "junit-vintage", "junit-jupiter" - } + useJUnitPlatform() } dependencies { annotationProcessor(platform(project(":spinnaker-dependencies"))) annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") - testRuntimeOnly("org.junit.vintage:junit-vintage-engine") + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } } } diff --git a/gradle.properties b/gradle.properties index 9178c06c3..0c2557f59 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ -kotlinVersion=1.4.32 +kotlinVersion=1.5.32 org.gradle.parallel=true -spinnakerGradleVersion=8.26.0 +spinnakerGradleVersion=8.32.1 targetJava11=true includeRuntimes=actuator,core,eureka,retrofit,secrets-aws,secrets-gcp,stackdriver,swagger,tomcat,web diff --git a/gradle/kotlin-test.gradle b/gradle/kotlin-test.gradle index b8a2f8229..b6d4c5021 100644 --- a/gradle/kotlin-test.gradle +++ b/gradle/kotlin-test.gradle @@ -20,6 +20,7 @@ apply plugin: "kotlin" dependencies { + testImplementation(platform(project(":spinnaker-dependencies"))) testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" testImplementation "org.junit.jupiter:junit-jupiter-api" @@ -29,13 +30,14 @@ dependencies { testImplementation "dev.minutest:minutest" testImplementation "io.mockk:mockk" + testRuntimeOnly(platform(project(":spinnaker-dependencies"))) testRuntimeOnly "org.junit.platform:junit-platform-launcher" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } compileTestKotlin { kotlinOptions { - languageVersion = "1.4" + languageVersion = "1.5" jvmTarget = "11" } } diff --git a/gradle/kotlin.gradle b/gradle/kotlin.gradle index e2884cf25..203c24938 100644 --- a/gradle/kotlin.gradle +++ b/gradle/kotlin.gradle @@ -19,6 +19,7 @@ apply plugin: "kotlin-spring" apply plugin: "io.gitlab.arturbosch.detekt" dependencies { + testImplementation(platform(project(":spinnaker-dependencies"))) testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.platform:junit-platform-runner" testImplementation "org.spekframework.spek2:spek-dsl-jvm" @@ -28,6 +29,7 @@ dependencies { testImplementation "dev.minutest:minutest" testImplementation "io.mockk:mockk" + testRuntimeOnly(platform(project(":spinnaker-dependencies"))) testRuntimeOnly "org.junit.platform:junit-platform-launcher" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" testRuntimeOnly "org.junit.vintage:junit-vintage-engine" @@ -37,14 +39,14 @@ dependencies { compileKotlin { kotlinOptions { - languageVersion = "1.4" + languageVersion = "1.5" jvmTarget = "11" } } compileTestKotlin { kotlinOptions { - languageVersion = "1.4" + languageVersion = "1.5" jvmTarget = "11" } } diff --git a/gradle/lombok.gradle b/gradle/lombok.gradle index a94257fc1..93f41b25d 100644 --- a/gradle/lombok.gradle +++ b/gradle/lombok.gradle @@ -1,8 +1,10 @@ dependencies { compileOnly "org.projectlombok:lombok" + compileOnly(platform(project(":spinnaker-dependencies"))) annotationProcessor "org.projectlombok:lombok" annotationProcessor(platform(project(":spinnaker-dependencies"))) testCompileOnly "org.projectlombok:lombok" + testCompileOnly(platform(project(":spinnaker-dependencies"))) testAnnotationProcessor(platform(project(":spinnaker-dependencies"))) testAnnotationProcessor "org.projectlombok:lombok" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c02..943f0cbfa 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 28ff446a2..508322917 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..65dcd68d6 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/kork-actuator/kork-actuator.gradle b/kork-actuator/kork-actuator.gradle index 13686dfa7..0f441b752 100644 --- a/kork-actuator/kork-actuator.gradle +++ b/kork-actuator/kork-actuator.gradle @@ -19,6 +19,7 @@ apply plugin: "java-library" apply from: "$rootDir/gradle/kotlin-test.gradle" dependencies { + compileOnly(platform(project(":spinnaker-dependencies"))) implementation(platform(project(":spinnaker-dependencies"))) implementation "org.springframework.security:spring-security-core" implementation "org.springframework.boot:spring-boot-starter-security" diff --git a/kork-artifacts/kork-artifacts.gradle b/kork-artifacts/kork-artifacts.gradle index 8be01c05e..a6a8b0fe9 100644 --- a/kork-artifacts/kork-artifacts.gradle +++ b/kork-artifacts/kork-artifacts.gradle @@ -5,11 +5,22 @@ dependencies { api(platform(project(":spinnaker-dependencies"))) implementation project(":kork-annotations") + implementation project(":kork-exceptions") + implementation project(":kork-security") implementation "com.fasterxml.jackson.core:jackson-databind" + api 'software.amazon.awssdk:s3' + api 'software.amazon.awssdk:sts' + implementation "org.apache.httpcomponents:httpclient" + implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation "org.springframework.security:spring-security-core" + implementation 'org.apache.logging.log4j:log4j-api' api "com.hubspot.jinjava:jinjava" testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-params" + testImplementation "org.mockito:mockito-core" + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation project(":kork-core") testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/ArtifactTypes.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/ArtifactTypes.java new file mode 100644 index 000000000..9d3d0404c --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/ArtifactTypes.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ArtifactTypes { + EMBEDDED_BASE64("embedded/base64"), + REMOTE_BASE64("remote/base64"), + ; + + @Getter private final String mimeType; +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/README.md b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/README.md new file mode 100644 index 000000000..0308f4093 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/README.md @@ -0,0 +1,142 @@ +# Artifact Storage + +Artifact Storage is a feature that allows for embedded artifacts to be +persisted to some storage, eg the S3ArtifactStore. Spinnaker keeps a history, +which is called the pipeline context, that contains everything that an +execution had done. Pipeline contexts can be very large, especially with +complicated pipelines, and size can be further increased when users have large +artifacts. Spinnaker will duplicate +these artifacts whenever any stage uses any of those artifacts. Using an +artifact store reduces this overhead by providing a reference link of, +`ref:///`. This reduces the context size +tremendously, but will vary depending on the size of the pipeline, as well as +how that artifact is used, but we've seen improvements of 80% for some +pipelines. + +## Architecture + + +-----------+ + | | + | Orca | + | | + +-----------+ + | ^ + | | (outgoing artifact compressed) + | +----------------------+ + | (bake request) | + +---------------------+ | + v | + +---------------+ (fetch) +-------------+ + | |<-----------------------------| | + | Clouddriver | | Rosco | + | | | | + | s3 get | (full artifact returned) | s3 stores | + | artifacts |----------------------------->| artifacts | + +---------------+ +-------------+ + + +Artifact storage is divided into two operations of get and store, and there are +primarily two services that utilize each of these operations. Further the +artifact storage system relies on Spring's (de)serializing to call these +operations to limit the amount of code changes needed within these services. + +When bootstrapping Spring we add in custom bean serializers and deserializers to +handle storage or retrieval of an artifact. + +Rosco is primarily used for baking artifacts which will generate something +deployable. When Rosco responds to a bake request, the custom serializer +injected in Rosco at startup stores the artifact and returns a `remote/base64` +artifact instead of the usual `embedded/base64`. + +Clouddriver, for this document, handles mostly deployment and has some endpoints +regarding artifacts. It does do a little more than this, but we only care about +these two particular operations. When any request comes in, Spring will use its +custom deserializers to expand any artifact in its payload since any request +with artifacts, probably wants to do some operation on those artifacts. Further +Clouddriver also allows for fetching of artifacts. Orca and Rosco both make +calls to the `/artifact/fetch` endpoint. Where Rosco uses it to fetch an +artifact to be baked, and Orca uses it primarily when dealing with deploy +manifests. When a request is sent to the fetch endpoint, Clouddriver will always +return the full `embedded/base64` artifact back to the service. It is up to the +service receiving the artifact to compress it. Luckily, for Orca, we don't have +to worry about compression, since this no longer becomes an artifact, but a +manifest instead. + +Orca is a special case as it mostly does orchestration, but does cause some +artifacts to be duplicated when handling the expected artifact logic. We inject +some logic to handle the duplication along with ensuring that matching against +expected artifacts still works properly. So if a `embedded/base64` type needs to +match against a `remote/base64` type, Orca will use the artifact store to +retrieve that artifact, and do the comparison. In addition, Orca will store any +expected artifacts, to limit the context size. + +Orca also handles SpEL evaluation which means our new `remote/base64` type +should be backwards compatible with existing pipelines. To ensure this, we +utilized the Spring converters, and injected our own custom converter that will +check if some `String` is a remote base64 URI, and if it is, retrieve it. + +## Configuration + +To enable artifact storage, simple add this to your `spinnaker-local.yml` file + +```yaml +artifact-store: + type: s3 + s3: + enabled: true + bucket: some-artifact-store-bucket +``` + +### Rosco and Helm + +If any pipelines are passing artifact references to bake stages as a parameter, +enabling this field will allow those URIs to be expanded to the full +references: + +```yaml +artifact-store: + type: s3 + helm: + expandOverrides: true +``` + +## Storage Options + +### S3 + +[S3](https://aws.amazon.com/s3/) is an object store provided by AWS. The +current S3ArtifactStore implementation provides various ways to authenticate +against AWS. + +```yaml +artifact-store: + type: s3 + s3: + enabled: true + profile: dev # if you want to authenticate using a certain profile + region: us-west-2 # allows for specified regions + bucket: some-artifact-store-bucket +``` + +While the implementation is S3 specific, this does not limit usages of other S3 +compatible storage engines. For example, something like +[SeaweedFS](https://github.com/seaweedfs/seaweedfs) can be used to test locally. +with + +## Local Testing + +To test the artifact store locally, we will use SeaweedFS. To start the storage simply run +`docker run -p 8333:8333 chrislusf/seaweedfs server -s3` + +Next enable the configuration + +```yaml +artifact-store: + type: s3 + s3: + enabled: true + url: http://localhost:8333 # this URL will be used to make S3 API requests to + bucket: some-artifact-store-bucket +``` + +Start Spinnaker, and you should see reference links in your pipeline contexts. diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDecorator.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDecorator.java new file mode 100644 index 000000000..1bbb68cb9 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDecorator.java @@ -0,0 +1,23 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; + +/** Primarily used to set any custom fields to an artifact */ +public interface ArtifactDecorator { + Artifact.ArtifactBuilder decorate(Artifact.ArtifactBuilder builder); +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDeserializer.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDeserializer.java new file mode 100644 index 000000000..df84ea2b6 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDeserializer.java @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.exceptions.ArtifactStoreIOException; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Qualifier; + +/** + * ArtifactDeserializer is a custom deserializer that will allow for artifacts to be fetched from + * some artifact store as long as the referenceLink field is set and the reference field is null. + */ +public class ArtifactDeserializer extends StdDeserializer { + private final ObjectMapper defaultObjectMapper; + private final ArtifactStore storage; + + public ArtifactDeserializer( + @Qualifier(value = "artifactObjectMapper") ObjectMapper defaultObjectMapper, + ArtifactStore storage) { + super(Artifact.class); + this.defaultObjectMapper = defaultObjectMapper; + this.storage = storage; + } + + @Override + public Artifact deserialize(JsonParser parser, DeserializationContext ctx) throws IOException { + Artifact artifact = defaultObjectMapper.readValue(parser, Artifact.class); + if (ArtifactTypes.REMOTE_BASE64.getMimeType().equals(artifact.getType())) { + return ArtifactStoreIOException.throwIOException( + () -> + storage.get( + ArtifactReferenceURI.parse(artifact.getReference()), + new ArtifactMergeReferenceDecorator(artifact))); + } + + return artifact; + } + + /** + * ArtifactMergeReferenceDecorator is used to take some artifact and replace its reference with + * the reference from another artifact. + */ + public static class ArtifactMergeReferenceDecorator implements ArtifactDecorator { + private final Artifact artifactToCopy; + + private ArtifactMergeReferenceDecorator(Artifact artifactToCopy) { + this.artifactToCopy = artifactToCopy; + } + + @Override + public Artifact.ArtifactBuilder decorate(Artifact.ArtifactBuilder builder) { + Artifact retrieved = builder.build(); + return artifactToCopy.toBuilder().reference(retrieved.getReference()); + } + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactReferenceURI.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactReferenceURI.java new file mode 100644 index 000000000..3fdfcfa0e --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactReferenceURI.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import java.util.Arrays; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.util.Strings; + +/** + * A URI that can parse and allow for ArtifactStorage to easily get specific information about the + * URI + */ +@Builder +@Getter +public class ArtifactReferenceURI { + /** + * uriScheme is used as an HTTP scheme to let us further distinguish a String that is a URI to an + * artifact. This is helpful in determining what is an artifact since sometimes we are only given + * a string rather than a full artifact. + */ + private static final String uriScheme = "ref://"; + + private final List uriPaths; + + public String uri() { + return uriScheme + paths(); + } + + public String paths() { + return Strings.join(uriPaths, '/'); + } + + /** Used to determine whether a String is in the artifact reference URI format. */ + public static boolean is(String reference) { + return reference.startsWith(uriScheme); + } + + public static ArtifactReferenceURI parse(String reference) { + String noSchemeURI = StringUtils.removeStart(reference, uriScheme); + String[] paths = StringUtils.split(noSchemeURI, '/'); + return ArtifactReferenceURI.builder().uriPaths(Arrays.asList(paths)).build(); + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStore.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStore.java new file mode 100644 index 000000000..7e2d9566e --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStore.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import lombok.Getter; +import lombok.extern.log4j.Log4j2; + +/** ArtifactStore allows for different types of artifact storage to be used during runtime */ +@Log4j2 +public class ArtifactStore implements ArtifactStoreGetter, ArtifactStoreStorer { + @Getter private static volatile ArtifactStore instance = null; + + private final ArtifactStoreGetter artifactStoreGetter; + + private final ArtifactStoreStorer artifactStoreStorer; + + public ArtifactStore( + ArtifactStoreGetter artifactStoreGetter, ArtifactStoreStorer artifactStoreStorer) { + this.artifactStoreGetter = artifactStoreGetter; + this.artifactStoreStorer = artifactStoreStorer; + } + + /** Store an artifact in the artifact store */ + public Artifact store(Artifact artifact) { + return artifactStoreStorer.store(artifact); + } + + /** + * get is used to return an artifact with some id, while also decorating that artifact with any + * necessary fields needed which should be then be returned by the artifact store. + */ + public Artifact get(ArtifactReferenceURI uri, ArtifactDecorator... decorators) { + return artifactStoreGetter.get(uri, decorators); + } + + public static void setInstance(ArtifactStore storage) { + synchronized (ArtifactStore.class) { + if (instance == null) { + instance = storage; + return; + } + + log.warn("Multiple attempts in setting the singleton artifact store"); + } + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfiguration.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfiguration.java new file mode 100644 index 000000000..c2a0b6d60 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfiguration.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.kork.artifacts.artifactstore.s3.S3ArtifactStoreConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@EnableConfigurationProperties(ArtifactStoreConfigurationProperties.class) +@Import(S3ArtifactStoreConfiguration.class) +public class ArtifactStoreConfiguration { + /** + * this is strictly used due to Spring and Jackson not behaving nicely together. + * Unfortunately, @JsonDeserializer will construct its own deserializer utilizing beans and thus + * not using the object mapper we want to use + */ + @Bean + public ObjectMapper artifactObjectMapper() { + return new ObjectMapper(); + } + + @Bean + public ArtifactStoreURIBuilder artifactStoreURIBuilder() { + return new ArtifactStoreURISHA256Builder(); + } + + @ConditionalOnMissingBean(ArtifactStoreGetter.class) + @Bean + public ArtifactStoreGetter artifactStoreGetter() { + return new NoopArtifactStoreGetter(); + } + + @ConditionalOnMissingBean(ArtifactStoreStorer.class) + @Bean + public ArtifactStoreStorer artifactStoreStorer() { + return new NoopArtifactStoreStorer(); + } + + @Bean + public ArtifactStore artifactStore( + ArtifactStoreGetter artifactStoreGetter, ArtifactStoreStorer artifactStoreStorer) { + ArtifactStore artifactStore = new ArtifactStore(artifactStoreGetter, artifactStoreStorer); + ArtifactStore.setInstance(artifactStore); + return artifactStore; + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfigurationProperties.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfigurationProperties.java new file mode 100644 index 000000000..6f06ae786 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfigurationProperties.java @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties("artifact-store") +public class ArtifactStoreConfigurationProperties { + private String applicationsRegex = null; + + /** The type of artifact store to use (e.g. s3). */ + private String type = null; + + /** Configuration for an s3 client which will utilize credentials in the AWS credentials file. */ + @Data + public static class S3ClientConfig { + private boolean enabled = false; + private String profile = null; + private String region = null; + /** + * Url may be used to override the contact URL to an s3 compatible object store. This is useful + * for testing utilizing things like seaweedfs. + */ + private String url = null; + + private String bucket = null; + private String accessKey = null; + private String secretKey = null; + private boolean forcePathStyle = true; + } + + @Data + public static class HelmConfig { + /** Enables Rosco to expand any artifact URIs passed as parameters for Helm. */ + private boolean expandOverrides = false; + } + + private S3ClientConfig s3 = new S3ClientConfig(); + private HelmConfig helm = new HelmConfig(); +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreGetter.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreGetter.java new file mode 100644 index 000000000..3a4fd6cef --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreGetter.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Salesforce Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; + +/** + * ArtifactStoreGetter is an interface that allows for different types of artifact storage to be + * used during runtime. + */ +public interface ArtifactStoreGetter { + /** + * get is used to return an artifact with some id, while also decorating that artifact with any + * necessary fields needed which should be then be returned by the artifact store. + */ + public Artifact get(ArtifactReferenceURI uri, ArtifactDecorator... decorators); +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreStorer.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreStorer.java new file mode 100644 index 000000000..d817ac351 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreStorer.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Salesforce Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; + +/** + * ArtifactStoreStorer is an interface that allows for different types of artifact storage to be + * used during runtime. + */ +public interface ArtifactStoreStorer { + + Artifact store(Artifact artifact); +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURIBuilder.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURIBuilder.java new file mode 100644 index 000000000..cef9904e4 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURIBuilder.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; + +public abstract class ArtifactStoreURIBuilder { + /** + * Returns the remote artifact URI that will be associated with some artifact. + * + * @param context is the context in which this artifact was run in, e.g. the application. + * @param artifact that will be associated with the generated URI. + * @return the remote URI + */ + public abstract ArtifactReferenceURI buildArtifactURI(String context, Artifact artifact); + + /** + * buildRawURI is used when you have the raw path and context. This method just simply returns the + * properly formatted URI using the URI builder that extends this class. + * + *

This function is primarily used in clouddriver when deck is asking for the raw artifact to + * be displayed. Since we don't have the artifact, but only the context and some raw ID from the + * gate endpoint, + * + *

/context/hash
+ * + *

we need to reconstruct the full remote URI in clouddriver. + * + *

{@code
+   * String application = "my-spinnaker-application";
+   * String artifactSHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+   *
+   * ArtifactStoreURIBuilder uriBuilder = new ArtifactStoreURISHA256Builder();
+   * // returns ref://my-spinnaker-application/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+   * String uriString = uriBuilder.buildRawURI(application, artifactSHA256);
+   * }
+ * + * @param context is the context in which this artifact was run in, e.g. the application. + * @param paths are any individual path required for distinguishing an artifact. + * @return a properly formatted artifact store URI + */ + public abstract ArtifactReferenceURI buildURIFromPaths(String context, String... paths); +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURISHA256Builder.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURISHA256Builder.java new file mode 100644 index 000000000..ee739eaa0 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURISHA256Builder.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.google.common.hash.Hashing; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class to abstract away the need for other classes to know the {@link * #uriPrefix} format. + */ +public class ArtifactStoreURISHA256Builder extends ArtifactStoreURIBuilder { + @Override + public ArtifactReferenceURI buildArtifactURI(String context, Artifact artifact) { + String ref = artifact.getReference(); + if (ref == null) { + throw new NullPointerException("Artifact reference cannot be null"); + } + + List uriPaths = + List.of( + context, + Hashing.sha256() + .hashBytes(artifact.getReference().getBytes(StandardCharsets.UTF_8)) + .toString()); + return ArtifactReferenceURI.builder().uriPaths(uriPaths).build(); + } + + @Override + public ArtifactReferenceURI buildURIFromPaths(String context, String... paths) { + List uriPaths = new ArrayList<>(); + uriPaths.add(context); + uriPaths.addAll(List.of(paths)); + + return ArtifactReferenceURI.builder().uriPaths(uriPaths).build(); + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/EmbeddedArtifactSerializer.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/EmbeddedArtifactSerializer.java new file mode 100644 index 000000000..365eb5b8a --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/EmbeddedArtifactSerializer.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.exceptions.ArtifactStoreIOException; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.io.IOException; + +/** + * EmbeddedArtifactSerializer will store any embedded/base64 artifact into the ArtifactStore + * assuming that artifact has a reference link. + */ +public class EmbeddedArtifactSerializer extends StdSerializer { + private final ObjectMapper defaultObjectMapper; + private final ArtifactStore storage; + + public EmbeddedArtifactSerializer(ObjectMapper defaultObjectMapper, ArtifactStore storage) { + super(Artifact.class); + this.defaultObjectMapper = defaultObjectMapper; + this.storage = storage; + } + + @Override + public void serialize(Artifact artifact, JsonGenerator gen, SerializerProvider provider) + throws IOException { + if (!shouldStoreArtifact(artifact)) { + defaultObjectMapper.writeValue(gen, artifact); + return; + } + + Artifact stored = ArtifactStoreIOException.throwIOException(() -> storage.store(artifact)); + defaultObjectMapper.writeValue(gen, stored); + } + + /** + * shouldStore will return whether we want to store the reference in the ArtifactStore or not. + * This checks to ensure the reference isn't null or an empty string. Further we only care about + * 'embedded/base64' artifact types, since that is directly embedding the artifacts into the + * context + */ + private static boolean shouldStoreArtifact(Artifact artifact) { + String ref = artifact.getReference(); + return ArtifactTypes.EMBEDDED_BASE64.getMimeType().equals(artifact.getType()) + && !(ref == null || ref.isEmpty()); + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/NoopArtifactStoreGetter.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/NoopArtifactStoreGetter.java new file mode 100644 index 000000000..e1b8dbeec --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/NoopArtifactStoreGetter.java @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Salesforce Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; + +/** A no-op ArtifactStoreGetter. In other words, don't actually get the artifact. */ +public class NoopArtifactStoreGetter implements ArtifactStoreGetter { + + public Artifact get(ArtifactReferenceURI uri, ArtifactDecorator... decorators) { + throw new IllegalStateException( + "unable to retrieve artifact " + + uri.toString() + + " since there's no artifact store getter configured"); + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/NoopArtifactStoreStorer.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/NoopArtifactStoreStorer.java new file mode 100644 index 000000000..b441dfd9d --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/NoopArtifactStoreStorer.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Salesforce Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; + +/** A no-op ArtifactStoreStorer. In other words, don't actually store the artifact. */ +public class NoopArtifactStoreStorer implements ArtifactStoreStorer { + + public Artifact store(Artifact artifact) { + return artifact; + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/exceptions/ArtifactStoreIOException.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/exceptions/ArtifactStoreIOException.java new file mode 100644 index 000000000..442c7606e --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/exceptions/ArtifactStoreIOException.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore.exceptions; + +import java.io.IOException; +import java.util.function.Supplier; + +/** + * An exception used by the artifact store (de)serializers since the (de)serialize methods only + * throw IOExceptions, and if any other exception is thrown jackson assumes it's some JSON error. + */ +public class ArtifactStoreIOException extends IOException { + public ArtifactStoreIOException(Exception e) { + super(e); + } + + /** + * Helper methods to catch any exception thrown by a method and instead throw an + * ArtifactStoreIOException + */ + public static T throwIOException(Supplier fn) throws IOException { + try { + return fn.get(); + } catch (Exception e) { + throw new ArtifactStoreIOException(e); + } + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStore.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStore.java new file mode 100644 index 000000000..a73b2fbe2 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStore.java @@ -0,0 +1,22 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore.s3; + +public final class S3ArtifactStore { + public static final String ENFORCE_PERMS_KEY = "application"; + + private S3ArtifactStore() {} +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreConfiguration.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreConfiguration.java new file mode 100644 index 000000000..e792d0c13 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreConfiguration.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore.s3; + +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreConfigurationProperties; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreGetter; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreStorer; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreURIBuilder; +import java.net.URI; +import java.util.Optional; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.PermissionEvaluator; +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; + +@Configuration +@Log4j2 +@ConditionalOnProperty(name = "artifact-store.type", havingValue = "s3") +public class S3ArtifactStoreConfiguration { + + @Bean + @ConditionalOnExpression("${artifact-store.s3.enabled:false}") + public ArtifactStoreStorer artifactStoreStorer( + ArtifactStoreConfigurationProperties properties, + @Qualifier("artifactS3Client") S3Client s3Client, + ArtifactStoreURIBuilder artifactStoreURIBuilder) { + return new S3ArtifactStoreStorer( + s3Client, + properties.getS3().getBucket(), + artifactStoreURIBuilder, + properties.getApplicationsRegex()); + } + + @Bean + public ArtifactStoreGetter artifactStoreGetter( + Optional permissionEvaluator, + ArtifactStoreConfigurationProperties properties, + @Qualifier("artifactS3Client") S3Client s3Client) { + + if (permissionEvaluator.isEmpty()) { + log.warn( + "PermissionEvaluator is not present. This means anyone will be able to access any artifact in the store."); + } + + String bucket = properties.getS3().getBucket(); + + return new S3ArtifactStoreGetter(s3Client, permissionEvaluator.orElse(null), bucket); + } + + @Bean + public S3Client artifactS3Client(ArtifactStoreConfigurationProperties properties) { + S3ClientBuilder builder = S3Client.builder(); + ArtifactStoreConfigurationProperties.S3ClientConfig config = properties.getS3(); + + // Overwriting the URL is primarily used for S3 compatible object stores + // like seaweedfs + if (config.getUrl() != null) { + builder = + builder + .credentialsProvider(getCredentialsProvider(config)) + .forcePathStyle(config.isForcePathStyle()) + .endpointOverride(URI.create(config.getUrl())); + } else if (config.getProfile() != null) { + builder = builder.credentialsProvider(ProfileCredentialsProvider.create(config.getProfile())); + } + + if (config.getRegion() != null) { + builder = builder.region(Region.of(config.getRegion())); + } + + return builder.build(); + } + + private AwsCredentialsProvider getCredentialsProvider( + ArtifactStoreConfigurationProperties.S3ClientConfig config) { + if (config.getAccessKey() != null) { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(config.getAccessKey(), config.getSecretKey())); + } else { + return AnonymousCredentialsProvider.create(); + } + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreGetter.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreGetter.java new file mode 100644 index 000000000..778c46528 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreGetter.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore.s3; + +import static com.netflix.spinnaker.kork.artifacts.artifactstore.s3.S3ArtifactStore.ENFORCE_PERMS_KEY; + +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactDecorator; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactReferenceURI; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreGetter; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.Base64; +import java.util.NoSuchElementException; +import lombok.extern.log4j.Log4j2; +import org.springframework.security.access.PermissionEvaluator; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.GetObjectTaggingRequest; +import software.amazon.awssdk.services.s3.model.GetObjectTaggingResponse; +import software.amazon.awssdk.services.s3.model.Tag; + +/** Retrieve objects from an s3 compatible service */ +@Log4j2 +public class S3ArtifactStoreGetter implements ArtifactStoreGetter { + private final S3Client s3Client; + private final PermissionEvaluator permissionEvaluator; + private final String bucket; + + public S3ArtifactStoreGetter( + S3Client s3Client, PermissionEvaluator permissionEvaluator, String bucket) { + this.s3Client = s3Client; + this.bucket = bucket; + this.permissionEvaluator = permissionEvaluator; + } + + /** + * get will return the Artifact with the provided id, and will lastly run the {@link + * ArtifactDecorator} to further populate the artifact for returning + */ + @Override + public Artifact get(ArtifactReferenceURI uri, ArtifactDecorator... decorators) { + hasAuthorization( + uri, + AuthenticatedRequest.getSpinnakerUser() + .orElseThrow( + () -> new NoSuchElementException("Could not authenticate due to missing user id"))); + + GetObjectRequest request = GetObjectRequest.builder().bucket(bucket).key(uri.paths()).build(); + + ResponseBytes resp = s3Client.getObjectAsBytes(request); + Artifact.ArtifactBuilder builder = + Artifact.builder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference(Base64.getEncoder().encodeToString(resp.asByteArray())); + + if (decorators == null) { + return builder.build(); + } + + for (ArtifactDecorator decorator : decorators) { + builder = decorator.decorate(builder); + } + + return builder.build(); + } + + /** + * hasAuthorization will ensure that the user has proper permissions for retrieving the stored + * artifact + * + * @throws AuthenticationServiceException when user does not have correct permissions + */ + private void hasAuthorization(ArtifactReferenceURI uri, String userId) { + GetObjectTaggingRequest request = + GetObjectTaggingRequest.builder().bucket(bucket).key(uri.paths()).build(); + + GetObjectTaggingResponse resp = s3Client.getObjectTagging(request); + Tag tag = + resp.tagSet().stream() + .filter(t -> t.key().equals(ENFORCE_PERMS_KEY)) + .findFirst() + .orElse(null); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (tag == null + || (permissionEvaluator != null + && !permissionEvaluator.hasPermission(auth, tag.value(), "application", "READ"))) { + log.error( + "Could not authenticate to retrieve artifact user={} applicationOfStoredArtifact={}", + userId, + (tag == null) ? "(none)" : tag.value()); + throw new AuthenticationServiceException( + userId + " does not have permission to access this artifact"); + } + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreStorer.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreStorer.java new file mode 100644 index 000000000..bb767e398 --- /dev/null +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreStorer.java @@ -0,0 +1,173 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore.s3; + +import static com.netflix.spinnaker.kork.artifacts.artifactstore.s3.S3ArtifactStore.ENFORCE_PERMS_KEY; + +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactReferenceURI; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreStorer; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreURIBuilder; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.Base64; +import java.util.regex.Pattern; +import lombok.extern.log4j.Log4j2; +import org.apache.http.HttpStatus; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.Tag; +import software.amazon.awssdk.services.s3.model.Tagging; + +/** + * S3ArtifactStoreStorer will store artifacts in a s3 compatible service + * + *

Note: It is very important that the S3 bucket has object lock on it to prevent multiple writes + * {@see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html} + */ +@Log4j2 +public class S3ArtifactStoreStorer implements ArtifactStoreStorer { + private final S3Client s3Client; + private final String bucket; + private final ArtifactStoreURIBuilder uriBuilder; + private final String applicationsRegex; + + public S3ArtifactStoreStorer( + S3Client s3Client, + String bucket, + ArtifactStoreURIBuilder uriBuilder, + String applicationsRegex) { + this.s3Client = s3Client; + this.bucket = bucket; + this.uriBuilder = uriBuilder; + this.applicationsRegex = applicationsRegex; + } + + /** + * Will store the artifact using the {@link #s3Client} in some {@link #bucket} + * + *

This method also persists "permissions" by storing the execution id that made the original + * store call. In the event a service wants to retrieve said artifact, they will also need to + * provide the proper execution id + */ + @Override + public Artifact store(Artifact artifact) { + String application = AuthenticatedRequest.getSpinnakerApplication().orElse(null); + if (application == null) { + log.warn("failed to retrieve application from request artifact={}", artifact.getName()); + return artifact; + } + + if (applicationsRegex != null && !Pattern.matches(applicationsRegex, application)) { + return artifact; + } + + ArtifactReferenceURI ref = uriBuilder.buildArtifactURI(application, artifact); + Artifact remoteArtifact = + artifact.toBuilder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference(ref.uri()) + .build(); + + if (objectExists(ref)) { + return remoteArtifact; + } + + // purpose of tagging is to ensure some sort of identity is persisted to + // enforce permissions when retrieving the artifact + Tag accountTag = Tag.builder().key(ENFORCE_PERMS_KEY).value(application).build(); + + PutObjectRequest request = + PutObjectRequest.builder() + .bucket(bucket) + .key(ref.paths()) + .tagging(Tagging.builder().tagSet(accountTag).build()) + .build(); + + s3Client.putObject(request, RequestBody.fromBytes(getReferenceAsBytes(artifact))); + return remoteArtifact; + } + + private byte[] getReferenceAsBytes(Artifact artifact) { + String reference = artifact.getReference(); + if (reference == null) { + throw new IllegalArgumentException("reference cannot be null"); + } + + String type = artifact.getType(); + if (type != null && type.endsWith("/base64")) { + return Base64.getDecoder().decode(reference); + } + + return reference.getBytes(); + } + + /** + * Helper method to check whether the object exists. This is not thread safe, nor would it help in + * a distributed system due to how S3 works (no conditional statements). If preventing multiple + * writes of the same object is important, another filestore/db needs to be used, possibly + * dynamodb. + */ + private boolean objectExists(ArtifactReferenceURI uri) { + HeadObjectRequest request = HeadObjectRequest.builder().bucket(bucket).key(uri.paths()).build(); + try { + s3Client.headObject(request); + log.debug("Artifact exists. No need to store. reference={}", uri.uri()); + return true; + } catch (NoSuchKeyException e) { + // pretty gross that we need to use exceptions as control flow, but the + // java SDK doesn't have any other way of check if an object exists in s3 + log.info("Artifact does not exist reference={}", uri.uri()); + return false; + } catch (S3Exception e) { + int statusCode = e.statusCode(); + log.error( + "Artifact store failed head object request statusCode={} reference={}", + statusCode, + uri.uri()); + + if (statusCode != 0) { + // due to this being a HEAD request, there is no message giving a clear + // indication of what failed. Rather than seeing a useful message back + // to gate, we instead see just null. To alleviate this, we wrap the + // exception with a more meaningful message + throw new SpinnakerException(buildHeadObjectExceptionMessage(e), e); + } + + throw new SpinnakerException("S3 head object failed", e); + } + } + + /** + * S3's head object can only return 400, 403, and 404, and based on the HTTP status code, we will + * return the appropriate message back + */ + private static String buildHeadObjectExceptionMessage(S3Exception e) { + switch (e.statusCode()) { + case HttpStatus.SC_FORBIDDEN: + return "Failed to query artifact due to IAM permissions either on the bucket or object"; + case HttpStatus.SC_BAD_REQUEST: + return "Failed to query artifact due to invalid request"; + default: + return String.format("Failed to query artifact: %d", e.statusCode()); + } + } +} diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/Artifact.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/Artifact.java index 2adedc1d1..0ee1492e2 100644 --- a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/Artifact.java +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/Artifact.java @@ -29,9 +29,12 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.Builder; +import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.ToString; +import lombok.extern.jackson.Jacksonized; @Getter @ToString @@ -101,5 +104,17 @@ public ArtifactBuilder putMetadata(String key, Object value) { metadata.put(key, value); return this; } + + public String getReference() { + return reference; + } + } + + @Data + @Builder + @Jacksonized + @RequiredArgsConstructor + public static class StoredView { + private final String reference; } } diff --git a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifact.java b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifact.java index 0f42393de..cabbc8a9c 100644 --- a/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifact.java +++ b/kork-artifacts/src/main/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifact.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.google.common.base.Strings; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStore; import java.util.Optional; import java.util.regex.Pattern; import javax.annotation.Nullable; @@ -48,6 +50,11 @@ private ExpectedArtifact( Artifact defaultArtifact, String id, Artifact boundArtifact) { + + defaultArtifact = store(defaultArtifact); + boundArtifact = store(boundArtifact); + matchArtifact = store(matchArtifact); + this.matchArtifact = Optional.ofNullable(matchArtifact).orElseGet(() -> Artifact.builder().build()); this.usePriorArtifact = usePriorArtifact; @@ -65,9 +72,7 @@ private ExpectedArtifact( * @return true i.f.f. the artifacts match */ public boolean matches(Artifact other) { - String thisType = matchArtifact.getType(); - String otherType = other.getType(); - if (!matches(thisType, otherType)) { + if (!matchTypes(matchArtifact.getType(), other.getType())) { return false; } @@ -89,25 +94,75 @@ public boolean matches(Artifact other) { return false; } - String thisReference = matchArtifact.getReference(); - String otherReference = other.getReference(); - if (!matches(thisReference, otherReference)) { - return false; + return matches(matchArtifact.getReference(), other.getReference()); + } + + private boolean matches(@Nullable String us, @Nullable String other) { + if (StringUtils.isEmpty(us)) { + return true; } - // Explicitly avoid matching on UUID, provenance & artifactAccount + if (other == null) { + return false; + } - return true; + // The strict equals is mostly to ensure base64 references can be compared + // against each other, since the '+' is a completely valid base64 character. + // The '+' in regex has the meaning of one or more, and will change the + // semantics of comparing two references. + // + // So rather than having references implement its own matching, users may + // rely on matching artifacts with some regex. To be backwards compatible we + // will do a strict comparison as well as pattern matching. + return us.equals(other) || patternMatches(us, other); } - private boolean matches(@Nullable String us, @Nullable String other) { - return StringUtils.isEmpty(us) || (other != null && patternMatches(us, other)); + /** + * Checks to see if artifact types are compatible/matchable. This handles the four known cases of: + * + *

+   * type_a matches type_b
+   * type_a is embedded/base and type_b is remote/base64
+   * type_b is embedded/base and type_a is remote/base64
+   * and false otherwise
+   * 
+ */ + private boolean matchTypes(String us, String other) { + if (matches(us, other)) { + return true; + } + + if (us.equals(ArtifactTypes.EMBEDDED_BASE64.getMimeType())) { + return other.equals(ArtifactTypes.REMOTE_BASE64.getMimeType()); + } + + if (other.equals(ArtifactTypes.EMBEDDED_BASE64.getMimeType())) { + return us.equals(ArtifactTypes.REMOTE_BASE64.getMimeType()); + } + + return false; } private boolean patternMatches(String us, String other) { return Pattern.compile(us).matcher(other).matches(); } + /** Helper store method to easily store the artifact if needed */ + private static Artifact store(Artifact artifact) { + ArtifactStore storage = ArtifactStore.getInstance(); + if (artifact == null + || storage == null + || !ArtifactTypes.EMBEDDED_BASE64.getMimeType().equals(artifact.getType())) { + return artifact; + } + + if (artifact.getReference() != null && !artifact.getReference().isEmpty()) { + return storage.store(artifact); + } + + return artifact; + } + @JsonPOJOBuilder(withPrefix = "") public static final class ExpectedArtifactBuilder {} } diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDeserializerTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDeserializerTest.java new file mode 100644 index 000000000..25b369f3d --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactDeserializerTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ArtifactDeserializerTest { + private class InMemoryArtifactStore { + private final Map storageMap = new HashMap<>(); + + public InMemoryArtifactStore put(String id, Artifact artifact) { + storageMap.put(id, artifact); + return this; + } + + public Artifact get(String id) { + return storageMap.get(id); + } + } + + private class InMemoryArtifactStoreStorer implements ArtifactStoreStorer { + public final InMemoryArtifactStore inMemoryArtifactStore; + + public InMemoryArtifactStoreStorer(InMemoryArtifactStore inMemoryArtifactStore) { + this.inMemoryArtifactStore = inMemoryArtifactStore; + } + + @Override + public Artifact store(Artifact artifact) { + inMemoryArtifactStore.put(artifact.getReference(), artifact); + return artifact; + } + } + + private class InMemoryArtifactStoreGetter implements ArtifactStoreGetter { + public final InMemoryArtifactStore inMemoryArtifactStore; + + public InMemoryArtifactStoreGetter(InMemoryArtifactStore inMemoryArtifactStore) { + this.inMemoryArtifactStore = inMemoryArtifactStore; + } + + @Override + public Artifact get(ArtifactReferenceURI uri, ArtifactDecorator... decorator) { + return inMemoryArtifactStore.get(uri.uri()); + } + } + + @Test + public void simpleDeserialization() throws IOException { + String artifactJSON = + "{\"type\":\"remote/base64\",\"customKind\":false,\"name\":null,\"version\":null,\"location\":null,\"reference\":\"ref://link\",\"metadata\":{},\"artifactAccount\":null,\"provenance\":null,\"uuid\":null}"; + String expectedReference = "foobar"; + Artifact expectedArtifact = + Artifact.builder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference(expectedReference) + .build(); + InMemoryArtifactStore inMemoryArtifactStore = + new InMemoryArtifactStore().put("ref://link", expectedArtifact); + ArtifactStore storage = + new ArtifactStore( + new InMemoryArtifactStoreGetter(inMemoryArtifactStore), + new InMemoryArtifactStoreStorer(inMemoryArtifactStore)); + ArtifactDeserializer deserializer = new ArtifactDeserializer(new ObjectMapper(), storage); + + // We avoid using an object mapper here since the Artifact class has a + // deserializer annotation which causes our deserializer to be ignored. So + // rather than using a mixin and setting all that up, this is easier. + JsonParser parser = new JsonFactory().createParser(artifactJSON); + Artifact receivedArtifact = deserializer.deserialize(parser, null); + assertNotNull(receivedArtifact); + assertEquals(expectedArtifact.getReference(), receivedArtifact.getReference()); + assertEquals(ArtifactTypes.REMOTE_BASE64.getMimeType(), receivedArtifact.getType()); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactReferenceURITest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactReferenceURITest.java new file mode 100644 index 000000000..8afb65b15 --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactReferenceURITest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; + +class ArtifactReferenceURITest { + @Test + public void testParse() { + String expectedRef = "ref://path1/path2/path3/path4"; + ArtifactReferenceURI uri = ArtifactReferenceURI.parse(expectedRef); + List expectedPaths = List.of("path1", "path2", "path3", "path4"); + + assertEquals(expectedRef, uri.uri()); + assertEquals(expectedPaths, uri.getUriPaths()); + assertEquals(StringUtils.join(expectedPaths, '/'), uri.paths()); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfigurationTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfigurationTest.java new file mode 100644 index 000000000..448fef717 --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreConfigurationTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class ArtifactStoreConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(ArtifactStoreConfiguration.class)); + + @BeforeEach + void init(TestInfo testInfo) { + System.out.println("--------------- Test " + testInfo.getDisplayName()); + } + + @Test + void testArtifactStoreDefaults() { + runner.run( + ctx -> { + assertThat(ctx).hasSingleBean(ArtifactStoreURIBuilder.class); + assertThat(ctx).hasSingleBean(ArtifactStore.class); + assertThat(ctx).hasSingleBean(NoopArtifactStoreGetter.class); + assertThat(ctx).hasSingleBean(NoopArtifactStoreStorer.class); + }); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreTest.java new file mode 100644 index 000000000..51fe2c572 --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import org.junit.jupiter.api.Test; + +class ArtifactStoreTest { + + private ArtifactStoreGetter artifactStoreGetter = mock(ArtifactStoreGetter.class); + private ArtifactStoreStorer artifactStoreStorer = mock(ArtifactStoreStorer.class); + private ArtifactStore artifactStore = new ArtifactStore(artifactStoreGetter, artifactStoreStorer); + + @Test + void testArtifactStoreDelegatesToGetter() { + ArtifactReferenceURI uri = mock(ArtifactReferenceURI.class); + ArtifactDecorator artifactDecorator = mock(ArtifactDecorator.class); + + artifactStore.get(uri, artifactDecorator); + + verify(artifactStoreGetter).get(uri, artifactDecorator); + verifyNoMoreInteractions(artifactStoreGetter); + verifyNoInteractions(artifactStoreStorer); + } + + @Test + void testArtifactStoreDelegatesToStorer() { + Artifact inputArtifact = Artifact.builder().build(); + Artifact storedArtifact = Artifact.builder().build(); + when(artifactStore.store(inputArtifact)).thenReturn(storedArtifact); + + Artifact retval = artifactStore.store(inputArtifact); + assertThat(retval).isEqualTo(storedArtifact); + + verify(artifactStoreStorer).store(inputArtifact); + verifyNoMoreInteractions(artifactStoreStorer); + verifyNoInteractions(artifactStoreGetter); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURISHA256BuilderTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURISHA256BuilderTest.java new file mode 100644 index 000000000..5ccac3b2e --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/ArtifactStoreURISHA256BuilderTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.common.hash.Hashing; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ArtifactStoreURISHA256BuilderTest { + @Test + public void testSimpleURI() { + ArtifactStoreURIBuilder builder = new ArtifactStoreURISHA256Builder(); + ArtifactReferenceURI uri = builder.buildURIFromPaths("application", "foo"); + String expectedURIString = "ref://application/foo"; + String expectedPaths = "application/foo"; + assertEquals(expectedURIString, uri.uri()); + assertEquals(expectedPaths, uri.paths()); + } + + @Test + public void testProperSHA() { + ArtifactStoreURIBuilder builder = new ArtifactStoreURISHA256Builder(); + String reference = "hello world"; + String expectedSHA = + Hashing.sha256().hashBytes(reference.getBytes(StandardCharsets.UTF_8)).toString(); + Artifact artifact = Artifact.builder().type("embedded/base64").reference(reference).build(); + ArtifactReferenceURI uri = builder.buildArtifactURI("application", artifact); + String expectedURIString = "ref://application/" + expectedSHA; + String expectedPaths = "application/" + expectedSHA; + assertEquals(expectedURIString, uri.uri()); + assertEquals(expectedPaths, uri.paths()); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/EmbeddedArtifactSerializerTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/EmbeddedArtifactSerializerTest.java new file mode 100644 index 000000000..b76c2417a --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/EmbeddedArtifactSerializerTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.exceptions.ArtifactStoreIOException; +import com.netflix.spinnaker.kork.artifacts.artifactstore.s3.S3ArtifactStoreGetter; +import com.netflix.spinnaker.kork.artifacts.artifactstore.s3.S3ArtifactStoreStorer; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.kork.common.Header; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.stream.Stream; +import org.apache.commons.codec.binary.Base64; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +class EmbeddedArtifactSerializerTest { + @ParameterizedTest(name = "{index} {0}") + @MethodSource("generateTestCase") + public void serializeEmbeddedBase64Artifact_test( + String name, String expectedJson, Artifact artifact, Artifact mockArtifact) + throws IOException { + ArtifactStore storage = Mockito.mock(ArtifactStore.class); + when(storage.store(Mockito.any())).thenReturn(mockArtifact); + + EmbeddedArtifactSerializer serializer = + new EmbeddedArtifactSerializer(new ObjectMapper(), storage); + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addSerializer(Artifact.class, serializer); + objectMapper.registerModule(module); + + String result = objectMapper.writeValueAsString(artifact); + assertEquals(expectedJson, result); + } + + @Test + public void ensureS3ExceptionHasProperMessages() { + S3Client client = mock(S3Client.class); + when(client.headObject((HeadObjectRequest) Mockito.any())) + .thenThrow(S3Exception.builder().statusCode(400).build()); + AuthenticatedRequest.set(Header.APPLICATION, "my-application"); + ArtifactStoreGetter s3ArtifactStoreGetter = + new S3ArtifactStoreGetter(client, null, "my-bucket"); + ArtifactStoreStorer artifactStoreStorer = + new S3ArtifactStoreStorer(client, "my-bucket", new ArtifactStoreURISHA256Builder(), null); + ArtifactStore artifactStore = new ArtifactStore(s3ArtifactStoreGetter, artifactStoreStorer); + + EmbeddedArtifactSerializer serializer = + new EmbeddedArtifactSerializer(new ObjectMapper(), artifactStore); + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addSerializer(Artifact.class, serializer); + objectMapper.registerModule(module); + + ArtifactStoreIOException e = + assertThrows( + ArtifactStoreIOException.class, + () -> { + objectMapper.writeValue( + new ByteArrayOutputStream(), + Artifact.builder() + .type(ArtifactTypes.EMBEDDED_BASE64.getMimeType()) + .reference("aGVsbG8gd29ybGQK") // arbitrary + .build()); + }); + + String expectedExceptionMessage = + "com.netflix.spinnaker.kork.exceptions.SpinnakerException: Failed to query artifact due to invalid request"; + assertEquals(expectedExceptionMessage, e.getMessage()); + } + + private static Stream generateTestCase() { + return Stream.of( + Arguments.of( + "simple", + "{\"type\":\"remote/base64\",\"customKind\":false,\"name\":null,\"version\":null,\"location\":null,\"reference\":\"link\",\"metadata\":{},\"artifactAccount\":null,\"provenance\":null,\"uuid\":null}", + Artifact.builder() + .type(ArtifactTypes.EMBEDDED_BASE64.getMimeType()) + .reference(Base64.encodeBase64String("foo".getBytes())) + .build(), + Artifact.builder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference("link") + .build()), + Arguments.of( + "stored", + "{\"type\":\"remote/base64\",\"customKind\":false,\"name\":null,\"version\":null,\"location\":null,\"reference\":\"link\",\"metadata\":{},\"artifactAccount\":null,\"provenance\":null,\"uuid\":null}", + Artifact.builder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference("link") + .build(), + Artifact.builder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference("link") + .build()), + Arguments.of( + "does-not-exist", + "{\"type\":\"nonexistent-type\",\"customKind\":false,\"name\":null,\"version\":null,\"location\":null,\"reference\":\"Zm9v\",\"metadata\":{},\"artifactAccount\":null,\"provenance\":null,\"uuid\":null}", + Artifact.builder() + .type("nonexistent-type") + .reference(Base64.encodeBase64String("foo".getBytes())) + .build(), + Artifact.builder() + .type(ArtifactTypes.REMOTE_BASE64.getMimeType()) + .reference("link") + .build())); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/exceptions/ArtifactStoreIOExceptionTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/exceptions/ArtifactStoreIOExceptionTest.java new file mode 100644 index 000000000..12990cc2a --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/exceptions/ArtifactStoreIOExceptionTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.artifacts.artifactstore.exceptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import org.junit.jupiter.api.Test; + +public class ArtifactStoreIOExceptionTest { + @Test + public void validateThrowingException() { + String expectedMessage = "foobar"; + + // assertThrows does not work with closures properly + // see https://github.com/junit-team/junit5/issues/1414 + try { + ArtifactStoreIOException.throwIOException( + () -> { + throw new RuntimeException(expectedMessage); + }); + + fail(); + } catch (IOException e) { + assertEquals(expectedMessage, e.getCause().getMessage()); + } + } + + @Test + public void validateNoThrow() throws IOException { + String expected = "foo"; + String received = ArtifactStoreIOException.throwIOException(() -> "foo"); + + assertEquals(expected, received); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreConfigurationTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreConfigurationTest.java new file mode 100644 index 000000000..9374573a1 --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreConfigurationTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.artifacts.artifactstore.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStore; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreConfiguration; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreURIBuilder; +import com.netflix.spinnaker.kork.artifacts.artifactstore.NoopArtifactStoreGetter; +import com.netflix.spinnaker.kork.artifacts.artifactstore.NoopArtifactStoreStorer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; + +class S3ArtifactStoreConfigurationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(ArtifactStoreConfiguration.class)); + + @BeforeEach + void init(TestInfo testInfo) { + System.out.println("--------------- Test " + testInfo.getDisplayName()); + } + + @AfterEach + void cleanup() { + // Clear ArtifactStore.instance so we don't leave lingering state for other + // tests that assume it's null. + // + // Note that ArtifactStore.setInstance(null) doesn't set instance to null + // if it's already set. + ReflectionTestUtils.setField(ArtifactStore.class, "instance", null); + } + + @Test + void testArtifactStoreS3Disabled() { + runner + .withPropertyValues( + "artifact-store.type=s3", + "artifact-store.s3.enabled=false", + "artifact-store.s3.region=us-west-2") // arbitrary region + .run( + ctx -> { + assertThat(ctx).hasSingleBean(ArtifactStoreURIBuilder.class); + assertThat(ctx).hasSingleBean(ArtifactStore.class); + assertThat(ctx).hasSingleBean(S3ArtifactStoreGetter.class); + assertThat(ctx).hasSingleBean(NoopArtifactStoreStorer.class); + assertThat(ctx).doesNotHaveBean(NoopArtifactStoreGetter.class); + assertThat(ctx).doesNotHaveBean(S3ArtifactStoreStorer.class); + assertThat(ctx).hasSingleBean(S3Client.class); + }); + } + + @Test + void testArtifactStoreS3Enabled() { + runner + .withPropertyValues( + "artifact-store.type=s3", + "artifact-store.s3.enabled=true", + "artifact-store.s3.region=us-west-2") // arbitrary region + .run( + ctx -> { + assertThat(ctx).hasSingleBean(ArtifactStoreURIBuilder.class); + assertThat(ctx).hasSingleBean(ArtifactStore.class); + assertThat(ctx).hasSingleBean(S3ArtifactStoreGetter.class); + assertThat(ctx).hasSingleBean(S3ArtifactStoreStorer.class); + assertThat(ctx).doesNotHaveBean(NoopArtifactStoreGetter.class); + assertThat(ctx).doesNotHaveBean(NoopArtifactStoreStorer.class); + assertThat(ctx).hasSingleBean(S3Client.class); + }); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreStorerTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreStorerTest.java new file mode 100644 index 000000000..e1d8f9790 --- /dev/null +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/artifactstore/s3/S3ArtifactStoreStorerTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.artifacts.artifactstore.s3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreURISHA256Builder; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.kork.common.Header; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +public class S3ArtifactStoreStorerTest { + @Test + public void testExceptionPathOfObjectExists() { + S3Client client = mock(S3Client.class); + when(client.headObject((HeadObjectRequest) Mockito.any())) + .thenThrow(S3Exception.builder().statusCode(400).build()); + AuthenticatedRequest.set(Header.APPLICATION, "my-application"); + S3ArtifactStoreStorer artifactStoreStorer = + new S3ArtifactStoreStorer(client, "my-bucket", new ArtifactStoreURISHA256Builder(), null); + String expectedExceptionMessage = "Failed to query artifact due to invalid request"; + SpinnakerException e = + Assertions.assertThrows( + SpinnakerException.class, + () -> { + artifactStoreStorer.store( + Artifact.builder() + .type(ArtifactTypes.EMBEDDED_BASE64.getMimeType()) + .reference("aGVsbG8gd29ybGQK") + .build()); + }); + assertEquals(expectedExceptionMessage, e.getMessage()); + } +} diff --git a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifactTest.java b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifactTest.java index 18e15f62b..48899833f 100644 --- a/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifactTest.java +++ b/kork-artifacts/src/test/java/com/netflix/spinnaker/kork/artifacts/model/ExpectedArtifactTest.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.netflix.spinnaker.kork.artifacts.ArtifactTypes; import java.io.IOException; import java.util.function.Function; import java.util.stream.Stream; @@ -69,6 +70,28 @@ void roundTripSerialization() throws IOException { assertThat(deserializedArtifact).isEqualTo(artifact); } + @Test + void checkEmbeddedStoredTypesMatch() { + Artifact embeddedTypeArtifact = + Artifact.builder().type(ArtifactTypes.EMBEDDED_BASE64.getMimeType()).build(); + + Artifact storedTypeArtifact = + Artifact.builder().type(ArtifactTypes.REMOTE_BASE64.getMimeType()).build(); + + Artifact noMatchTypeArtifact = Artifact.builder().type("does-not-exist").build(); + + ExpectedArtifact expectedArtifact = + ExpectedArtifact.builder().matchArtifact(embeddedTypeArtifact).build(); + assertThat(expectedArtifact.matches(storedTypeArtifact)).isTrue(); + assertThat(expectedArtifact.matches(embeddedTypeArtifact)).isTrue(); + assertThat(expectedArtifact.matches(noMatchTypeArtifact)).isFalse(); + + expectedArtifact = ExpectedArtifact.builder().matchArtifact(storedTypeArtifact).build(); + assertThat(expectedArtifact.matches(embeddedTypeArtifact)).isTrue(); + assertThat(expectedArtifact.matches(storedTypeArtifact)).isTrue(); + assertThat(expectedArtifact.matches(noMatchTypeArtifact)).isFalse(); + } + private String fullExpectedArtifactJson() { return jsonFactory .objectNode() @@ -166,4 +189,37 @@ private static Stream noMatchConstructors() { s -> Artifact.builder().artifactAccount(s).build()) .map(Arguments::of); } + + @ParameterizedTest + @MethodSource("referenceCases") + void testReferenceMatches( + String matchReference, String otherReference, boolean shouldMatch, String caseName) { + ExpectedArtifact expectedArtifact = + ExpectedArtifact.builder() + .id("test") + .matchArtifact( + Artifact.builder() + .type(ArtifactTypes.EMBEDDED_BASE64.getMimeType()) + .reference(matchReference) + .build()) + .build(); + + assertThat( + expectedArtifact.matches( + Artifact.builder() + .type(ArtifactTypes.EMBEDDED_BASE64.getMimeType()) + .reference(otherReference) + .build())) + .as(caseName) + .isEqualTo(shouldMatch); + } + + private static Stream referenceCases() { + return Stream.of( + Arguments.of("SGVsbG8gV29ybGQK", "SGVsbG8gV29ybGQK", true, "simple"), + Arguments.of("SGVsbG8gV29ybGQK", null, false, "other reference as null"), + Arguments.of("SGVsbG8gV29ybGQK", "", false, "other reference is empty"), + Arguments.of("", "SGVsbG8gV29ybGQK", true, "match reference is empty"), + Arguments.of("+++", "+++", true, "valid base64 but invalid regex")); + } } diff --git a/kork-aws/kork-aws.gradle b/kork-aws/kork-aws.gradle index 4e3aa2aa8..429be4fc3 100644 --- a/kork-aws/kork-aws.gradle +++ b/kork-aws/kork-aws.gradle @@ -42,5 +42,4 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-engine" testImplementation "org.junit.jupiter:junit-jupiter-params" - testImplementation "org.junit.platform:junit-platform-runner" } diff --git a/kork-aws/src/test/java/com/netflix/spinnaker/kork/aws/ARNTest.java b/kork-aws/src/test/java/com/netflix/spinnaker/kork/aws/ARNTest.java index c8b50d429..cac753267 100644 --- a/kork-aws/src/test/java/com/netflix/spinnaker/kork/aws/ARNTest.java +++ b/kork-aws/src/test/java/com/netflix/spinnaker/kork/aws/ARNTest.java @@ -21,10 +21,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.junit.platform.runner.JUnitPlatform; -import org.junit.runner.RunWith; -@RunWith(JUnitPlatform.class) public class ARNTest { @ParameterizedTest diff --git a/kork-core/kork-core.gradle b/kork-core/kork-core.gradle index c6e5cdbea..c3146d1e6 100644 --- a/kork-core/kork-core.gradle +++ b/kork-core/kork-core.gradle @@ -18,6 +18,7 @@ dependencies { api "io.github.resilience4j:resilience4j-retry" api "io.github.resilience4j:resilience4j-spring-boot2" + implementation "org.apache.logging.log4j:log4j-api" implementation "com.fasterxml.jackson.core:jackson-annotations" implementation "com.fasterxml.jackson.core:jackson-databind" implementation "com.netflix.spectator:spectator-ext-gc" @@ -29,5 +30,5 @@ dependencies { testImplementation "org.spockframework:spock-core" testRuntimeOnly "cglib:cglib-nodep" testRuntimeOnly "org.objenesis:objenesis" - testRuntimeOnly "org.slf4j:slf4j-simple" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } diff --git a/kork-core/src/main/java/com/netflix/spinnaker/kork/common/Header.java b/kork-core/src/main/java/com/netflix/spinnaker/kork/common/Header.java index 257d0b333..2ee731034 100644 --- a/kork-core/src/main/java/com/netflix/spinnaker/kork/common/Header.java +++ b/kork-core/src/main/java/com/netflix/spinnaker/kork/common/Header.java @@ -31,6 +31,8 @@ public enum Header { EXECUTION_ID("X-SPINNAKER-EXECUTION-ID", false), EXECUTION_TYPE("X-SPINNAKER-EXECUTION-TYPE", false), APPLICATION("X-SPINNAKER-APPLICATION", false), + ACCOUNT("X-SPINNAKER-ACCOUNT", false), + CLOUD_PROVIDER("X-SPINNAKER-CLOUD-PROVIDER", false), PLUGIN_ID("X-SPINNAKER-PLUGIN-ID", false), PLUGIN_EXTENSION("X-SPINNAKER-PLUGIN-EXTENSION", false); diff --git a/kork-core/src/test/java/com/netflix/spinnaker/kork/jackson/ObjectMapperSubtypeConfigurerTest.java b/kork-core/src/test/java/com/netflix/spinnaker/kork/jackson/ObjectMapperSubtypeConfigurerTest.java index 258fa64cc..0b82bf6f2 100644 --- a/kork-core/src/test/java/com/netflix/spinnaker/kork/jackson/ObjectMapperSubtypeConfigurerTest.java +++ b/kork-core/src/test/java/com/netflix/spinnaker/kork/jackson/ObjectMapperSubtypeConfigurerTest.java @@ -15,7 +15,8 @@ */ package com.netflix.spinnaker.kork.jackson; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; @@ -27,14 +28,14 @@ import com.netflix.spinnaker.kork.jackson.ObjectMapperSubtypeConfigurer.StringSubtypeLocator; import java.util.ArrayList; import java.util.List; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; public class ObjectMapperSubtypeConfigurerTest { ObjectMapper mapper; - @Before + @BeforeEach public void setup() { mapper = new ObjectMapper(); } @@ -58,11 +59,14 @@ public void shouldRegisterSubtypesByName() throws JsonProcessingException { assertEquals("{\"kind\":\"child\"}", mapper.writeValueAsString(new ChildType())); } - @Test(expected = InvalidSubtypeConfigurationException.class) + @Test public void shouldThrowWhenSubtypeNameIsUndefined() { - new ObjectMapperSubtypeConfigurer(true) - .registerSubtype( - mapper, new ClassSubtypeLocator(UndefinedRootType.class, searchPackages())); + assertThrows( + InvalidSubtypeConfigurationException.class, + () -> + new ObjectMapperSubtypeConfigurer(true) + .registerSubtype( + mapper, new ClassSubtypeLocator(UndefinedRootType.class, searchPackages()))); } List searchPackages() { diff --git a/kork-credentials-api/kork-credentials-api.gradle b/kork-credentials-api/kork-credentials-api.gradle index 74f70a22c..83478be42 100644 --- a/kork-credentials-api/kork-credentials-api.gradle +++ b/kork-credentials-api/kork-credentials-api.gradle @@ -19,6 +19,7 @@ apply from: "$rootDir/gradle/lombok.gradle" dependencies { implementation(platform(project(":spinnaker-dependencies"))) + api project(":kork-plugins-api") implementation project(":kork-annotations") implementation project(":kork-exceptions") implementation 'javax.annotation:javax.annotation-api' diff --git a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsLifecycleHandler.java b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsLifecycleHandler.java index 374c9defe..4b8045c0b 100644 --- a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsLifecycleHandler.java +++ b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsLifecycleHandler.java @@ -16,6 +16,8 @@ package com.netflix.spinnaker.credentials; +import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; + /** * After {@link Credentials} have been parsed, they can be activated, refreshed, or retired - e.g. * adding agents. This happens before credentials are added or updated in the {@link @@ -23,7 +25,8 @@ * * @param */ -public interface CredentialsLifecycleHandler { +public interface CredentialsLifecycleHandler + extends SpinnakerExtensionPoint { /** * Credentials have been added. This is called before credentials are available in {@link diff --git a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsRepository.java b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsRepository.java index a7f370d19..b085f74fc 100644 --- a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsRepository.java +++ b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/CredentialsRepository.java @@ -16,6 +16,7 @@ package com.netflix.spinnaker.credentials; +import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; import java.util.Set; import javax.annotation.Nullable; @@ -24,7 +25,7 @@ * * @param */ -public interface CredentialsRepository { +public interface CredentialsRepository extends SpinnakerExtensionPoint { /** * @param name * @return Credentials with the given name or null diff --git a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/AbstractCredentialsLoader.java b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/AbstractCredentialsLoader.java index 84749f06c..7d3d87b23 100644 --- a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/AbstractCredentialsLoader.java +++ b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/AbstractCredentialsLoader.java @@ -21,7 +21,8 @@ import javax.annotation.PostConstruct; import lombok.Getter; -public abstract class AbstractCredentialsLoader { +public abstract class AbstractCredentialsLoader + implements CredentialsLoader { @Getter protected final CredentialsRepository credentialsRepository; public AbstractCredentialsLoader(CredentialsRepository credentialsRepository) { @@ -34,5 +35,6 @@ public AbstractCredentialsLoader(CredentialsRepository credentialsRepository) * thereafter. */ @PostConstruct + @Override public abstract void load(); } diff --git a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsDefinitionSource.java b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsDefinitionSource.java index f09eb677d..20b88832f 100644 --- a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsDefinitionSource.java +++ b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsDefinitionSource.java @@ -16,6 +16,7 @@ package com.netflix.spinnaker.credentials.definition; +import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; import java.util.List; /** @@ -24,6 +25,7 @@ * * @param */ -public interface CredentialsDefinitionSource { +public interface CredentialsDefinitionSource + extends SpinnakerExtensionPoint { List getCredentialsDefinitions(); } diff --git a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsLoader.java b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsLoader.java new file mode 100644 index 000000000..8b40c1943 --- /dev/null +++ b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsLoader.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.credentials.definition; + +import com.netflix.spinnaker.credentials.Credentials; +import com.netflix.spinnaker.credentials.CredentialsRepository; +import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; + +public interface CredentialsLoader extends SpinnakerExtensionPoint { + CredentialsRepository getCredentialsRepository(); + + void load(); +} diff --git a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsParser.java b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsParser.java index 489a280d7..dad1b110c 100644 --- a/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsParser.java +++ b/kork-credentials-api/src/main/java/com/netflix/spinnaker/credentials/definition/CredentialsParser.java @@ -17,6 +17,7 @@ package com.netflix.spinnaker.credentials.definition; import com.netflix.spinnaker.credentials.Credentials; +import com.netflix.spinnaker.kork.plugins.api.internal.SpinnakerExtensionPoint; import javax.annotation.Nullable; /** @@ -25,7 +26,8 @@ * @param * @param */ -public interface CredentialsParser { +public interface CredentialsParser + extends SpinnakerExtensionPoint { /** Parses a definition into credentials. Can return null if the definition is to be ignored. */ @Nullable U parse(T credentials); diff --git a/kork-credentials/kork-credentials.gradle b/kork-credentials/kork-credentials.gradle index 1901bd0c0..0e7a3458d 100644 --- a/kork-credentials/kork-credentials.gradle +++ b/kork-credentials/kork-credentials.gradle @@ -21,6 +21,7 @@ dependencies { api project(":kork-credentials-api") api project(":kork-annotations") implementation(platform(project(":spinnaker-dependencies"))) + implementation 'org.apache.logging.log4j:log4j-api' implementation "org.springframework.boot:spring-boot" implementation 'org.springframework.boot:spring-boot-starter-json' implementation 'javax.annotation:javax.annotation-api' diff --git a/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfiguration.java b/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfiguration.java index a207a9535..fab0b275c 100644 --- a/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfiguration.java +++ b/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfiguration.java @@ -26,6 +26,7 @@ import javax.annotation.Nullable; import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.config.ConstructorArgumentValues; import org.springframework.beans.factory.support.DefaultListableBeanFactory; @@ -171,7 +172,8 @@ protected AbstractCredentialsLoader registerCredentialsLoader( protected Optional getParameterizedBean( ApplicationContext applicationContext, Class paramClass, Class... generics) { ResolvableType resolvableType = ResolvableType.forClassWithGenerics(paramClass, generics); - String[] beanNames = applicationContext.getBeanNamesForType(resolvableType); + String[] beanNames = + BeanFactoryUtils.beanNamesForTypeIncludingAncestors(applicationContext, resolvableType); if (beanNames.length == 1) { return Optional.of((T) applicationContext.getBean(beanNames[0])); } diff --git a/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/Poller.java b/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/Poller.java index f19a61ce7..a814cca0a 100644 --- a/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/Poller.java +++ b/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/Poller.java @@ -17,7 +17,7 @@ package com.netflix.spinnaker.credentials.poller; import com.netflix.spinnaker.credentials.Credentials; -import com.netflix.spinnaker.credentials.definition.AbstractCredentialsLoader; +import com.netflix.spinnaker.credentials.definition.CredentialsLoader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,7 +34,7 @@ @Slf4j @RequiredArgsConstructor public class Poller implements Runnable { - private final AbstractCredentialsLoader credentialsLoader; + private final CredentialsLoader credentialsLoader; public void run() { try { diff --git a/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/PollerConfiguration.java b/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/PollerConfiguration.java index 572e78b9f..90c888ebc 100644 --- a/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/PollerConfiguration.java +++ b/kork-credentials/src/main/java/com/netflix/spinnaker/credentials/poller/PollerConfiguration.java @@ -16,11 +16,12 @@ package com.netflix.spinnaker.credentials.poller; -import com.netflix.spinnaker.credentials.definition.AbstractCredentialsLoader; -import java.util.List; +import com.netflix.spinnaker.credentials.Credentials; +import com.netflix.spinnaker.credentials.definition.CredentialsLoader; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.SchedulingConfigurer; import org.springframework.scheduling.config.ScheduledTaskRegistrar; @@ -28,10 +29,10 @@ @EnableConfigurationProperties(PollerConfigurationProperties.class) @RequiredArgsConstructor -@Slf4j +@NonnullByDefault public class PollerConfiguration implements SchedulingConfigurer { private final PollerConfigurationProperties config; - private final List> pollers; + private final ObjectProvider> pollers; @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { diff --git a/kork-credentials/src/test/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfigurationTest.java b/kork-credentials/src/test/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfigurationTest.java index 8dca9830f..007f6bab2 100644 --- a/kork-credentials/src/test/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfigurationTest.java +++ b/kork-credentials/src/test/java/com/netflix/spinnaker/credentials/CredentialsTypeBaseConfigurationTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.context.support.StaticApplicationContext; public class CredentialsTypeBaseConfigurationTest { @@ -121,6 +122,24 @@ public void testOverrideCredentialsRepository() { assertThat(beanNames[0]).isEqualTo("customRepository"); } + @Test + void testOverrideBeanInParentContext() { + var childContext = new StaticApplicationContext(context); + config = new CredentialsTypeBaseConfiguration<>(childContext, props); + TestCredentialsRepository repository = + new TestCredentialsRepository(CREDENTIALS_TYPE, new NoopCredentialsLifecycleHandler<>()); + context.getBeanFactory().registerSingleton("customRepository", repository); + config.afterPropertiesSet(); + // This test runner ignores PostConstruct annotations + config.getCredentialsLoader().load(); + + String[] beanNames = + BeanFactoryUtils.beanNamesForTypeIncludingAncestors( + childContext, CredentialsRepository.class); + assertThat(beanNames).hasSize(1); + assertThat(beanNames[0]).isEqualTo("customRepository"); + } + @Test public void testParallel() { CredentialsDefinitionSource source = () -> List.of(new TestAccount("account1")); diff --git a/kork-crypto/kork-crypto.gradle b/kork-crypto/kork-crypto.gradle new file mode 100644 index 000000000..201487335 --- /dev/null +++ b/kork-crypto/kork-crypto.gradle @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +apply plugin: "java-library" +apply plugin: "java-test-fixtures" +apply from: "$rootDir/gradle/lombok.gradle" + +dependencies { + api(platform(project(":spinnaker-dependencies"))) + + api project(":kork-annotations") + + implementation "org.bouncycastle:bcpkix-jdk18on" + implementation "org.springframework:spring-aop" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + + testFixturesApi "org.bouncycastle:bcpkix-jdk18on" + testFixturesApi "org.springframework:spring-core" +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/CipherSuites.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/CipherSuites.java new file mode 100644 index 000000000..6a082cbc3 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/CipherSuites.java @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provides a common source for lists of TLS cipher suite baselines. + * + * @see Mozilla Server Side TLS + * recommendations + */ +public final class CipherSuites { + private static final List REQUIRED = + List.of("TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256"); + private static final List BROWSER_COMPATIBILITY = + List.of( + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"); + private static final List RESTRICTED = + List.of( + "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256"); + + /** + * Returns the list of baseline ciphers that should be enabled for TLS. These include the required + * ciphers for TLSv1.3. + * + * @see Modern + * compatibility recommendations + */ + public static List getRequiredCiphers() { + return REQUIRED; + } + + public static List getRecommendedCiphers() { + var ciphers = new ArrayList<>(getRequiredCiphers()); + ciphers.addAll(BROWSER_COMPATIBILITY); + return ciphers; + } + + public static List getIntermediateCompatibilityCiphers() { + var ciphers = getRecommendedCiphers(); + ciphers.addAll(RESTRICTED); + return ciphers; + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/IdentityX509KeyManager.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/IdentityX509KeyManager.java new file mode 100644 index 000000000..b0c7fe5b0 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/IdentityX509KeyManager.java @@ -0,0 +1,80 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Duration; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import lombok.RequiredArgsConstructor; + +/** + * Provides a simple {@link X509ExtendedKeyManager} that uses a single {@link X509Identity} as the + * source for any keys and certificates required. This is most useful when paired with {@linkplain + * X509IdentitySource#refreshable(Duration) a refreshable identity}, though if the lifetime of the + * identity's certificate is expected to outlive the application instance, then a static identity + * may also be used. + */ +@RequiredArgsConstructor +public class IdentityX509KeyManager extends X509ExtendedKeyManager { + public static final String ALIAS = "identity"; + + private final X509Identity identity; + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return ALIAS; + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return ALIAS; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[] {ALIAS}; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return ALIAS; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return new String[] {ALIAS}; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return ALIAS; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return identity.getCertificateChain(); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return identity.getPrivateCredential().getPrivateKey(); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/KeyFactories.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/KeyFactories.java new file mode 100644 index 000000000..d10179bd9 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/KeyFactories.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x9.X9ObjectIdentifiers; + +public final class KeyFactories { + private static final Map KEY_FACTORIES_BY_ALGORITHM_IDENTIFIER; + + static { + try { + KEY_FACTORIES_BY_ALGORITHM_IDENTIFIER = + Map.of( + X9ObjectIdentifiers.id_ecPublicKey, KeyFactory.getInstance("EC"), + PKCSObjectIdentifiers.rsaEncryption, KeyFactory.getInstance("RSA")); + } catch (NoSuchAlgorithmException e) { + throw new NestedSecurityRuntimeException(e); + } + } + + private KeyFactories() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static KeyFactory getKeyFactory(ASN1ObjectIdentifier algorithmIdentifier) { + KeyFactory keyFactory = KEY_FACTORIES_BY_ALGORITHM_IDENTIFIER.get(algorithmIdentifier); + if (keyFactory == null) { + throw new UnsupportedOperationException( + "Unsupported key algorithm identifier: " + algorithmIdentifier); + } + return keyFactory; + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/NestedSecurityIOException.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/NestedSecurityIOException.java new file mode 100644 index 000000000..d88cd760a --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/NestedSecurityIOException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.security.GeneralSecurityException; +import org.springframework.core.NestedIOException; + +public class NestedSecurityIOException extends NestedIOException { + public NestedSecurityIOException(GeneralSecurityException e) { + super(e.getMessage(), e); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/NestedSecurityRuntimeException.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/NestedSecurityRuntimeException.java new file mode 100644 index 000000000..dc7d5bbe6 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/NestedSecurityRuntimeException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.security.GeneralSecurityException; +import org.springframework.core.NestedRuntimeException; + +public class NestedSecurityRuntimeException extends NestedRuntimeException { + public NestedSecurityRuntimeException(GeneralSecurityException e) { + super(e.getMessage(), e); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PEMIdentitySource.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PEMIdentitySource.java new file mode 100644 index 000000000..c550168b5 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PEMIdentitySource.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.UnsupportedEncodingException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; + +/** + * Implements an identity source based on a PEM-encoded private key and certificate file. Private + * keys must not be encrypted (e.g., they must include the {@code -nodes} option when generating + * them via OpenSSL). The certificate file must use the same key algorithm as the private key file. + * Supported key algorithms include RSA and EC. + */ +@RequiredArgsConstructor +public class PEMIdentitySource implements X509IdentitySource { + + private final Path keyFile; + private final Path certificateFile; + + @Getter private Instant lastLoaded = Instant.MIN; + @Getter private Instant expiresAt = Instant.MAX; + + @Override + public Instant getLastModified() { + try { + return Files.getLastModifiedTime(certificateFile).toInstant(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public X509Identity load() throws IOException { + PrivateKey privateKey; + try (var parser = new PEMParser(Files.newBufferedReader(keyFile))) { + var object = parser.readObject(); + PrivateKeyInfo keyInfo; + if (object instanceof PrivateKeyInfo) { + keyInfo = (PrivateKeyInfo) object; + } else if (object instanceof PEMKeyPair) { + keyInfo = ((PEMKeyPair) object).getPrivateKeyInfo(); + } else { + // could be an encrypted private key? + throw new UnsupportedEncodingException( + "Unsupported private key data type: " + object.getClass()); + } + var keySpec = new PKCS8EncodedKeySpec(keyInfo.getEncoded()); + var keyFactory = KeyFactories.getKeyFactory(keyInfo.getPrivateKeyAlgorithm().getAlgorithm()); + privateKey = keyFactory.generatePrivate(keySpec); + } catch (InvalidKeySpecException e) { + throw new NestedSecurityIOException(e); + } + List certificates = new ArrayList<>(); + try (var parser = new PEMParser(Files.newBufferedReader(certificateFile))) { + var certificateFactory = StandardCrypto.getX509CertificateFactory(); + Object parsedCertificate; + while ((parsedCertificate = parser.readObject()) != null) { + var certificateHolder = (X509CertificateHolder) parsedCertificate; + var cert = + (X509Certificate) + certificateFactory.generateCertificate( + new ByteArrayInputStream(certificateHolder.getEncoded())); + Instant notAfter = cert.getNotAfter().toInstant(); + if (expiresAt.isAfter(notAfter)) { + expiresAt = notAfter; + } + certificates.add(cert); + } + } catch (CertificateException e) { + throw new NestedSecurityIOException(e); + } + lastLoaded = Instant.now(); + return new StaticX509Identity(privateKey, certificates.toArray(X509Certificate[]::new)); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PasswordProtectedKeyStoreIdentitySource.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PasswordProtectedKeyStoreIdentitySource.java new file mode 100644 index 000000000..faab0a043 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PasswordProtectedKeyStoreIdentitySource.java @@ -0,0 +1,114 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Identity source using a keystore file and password provider functions for the keystore and + * identity private key. + */ +@RequiredArgsConstructor +public class PasswordProtectedKeyStoreIdentitySource implements X509IdentitySource { + private final Path keystoreFile; + private final String keystoreType; + private final PasswordProvider keystorePasswordProvider; + private final PasswordProvider privateKeyPasswordProvider; + + @Getter private Instant lastLoaded = Instant.MIN; + @Getter private Instant expiresAt = Instant.MAX; + + @Override + public Instant getLastModified() { + try { + return Files.getLastModifiedTime(keystoreFile).toInstant(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public X509Identity load() throws IOException { + KeyStore keyStore; + char[] password; + try { + keyStore = KeyStore.getInstance(keystoreType); + password = keystorePasswordProvider.password(); + } catch (GeneralSecurityException e) { + throw new NestedSecurityIOException(e); + } + try (var stream = Files.newInputStream(keystoreFile)) { + keyStore.load(stream, password); + } catch (CertificateException | NoSuchAlgorithmException e) { + throw new NestedSecurityIOException(e); + } finally { + Arrays.fill(password, (char) 0); + } + X509Identity identity; + try { + password = privateKeyPasswordProvider.password(); + identity = findIdentity(keyStore, new KeyStore.PasswordProtection(password)); + } catch (GeneralSecurityException e) { + throw new NestedSecurityIOException(e); + } finally { + Arrays.fill(password, (char) 0); + } + for (X509Certificate certificate : identity.getCertificateChain()) { + Instant notAfter = certificate.getNotAfter().toInstant(); + if (expiresAt.isAfter(notAfter)) { + expiresAt = notAfter; + } + } + lastLoaded = Instant.now(); + return identity; + } + + private X509Identity findIdentity( + KeyStore keyStore, KeyStore.ProtectionParameter protectionParameter) + throws GeneralSecurityException { + var aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + var alias = aliases.nextElement(); + if (keyStore.isKeyEntry(alias)) { + var entry = keyStore.getEntry(alias, protectionParameter); + if (entry instanceof KeyStore.PrivateKeyEntry) { + var privateKeyEntry = (KeyStore.PrivateKeyEntry) entry; + var chain = privateKeyEntry.getCertificateChain(); + if (chain instanceof X509Certificate[]) { + X509Certificate[] certificateChain = (X509Certificate[]) chain; + PrivateKey privateKey = privateKeyEntry.getPrivateKey(); + return new StaticX509Identity(privateKey, certificateChain); + } + } + } + } + throw new IllegalArgumentException("No private key entry found in keystore: " + keystoreFile); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PasswordProvider.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PasswordProvider.java new file mode 100644 index 000000000..6f9677f8e --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/PasswordProvider.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +/** Function for providing a password for use with decrypting keystores. */ +@FunctionalInterface +public interface PasswordProvider { + char[] password() throws IOException, GeneralSecurityException; +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/RefreshableX509Identity.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/RefreshableX509Identity.java new file mode 100644 index 000000000..5426044b6 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/RefreshableX509Identity.java @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.aop.target.dynamic.AbstractRefreshableTargetSource; + +/** + * Implements a refreshable {@link X509Identity} using Spring AOP. This target source should be used + * in a {@link org.springframework.aop.framework.ProxyFactory} to create a dynamic proxy for {@link + * X509Identity}. + * + * @see X509IdentitySource#refreshable(Duration) + */ +@RequiredArgsConstructor +public class RefreshableX509Identity extends AbstractRefreshableTargetSource { + private final X509IdentitySource identitySource; + + @Override + protected X509Identity freshTarget() { + try { + return identitySource.load(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + protected boolean requiresRefresh() { + Instant lastLoaded = identitySource.getLastLoaded(); + Instant lastModified = identitySource.getLastModified(); + Instant expiresAt = identitySource.getExpiresAt(); + return lastLoaded.isBefore(lastModified) || Instant.now().isAfter(expiresAt); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/SecureRandomBuilder.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/SecureRandomBuilder.java new file mode 100644 index 000000000..71ddf3e6a --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/SecureRandomBuilder.java @@ -0,0 +1,154 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.DrbgParameters; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.SecureRandomParameters; +import javax.annotation.Nullable; + +/** + * Builder class for creating a {@link SecureRandom} instance using a deterministic random bit + * generator (DRBG). + * + * @see NIST + * Special Publication 800-90A Revision 1 (Recommendation for Random Number Generation Using + * Deterministic Random Bit Generators) + * @see DrbgParameters + */ +public class SecureRandomBuilder { + private String algorithm = "DRBG"; + @Nullable private String providerName; + @Nullable private Provider provider; + private int bitStrength = -1; + private boolean reseed; + private boolean predictionResistance; + @Nullable private byte[] personalizationString; + + /** + * Overrides the {@linkplain SecureRandom#getInstance(String) algorithm name} to use. By default, + * this is {@code DRBG}. + */ + public SecureRandomBuilder withAlgorithm(String algorithm) { + this.algorithm = algorithm; + return this; + } + + /** Specifies a particular security provider name to use. */ + public SecureRandomBuilder withProvider(String provider) { + providerName = provider; + return this; + } + + /** Specifies a particular security provider to use. */ + public SecureRandomBuilder withProvider(Provider provider) { + this.provider = provider; + return this; + } + + /** + * Specifies the required security strength in bits for the built random generator. If set to -1 + * or otherwise left unspecified, then the default strength will be used depending on the system + * configuration. The default Sun provider uses a strength of 128. + * + * @see DrbgParameters + */ + public SecureRandomBuilder withStrength(int bitStrength) { + this.bitStrength = bitStrength; + return this; + } + + /** + * Enables support for {@link SecureRandom#reseed()} and {@link + * SecureRandom#reseed(SecureRandomParameters)}. Long-running use of a random generator may + * periodically desire reseeding from an underlying entropy source. The default Sun provider + * supports reseeding. + * + * @see DrbgParameters + */ + public SecureRandomBuilder withReseedSupport() { + reseed = true; + return this; + } + + /** + * Enables support for prediction resistance (and by extension, reseeding). The default Sun + * provider supports prediction resistance. + * + * @see DrbgParameters + */ + public SecureRandomBuilder withPredictionResistance() { + predictionResistance = true; + return this; + } + + /** + * Specifies a personalization string to use during instantiation of the random generator. A + * personalization string is useful for separating different uses of random generators. + * + * @see DrbgParameters + */ + public SecureRandomBuilder withPersonalizationString(byte[] personalizationString) { + this.personalizationString = personalizationString.clone(); + return this; + } + + /** + * Specifies a personalization string which is converted to UTF-8. + * + * @see #withPersonalizationString(byte[]) + */ + public SecureRandomBuilder withPersonalizationString(String personalizationString) { + this.personalizationString = personalizationString.getBytes(StandardCharsets.UTF_8); + return this; + } + + /** Creates a random generator using the settings from this builder. */ + public SecureRandom build() { + DrbgParameters.Capability capability; + if (predictionResistance) { + capability = DrbgParameters.Capability.PR_AND_RESEED; + } else if (reseed) { + capability = DrbgParameters.Capability.RESEED_ONLY; + } else { + capability = DrbgParameters.Capability.NONE; + } + var parameters = DrbgParameters.instantiation(bitStrength, capability, personalizationString); + var name = providerName; + var prov = provider; + try { + if (name != null) { + return SecureRandom.getInstance(algorithm, parameters, name); + } + if (prov != null) { + return SecureRandom.getInstance(algorithm, parameters, prov); + } + return SecureRandom.getInstance(algorithm, parameters); + } catch (NoSuchAlgorithmException | NoSuchProviderException e) { + throw new NestedSecurityRuntimeException(e); + } + } + + /** Creates a new builder for {@link SecureRandom} instances. */ + public static SecureRandomBuilder create() { + return new SecureRandomBuilder(); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/StandardCrypto.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/StandardCrypto.java new file mode 100644 index 000000000..9560bcd2c --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/StandardCrypto.java @@ -0,0 +1,72 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +/** + * Provides simpler access to standard Java cryptography algorithm classes. These are all included + * in standard Java distributions. + * + * @see Standard + * algorithm names + */ +public final class StandardCrypto { + + private StandardCrypto() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static CertificateFactory getX509CertificateFactory() { + try { + return CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new NestedSecurityRuntimeException(e); + } + } + + public static KeyStore getPKCS12KeyStore() { + try { + return KeyStore.getInstance("PKCS12"); + } catch (KeyStoreException e) { + throw new NestedSecurityRuntimeException(e); + } + } + + public static TrustManagerFactory getPKIXTrustManagerFactory() { + try { + return TrustManagerFactory.getInstance("PKIX"); + } catch (NoSuchAlgorithmException e) { + throw new NestedSecurityRuntimeException(e); + } + } + + public static SSLContext getTLSContext() { + try { + return SSLContext.getInstance("TLS"); + } catch (NoSuchAlgorithmException e) { + throw new NestedSecurityRuntimeException(e); + } + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/StaticX509Identity.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/StaticX509Identity.java new file mode 100644 index 000000000..3fe18c9dc --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/StaticX509Identity.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import javax.security.auth.DestroyFailedException; +import javax.security.auth.x500.X500PrivateCredential; +import lombok.Getter; + +/** + * Provides a static implementation of an {@link X509Identity}. This identity is configured with a + * parsed {@link PrivateKey} and corresponding {@link X509Certificate} chain. + */ +public class StaticX509Identity implements X509Identity { + private final X509Certificate[] certificateChain; + @Getter private final X500PrivateCredential privateCredential; + + public StaticX509Identity(PrivateKey privateKey, X509Certificate[] certificateChain) { + if (certificateChain.length == 0) { + throw new IllegalArgumentException("Certificate chain must have at least one certificate"); + } + this.certificateChain = certificateChain.clone(); + this.privateCredential = new X500PrivateCredential(certificateChain[0], privateKey); + } + + @Override + public X509Certificate[] getCertificateChain() { + return certificateChain.clone(); + } + + @Override + public void destroy() throws DestroyFailedException { + privateCredential.destroy(); + Arrays.fill(certificateChain, null); + } + + @Override + public boolean isDestroyed() { + return privateCredential.isDestroyed(); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/TrustStores.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/TrustStores.java new file mode 100644 index 000000000..a1f558a63 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/TrustStores.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.stream.Stream; +import javax.net.ssl.X509TrustManager; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.util.Encodable; + +/** + * Provides utility methods related to trust stores. Trust stores are used to validate certificate + * chains in TLS and other X.509 use cases. + */ +public final class TrustStores { + private TrustStores() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static KeyStore loadPEM(Path caCertificates) + throws CertificateException, IOException, NoSuchAlgorithmException, KeyStoreException { + var keyStore = StandardCrypto.getPKCS12KeyStore(); + keyStore.load(null, null); + try (var parser = new PEMParser(Files.newBufferedReader(caCertificates))) { + var certificateFactory = StandardCrypto.getX509CertificateFactory(); + Object parsedCertificate; + while ((parsedCertificate = parser.readObject()) != null) { + var certificateStream = + new ByteArrayInputStream(((Encodable) parsedCertificate).getEncoded()); + var certificate = certificateFactory.generateCertificate(certificateStream); + var alias = X509Identity.generateAlias(certificate); + keyStore.setCertificateEntry(alias, certificate); + } + } + return keyStore; + } + + public static X509TrustManager loadTrustManager(KeyStore keyStore) throws KeyStoreException { + var trustManagerFactory = StandardCrypto.getPKIXTrustManagerFactory(); + trustManagerFactory.init(keyStore); + return Stream.of(trustManagerFactory.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Provided KeyStore does not contain any X.509 certificates")); + } + + public static X509TrustManager getSystemTrustManager() { + var trustManagerFactory = StandardCrypto.getPKIXTrustManagerFactory(); + try { + trustManagerFactory.init((KeyStore) null); + } catch (KeyStoreException e) { + throw new NestedSecurityRuntimeException(e); + } + return Stream.of(trustManagerFactory.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No system default trust store configured")); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/X509Identity.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/X509Identity.java new file mode 100644 index 000000000..f176ad7d5 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/X509Identity.java @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Base64; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import javax.security.auth.Destroyable; +import javax.security.auth.x500.X500PrivateCredential; +import org.bouncycastle.crypto.digests.SHAKEDigest; + +/** Represents a cryptographic identity using a private key and certificate. */ +public interface X509Identity extends Destroyable { + + /** Returns the private key and certificate for this identity. */ + X500PrivateCredential getPrivateCredential(); + + /** Returns the certificate chain for this identity. */ + X509Certificate[] getCertificateChain(); + + /** + * Creates an {@link SSLContext} from this identity using the system default {@link TrustManager} + * and {@link SecureRandom}. + * + * @return a new SSLContext using this identity for authentication + * @throws KeyManagementException if there is an error initializing the SSLContext + */ + default SSLContext createSSLContext() throws KeyManagementException { + var context = StandardCrypto.getTLSContext(); + var keyManagers = new KeyManager[] {new IdentityX509KeyManager(this)}; + context.init(keyManagers, null, null); + return context; + } + + /** + * Creates an {@link SSLContext} from this identity using a specific trust manager. + * + * @param trustManager the trust manager to use for validating TLS peers + * @return a new SSLContext using this identity for authentication + * @throws KeyManagementException if there is an error initializing the SSLContext + * @see TrustStores#loadTrustManager(KeyStore) + */ + default SSLContext createSSLContext(X509TrustManager trustManager) throws KeyManagementException { + var context = StandardCrypto.getTLSContext(); + context.init( + new KeyManager[] {new IdentityX509KeyManager(this)}, + new TrustManager[] {trustManager}, + null); + return context; + } + + /** + * Creates an {@link SSLContext} from this identity using a specific trust manager and source of + * randomness. + * + * @param trustManager the trust manager to use for validating TLS peers + * @param secureRandom the source of randomness to use for generating cryptographic bits + * @return a new SSLContext using this identity for authentication + * @throws KeyManagementException if there is an error initializing the SSLContext + */ + default SSLContext createSSLContext(X509TrustManager trustManager, SecureRandom secureRandom) + throws KeyManagementException { + var context = StandardCrypto.getTLSContext(); + context.init( + new KeyManager[] {new IdentityX509KeyManager(this)}, + new TrustManager[] {trustManager}, + secureRandom); + return context; + } + + /** + * Generates a certificate alias string. This alias is computed from an extensible output function + * (XOF) of the certificate's public key. + * + * @param certificate certificate to compute an alias for + * @return the computed alias + */ + static String generateAlias(Certificate certificate) { + var encodedPublicKey = certificate.getPublicKey().getEncoded(); + // SHAKE is an extensible output function variant of SHA-3; this particular version is SHAKE-128 + // https://crypto.stackexchange.com/a/54249 + var digest = new SHAKEDigest(); + digest.update(encodedPublicKey, 0, encodedPublicKey.length); + // use a size divisible by 3 for nicer base64 encoded output (20 chars for 15 bytes) + var hash = new byte[15]; + digest.doFinal(hash, 0, hash.length); + return Base64.getEncoder().encodeToString(hash); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/X509IdentitySource.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/X509IdentitySource.java new file mode 100644 index 000000000..b9ae2c33d --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/X509IdentitySource.java @@ -0,0 +1,117 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import org.springframework.aop.framework.ProxyFactory; + +/** + * Provides a source for loading an {@link X509Identity} from some underlying key and certificate. + * These sources should keep track of the last time an identity was loaded along with the earliest + * expiration date of any contained certificates. These identities may be adapted into refreshable + * identities via {@link #refreshable(Duration)} which specifies a polling duration in which to + * recheck if the identity should be reloaded. + */ +public interface X509IdentitySource { + /** + * Returns the time this source last loaded an identity. This may return {@link Instant#MIN} if no + * identity has been loaded yet. + */ + Instant getLastLoaded(); + + /** Returns the time that the key or certificate source was last modified. */ + Instant getLastModified(); + + /** + * Returns the earliest date and time of expiration of the certificates included in this source. + * This may return {@link Instant#MAX} if no expiration date is known. + */ + Instant getExpiresAt(); + + /** + * Loads an {@link X509Identity} from this underlying source. Any thrown {@link + * java.security.GeneralSecurityException} instances should be rethrown in a {@link + * NestedSecurityIOException}. + */ + X509Identity load() throws IOException; + + /** + * Creates a refreshable {@link X509Identity} from this source and the given refresh check delay. + * The returned identity will periodically check if a reload is required based on the last + * modified timestamp of the source along with the expiration of the certificates. + * + * @see IdentityX509KeyManager + */ + default X509Identity refreshable(Duration refreshCheckDelay) { + var identity = new RefreshableX509Identity(this); + identity.setRefreshCheckDelay(refreshCheckDelay.toMillis()); + return ProxyFactory.getProxy(X509Identity.class, identity); + } + + /** Creates an identity source from a PEM-encoded private key file and certificate file. */ + static X509IdentitySource fromPEM(Path keyFile, Path certificateFile) { + return new PEMIdentitySource(keyFile, certificateFile); + } + + /** + * Creates an identity source from a PKCS#12-encoded keystore file and password provider function. + */ + static X509IdentitySource fromPKCS12(Path keystoreFile, PasswordProvider passwordProvider) { + return fromPKCS12(keystoreFile, passwordProvider, passwordProvider); + } + + /** + * Creates an identity source from a PKCS#12-encoded keystore file, keystore password provider + * function, and identity private key password provider function. + */ + static X509IdentitySource fromPKCS12( + Path keystoreFile, + PasswordProvider keystorePasswordProvider, + PasswordProvider privateKeyPasswordProvider) { + return fromKeyStore( + keystoreFile, "PKCS12", keystorePasswordProvider, privateKeyPasswordProvider); + } + + /** Creates an identity source from a password-protected {@link java.security.KeyStore} file. */ + static X509IdentitySource fromKeyStore( + Path keystoreFile, String keystoreType, PasswordProvider passwordProvider) { + return fromKeyStore(keystoreFile, keystoreType, passwordProvider, passwordProvider); + } + + /** + * Creates an identity source from a password-protected {@link java.security.KeyStore} file. + * + * @param keystoreFile path to the keystore file to read + * @param keystoreType the type of the keystore (typically {@code PKCS12}) + * @param keystorePasswordProvider function for obtaining the password to decrypt the keystore + * file + * @param privateKeyPasswordProvider function for obtaining the password to decrypt the identity + * private key (this is typically the same as the keystore password) + * @return an identity source from the provided keystore details + */ + static X509IdentitySource fromKeyStore( + Path keystoreFile, + String keystoreType, + PasswordProvider keystorePasswordProvider, + PasswordProvider privateKeyPasswordProvider) { + return new PasswordProtectedKeyStoreIdentitySource( + keystoreFile, keystoreType, keystorePasswordProvider, privateKeyPasswordProvider); + } +} diff --git a/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/package-info.java b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/package-info.java new file mode 100644 index 000000000..b3566aaf8 --- /dev/null +++ b/kork-crypto/src/main/java/com/netflix/spinnaker/kork/crypto/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +@NonnullByDefault +package com.netflix.spinnaker.kork.crypto; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; diff --git a/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PEMIdentitySourceTest.java b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PEMIdentitySourceTest.java new file mode 100644 index 000000000..d100dc58f --- /dev/null +++ b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PEMIdentitySourceTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.netflix.spinnaker.kork.crypto.test.CertificateIdentity; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; + +class PEMIdentitySourceTest { + @Test + void smokeTest() throws Exception { + var certificateIdentity = CertificateIdentity.generateSelfSigned(); + var keyFile = Files.createTempFile("identity", ".key"); + var certificateFile = Files.createTempFile("identity", ".crt"); + certificateIdentity.saveAsPEM(keyFile, certificateFile); + var identitySource = X509IdentitySource.fromPEM(keyFile, certificateFile); + var identityCredential = identitySource.load().getPrivateCredential(); + assertEquals(certificateIdentity.getCertificate(), identityCredential.getCertificate()); + assertEquals(certificateIdentity.getPrivateKey(), identityCredential.getPrivateKey()); + } +} diff --git a/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PEMTrustStoreLoaderTest.java b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PEMTrustStoreLoaderTest.java new file mode 100644 index 000000000..40cbde18f --- /dev/null +++ b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PEMTrustStoreLoaderTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.netflix.spinnaker.kork.crypto.test.CertificateIdentity; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.Certificate; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.junit.jupiter.api.Test; + +class PEMTrustStoreLoaderTest { + @Test + void smokeTest() throws Exception { + CertificateIdentity certificateIdentity = CertificateIdentity.generateSelfSigned(); + Path certificate = Files.createTempFile("ca", ".crt"); + try (var writer = new JcaPEMWriter(Files.newBufferedWriter(certificate))) { + writer.writeObject(certificateIdentity.getCertificate()); + } + KeyStore keyStore = TrustStores.loadPEM(certificate); + Certificate cert = keyStore.getCertificate(certificateIdentity.getAlias()); + assertThat(cert).isEqualTo(certificateIdentity.getCertificate()); + } +} diff --git a/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PKCS12IdentitySourceTest.java b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PKCS12IdentitySourceTest.java new file mode 100644 index 000000000..5c7a2c17c --- /dev/null +++ b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/PKCS12IdentitySourceTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.netflix.spinnaker.kork.crypto.test.CertificateIdentity; +import com.netflix.spinnaker.kork.crypto.test.TestCrypto; +import java.nio.file.Files; +import java.nio.file.Path; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +class PKCS12IdentitySourceTest { + @Test + @SneakyThrows + void smokeTest() { + CertificateIdentity certificateIdentity = CertificateIdentity.generateSelfSigned(); + Path keystoreFile = Files.createTempFile("keystore", ".p12"); + char[] password = TestCrypto.generatePassword(24); + certificateIdentity.saveAsPKCS12(keystoreFile, password); + var identitySource = X509IdentitySource.fromPKCS12(keystoreFile, password::clone); + var identityCredential = identitySource.load().getPrivateCredential(); + assertEquals(certificateIdentity.getCertificate(), identityCredential.getCertificate()); + assertEquals(certificateIdentity.getPrivateKey(), identityCredential.getPrivateKey()); + } +} diff --git a/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/RefreshableIdentityTest.java b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/RefreshableIdentityTest.java new file mode 100644 index 000000000..8bfdcbd7b --- /dev/null +++ b/kork-crypto/src/test/java/com/netflix/spinnaker/kork/crypto/RefreshableIdentityTest.java @@ -0,0 +1,168 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.netflix.spinnaker.kork.crypto.test.CertificateIdentity; +import com.netflix.spinnaker.kork.crypto.test.TestCrypto; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.time.Duration; +import javax.net.ssl.SSLContext; +import javax.security.auth.x500.X500Principal; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RefreshableIdentityTest { + SSLContext serverContext; + SSLContext clientContext; + + @BeforeEach + void setUp() throws Exception { + // start with a fresh CA cert + var ca = CertificateIdentity.generateSelfSigned(); + var caKeyFile = Files.createTempFile("ca", ".key"); + var caCertFile = Files.createTempFile("ca", ".crt"); + ca.saveAsPEM(caKeyFile, caCertFile); + var trustManager = TrustStores.loadTrustManager(TrustStores.loadPEM(caCertFile)); + + // common cert attributes + var tlsKeyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyAgreement); + + // generate server keypair + var serverKeyPair = TestCrypto.generateKeyPair(); + var serverPublicKey = serverKeyPair.getPublic(); + var serverPrivateKey = serverKeyPair.getPrivate(); + // traditionally, a TLS server certificate relied on the common name in the subject for the + // hostname, but these days, we specify the hostname in the subject alternative names extension + var serverName = new X500Principal("CN=localhost"); + var serverAlternativeName = new DERSequence(new GeneralName(GeneralName.dNSName, "localhost")); + var serverExtensions = new ExtensionsGenerator(); + serverExtensions.addExtension(Extension.keyUsage, true, tlsKeyUsage); + serverExtensions.addExtension( + Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth)); + serverExtensions.addExtension(Extension.subjectAlternativeName, false, serverAlternativeName); + + // request CA signature and store as PEM files + var serverCertificationRequest = + new JcaPKCS10CertificationRequestBuilder(serverName, serverPublicKey) + .addAttribute( + PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, serverExtensions.generate()) + .build(CertificateIdentity.signerFrom(serverPrivateKey)); + var serverIdentity = + CertificateIdentity.fromCredentials( + serverPrivateKey, ca.signCertificationRequest(serverCertificationRequest)); + var serverKeyFile = Files.createTempFile("server", ".key"); + var serverCertFile = Files.createTempFile("server", ".crt"); + serverIdentity.saveAsPEM(serverKeyFile, serverCertFile); + var serverIdentitySource = X509IdentitySource.fromPEM(serverKeyFile, serverCertFile); + var server = serverIdentitySource.refreshable(Duration.ofMinutes(15)); + serverContext = server.createSSLContext(trustManager); + + // generate client keypair + var clientKeyPair = TestCrypto.generateKeyPair(); + var clientPublicKey = clientKeyPair.getPublic(); + var clientPrivateKey = clientKeyPair.getPrivate(); + var clientName = new X500Principal("CN=spinnaker, O=spinnaker"); + var clientAlternativeName = + new DERSequence(new GeneralName(GeneralName.rfc822Name, "spinnaker@localhost")); + var clientExtensions = new ExtensionsGenerator(); + clientExtensions.addExtension(Extension.keyUsage, true, tlsKeyUsage); + clientExtensions.addExtension( + Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth)); + clientExtensions.addExtension(Extension.subjectAlternativeName, false, clientAlternativeName); + + // request CA signature and store as PEM files + var clientCertificationRequest = + new JcaPKCS10CertificationRequestBuilder(clientName, clientPublicKey) + .addAttribute( + PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, clientExtensions.generate()) + .build(CertificateIdentity.signerFrom(clientPrivateKey)); + var clientIdentity = + CertificateIdentity.fromCredentials( + clientPrivateKey, ca.signCertificationRequest(clientCertificationRequest)); + var clientKeyFile = Files.createTempFile("client", ".key"); + var clientCertFile = Files.createTempFile("client", ".crt"); + clientIdentity.saveAsPEM(clientKeyFile, clientCertFile); + var clientIdentitySource = X509IdentitySource.fromPEM(clientKeyFile, clientCertFile); + var client = clientIdentitySource.refreshable(Duration.ofMinutes(15)); + clientContext = client.createSSLContext(trustManager); + } + + @Test + void smokeTest() throws Exception { + var server = HttpsServer.create(); + server.setHttpsConfigurator(new HttpsConfigurator(serverContext)); + + // set up a simple echo handler + server.createContext( + "/echo", + exchange -> { + try (var request = exchange.getRequestBody(); + var response = exchange.getResponseBody()) { + byte[] body = request.readAllBytes(); + exchange.sendResponseHeaders(200, body.length); + response.write(body); + } + }); + // set up a random server port to serve from + int port; + try (var socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + port = socket.getLocalPort(); + } + var serverAddress = new InetSocketAddress("localhost", port); + server.bind(serverAddress, 0); + server.start(); + try { + var client = HttpClient.newBuilder().sslContext(clientContext).build(); + var request = + HttpRequest.newBuilder( + new URI( + "https", + null, + serverAddress.getHostName(), + serverAddress.getPort(), + "/echo", + null, + null)) + .POST(HttpRequest.BodyPublishers.ofString("Hello, world!")) + .build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals("Hello, world!", response.body()); + } finally { + server.stop(1); + } + } +} diff --git a/kork-crypto/src/testFixtures/java/com/netflix/spinnaker/kork/crypto/test/CertificateIdentity.java b/kork-crypto/src/testFixtures/java/com/netflix/spinnaker/kork/crypto/test/CertificateIdentity.java new file mode 100644 index 000000000..fcd075eee --- /dev/null +++ b/kork-crypto/src/testFixtures/java/com/netflix/spinnaker/kork/crypto/test/CertificateIdentity.java @@ -0,0 +1,219 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto.test; + +import com.netflix.spinnaker.kork.crypto.NestedSecurityIOException; +import com.netflix.spinnaker.kork.crypto.NestedSecurityRuntimeException; +import com.netflix.spinnaker.kork.crypto.StandardCrypto; +import com.netflix.spinnaker.kork.crypto.X509Identity; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import javax.security.auth.x500.X500PrivateCredential; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; + +/** + * Utilities for generating root certificate authorities and signing certification requests. + * + * @see generating a self-signed certificate + * @see org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder + * @see org.bouncycastle.asn1.x509.ExtensionsGenerator + */ +public class CertificateIdentity { + /** Distinguished name for a test root certificate authority. */ + public static final X500Name ISSUER = new X500Name("CN=Test Certificate Authority"); + + private final X500PrivateCredential credential; + private final ContentSigner signer; + + private CertificateIdentity(X500PrivateCredential credential) { + this.credential = credential; + signer = signerFrom(credential.getPrivateKey()); + } + + /** + * Saves this certificate identity as a PKCS#12-encoded keystore file with the provided password. + * Both the keystore and this identity's private key will be protected with the provided password. + */ + public void saveAsPKCS12(Path keystoreFile, char[] password) throws IOException { + var entry = + new KeyStore.PrivateKeyEntry(getPrivateKey(), new X509Certificate[] {getCertificate()}); + var protectionParameter = new KeyStore.PasswordProtection(password); + var keyStore = StandardCrypto.getPKCS12KeyStore(); + try (var output = Files.newOutputStream(keystoreFile)) { + keyStore.load(null, null); + keyStore.setEntry(getAlias(), entry, protectionParameter); + keyStore.store(output, password); + } catch (GeneralSecurityException e) { + throw new NestedSecurityIOException(e); + } + } + + /** + * Saves this certificate identity as a pair of PEM-encoded private key and certificate files. The + * private key is not password-protected. + */ + public void saveAsPEM(Path keyFile, Path certificateFile) throws IOException { + Base64.Encoder encoder = Base64.getEncoder(); + try (var writer = Files.newBufferedWriter(keyFile)) { + writer.write("-----BEGIN PRIVATE KEY-----"); + writer.newLine(); + writer.write(encoder.encodeToString(getPrivateKey().getEncoded())); + writer.newLine(); + writer.write("-----END PRIVATE KEY-----"); + writer.newLine(); + } + try (var writer = Files.newBufferedWriter(certificateFile)) { + writer.write("-----BEGIN CERTIFICATE-----"); + writer.newLine(); + writer.write(encoder.encodeToString(getCertificate().getEncoded())); + writer.newLine(); + writer.write("-----END CERTIFICATE-----"); + writer.newLine(); + } catch (CertificateEncodingException e) { + throw new NestedSecurityIOException(e); + } + } + + /** + * Signs the given certification request using this certificate identity. Returns a new X.509 + * certificate valid for an hour including all the requested extensions. + */ + public X509Certificate signCertificationRequest(PKCS10CertificationRequest request) + throws IOException { + BigInteger serial = generateSerial(); + Date notBefore = new Date(); + Date notAfter = Date.from(notBefore.toInstant().plus(Duration.ofHours(1))); + var builder = + new X509v3CertificateBuilder( + ISSUER, + serial, + notBefore, + notAfter, + request.getSubject(), + request.getSubjectPublicKeyInfo()); + + // next, copy all requested extensions (we'll pretend to support them all) + var extensions = request.getRequestedExtensions(); + for (ASN1ObjectIdentifier oid : extensions.getCriticalExtensionOIDs()) { + builder.addExtension(oid, true, extensions.getExtensionParsedValue(oid)); + } + for (ASN1ObjectIdentifier oid : extensions.getNonCriticalExtensionOIDs()) { + builder.addExtension(oid, false, extensions.getExtensionParsedValue(oid)); + } + + X509CertificateHolder certificateHolder = builder.build(signer); + return convertFromBC(certificateHolder); + } + + public X509Certificate getCertificate() { + return credential.getCertificate(); + } + + public PrivateKey getPrivateKey() { + return credential.getPrivateKey(); + } + + public String getAlias() { + return credential.getAlias(); + } + + public static CertificateIdentity generateSelfSigned() throws IOException { + var keyPair = TestCrypto.generateKeyPair(); + var privateKey = keyPair.getPrivate(); + var signer = signerFrom(privateKey); + + // set up a certificate authority + X509CertificateHolder certificateHolder = + builderForPublicKey(keyPair.getPublic()) + // specify key usage to allow certificate signing as a CA cert + .addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign)) + // specify this certificate is a CA cert (a leaf certificate) + .addExtension(Extension.basicConstraints, true, new BasicConstraints(true)) + .build(signer); + var certificate = convertFromBC(certificateHolder); + return fromCredentials(privateKey, certificate); + } + + public static ContentSigner signerFrom(PrivateKey privateKey) { + try { + return new JcaContentSignerBuilder("SHA256withECDSA").build(privateKey); + } catch (OperatorCreationException e) { + Throwable cause = e.getCause(); + throw cause instanceof GeneralSecurityException + ? new NestedSecurityRuntimeException((GeneralSecurityException) cause) + : new IllegalArgumentException(e); + } + } + + public static CertificateIdentity fromCredentials( + PrivateKey privateKey, X509Certificate certificate) { + var alias = X509Identity.generateAlias(certificate); + var credential = new X500PrivateCredential(certificate, privateKey, alias); + return new CertificateIdentity(credential); + } + + private static X509v3CertificateBuilder builderForPublicKey(PublicKey publicKey) { + BigInteger serial = generateSerial(); + // use a validity range of now to one year from now + Date notBefore = new Date(); + Date notAfter = Date.from(notBefore.toInstant().plus(Duration.ofDays(1))); + return new JcaX509v3CertificateBuilder(ISSUER, serial, notBefore, notAfter, ISSUER, publicKey); + } + + private static BigInteger generateSerial() { + // ensure we use somewhat unique serial numbers + return BigInteger.valueOf(System.currentTimeMillis()); + } + + private static X509Certificate convertFromBC(X509CertificateHolder certificateHolder) + throws IOException { + // convert BC X.509 certificate into a standard Java X509Certificate + CertificateFactory certificateFactory = StandardCrypto.getX509CertificateFactory(); + try { + return (X509Certificate) + certificateFactory.generateCertificate( + new ByteArrayInputStream(certificateHolder.getEncoded())); + } catch (CertificateException e) { + throw new NestedSecurityIOException(e); + } + } +} diff --git a/kork-crypto/src/testFixtures/java/com/netflix/spinnaker/kork/crypto/test/TestCrypto.java b/kork-crypto/src/testFixtures/java/com/netflix/spinnaker/kork/crypto/test/TestCrypto.java new file mode 100644 index 000000000..5efddeac7 --- /dev/null +++ b/kork-crypto/src/testFixtures/java/com/netflix/spinnaker/kork/crypto/test/TestCrypto.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.crypto.test; + +import com.netflix.spinnaker.kork.crypto.NestedSecurityRuntimeException; +import com.netflix.spinnaker.kork.crypto.SecureRandomBuilder; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +public final class TestCrypto { + private static final ThreadLocal KEY_PAIR_GENERATOR_THREAD_LOCAL = + ThreadLocal.withInitial( + () -> { + try { + var generator = KeyPairGenerator.getInstance("EC"); + var random = + SecureRandomBuilder.create() + .withStrength(256) + .withPersonalizationString("test keypair generator") + .build(); + generator.initialize(256, random); + return generator; + } catch (NoSuchAlgorithmException e) { + throw new NestedSecurityRuntimeException(e); + } + }); + private static final ThreadLocal PASSWORD_GENERATOR = + ThreadLocal.withInitial( + () -> + SecureRandomBuilder.create().withPersonalizationString("password generator").build()); + + private TestCrypto() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static KeyPair generateKeyPair() { + return KEY_PAIR_GENERATOR_THREAD_LOCAL.get().generateKeyPair(); + } + + public static char[] generatePassword(int length) { + SecureRandom random = PASSWORD_GENERATOR.get(); + char[] password = new char[length]; + for (int i = 0; i < length; i++) { + password[i] = (char) ('a' + random.nextInt(26)); + } + return password; + } +} diff --git a/kork-exceptions/src/main/java/com/netflix/spinnaker/kork/exceptions/SpinnakerException.java b/kork-exceptions/src/main/java/com/netflix/spinnaker/kork/exceptions/SpinnakerException.java index c15833246..3c4dcf0a7 100644 --- a/kork-exceptions/src/main/java/com/netflix/spinnaker/kork/exceptions/SpinnakerException.java +++ b/kork-exceptions/src/main/java/com/netflix/spinnaker/kork/exceptions/SpinnakerException.java @@ -77,4 +77,29 @@ public SpinnakerException( String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } + + /** + * Creates a new instance of the exception, but with a new message. + * + *

This method in and of itself is not really useful (using something like new + * SpinnakerException(message, e) would work). However, it becomes useful when all other child + * classes of SpinnakerException override it. + * + *

This allows a caller to use a generic catch statement to update the message, all in one + * line, i.e + * + *

+   *   catch (SpinnakerException e) {
+   *    // if the child class has a newInstance override, the return type will stay
+   *    // the type of the child class
+   *    return e.newInstance("new message");
+   *   }
+   * 
+ * + * @param message + * @return + */ + public SpinnakerException newInstance(String message) { + return new SpinnakerException(message, this); + } } diff --git a/kork-expressions/kork-expressions.gradle b/kork-expressions/kork-expressions.gradle index 5eb64e79f..67903e659 100644 --- a/kork-expressions/kork-expressions.gradle +++ b/kork-expressions/kork-expressions.gradle @@ -6,6 +6,7 @@ dependencies { api(platform(project(":spinnaker-dependencies"))) api project(":kork-api") + api project(":kork-artifacts") api project(":kork-plugins-api") api project(":kork-exceptions") api "com.fasterxml.jackson.core:jackson-databind" @@ -14,6 +15,7 @@ dependencies { implementation "org.springframework.boot:spring-boot" + testImplementation project(":kork-artifacts") testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" diff --git a/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ArtifactUriToReferenceConverter.java b/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ArtifactUriToReferenceConverter.java new file mode 100644 index 000000000..cacfbc635 --- /dev/null +++ b/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ArtifactUriToReferenceConverter.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.netflix.spinnaker.kork.expressions; + +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactReferenceURI; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStore; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.expression.TypeConverter; +import org.springframework.expression.spel.support.StandardTypeConverter; + +/** + * This converter is used to check if a String is a Artifact reference URI. If it is, this will then + * pull the reference from the artifact store and return the reference back base64 encoded. + */ +public class ArtifactUriToReferenceConverter implements TypeConverter { + + private final ArtifactStore artifactStore; + + public ArtifactUriToReferenceConverter(ArtifactStore artifactStore) { + this.artifactStore = artifactStore; + } + + private final StandardTypeConverter defaultTypeConverter = new StandardTypeConverter(); + + @Override + public boolean canConvert(TypeDescriptor sourceType, @NotNull TypeDescriptor targetType) { + return isArtifactUriType(sourceType, targetType) + || defaultTypeConverter.canConvert(sourceType, targetType); + } + + private boolean isArtifactUriType(TypeDescriptor sourceType, @NotNull TypeDescriptor targetType) { + return sourceType != null + && sourceType.getObjectType() == String.class + && targetType.getObjectType() == String.class; + } + + @Override + public Object convertValue( + Object value, TypeDescriptor sourceType, @NotNull TypeDescriptor targetType) { + if (!isArtifactUriType(sourceType, targetType)) { + return defaultTypeConverter.convertValue(value, sourceType, targetType); + } + + if (artifactStore == null || !ArtifactReferenceURI.is((String) value)) { + return defaultTypeConverter.convertValue(value, sourceType, targetType); + } + + return artifactStore.get(ArtifactReferenceURI.parse((String) value)).getReference(); + } +} diff --git a/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupport.java b/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupport.java index 5d76ba82f..ad7e1347d 100644 --- a/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupport.java +++ b/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupport.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.netflix.spinnaker.kork.api.expressions.ExpressionFunctionProvider; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStore; import com.netflix.spinnaker.kork.expressions.allowlist.AllowListTypeLocator; import com.netflix.spinnaker.kork.expressions.allowlist.FilteredMethodResolver; import com.netflix.spinnaker.kork.expressions.allowlist.FilteredPropertyAccessor; @@ -163,6 +164,8 @@ private StandardEvaluationContext createEvaluationContext( StandardEvaluationContext evaluationContext = new StandardEvaluationContext(rootObject); evaluationContext.setTypeLocator(new AllowListTypeLocator()); + evaluationContext.setTypeConverter( + new ArtifactUriToReferenceConverter(ArtifactStore.getInstance())); evaluationContext.setMethodResolvers( Collections.singletonList(new FilteredMethodResolver(returnTypeRestrictor))); evaluationContext.setPropertyAccessors( diff --git a/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/config/ExpressionProperties.java b/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/config/ExpressionProperties.java index 66125ddeb..77100734f 100644 --- a/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/config/ExpressionProperties.java +++ b/kork-expressions/src/main/java/com/netflix/spinnaker/kork/expressions/config/ExpressionProperties.java @@ -17,15 +17,17 @@ package com.netflix.spinnaker.kork.expressions.config; import lombok.Data; +import lombok.experimental.Accessors; import org.springframework.boot.context.properties.ConfigurationProperties; @Data @ConfigurationProperties(prefix = "expression") public class ExpressionProperties { - private final FeatureFlag doNotEvalSpel = new FeatureFlag(); + private final FeatureFlag doNotEvalSpel = new FeatureFlag().setEnabled(true); @Data + @Accessors(chain = true) public static class FeatureFlag { private boolean enabled; } diff --git a/kork-expressions/src/test/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupportTest.java b/kork-expressions/src/test/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupportTest.java index 792aa0850..19024ec83 100644 --- a/kork-expressions/src/test/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupportTest.java +++ b/kork-expressions/src/test/java/com/netflix/spinnaker/kork/expressions/ExpressionsSupportTest.java @@ -17,11 +17,19 @@ package com.netflix.spinnaker.kork.expressions; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactDecorator; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactReferenceURI; +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStore; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.kork.expressions.config.ExpressionProperties; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.function.Function; import org.junit.jupiter.api.Test; @@ -29,6 +37,7 @@ import org.springframework.expression.ParserContext; import org.springframework.expression.common.TemplateParserContext; import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; public class ExpressionsSupportTest { private final ExpressionParser parser = new SpelExpressionParser(); @@ -97,4 +106,78 @@ public void testToJsonWhenComposedExpressionAndEvaluationContext() { assertThat(evaluated).isEqualTo("{\"json_file\":\"${#toJson(#doNotEval(file_json))}\"}"); } + + @Test + public void artifactReferenceInSpEL() { + MockArtifactStore artifactStore = new MockArtifactStore(); + ArtifactStore.setInstance(artifactStore); + ExpressionProperties expressionProperties = new ExpressionProperties(); + String expectedValue = "Hello world"; + artifactStore.cache.put("ref://app/sha", expectedValue); + String expr = "${#fromBase64(\"ref://app/sha\")}"; + Map testContext = + Collections.singletonMap( + "artifactReference", Collections.singletonMap("artifactReference", expr)); + + ExpressionsSupport expressionsSupport = new ExpressionsSupport(null, expressionProperties); + + StandardEvaluationContext evaluationContext = + expressionsSupport.buildEvaluationContext( + new ExpressionTransformTest.Pipeline(new ExpressionTransformTest.Trigger(123)), true); + + String evaluated = + new ExpressionTransform(parserContext, parser, Function.identity()) + .transformString(expr, evaluationContext, new ExpressionEvaluationSummary()); + + assertThat(evaluated).isEqualTo(expectedValue); + } + + @Test + public void delegatesTypeConversion() { + // If a thing is not an artifact URI, it should delegate to StandardTypeConverter + ExpressionProperties expressionProperties = new ExpressionProperties(); + + // StandardTypeConverter does things like convert ints to longs + String testInput = ("${new java.util.UUID(0,0).toString()}"); + Map testContext = Map.of(); + + String evaluated = + new ExpressionTransform(parserContext, parser, Function.identity()) + .transformString( + testInput, + new ExpressionsSupport(null, expressionProperties) + .buildEvaluationContext(testContext, true), + new ExpressionEvaluationSummary()); + + assertThat(evaluated).isEqualTo("00000000-0000-0000-0000-000000000000"); + } + + public class MockArtifactStore extends ArtifactStore { + public Map cache = new HashMap<>(); + + public MockArtifactStore() { + super(null, null); + } + + @Override + public Artifact store(Artifact artifact) { + return null; + } + + @Override + public Artifact get(ArtifactReferenceURI uri, ArtifactDecorator... decorators) { + String reference = cache.get(uri.uri()); + Artifact.ArtifactBuilder builder = + Artifact.builder() + .reference( + Base64.getEncoder().encodeToString(reference.getBytes(StandardCharsets.UTF_8))); + if (decorators != null) { + for (ArtifactDecorator decorator : decorators) { + builder = decorator.decorate(builder); + } + } + + return builder.build(); + } + } } diff --git a/kork-jedis-test/kork-jedis-test.gradle b/kork-jedis-test/kork-jedis-test.gradle index f47572afd..68aa64cef 100644 --- a/kork-jedis-test/kork-jedis-test.gradle +++ b/kork-jedis-test/kork-jedis-test.gradle @@ -4,5 +4,5 @@ dependencies { api(platform(project(":spinnaker-dependencies"))) api "redis.clients:jedis" - api "io.spinnaker.embedded-redis:embedded-redis" + implementation "org.testcontainers:testcontainers" } diff --git a/kork-jedis-test/src/main/java/com/netflix/spinnaker/kork/jedis/EmbeddedRedis.java b/kork-jedis-test/src/main/java/com/netflix/spinnaker/kork/jedis/EmbeddedRedis.java index 2f6c4fd48..b98400382 100644 --- a/kork-jedis-test/src/main/java/com/netflix/spinnaker/kork/jedis/EmbeddedRedis.java +++ b/kork-jedis-test/src/main/java/com/netflix/spinnaker/kork/jedis/EmbeddedRedis.java @@ -1,32 +1,23 @@ package com.netflix.spinnaker.kork.jedis; -import java.io.IOException; -import java.net.ServerSocket; -import java.net.URI; -import java.net.URISyntaxException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; -import redis.clients.jedis.util.Pool; -import redis.embedded.RedisServer; public class EmbeddedRedis implements AutoCloseable { - private final URI connection; - private final RedisServer redisServer; + private static final int REDIS_PORT = 6379; - private Pool jedis; + private final GenericContainer redisContainer; - private EmbeddedRedis(int port) throws IOException, URISyntaxException { - this.connection = URI.create(String.format("redis://127.0.0.1:%d/0", port)); - this.redisServer = - RedisServer.builder() - .port(port) - .setting("bind 127.0.0.1") - .setting("appendonly no") - .setting("save \"\"") - .setting("databases 1") - .build(); - this.redisServer.start(); + private JedisPool jedis; + + private EmbeddedRedis() { + redisContainer = + new GenericContainer<>(DockerImageName.parse("library/redis:5-alpine")) + .withExposedPorts(REDIS_PORT); + redisContainer.start(); } @Override @@ -35,20 +26,20 @@ public void close() { } public void destroy() { - try { - this.redisServer.stop(); - } catch (Exception e) { - throw new RuntimeException(e); - } + redisContainer.stop(); + } + + public String getHost() { + return redisContainer.getHost(); } public int getPort() { - return redisServer.ports().get(0); + return redisContainer.getMappedPort(REDIS_PORT); } - public Pool getPool() { + public JedisPool getPool() { if (jedis == null) { - jedis = new JedisPool(connection); + jedis = new JedisPool(getHost(), getPort()); } return jedis; } @@ -58,13 +49,6 @@ public Jedis getJedis() { } public static EmbeddedRedis embed() { - try { - ServerSocket serverSocket = new ServerSocket(0); - int port = serverSocket.getLocalPort(); - serverSocket.close(); - return new EmbeddedRedis(port); - } catch (IOException | URISyntaxException e) { - throw new RuntimeException("Failed to create embedded Redis", e); - } + return new EmbeddedRedis(); } } diff --git a/kork-jedis/kork-jedis.gradle b/kork-jedis/kork-jedis.gradle index d59905b4c..0d6703cc1 100644 --- a/kork-jedis/kork-jedis.gradle +++ b/kork-jedis/kork-jedis.gradle @@ -17,7 +17,8 @@ dependencies { testImplementation project(":kork-jedis-test") testImplementation "org.mockito:mockito-core" testImplementation "org.spockframework:spock-core" - testImplementation "junit:junit" + testImplementation "org.junit.jupiter:junit-jupiter-api" testRuntimeOnly "cglib:cglib-nodep" testRuntimeOnly "org.objenesis:objenesis" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } diff --git a/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/RedisClientConnectionPropertiesTest.java b/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/RedisClientConnectionPropertiesTest.java index 3ae3c0ec3..810ff0bd7 100644 --- a/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/RedisClientConnectionPropertiesTest.java +++ b/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/RedisClientConnectionPropertiesTest.java @@ -17,13 +17,13 @@ package com.netflix.spinnaker.kork.jedis; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URI; -import org.junit.Test; +import org.junit.jupiter.api.Test; import redis.clients.jedis.Protocol; public class RedisClientConnectionPropertiesTest { diff --git a/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/telemetry/InstrumentedJedisPoolTest.java b/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/telemetry/InstrumentedJedisPoolTest.java index 44957ace3..d753bc99b 100644 --- a/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/telemetry/InstrumentedJedisPoolTest.java +++ b/kork-jedis/src/test/java/com/netflix/spinnaker/kork/jedis/telemetry/InstrumentedJedisPoolTest.java @@ -16,11 +16,11 @@ package com.netflix.spinnaker.kork.jedis.telemetry; -import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.mock; import com.netflix.spectator.api.Registry; -import org.junit.Test; +import org.junit.jupiter.api.Test; import redis.clients.jedis.JedisPool; public class InstrumentedJedisPoolTest { diff --git a/kork-moniker/kork-moniker.gradle b/kork-moniker/kork-moniker.gradle index 1d1fdbec5..40e521307 100644 --- a/kork-moniker/kork-moniker.gradle +++ b/kork-moniker/kork-moniker.gradle @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +apply plugin:"groovy" dependencies { api(platform(project(":spinnaker-dependencies"))) @@ -23,6 +24,5 @@ dependencies { testImplementation "org.spockframework:spock-core" testImplementation "org.junit.jupiter:junit-jupiter-api" - testRuntimeOnly "org.junit.vintage:junit-vintage-engine" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } diff --git a/kork-plugins-spring-api/kork-plugins-spring-api.gradle b/kork-plugins-spring-api/kork-plugins-spring-api.gradle index 30afa4da4..202d2464c 100644 --- a/kork-plugins-spring-api/kork-plugins-spring-api.gradle +++ b/kork-plugins-spring-api/kork-plugins-spring-api.gradle @@ -18,7 +18,7 @@ apply plugin: "java-library" apply from: "${project.rootDir}/gradle/kotlin-test.gradle" dependencies { - implementation(platform(project(":spinnaker-dependencies"))) + api(platform(project(":spinnaker-dependencies"))) api project(":kork-plugins-api") api "org.springframework.boot:spring-boot-starter-web" diff --git a/kork-plugins-tck/kork-plugins-tck.gradle b/kork-plugins-tck/kork-plugins-tck.gradle index c03fe410e..3ad9111fb 100644 --- a/kork-plugins-tck/kork-plugins-tck.gradle +++ b/kork-plugins-tck/kork-plugins-tck.gradle @@ -19,7 +19,6 @@ dependencies { testRuntimeOnly "cglib:cglib-nodep" testRuntimeOnly "org.objenesis:objenesis" - testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } diff --git a/kork-plugins-tck/src/test/kotlin/com/spinnaker/netflix/kork/plugins/TestPluginGeneratorTest.kt b/kork-plugins-tck/src/test/kotlin/com/spinnaker/netflix/kork/plugins/TestPluginGeneratorTest.kt index 228ae6847..c17b0bef5 100644 --- a/kork-plugins-tck/src/test/kotlin/com/spinnaker/netflix/kork/plugins/TestPluginGeneratorTest.kt +++ b/kork-plugins-tck/src/test/kotlin/com/spinnaker/netflix/kork/plugins/TestPluginGeneratorTest.kt @@ -38,16 +38,6 @@ class TestPluginGeneratorTest : JUnit5Minutests { expectThat(resolve("classes")).describedAs("classes directory").isDirectory() } - test("extensions index is written to META-INF") { - expectThat(resolve("classes/META-INF")).and { - isDirectory() - get { resolve("extensions.idx") }.and { - isRegularFile() - get { toFile().readText() }.isEqualTo("# Generated by PF4J\n") - } - } - } - test("generated class is written to subdirectories matching package") { expectThat(resolve("classes/com/netflix/spinnaker/kork/plugins/testplugin/generated")).and { isDirectory() diff --git a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/http/OkHttpRemoteExtensionTransport.kt b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/http/OkHttpRemoteExtensionTransport.kt index 7a9141650..6760f2ef7 100644 --- a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/http/OkHttpRemoteExtensionTransport.kt +++ b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/http/OkHttpRemoteExtensionTransport.kt @@ -29,10 +29,11 @@ import com.netflix.spinnaker.kork.plugins.remote.extension.transport.RemoteExten import com.netflix.spinnaker.security.AuthenticatedRequest import okhttp3.Headers import okhttp3.HttpUrl -import okhttp3.MediaType +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody /** * An HTTP [RemoteExtensionTransport], OkHttp for the client. @@ -55,16 +56,15 @@ class OkHttpRemoteExtensionTransport( .url(url) .headers(buildHeaders(httpConfig.headers.invokeHeaders)) .post( - RequestBody.create( - MediaType.parse("application/json"), - objectMapper.writeValueAsString(remoteExtensionPayload) + objectMapper.writeValueAsString(remoteExtensionPayload).toRequestBody( + ("application/json").toMediaType() ) ) .build() val response = client.newCall(request).execute() if (!response.isSuccessful) { - val reason = response.body()?.string() ?: "Unknown reason: ${response.code()}" + val reason = response.body?.string() ?: "Unknown reason: ${response.code}" throw OkHttpRemoteExtensionTransportException(reason) } }.call() @@ -76,20 +76,19 @@ class OkHttpRemoteExtensionTransport( .url(url) .headers(buildHeaders(httpConfig.headers.writeHeaders)) .post( - RequestBody.create( - MediaType.parse("application/json"), - objectMapper.writeValueAsString(remoteExtensionPayload) + objectMapper.writeValueAsString(remoteExtensionPayload).toRequestBody( + ("application/json").toMediaType() ) ) .build() val response = client.newCall(request).execute() if (!response.isSuccessful) { - val reason = response.body()?.string() ?: "Unknown reason: ${response.code()}" + val reason = response.body?.string() ?: "Unknown reason: ${response.code}" throw OkHttpRemoteExtensionTransportException(reason) } - objectMapper.readValue(response.body()?.string(), RemoteExtensionResponse::class.java) + objectMapper.readValue(response.body?.string(), RemoteExtensionResponse::class.java) }.call() } @@ -103,16 +102,16 @@ class OkHttpRemoteExtensionTransport( val response = client.newCall(request).execute() if (!response.isSuccessful) { - val reason = response.body()?.string() ?: "Unknown reason: ${response.code()}" + val reason = response.body?.string() ?: "Unknown reason: ${response.code}" throw OkHttpRemoteExtensionTransportException(reason) } - objectMapper.readValue(response.body()?.string(), RemoteExtensionResponse::class.java) + objectMapper.readValue(response.body?.string(), RemoteExtensionResponse::class.java) }.call() } private fun buildUrl(additionalParams: Map): HttpUrl { - val httpUrlBuilder = HttpUrl.parse(httpConfig.url)?.newBuilder() + val httpUrlBuilder = httpConfig.url.toHttpUrlOrNull()?.newBuilder() ?: throw IntegrationException("Unable to parse url '${httpConfig.url}'") (httpConfig.queryParams + additionalParams).forEach { diff --git a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/repository/PluginRefPluginRepository.kt b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/repository/PluginRefPluginRepository.kt index 5ff62e71a..4534b3dcb 100644 --- a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/repository/PluginRefPluginRepository.kt +++ b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/repository/PluginRefPluginRepository.kt @@ -26,6 +26,6 @@ import org.pf4j.util.ExtensionFileFilter /** * A [PluginRepository] supporting [PluginRef] type [Plugin]s by matching files with the extension [PluginRef.EXTENSION]. */ -class PluginRefPluginRepository(pluginPath: Path) : BasePluginRepository(pluginPath, ExtensionFileFilter(PluginRef.EXTENSION)) { +class PluginRefPluginRepository(pluginPath: Path) : BasePluginRepository(listOf(pluginPath), ExtensionFileFilter(PluginRef.EXTENSION)) { override fun deletePluginPath(pluginPath: Path?): Boolean = false } diff --git a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClient.kt b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClient.kt index 97c8cf598..9be8ad6f8 100644 --- a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClient.kt +++ b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClient.kt @@ -21,11 +21,12 @@ import com.netflix.spinnaker.kork.plugins.api.httpclient.HttpClient import com.netflix.spinnaker.kork.plugins.api.httpclient.Request import com.netflix.spinnaker.kork.plugins.api.httpclient.Response import java.io.IOException -import okhttp3.Headers -import okhttp3.HttpUrl -import okhttp3.MediaType +import okhttp3.Headers.Companion.toHeaders +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.slf4j.LoggerFactory /** @@ -96,7 +97,7 @@ class Ok3HttpClient( private fun requestBuilder(request: Request): okhttp3.Request.Builder { val url = (baseUrl + request.path).replace("//", "/") - val httpUrlBuilder = HttpUrl.parse(url)?.newBuilder() + val httpUrlBuilder = url.toHttpUrlOrNull()?.newBuilder() ?: throw IntegrationException("Unable to parse url '$baseUrl'") request.queryParams.forEach { httpUrlBuilder.addQueryParameter(it.key, it.value) @@ -104,11 +105,13 @@ class Ok3HttpClient( return okhttp3.Request.Builder() .tag("$name.${request.name}") .url(httpUrlBuilder.build()) - .headers(Headers.of(request.headers)) + .headers(request.headers.toHeaders()) } private fun Request.okHttpRequestBody(): RequestBody = - RequestBody.create(MediaType.parse(contentType), objectMapper.writeValueAsString(body)) + objectMapper.writeValueAsString(body).toRequestBody( + contentType.toMediaTypeOrNull() + ) private fun okhttp3.Response.toGenericResponse(): Response { return Ok3Response( diff --git a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3Response.kt b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3Response.kt index 5d6ccc274..3f8957afc 100644 --- a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3Response.kt +++ b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3Response.kt @@ -55,11 +55,11 @@ class Ok3Response( Optional.ofNullable(exception) override fun getStatusCode(): Int = - response?.code() ?: -1 + response?.code ?: -1 override fun getHeaders(): Map = response - ?.headers() + ?.headers ?.toMultimap() ?.map { it.key to it.value.joinToString(",") } ?.toMap() @@ -71,7 +71,7 @@ class Ok3Response( */ fun finalize() { try { - response?.body()?.close() + response?.body?.close() responseBody?.close() } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { log.warn("Failed to cleanup resource", e) diff --git a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloader.kt b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloader.kt index 7c2510790..a2ef75b8b 100644 --- a/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloader.kt +++ b/kork-plugins/src/main/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloader.kt @@ -43,9 +43,9 @@ class Front50FileDownloader( val response = retry.executeCallable { okHttpClient.newCall(request).execute() } - val body = response.body() + val body = response.body if (!response.isSuccessful || body == null) { - throw NotFoundException("Plugin binary could not be downloaded, received HTTP ${response.code()}") + throw NotFoundException("Plugin binary could not be downloaded, received HTTP ${response.code}") } return downloadDir.resolve(Paths.get(fileUrl.path + binaryExtension).fileName).also { diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/PluginSecretTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/PluginSecretTest.kt index 0d8270201..18690d709 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/PluginSecretTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/PluginSecretTest.kt @@ -113,7 +113,7 @@ class PluginSecretTest : JUnit5Minutests { } @TestConfiguration - private class TestSecretEngineConfiguration { + class TestSecretEngineConfiguration { @Bean fun testSecretEngine(): SecretEngine = object : SecretEngine { override fun clearCache() = Unit diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/OkHttpRemoteExtensionTransportTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/OkHttpRemoteExtensionTransportTest.kt index 64486a33c..766198c00 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/OkHttpRemoteExtensionTransportTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/remote/extension/transport/OkHttpRemoteExtensionTransportTest.kt @@ -10,11 +10,11 @@ import dev.minutest.junit.JUnit5Minutests import dev.minutest.rootContext import io.mockk.every import io.mockk.mockk -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Response -import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import strikt.api.expectThat import strikt.assertions.isA @@ -31,7 +31,10 @@ class OkHttpRemoteExtensionTransportTest : JUnit5Minutests { .code(200) .message("OK") .header("Content-Type", "application/json") - .body(ResponseBody.create(MediaType.parse("application/json"), "{\"type\": \"readResponse\", \"foo\": \"bar\"}")) + .body(("{\"type\": \"readResponse\", \"foo\": \"bar\"}") + .toResponseBody(( + "application/json").toMediaType() + )) .build() every { client.newCall(any()).execute() } returns response @@ -47,7 +50,10 @@ class OkHttpRemoteExtensionTransportTest : JUnit5Minutests { .code(201) .message("OK") .header("Content-Type", "application/json") - .body(ResponseBody.create(MediaType.parse("application/json"), "{\"type\": \"writeResponse\", \"foo\": \"bar\"}")) + .body(("{\"type\": \"writeResponse\", \"foo\": \"bar\"}") + .toResponseBody(( + "application/json").toMediaType() + )) .build() every { client.newCall(any()).execute() } returns response diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClientTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClientTest.kt index 7613e2ba5..b59dca4b5 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClientTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3HttpClientTest.kt @@ -23,11 +23,11 @@ import dev.minutest.rootContext import io.mockk.every import io.mockk.mockk import okhttp3.Call -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Response -import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import strikt.api.expectThat import strikt.api.expectThrows import strikt.assertions.containsKey @@ -49,7 +49,7 @@ class Ok3HttpClientTest : JUnit5Minutests { .code(200) .message("OK") .header("Content-Type", "plain/text") - .body(ResponseBody.create(MediaType.parse("plain/text"), "hi")) + .body("hi".toResponseBody(("plain/text").toMediaType())) .build() every { okHttpClient.newCall(any()) } returns call diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3ResponseTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3ResponseTest.kt index 4b885c089..57a8e71df 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3ResponseTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/sdk/httpclient/Ok3ResponseTest.kt @@ -21,10 +21,10 @@ import dev.minutest.junit.JUnit5Minutests import dev.minutest.rootContext import io.mockk.mockk import java.io.IOException -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.Protocol import okhttp3.Response -import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import strikt.api.expectThat import strikt.assertions.isFalse import strikt.assertions.isTrue @@ -67,7 +67,7 @@ internal class Ok3ResponseTest : JUnit5Minutests { .message("OK") .protocol(Protocol.HTTP_1_1) .header("Content-Type", "plain/text") - .body(ResponseBody.create(MediaType.parse("plain/text"), "test")) + .body("test".toResponseBody(("plain/text").toMediaType())) .build() } } diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloaderTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloaderTest.kt index 0de4a6a26..9594e3bb0 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloaderTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/downloader/Front50FileDownloaderTest.kt @@ -21,10 +21,10 @@ import io.mockk.every import io.mockk.mockk import java.net.URL import okhttp3.Call -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Response -import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import strikt.api.expect import strikt.api.expectThat import strikt.assertions.isEqualTo @@ -40,8 +40,8 @@ class Front50FileDownloaderTest : JUnit5Minutests { test("files are downloaded to a temp directory") { every { response.isSuccessful } returns true - every { response.code() } returns 200 - every { response.body() } returns ResponseBody.create(MediaType.parse("application/zip"), "oh hi") + every { response.code } returns 200 + every { response.body } returns "oh hi".toResponseBody(("application/zip").toMediaType()) expectThat(subject.downloadFile(URL("http://front50.com/myplugin.zip"))) { get { toFile().readText() }.isEqualTo("oh hi") diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/release/source/Front50PluginInfoReleaseSourceTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/release/source/Front50PluginInfoReleaseSourceTest.kt index d049fc5bf..5beff9a32 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/release/source/Front50PluginInfoReleaseSourceTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/update/release/source/Front50PluginInfoReleaseSourceTest.kt @@ -26,8 +26,8 @@ import dev.minutest.junit.JUnit5Minutests import dev.minutest.rootContext import io.mockk.every import io.mockk.mockk -import okhttp3.MediaType -import okhttp3.ResponseBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody import retrofit2.Call import retrofit2.Response import strikt.api.expectThat @@ -61,7 +61,7 @@ class Front50PluginInfoReleaseSourceTest : JUnit5Minutests { val call: Call = mockk(relaxed = true) every { front50Service.pinVersions(eq("orca-v000"), eq("orca"), eq("us-west-2"), any()) } returns call - every { call.execute() } returns Response.error(500, ResponseBody.create(MediaType.get("application/json"), "{}")) + every { call.execute() } returns Response.error(500, ("{}").toResponseBody(("application/json").toMediaType())) subject.processReleases(releases) diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ComplexInjectionScenarioTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ComplexInjectionScenarioTest.kt index 4cb35894a..62be19eb9 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ComplexInjectionScenarioTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ComplexInjectionScenarioTest.kt @@ -115,14 +115,14 @@ class ComplexInjectionScenarioTest : JUnit5Minutests { @TestConfiguration @ComponentScan("com.netflix.spinnaker.kork.plugins.v2.scenarios.fixtures") - private class ComplexInjectionTestConfiguration { + class ComplexInjectionTestConfiguration { @Bean fun injectsPluginExtensions(testExtensions: List): InjectsPluginExtensions { return InjectsPluginExtensions(testExtensions) } } - private class InjectsPluginExtensions(val testExtensions: List) { + class InjectsPluginExtensions(val testExtensions: List) { @PostConstruct fun tryOutExtensions() { // Verify that extensions can be used immediately. diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceDependenciesScenarioTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceDependenciesScenarioTest.kt index b08a15d10..038c04ae6 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceDependenciesScenarioTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceDependenciesScenarioTest.kt @@ -78,7 +78,7 @@ class ServiceDependenciesScenarioTest : JUnit5Minutests { @Configuration @ComponentScan("com.netflix.spinnaker.kork.plugins.v2.scenarios.fixtures") - private class TestApplicationConfiguration + class TestApplicationConfiguration companion object { private val GENERATED = testPlugin { diff --git a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceInjectionScenarioTest.kt b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceInjectionScenarioTest.kt index 077a5211e..e33739366 100644 --- a/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceInjectionScenarioTest.kt +++ b/kork-plugins/src/test/kotlin/com/netflix/spinnaker/kork/plugins/v2/scenarios/ServiceInjectionScenarioTest.kt @@ -73,14 +73,14 @@ class ServiceInjectionScenarioTest : JUnit5Minutests { } @TestConfiguration - private class ServiceInjectionTestConfiguration { + class ServiceInjectionTestConfiguration { @Bean fun injectsPluginExtensions(testExtensions: List): InjectsPluginExtensions { return InjectsPluginExtensions(testExtensions) } } - private class InjectsPluginExtensions(val testExtensions: List) { + class InjectsPluginExtensions(val testExtensions: List) { @PostConstruct fun tryOutExtensions() { // Verify that extensions can be used immediately. diff --git a/kork-retrofit/kork-retrofit.gradle b/kork-retrofit/kork-retrofit.gradle index de61a8a5c..2f70bdb86 100644 --- a/kork-retrofit/kork-retrofit.gradle +++ b/kork-retrofit/kork-retrofit.gradle @@ -4,14 +4,14 @@ apply from: "$rootDir/gradle/lombok.gradle" dependencies { api(platform(project(":spinnaker-dependencies"))) + api "com.squareup.retrofit:retrofit" + api "com.squareup.retrofit2:retrofit" implementation project(":kork-web") - implementation "com.squareup.retrofit:retrofit" implementation "com.jakewharton.retrofit:retrofit1-okhttp3-client" implementation "com.squareup.retrofit:converter-jackson" implementation "io.zipkin.brave:brave-instrumentation-okhttp3" - implementation "com.squareup.retrofit2:retrofit" implementation "com.squareup.retrofit2:converter-jackson" implementation "com.squareup.okhttp3:logging-interceptor" implementation "com.google.guava:guava" diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/ErrorHandlingExecutorCallAdapterFactory.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/ErrorHandlingExecutorCallAdapterFactory.java index 37e3234eb..ec3aec12a 100644 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/ErrorHandlingExecutorCallAdapterFactory.java +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/ErrorHandlingExecutorCallAdapterFactory.java @@ -16,7 +16,8 @@ package com.netflix.spinnaker.kork.retrofit; -import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerConversionException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerNetworkException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerServerException; @@ -26,11 +27,9 @@ import java.lang.reflect.Type; import java.util.Objects; import java.util.concurrent.Executor; -import javax.annotation.Nonnull; import javax.annotation.Nullable; import okhttp3.Request; import okio.Timeout; -import org.springframework.http.HttpStatus; import retrofit2.Call; import retrofit2.CallAdapter; import retrofit2.Callback; @@ -146,29 +145,21 @@ static final class ExecutorCallbackCall implements Call { */ @Override public Response execute() { - Response syncResp; + Response syncResp = null; try { syncResp = delegate.execute(); if (syncResp.isSuccessful()) { return syncResp; } + } catch (JsonProcessingException jpe) { + throw new SpinnakerConversionException( + "Failed to process response body", jpe, delegate.request()); } catch (IOException e) { - throw new SpinnakerNetworkException(RetrofitException.networkError(e)); + throw new SpinnakerNetworkException(e, delegate.request()); } catch (Exception e) { - throw new SpinnakerServerException(RetrofitException.unexpectedError(e)); + throw new SpinnakerServerException(e, delegate.request()); } - throw createSpinnakerHttpException(syncResp); - } - - @Nonnull - private SpinnakerHttpException createSpinnakerHttpException(Response response) { - SpinnakerHttpException retval = - new SpinnakerHttpException(RetrofitException.httpError(response, retrofit)); - if ((response.code() == HttpStatus.NOT_FOUND.value()) - || (response.code() == HttpStatus.BAD_REQUEST.value())) { - retval.setRetryable(false); - } - return retval; + throw new SpinnakerHttpException(syncResp, retrofit); } /** @@ -249,7 +240,7 @@ public void run() { public void run() { callback.onFailure( executorCallbackCall, - executorCallbackCall.createSpinnakerHttpException(response)); + new SpinnakerHttpException(response, executorCallbackCall.retrofit)); } }); } @@ -260,11 +251,11 @@ public void onFailure(Call call, final Throwable t) { SpinnakerServerException exception; if (t instanceof IOException) { - exception = new SpinnakerNetworkException(RetrofitException.networkError((IOException) t)); + exception = new SpinnakerNetworkException(t, call.request()); } else if (t instanceof SpinnakerHttpException) { exception = (SpinnakerHttpException) t; } else { - exception = new SpinnakerServerException(RetrofitException.unexpectedError(t)); + exception = new SpinnakerServerException(t, call.request()); } final SpinnakerServerException finalException = exception; callbackExecutor.execute( diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/Retrofit2SyncCall.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/Retrofit2SyncCall.java index 295df3115..189ef1018 100644 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/Retrofit2SyncCall.java +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/Retrofit2SyncCall.java @@ -16,7 +16,6 @@ package com.netflix.spinnaker.kork.retrofit; -import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerNetworkException; import java.io.IOException; import retrofit2.Call; @@ -34,7 +33,7 @@ public static T execute(Call call) { try { return call.execute().body(); } catch (IOException e) { - throw new SpinnakerNetworkException(RetrofitException.networkError(e)); + throw new SpinnakerNetworkException(e, call.request()); } } } diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/RetrofitException.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/RetrofitException.java deleted file mode 100644 index 9dcf74946..000000000 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/RetrofitException.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2023 OpsMx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * 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. - */ - -package com.netflix.spinnaker.kork.retrofit.exceptions; - -import java.io.IOException; -import java.lang.annotation.Annotation; -import okhttp3.ResponseBody; -import retrofit2.Converter; -import retrofit2.Response; -import retrofit2.Retrofit; - -/** - * The {@link RetrofitException} class is similar to {@link retrofit.RetrofitError} as RetrofitError - * class is removed in retrofit2. To handle the exception globally and achieve similar logic as - * retrofit in retrofit2, this exception used along with {@link - * com.netflix.spinnaker.kork.retrofit.ErrorHandlingExecutorCallAdapterFactory}. - */ -public class RetrofitException extends RuntimeException { - public static RetrofitException httpError(Response response, Retrofit retrofit) { - String message = response.code() + " " + response.message(); - return new RetrofitException(message, response, null, retrofit); - } - - public static RetrofitException networkError(IOException exception) { - return new RetrofitException(exception.getMessage(), null, exception, null); - } - - public static RetrofitException unexpectedError(Throwable exception) { - return new RetrofitException(exception.getMessage(), null, exception, null); - } - - /** Response from server, which contains causes for the failure */ - private final Response response; - - /** - * Client used while the service creation, which has convertor logic to be used to parse the - * response - */ - private final Retrofit retrofit; - - RetrofitException(String message, Response response, Throwable exception, Retrofit retrofit) { - super(message, exception); - this.response = response; - this.retrofit = retrofit; - } - - /** Response object containing status code, headers, body, etc. */ - public Response getResponse() { - return response; - } - - /** - * HTTP response body converted to specified {@code type}. {@code null} if there is no response. - * - * @throws RuntimeException wrapping the underlying IOException if unable to convert the body to - * the specified {@code type}. - */ - public T getErrorBodyAs(Class type) { - if (response == null || response.errorBody() == null) { - return null; - } - Converter converter = retrofit.responseBodyConverter(type, new Annotation[0]); - try { - return converter.convert(response.errorBody()); - } catch (IOException e) { - throw new RuntimeException(e); - } - } -} diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerConversionException.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerConversionException.java new file mode 100644 index 000000000..a8a5bebfa --- /dev/null +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerConversionException.java @@ -0,0 +1,53 @@ +/* + * Copyright 2023 OpsMx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.retrofit.exceptions; + +import okhttp3.Request; +import retrofit.RetrofitError; + +/** Wraps an exception converting a successful retrofit http response body to its indicated type */ +public class SpinnakerConversionException extends SpinnakerServerException { + + /** + * Construct a SpinnakerServerException from retrofit2 with a message and cause (e.g. an exception + * converting a response to the specified type). + */ + public SpinnakerConversionException(String message, Throwable cause, Request request) { + super(message, cause, request); + setRetryable(false); + } + + /** + * Construct a SpinnakerConversionException from another SpinnakerConversionException (e.g. via + * newInstance). + */ + public SpinnakerConversionException(String message, SpinnakerConversionException cause) { + super(message, cause); + setRetryable(false); + } + + /** Construct a SpinnakerConversionException corresponding to a RetrofitError. */ + public SpinnakerConversionException(RetrofitError e) { + super(e); + setRetryable(false); + } + + @Override + public SpinnakerConversionException newInstance(String message) { + return new SpinnakerConversionException(message, this); + } +} diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpException.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpException.java index c3ebb0388..500e93d30 100644 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpException.java +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpException.java @@ -17,34 +17,118 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; import com.google.common.base.Preconditions; -import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.kork.annotations.NullableByDefault; +import java.lang.annotation.Annotation; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import okhttp3.ResponseBody; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import retrofit.RetrofitError; import retrofit.client.Response; +import retrofit2.Converter; +import retrofit2.Retrofit; /** * An exception that exposes the {@link Response} of a given HTTP {@link RetrofitError} or {@link - * okhttp3.Response} of a {@link RetrofitException} if retrofit 2.x used and a detail message that - * extracts useful information from the {@link Response} or {@link okhttp3.Response}. Both {@link - * Response} and {@link okhttp3.Response} can't be set together.. + * okhttp3.Response} if retrofit 2.x used and a detail message that extracts useful information from + * the {@link Response} or {@link okhttp3.Response}. Both {@link Response} and {@link + * okhttp3.Response} can't be set together. */ -@NonnullByDefault +@NullableByDefault +@Slf4j public class SpinnakerHttpException extends SpinnakerServerException { + private final Response response; + private HttpHeaders headers; - private final retrofit2.Response retrofit2Response; + private final retrofit2.Response retrofit2Response; + + /** + * A message derived from a RetrofitError's response body, or null if a custom message has been + * provided. + */ + private final String rawMessage; + + private final Map responseBody; + + private final int responseCode; + + /** + * The reason from the http response. See + * https://datatracker.ietf.org/doc/html/rfc2616#section-6.1 + */ + private final String reason; + /** Construct a SpinnakerHttpException corresponding to a RetrofitError. */ public SpinnakerHttpException(RetrofitError e) { super(e); + + // Arbitrary RetrofitErrors can have a null Response object (e.g. see + // RetrofitError.networkError). But, given that RetrofitError.httpError + // assumes a non-null Response, let's do the same in SpinnakerHttpException. + Objects.requireNonNull(e.getResponse(), "SpinnakerHttpException requires a Response object"); + this.response = e.getResponse(); this.retrofit2Response = null; + + String tmpMessage = null; + Map body = null; + try { + body = (Map) e.getBodyAs(HashMap.class); + } catch (Exception responseBodyException) { + // This is only an error if the mime type indicates json, but then it's + // not spinnaker's error, it's an arguably malformed http response. It's + // potentially interesting to log, but...what to include in the log + // message? We've already (likely) read the response body once, so unless + // we copy it ahead of time, we can't depend on being able to e.g. attempt + // to convert it to a String and use that in a log message (or potentially + // even in message for this exception. That seems like a lot to do for + // malformed json, and even for non-json responses (e.g. html). So, don't + // try to log anything from the response body itself. + log.debug( + "unable to convert response to map ({}, {})", + e.getUrl(), + e.getMessage(), + responseBodyException); + } + responseBody = body; + if (responseBody != null) { + tmpMessage = (String) responseBody.get("message"); + } + responseCode = response.getStatus(); + reason = response.getReason(); + rawMessage = tmpMessage != null ? tmpMessage : reason; } - public SpinnakerHttpException(RetrofitException e) { - super(e); + /** + * The constructor handles the HTTP retrofit2 exception, similar to retrofit logic. It is used + * with {@link com.netflix.spinnaker.kork.retrofit.ErrorHandlingExecutorCallAdapterFactory}. + */ + public SpinnakerHttpException( + retrofit2.Response retrofit2Response, retrofit2.Retrofit retrofit) { + super(retrofit2Response.raw().request()); this.response = null; - this.retrofit2Response = e.getResponse(); + this.retrofit2Response = retrofit2Response; + if ((retrofit2Response.code() == HttpStatus.NOT_FOUND.value()) + || (retrofit2Response.code() == HttpStatus.BAD_REQUEST.value())) { + setRetryable(false); + } + responseBody = this.getErrorBodyAs(retrofit); + responseCode = retrofit2Response.code(); + reason = retrofit2Response.message(); + this.rawMessage = + responseBody != null + ? (String) responseBody.getOrDefault("message", retrofit2Response.message()) + : retrofit2Response.message(); + } + + private String getRawMessage() { + return rawMessage; } /** @@ -71,16 +155,17 @@ public SpinnakerHttpException(String message, SpinnakerHttpException cause) { this.response = cause.response; this.retrofit2Response = cause.retrofit2Response; + rawMessage = null; + this.responseBody = cause.responseBody; + this.responseCode = cause.responseCode; + this.reason = cause.reason; } public int getResponseCode() { - if (response != null) { - return response.getStatus(); - } else { - return retrofit2Response.code(); - } + return responseCode; } + @Nonnull public HttpHeaders getHeaders() { if (headers == null) { headers = new HttpHeaders(); @@ -109,16 +194,52 @@ public String getMessage() { return super.getMessage(); } - if (retrofit2Response != null) { + if (getHttpMethod() == null) { return String.format( - "Status: %s, URL: %s, Message: %s", + "Status: %s, URL: %s, Message: %s", responseCode, this.getUrl(), getRawMessage()); + } + + return String.format( + "Status: %s, Method: %s, URL: %s, Message: %s", + responseCode, getHttpMethod(), this.getUrl(), getRawMessage()); + } + + @Override + public SpinnakerHttpException newInstance(String message) { + return new SpinnakerHttpException(message, this); + } + + public Map getResponseBody() { + return this.responseBody; + } + + public String getReason() { + return this.reason; + } + + /** + * HTTP error response body converted to the specified {@code type}. + * + * @return null if there's no response or unable to convert the body to the specified {@code + * type}. + */ + private Map getErrorBodyAs(Retrofit retrofit) { + if (retrofit2Response == null) { + return null; + } + + Converter converter = + retrofit.responseBodyConverter(Map.class, new Annotation[0]); + try { + return converter.convert(retrofit2Response.errorBody()); + } catch (Exception e) { + log.debug( + "unable to convert response to map ({} {}, {})", + retrofit2Response.raw().request().method(), retrofit2Response.code(), - retrofit2Response.raw().request().url().toString(), - getRawMessage()); - } else { - return String.format( - "Status: %s, URL: %s, Message: %s", - response.getStatus(), response.getUrl(), getRawMessage()); + retrofit2Response.raw().request().url(), + e); + return null; } } } diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerNetworkException.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerNetworkException.java index 0c3b4834a..febf466e0 100644 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerNetworkException.java +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerNetworkException.java @@ -17,16 +17,36 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import okhttp3.Request; import retrofit.RetrofitError; -/** Wraps an exception of kind {@link RetrofitError.Kind} NETWORK. */ +/** Represents a network error while attempting to execute a retrofit http client request. */ @NonnullByDefault public final class SpinnakerNetworkException extends SpinnakerServerException { + + /** + * Construct a SpinnakerNetworkException from retrofit2 with a cause (e.g. an exception sending a + * request or processing a response). + */ + public SpinnakerNetworkException(Throwable cause, Request request) { + super(cause, request); + } + + /** + * Construct a SpinnakerNetworkException from another SpinnakerNetworkException (e.g. via + * newInstance). + */ + public SpinnakerNetworkException(String message, SpinnakerNetworkException cause) { + super(message, cause); + } + + /** Construct a SpinnakerNetworkException corresponding to a RetrofitError. */ public SpinnakerNetworkException(RetrofitError e) { super(e); } - public SpinnakerNetworkException(RetrofitException e) { - super(e); + @Override + public SpinnakerNetworkException newInstance(String message) { + return new SpinnakerNetworkException(message, this); } } diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandler.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandler.java index b03c54582..ca03cb643 100644 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandler.java +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandler.java @@ -17,6 +17,8 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import java.util.function.Function; import org.springframework.http.HttpStatus; import retrofit.ErrorHandler; import retrofit.RetrofitError; @@ -58,8 +60,30 @@ public Throwable handleError(RetrofitError e) { return retval; case NETWORK: return new SpinnakerNetworkException(e); + case CONVERSION: + return new SpinnakerConversionException(e); default: return new SpinnakerServerException(e); } } + + /** + * For SpinnakerExceptions, return a new exception of the same type with the return value of + * handleError as its cause, with a message from messageBuilder. When handleError returns + * something other than SpinnakerException, this method is a no-op. + * + * @param e The {@link RetrofitError} thrown by an invocation of the {@link retrofit.RestAdapter} + * @param messageBuilder A function which takes in the throwable created by the handler, and + * outputs an error message string. The error message string is passed to a new exception + * which is then returned. This provides a mechanism to customize the string on the end + * throwable. + * @return + */ + public Throwable handleError(RetrofitError e, Function messageBuilder) { + Throwable exception = handleError(e); + if (exception instanceof SpinnakerException) { + return ((SpinnakerException) exception).newInstance(messageBuilder.apply(exception)); + } + return exception; + } } diff --git a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerException.java b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerException.java index eda2488be..2f6e4e6b2 100644 --- a/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerException.java +++ b/kork-retrofit/src/main/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerException.java @@ -16,85 +16,68 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; import com.netflix.spinnaker.kork.exceptions.SpinnakerException; -import java.util.Optional; import lombok.Getter; +import okhttp3.Request; import retrofit.RetrofitError; -/** An exception that exposes the message of a {@link RetrofitError}, or a custom message. */ +/** Represents an error while attempting to execute a retrofit http client request. */ @NonnullByDefault public class SpinnakerServerException extends SpinnakerException { + @Getter private final String url; + @Getter private final String httpMethod; + + /** Construct a SpinnakerServerException corresponding to a RetrofitError. */ + public SpinnakerServerException(RetrofitError e) { + super(e.getMessage(), e.getCause()); + url = e.getUrl(); + httpMethod = null; + } + /** - * A message derived from a RetrofitError's response body, or null if a custom message has been - * provided. + * Construct a SpinnakerServerException from retrofit2 with no cause (e.g. a non-200 http + * response). */ - private final String rawMessage; + public SpinnakerServerException(Request request) { + super(); + url = request.url().toString(); + httpMethod = request.method(); + } /** - * Parses the message from the {@link RetrofitErrorResponseBody} of a {@link RetrofitError}. - * - * @param e The {@link RetrofitError} thrown by an invocation of the {@link retrofit.RestAdapter} + * Construct a SpinnakerServerException from retrofit2 with a cause (e.g. an exception sending a + * request or processing a response). */ - public SpinnakerServerException(RetrofitError e) { - super(e.getCause()); - RetrofitErrorResponseBody body = - (RetrofitErrorResponseBody) e.getBodyAs(RetrofitErrorResponseBody.class); - this.rawMessage = - Optional.ofNullable(body).map(RetrofitErrorResponseBody::getMessage).orElse(e.getMessage()); + public SpinnakerServerException(Throwable cause, Request request) { + super(cause); + this.url = request.url().toString(); + this.httpMethod = request.method(); } - public SpinnakerServerException(RetrofitException e) { - super(e.getCause()); - RetrofitErrorResponseBody body = - (RetrofitErrorResponseBody) e.getErrorBodyAs(RetrofitErrorResponseBody.class); - this.rawMessage = - Optional.ofNullable(body).map(RetrofitErrorResponseBody::getMessage).orElse(e.getMessage()); + /** + * Construct a SpinnakerServerException from retrofit2 with a message and cause (e.g. an exception + * converting a response to the specified type). + */ + public SpinnakerServerException(String message, Throwable cause, Request request) { + super(message, cause); + this.url = request.url().toString(); + this.httpMethod = request.method(); } /** - * Construct a SpinnakerServerException with a specified message, instead of deriving one from a - * response body. - * - * @param message the message - * @param cause the cause. Note that this is required (i.e. can't be null) since in the absence of - * a cause or a RetrofitError that provides the cause, SpinnakerServerException is likely not - * the appropriate exception class to use. + * Construct a SpinnakerServerException from another SpinnakerServerException (e.g. via + * newInstance). */ - public SpinnakerServerException(String message, Throwable cause) { + public SpinnakerServerException(String message, SpinnakerServerException cause) { super(message, cause); - rawMessage = null; + this.url = cause.getUrl(); + this.httpMethod = cause.getHttpMethod(); } @Override - public String getMessage() { - if (rawMessage == null) { - return super.getMessage(); - } - return rawMessage; - } - - final String getRawMessage() { - return rawMessage; - } - - @Getter - // Use JsonIgnoreProperties because some responses contain properties that - // cannot be mapped to the RetrofitErrorResponseBody class. If the default - // JacksonConverter (with no extra configurations) is used to deserialize the - // response body and properties other than "message" exist in the JSON - // response, there will be an UnrecognizedPropertyException. - @JsonIgnoreProperties(ignoreUnknown = true) - private static final class RetrofitErrorResponseBody { - private final String message; - - @JsonCreator - RetrofitErrorResponseBody(@JsonProperty("message") String message) { - this.message = message; - } + public SpinnakerServerException newInstance(String message) { + return new SpinnakerServerException(message, this); } } diff --git a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/Retrofit2SyncCallTest.java b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/Retrofit2SyncCallTest.java index e6c1ce527..b622734d0 100644 --- a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/Retrofit2SyncCallTest.java +++ b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/Retrofit2SyncCallTest.java @@ -16,38 +16,45 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; -import static org.junit.Assert.assertThrows; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall; import java.io.IOException; +import okhttp3.HttpUrl; +import okhttp3.Request; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import retrofit2.Call; import retrofit2.Response; -public class Retrofit2SyncCallTest { +class Retrofit2SyncCallTest { @Test - public void testExecuteSuccss() throws IOException { - Call mockcall = Mockito.mock(Call.class); - when(mockcall.execute()).thenReturn(Response.success("testing")); - String execute = Retrofit2SyncCall.execute(mockcall); - assertEquals("testing", execute); + void testExecuteSuccess() throws IOException { + Call mockCall = mock(Call.class); + String responseBody = "testing"; + when(mockCall.execute()).thenReturn(Response.success(responseBody)); + String execute = Retrofit2SyncCall.execute(mockCall); + assertThat(execute).isEqualTo(responseBody); } @Test - public void testExecuteThrowException() throws IOException { - Call mockcall = Mockito.mock(Call.class); + void testExecuteThrowException() throws IOException { + Call mockCall = mock(Call.class); IOException ioException = new IOException("exception test"); - when(mockcall.execute()).thenThrow(ioException); - SpinnakerNetworkException networkEx = - assertThrows( - SpinnakerNetworkException.class, - () -> { - Retrofit2SyncCall.execute(mockcall); - }); - assertEquals(ioException, networkEx.getCause()); + when(mockCall.execute()).thenThrow(ioException); + + HttpUrl url = HttpUrl.parse("http://arbitrary-url"); + Request mockRequest = mock(Request.class); + when(mockCall.request()).thenReturn(mockRequest); + when(mockRequest.url()).thenReturn(url); + + SpinnakerNetworkException thrown = + catchThrowableOfType( + () -> Retrofit2SyncCall.execute(mockCall), SpinnakerNetworkException.class); + assertThat(thrown).hasCause(ioException); + assertThat(thrown.getUrl()).isEqualTo(url.toString()); } } diff --git a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpExceptionTest.java b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpExceptionTest.java new file mode 100644 index 000000000..80fea2ceb --- /dev/null +++ b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerHttpExceptionTest.java @@ -0,0 +1,151 @@ +/* + * Copyright 2023 OpsMx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + * + */ + +package com.netflix.spinnaker.kork.retrofit.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import com.google.gson.Gson; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import okhttp3.MediaType; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import retrofit.RetrofitError; +import retrofit.client.Response; +import retrofit.converter.GsonConverter; +import retrofit.mime.TypedString; +import retrofit2.Retrofit; +import retrofit2.converter.jackson.JacksonConverterFactory; + +class SpinnakerHttpExceptionTest { + private static final String CUSTOM_MESSAGE = "custom message"; + + @Test + void testSpinnakerHttpExceptionFromRetrofitError() { + String url = "http://localhost"; + int statusCode = 200; + String message = "arbitrary message"; + String reason = "reason"; + Response response = + new Response( + url, + statusCode, + reason, + List.of(), + new TypedString("{ message: \"" + message + "\", name: \"test\" }")); + RetrofitError retrofitError = + RetrofitError.httpError(url, response, new GsonConverter(new Gson()), String.class); + SpinnakerHttpException spinnakerHttpException = new SpinnakerHttpException(retrofitError); + assertThat(spinnakerHttpException.getResponseBody()).isNotNull(); + Map errorResponseBody = spinnakerHttpException.getResponseBody(); + assertThat(errorResponseBody.get("name")).isEqualTo("test"); + assertThat(spinnakerHttpException.getResponseCode()).isEqualTo(statusCode); + assertThat(spinnakerHttpException.getMessage()) + .isEqualTo("Status: " + statusCode + ", URL: " + url + ", Message: " + message); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(url); + assertThat(spinnakerHttpException.getReason()).isEqualTo(reason); + assertThat(spinnakerHttpException.getHttpMethod()).isNull(); + } + + @Test + void testSpinnakerHttpExceptionFromRetrofitException() { + final String validJsonResponseBodyString = "{\"name\":\"test\"}"; + ResponseBody responseBody = + ResponseBody.create( + MediaType.parse("application/json" + "; charset=utf-8"), validJsonResponseBodyString); + retrofit2.Response response = + retrofit2.Response.error(HttpStatus.NOT_FOUND.value(), responseBody); + + String url = "http://localhost/"; + Retrofit retrofit2Service = + new Retrofit.Builder() + .baseUrl(url) + .addConverterFactory(JacksonConverterFactory.create()) + .build(); + assertThat(retrofit2Service.baseUrl().toString()).isEqualTo(url); + SpinnakerHttpException notFoundException = + new SpinnakerHttpException(response, retrofit2Service); + assertThat(notFoundException.getResponseBody()).isNotNull(); + assertThat(notFoundException.getUrl()).isEqualTo(url); + assertThat(notFoundException.getReason()) + .isEqualTo("Response.error()"); // set by Response.error + Map errorResponseBody = notFoundException.getResponseBody(); + assertThat(errorResponseBody.get("name")).isEqualTo("test"); + assertThat(notFoundException.getResponseCode()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(notFoundException) + .hasMessageContaining(String.valueOf(HttpStatus.NOT_FOUND.value())); + assertThat(notFoundException.getHttpMethod()).isEqualTo(HttpMethod.GET.toString()); + } + + @Test + void testSpinnakerHttpException_NewInstance() { + String url = "http://localhost"; + String reason = "reason"; + Response response = new Response(url, 200, reason, List.of(), null); + try { + RetrofitError error = RetrofitError.httpError(url, response, null, null); + throw new SpinnakerHttpException(error); + } catch (SpinnakerException e) { + SpinnakerException newException = e.newInstance(CUSTOM_MESSAGE); + + assertThat(newException).isInstanceOf(SpinnakerHttpException.class); + assertThat(newException).hasMessage(CUSTOM_MESSAGE); + assertThat(newException).hasCause(e); + assertThat(((SpinnakerHttpException) newException).getResponseCode()) + .isEqualTo(response.getStatus()); + SpinnakerHttpException spinnakerHttpException = (SpinnakerHttpException) newException; + assertThat(spinnakerHttpException.getUrl()).isEqualTo(url); + assertThat(spinnakerHttpException.getReason()).isEqualTo(reason); + } + } + + @Test + void testNullResponse() { + RetrofitError retrofitError = + RetrofitError.networkError("http://some-url", new IOException("arbitrary exception")); + assertThat(retrofitError.getResponse()).isNull(); + + Throwable thrown = catchThrowable(() -> new SpinnakerHttpException(retrofitError)); + + assertThat(thrown).isInstanceOf(NullPointerException.class); + assertThat(thrown.getMessage()).isNotNull(); + } + + @Test + void testNonJsonErrorResponse() { + String url = "http://localhost"; + int statusCode = 500; + String reason = "reason"; + String body = "non-json response"; + Response response = new Response(url, statusCode, reason, List.of(), new TypedString(body)); + RetrofitError retrofitError = + RetrofitError.httpError(url, response, new GsonConverter(new Gson()), String.class); + SpinnakerHttpException spinnakerHttpException = new SpinnakerHttpException(retrofitError); + assertThat(spinnakerHttpException.getResponseBody()).isNull(); + assertThat(spinnakerHttpException.getResponseCode()).isEqualTo(statusCode); + assertThat(spinnakerHttpException.getMessage()) + .isEqualTo("Status: " + statusCode + ", URL: " + url + ", Message: " + reason); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(url); + assertThat(spinnakerHttpException.getReason()).isEqualTo(reason); + } +} diff --git a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofit2ErrorHandleTest.java b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofit2ErrorHandleTest.java index 550e238b4..bc294dc48 100644 --- a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofit2ErrorHandleTest.java +++ b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofit2ErrorHandleTest.java @@ -16,12 +16,8 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; import com.fasterxml.jackson.databind.ObjectMapper; import com.netflix.spinnaker.kork.retrofit.ErrorHandlingExecutorCallAdapterFactory; @@ -35,12 +31,13 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import retrofit2.Call; import retrofit2.Retrofit; import retrofit2.converter.jackson.JacksonConverterFactory; -public class SpinnakerRetrofit2ErrorHandleTest { +class SpinnakerRetrofit2ErrorHandleTest { private static Retrofit2Service retrofit2Service; @@ -48,8 +45,10 @@ public class SpinnakerRetrofit2ErrorHandleTest { private static String responseBodyString; + private static String baseUrl = mockWebServer.url("/").toString(); + @BeforeAll - public static void setupOnce() throws Exception { + static void setupOnce() throws Exception { Map responseBodyMap = new HashMap<>(); responseBodyMap.put("timestamp", "123123123123"); @@ -58,7 +57,7 @@ public static void setupOnce() throws Exception { retrofit2Service = new Retrofit.Builder() - .baseUrl(mockWebServer.url("/").toString()) + .baseUrl(baseUrl) .client( new OkHttpClient.Builder() .callTimeout(1, TimeUnit.SECONDS) @@ -71,61 +70,66 @@ public static void setupOnce() throws Exception { } @AfterAll - public static void shutdownOnce() throws Exception { + static void shutdownOnce() throws Exception { mockWebServer.shutdown(); } @Test - public void testRetrofitNotFoundIsNotRetryable() { + void testRetrofitNotFoundIsNotRetryable() { mockWebServer.enqueue( new MockResponse() .setResponseCode(HttpStatus.NOT_FOUND.value()) .setBody(responseBodyString)); SpinnakerHttpException notFoundException = - assertThrows(SpinnakerHttpException.class, () -> retrofit2Service.getRetrofit2().execute()); - assertNotNull(notFoundException.getRetryable()); - assertFalse(notFoundException.getRetryable()); + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerHttpException.class); + assertThat(notFoundException.getRetryable()).isNotNull(); + assertThat(notFoundException.getRetryable()).isFalse(); + assertThat(notFoundException.getUrl()).isEqualTo(mockWebServer.url("/retrofit2").toString()); } @Test - public void testRetrofitBadRequestIsNotRetryable() { + void testRetrofitBadRequestIsNotRetryable() { mockWebServer.enqueue( new MockResponse() .setResponseCode(HttpStatus.NOT_FOUND.value()) .setBody(responseBodyString)); SpinnakerHttpException spinnakerHttpException = - assertThrows(SpinnakerHttpException.class, () -> retrofit2Service.getRetrofit2().execute()); - assertNotNull(spinnakerHttpException.getRetryable()); - assertFalse(spinnakerHttpException.getRetryable()); + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getRetryable()).isNotNull(); + assertThat(spinnakerHttpException.getRetryable()).isFalse(); + assertThat(spinnakerHttpException.getUrl()) + .isEqualTo(mockWebServer.url("/retrofit2").toString()); } @Test - public void testRetrofitOtherClientErrorHasNullRetryable() { + void testRetrofitOtherClientErrorHasNullRetryable() { mockWebServer.enqueue( new MockResponse().setResponseCode(HttpStatus.GONE.value()).setBody(responseBodyString)); SpinnakerHttpException spinnakerHttpException = - assertThrows(SpinnakerHttpException.class, () -> retrofit2Service.getRetrofit2().execute()); - assertNull(spinnakerHttpException.getRetryable()); + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getRetryable()).isNull(); + assertThat(spinnakerHttpException.getUrl()) + .isEqualTo(mockWebServer.url("/retrofit2").toString()); } @Test - public void testRetrofitSimpleSpinnakerNetworkException() { + void testRetrofitSimpleSpinnakerNetworkException() { mockWebServer.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)); - - assertThrows(SpinnakerNetworkException.class, () -> retrofit2Service.getRetrofit2().execute()); + SpinnakerNetworkException spinnakerNetworkException = + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerNetworkException.class); + assertThat(spinnakerNetworkException.getUrl()) + .isEqualTo(mockWebServer.url("/retrofit2").toString()); } @Test - public void testRetrofitSimpleSpinnakerServerException() { - mockWebServer.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); - assertThrows(SpinnakerServerException.class, () -> retrofit2Service.getRetrofit2().execute()); - } - - @Test - public void testResponseHeadersInException() { + void testResponseHeadersInException() { // Check response headers are retrievable from a SpinnakerHttpException mockWebServer.enqueue( @@ -134,35 +138,50 @@ public void testResponseHeadersInException() { .setBody(responseBodyString) .setHeader("Test", "true")); SpinnakerHttpException spinnakerHttpException = - assertThrows(SpinnakerHttpException.class, () -> retrofit2Service.getRetrofit2().execute()); - assertTrue(spinnakerHttpException.getHeaders().containsKey("Test")); - assertTrue(spinnakerHttpException.getHeaders().get("Test").contains("true")); + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getHeaders().containsKey("Test")).isTrue(); + assertThat(spinnakerHttpException.getHeaders().get("Test").contains("true")).isTrue(); + assertThat(spinnakerHttpException.getUrl()) + .isEqualTo(mockWebServer.url("/retrofit2").toString()); } @Test - public void testNotParameterizedException() { + void testHttpMethodInException() { + // Check http request method is retrievable from a SpinnakerHttpException + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(HttpStatus.BAD_REQUEST.value()) + .setBody(responseBodyString)); + SpinnakerHttpException spinnakerHttpException = + catchThrowableOfType( + () -> retrofit2Service.deleteRetrofit2().execute(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getUrl()) + .isEqualTo(mockWebServer.url("/retrofit2").toString()); + assertThat(spinnakerHttpException.getHttpMethod()).isEqualTo(HttpMethod.DELETE.toString()); + } + @Test + void testNotParameterizedException() { IllegalArgumentException illegalArgumentException = - assertThrows( - IllegalArgumentException.class, - () -> retrofit2Service.testNotParameterized().execute()); - - assertEquals( - "Call return type must be parameterized as Call or Call", - illegalArgumentException.getCause().getMessage()); + catchThrowableOfType( + () -> retrofit2Service.testNotParameterized().execute(), + IllegalArgumentException.class); + assertThat(illegalArgumentException.getCause().getMessage()) + .isEqualTo("Call return type must be parameterized as Call or Call"); } @Test - public void testWrongReturnTypeException() { + void testWrongReturnTypeException() { IllegalArgumentException illegalArgumentException = - assertThrows( - IllegalArgumentException.class, () -> retrofit2Service.testWrongReturnType().execute()); + catchThrowableOfType( + () -> retrofit2Service.testWrongReturnType().execute(), IllegalArgumentException.class); - assertEquals( - "Unable to create call adapter for interface com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerRetrofit2ErrorHandleTest$DummyWithExecute\n" - + " for method Retrofit2Service.testWrongReturnType", - illegalArgumentException.getMessage()); + assertThat(illegalArgumentException) + .hasMessage( + "Unable to create call adapter for interface com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerRetrofit2ErrorHandleTest$DummyWithExecute\n" + + " for method Retrofit2Service.testWrongReturnType"); } interface Retrofit2Service { @@ -174,6 +193,51 @@ interface Retrofit2Service { @retrofit2.http.GET("/retrofit2/wrongReturnType") DummyWithExecute testWrongReturnType(); + + @retrofit2.http.DELETE("/retrofit2") + Call deleteRetrofit2(); + } + + @Test + void testSpinnakerConversionException() { + + String invalidJsonTypeResponseBody = "{'testcasename': 'testSpinnakerConversionException'"; + + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(HttpStatus.OK.value()) + .setBody(invalidJsonTypeResponseBody)); + + SpinnakerConversionException spinnakerConversionException = + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerConversionException.class); + assertThat(spinnakerConversionException.getRetryable()).isNotNull(); + assertThat(spinnakerConversionException.getRetryable()).isFalse(); + assertThat(spinnakerConversionException).hasMessage("Failed to process response body"); + assertThat(spinnakerConversionException.getUrl()) + .isEqualTo(mockWebServer.url("/retrofit2").toString()); + } + + @Test + void testNonJsonHttpErrorResponse() { + + String invalidJsonTypeResponseBody = "{'errorResponse': 'Failure'"; + int responseCode = HttpStatus.INTERNAL_SERVER_ERROR.value(); + String url = baseUrl + "retrofit2"; + String reason = "Server Error"; + + mockWebServer.enqueue( + new MockResponse().setResponseCode(responseCode).setBody(invalidJsonTypeResponseBody)); + SpinnakerHttpException spinnakerHttpException = + catchThrowableOfType( + () -> retrofit2Service.getRetrofit2().execute(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getResponseBody()).isNull(); + assertThat(spinnakerHttpException.getResponseCode()).isEqualTo(responseCode); + assertThat(spinnakerHttpException) + .hasMessage( + "Status: " + responseCode + ", Method: GET, URL: " + url + ", Message: " + reason); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(url); + assertThat(spinnakerHttpException.getReason()).isEqualTo(reason); } interface DummyWithExecute { diff --git a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandlerTest.java b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandlerTest.java index 4daf63be0..748fa7bc1 100644 --- a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandlerTest.java +++ b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitErrorHandlerTest.java @@ -16,11 +16,9 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.catchThrowableOfType; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,7 +27,7 @@ import java.util.Map; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; -import org.junit.Assert; +import okhttp3.mockwebserver.SocketPolicy; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -42,15 +40,16 @@ import retrofit.converter.JacksonConverter; import retrofit.http.GET; -public class SpinnakerRetrofitErrorHandlerTest { +class SpinnakerRetrofitErrorHandlerTest { private static RetrofitService retrofitService; private static final MockWebServer mockWebServer = new MockWebServer(); @BeforeAll - public static void setupOnce() throws Exception { + static void setupOnce() throws Exception { mockWebServer.start(); + retrofitService = new RestAdapter.Builder() .setEndpoint(mockWebServer.url("/").toString()) @@ -60,17 +59,24 @@ public static void setupOnce() throws Exception { } @AfterAll - public static void shutdownOnce() throws Exception { + static void shutdownOnce() throws Exception { mockWebServer.shutdown(); } @Test - public void testNotFoundIsNotRetryable() { + void testNotFoundIsNotRetryable() { mockWebServer.enqueue(new MockResponse().setResponseCode(HttpStatus.NOT_FOUND.value())); SpinnakerHttpException notFoundException = - assertThrows(SpinnakerHttpException.class, () -> retrofitService.getFoo()); - assertNotNull(notFoundException.getRetryable()); - assertFalse(notFoundException.getRetryable()); + catchThrowableOfType(() -> retrofitService.getFoo(), SpinnakerHttpException.class); + assertThat(notFoundException.getRetryable()).isNotNull(); + assertThat(notFoundException.getRetryable()).isFalse(); + } + + @Test + void testSpinnakerNetworkException() { + mockWebServer.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)); + assertThatExceptionOfType(SpinnakerNetworkException.class) + .isThrownBy(() -> retrofitService.getFoo()); } @ParameterizedTest(name = "Deserialize response using {0}") @@ -87,7 +93,7 @@ public void testNotFoundIsNotRetryable() { // is set when building out the RestAdapter @ValueSource( strings = {"Default_GSONConverter", "JacksonConverter", "JacksonConverterWithObjectMapper"}) - public void testResponseWithExtraField(String retrofitConverter) throws Exception { + void testResponseWithExtraField(String retrofitConverter) throws Exception { Map responseBodyMap = new HashMap<>(); responseBodyMap.put("timestamp", "123123123123"); responseBodyMap.put("message", "Not Found error Message"); @@ -130,53 +136,115 @@ public void testResponseWithExtraField(String retrofitConverter) throws Exceptio // "..." // // so make sure we get a SpinnakerHttpException from calling getFoo - assertThrows(SpinnakerHttpException.class, retrofitServiceTestConverter::getFoo); + SpinnakerHttpException spinnakerHttpException = + catchThrowableOfType(retrofitServiceTestConverter::getFoo, SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(mockWebServer.url("/foo").toString()); } @Test - public void testBadRequestIsNotRetryable() { + void testBadRequestIsNotRetryable() { mockWebServer.enqueue(new MockResponse().setResponseCode(HttpStatus.BAD_REQUEST.value())); SpinnakerHttpException spinnakerHttpException = - assertThrows(SpinnakerHttpException.class, () -> retrofitService.getFoo()); - assertNotNull(spinnakerHttpException.getRetryable()); - assertFalse(spinnakerHttpException.getRetryable()); + catchThrowableOfType(() -> retrofitService.getFoo(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getRetryable()).isNotNull(); + assertThat(spinnakerHttpException.getRetryable()).isFalse(); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(mockWebServer.url("/foo").toString()); } @Test - public void testOtherClientErrorHasNullRetryable() { + void testOtherClientErrorHasNullRetryable() { // Arbitrarily choose GONE as an example of a client (e.g. 4xx) error that // we expect to have null retryable mockWebServer.enqueue(new MockResponse().setResponseCode(HttpStatus.GONE.value())); SpinnakerHttpException spinnakerHttpException = - assertThrows(SpinnakerHttpException.class, () -> retrofitService.getFoo()); - assertNull(spinnakerHttpException.getRetryable()); + catchThrowableOfType(() -> retrofitService.getFoo(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getRetryable()).isNull(); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(mockWebServer.url("/foo").toString()); } @Test - public void testResponseHeadersInException() { + void testResponseHeadersInException() { // Check response headers are retrievable from a SpinnakerHttpException mockWebServer.enqueue( new MockResponse() .setResponseCode(HttpStatus.BAD_REQUEST.value()) .setHeader("Test", "true")); SpinnakerHttpException spinnakerHttpException = - assertThrows(SpinnakerHttpException.class, () -> retrofitService.getFoo()); - assertTrue(spinnakerHttpException.getHeaders().containsKey("Test")); - assertTrue(spinnakerHttpException.getHeaders().get("Test").contains("true")); + catchThrowableOfType(() -> retrofitService.getFoo(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getHeaders().containsKey("Test")).isTrue(); + assertThat(spinnakerHttpException.getHeaders().get("Test").contains("true")).isTrue(); + assertThat(spinnakerHttpException.getUrl()).isEqualTo(mockWebServer.url("/foo").toString()); + } + + @Test + void testExceptionFromRetrofitErrorHasNullHttpMethod() { + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(HttpStatus.BAD_REQUEST.value()) + .setHeader("Test", "true")); + SpinnakerHttpException spinnakerHttpException = + catchThrowableOfType(() -> retrofitService.getFoo(), SpinnakerHttpException.class); + assertThat(spinnakerHttpException.getHttpMethod()).isNull(); } @Test - public void testSimpleSpinnakerNetworkException() { + void testSimpleSpinnakerNetworkException() { String message = "my custom message"; IOException e = new IOException(message); - RetrofitError retrofitError = RetrofitError.networkError("http://localhost", e); + String url = "http://localhost"; + RetrofitError retrofitError = RetrofitError.networkError(url, e); SpinnakerRetrofitErrorHandler handler = SpinnakerRetrofitErrorHandler.getInstance(); Throwable throwable = handler.handleError(retrofitError); - Assert.assertEquals(message, throwable.getMessage()); + assertThat(throwable).hasMessage(message); + assertThat(throwable).isInstanceOf(SpinnakerNetworkException.class); + SpinnakerNetworkException spinnakerNetworkException = (SpinnakerNetworkException) throwable; + assertThat(spinnakerNetworkException.getUrl()).isEqualTo(url); + } + + @Test + void testSpinnakerConversionException() { + mockWebServer.enqueue( + new MockResponse().setBody("Invalid JSON response").setResponseCode(HttpStatus.OK.value())); + + SpinnakerConversionException spinnakerConversionException = + catchThrowableOfType(() -> retrofitService.getData(), SpinnakerConversionException.class); + assertThat(spinnakerConversionException.getRetryable()).isNotNull(); + assertThat(spinnakerConversionException.getRetryable()).isFalse(); + assertThat(spinnakerConversionException) + .hasMessageContaining("Expected BEGIN_OBJECT but was STRING at line 1 column 1 path $"); + assertThat(spinnakerConversionException.getUrl()) + .isEqualTo(mockWebServer.url("/data").toString()); + } + + @Test + void testChainSpinnakerException_SpinnakerNetworkException() { + SpinnakerRetrofitErrorHandler handler = SpinnakerRetrofitErrorHandler.getInstance(); + + String originalMessage = "original message"; + String newMessage = "new message"; + + IOException originalException = new IOException(originalMessage); + + String url = "http://localhost"; + RetrofitError retrofitError = RetrofitError.networkError(url, originalException); + + Throwable newException = + handler.handleError( + retrofitError, + (exception) -> String.format("%s: %s", newMessage, exception.getMessage())); + + assertThat(newException).isInstanceOf(SpinnakerNetworkException.class); + assertThat(newException).hasMessage("new message: original message"); + assertThat(newException.getCause()).hasMessage(originalMessage); + SpinnakerNetworkException spinnakerNetworkException = (SpinnakerNetworkException) newException; + assertThat(spinnakerNetworkException.getUrl()).isEqualTo(url); } interface RetrofitService { @GET("/foo") Response getFoo(); + + @GET("/data") + Map getData(); } } diff --git a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitExceptionHandlersTest.java b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitExceptionHandlersTest.java index cb342b20e..93bbc2ad5 100644 --- a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitExceptionHandlersTest.java +++ b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerRetrofitExceptionHandlersTest.java @@ -16,7 +16,7 @@ package com.netflix.spinnaker.kork.retrofit.exceptions; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -74,7 +74,7 @@ class SpinnakerRetrofitExceptionHandlersTest { private MemoryAppender memoryAppender; @BeforeEach - private void setup(TestInfo testInfo) { + void setup(TestInfo testInfo) { System.out.println("--------------- Test " + testInfo.getDisplayName()); memoryAppender = new MemoryAppender(SpinnakerRetrofitExceptionHandlers.class); } @@ -84,8 +84,8 @@ void testSpinnakerServerException() throws Exception { URI uri = getUri("/spinnakerServerException"); ResponseEntity entity = restTemplate.getForEntity(uri, String.class); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, entity.getStatusCode()); - assertEquals(1, memoryAppender.countEventsForLevel(Level.ERROR)); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(memoryAppender.countEventsForLevel(Level.ERROR)).isEqualTo(1); } @Test @@ -93,11 +93,11 @@ void testChainedSpinnakerServerException() throws Exception { URI uri = getUri("/chainedSpinnakerServerException"); ResponseEntity entity = restTemplate.getForEntity(uri, String.class); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, entity.getStatusCode()); - assertEquals(1, memoryAppender.countEventsForLevel(Level.ERROR)); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(memoryAppender.countEventsForLevel(Level.ERROR)).isEqualTo(1); // Make sure the message is what we expect. - assertEquals(1, memoryAppender.search(CUSTOM_MESSAGE, Level.ERROR).size()); + assertThat(memoryAppender.search(CUSTOM_MESSAGE, Level.ERROR)).hasSize(1); } @ParameterizedTest(name = "testSpinnakerHttpException status = {0}") @@ -106,15 +106,15 @@ void testSpinnakerHttpException(int status) throws Exception { URI uri = getUri("/spinnakerHttpException/" + String.valueOf(status)); ResponseEntity entity = restTemplate.getForEntity(uri, String.class); - assertEquals(status, entity.getStatusCode().value()); + assertThat(entity.getStatusCode().value()).isEqualTo(status); // Only expect error logging for a server error, debug otherwise. No need // to fill up logs with client errors assuming the server is doing the best // it can. - assertEquals( - 1, - memoryAppender.countEventsForLevel( - HttpStatus.resolve(status).is5xxServerError() ? Level.ERROR : Level.DEBUG)); + assertThat( + memoryAppender.countEventsForLevel( + HttpStatus.resolve(status).is5xxServerError() ? Level.ERROR : Level.DEBUG)) + .isEqualTo(1); } @ParameterizedTest(name = "testChainedSpinnakerHttpException status = {0}") @@ -123,24 +123,22 @@ void testChainedSpinnakerHttpException(int status) throws Exception { URI uri = getUri("/chainedSpinnakerHttpException/" + String.valueOf(status)); ResponseEntity entity = restTemplate.getForEntity(uri, String.class); - assertEquals(status, entity.getStatusCode().value()); + assertThat(entity.getStatusCode().value()).isEqualTo(status); // Only expect error logging for a server error, debug otherwise. No need // to fill up logs with client errors assuming the server is doing the best // it can. - assertEquals( - 1, - memoryAppender.countEventsForLevel( - HttpStatus.resolve(status).is5xxServerError() ? Level.ERROR : Level.DEBUG)); + assertThat( + memoryAppender.countEventsForLevel( + HttpStatus.resolve(status).is5xxServerError() ? Level.ERROR : Level.DEBUG)) + .isEqualTo(1); // Make sure the message is what we expect. - assertEquals( - 1, - memoryAppender - .search( + assertThat( + memoryAppender.search( CUSTOM_MESSAGE, - HttpStatus.resolve(status).is5xxServerError() ? Level.ERROR : Level.DEBUG) - .size()); + HttpStatus.resolve(status).is5xxServerError() ? Level.ERROR : Level.DEBUG)) + .hasSize(1); } private URI getUri(String path) { diff --git a/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerExceptionTest.java b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerExceptionTest.java new file mode 100644 index 000000000..2d24ec97c --- /dev/null +++ b/kork-retrofit/src/test/java/com/netflix/spinnaker/kork/retrofit/exceptions/SpinnakerServerExceptionTest.java @@ -0,0 +1,153 @@ +/* + * Copyright 2023 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.retrofit.exceptions; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.gson.Gson; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import java.io.IOException; +import java.util.List; +import okhttp3.Request; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import retrofit.RetrofitError; +import retrofit.client.Response; +import retrofit.converter.ConversionException; +import retrofit.converter.GsonConverter; +import retrofit.mime.TypedByteArray; + +class SpinnakerServerExceptionTest { + private static final String CUSTOM_MESSAGE = "custom message"; + + @Test + void testSpinnakerNetworkExceptionWithUrl() { + Throwable cause = new Throwable("arbitrary message"); + String url = "http://some-url/"; + Request request = new Request.Builder().url(url).build(); + SpinnakerNetworkException spinnakerNetworkException = + new SpinnakerNetworkException(cause, request); + assertThat(spinnakerNetworkException.getUrl()).isEqualTo(url); + assertThat(spinnakerNetworkException.getHttpMethod()).isEqualTo(HttpMethod.GET.toString()); + } + + @Test + void testSpinnakerNetworkExceptionWithSpecificMethod() { + Throwable cause = new Throwable("arbitrary message"); + String url = "http://some-url/"; + Request request = + new Request.Builder().url(url).method(HttpMethod.DELETE.toString(), null).build(); + SpinnakerNetworkException spinnakerNetworkException = + new SpinnakerNetworkException(cause, request); + assertThat(spinnakerNetworkException.getUrl()).isEqualTo(url); + assertThat(spinnakerNetworkException.getHttpMethod()).isEqualTo(HttpMethod.DELETE.toString()); + } + + @Test + void testSpinnakerNetworkException_NewInstance() { + IOException initialException = new IOException("message"); + String url = "http://localhost"; + try { + RetrofitError error = RetrofitError.networkError(url, initialException); + throw new SpinnakerNetworkException(error); + } catch (SpinnakerException e) { + SpinnakerException newException = e.newInstance(CUSTOM_MESSAGE); + + assertThat(newException).isInstanceOf(SpinnakerNetworkException.class); + assertThat(newException).hasMessage(CUSTOM_MESSAGE); + assertThat(newException).hasCause(e); + SpinnakerNetworkException spinnakerNetworkException = + (SpinnakerNetworkException) newException; + assertThat(spinnakerNetworkException.getUrl()).isEqualTo(url); + } + } + + @Test + void testSpinnakerServerExceptionWithUrl() { + Throwable cause = new Throwable("arbitrary message"); + String url = "http://some-url/"; + Request request = new Request.Builder().url(url).build(); + SpinnakerServerException spinnakerServerException = + new SpinnakerServerException(cause, request); + assertThat(spinnakerServerException.getUrl()).isEqualTo(url); + assertThat(spinnakerServerException.getHttpMethod()).isEqualTo(HttpMethod.GET.toString()); + } + + @Test + void testSpinnakerServerExceptionWithSpecificMethod() { + Throwable cause = new Throwable("arbitrary message"); + String url = "http://some-url/"; + Request request = + new Request.Builder().url(url).method(HttpMethod.DELETE.toString(), null).build(); + SpinnakerServerException spinnakerServerException = + new SpinnakerServerException(cause, request); + assertThat(spinnakerServerException.getUrl()).isEqualTo(url); + assertThat(spinnakerServerException.getHttpMethod()).isEqualTo(HttpMethod.DELETE.toString()); + } + + @Test + void testSpinnakerServerException_NewInstance() { + Throwable cause = new Throwable("message"); + String url = "http://localhost"; + try { + RetrofitError error = RetrofitError.unexpectedError(url, cause); + throw new SpinnakerServerException(error); + } catch (SpinnakerException e) { + SpinnakerException newException = e.newInstance(CUSTOM_MESSAGE); + + assertThat(newException).isInstanceOf(SpinnakerServerException.class); + assertThat(newException).hasMessage(CUSTOM_MESSAGE); + assertThat(newException).hasCause(e); + SpinnakerServerException spinnakerServerException = (SpinnakerServerException) newException; + assertThat(spinnakerServerException.getUrl()).isEqualTo(url); + } + } + + @Test + void testSpinnakerConversionException_NewInstance() { + String url = "http://localhost"; + String reason = "reason"; + + try { + Response response = + new Response( + url, + 200, + reason, + List.of(), + new TypedByteArray("application/json", "message".getBytes())); + ConversionException conversionException = + new ConversionException("message", new Throwable(reason)); + + RetrofitError retrofitError = + RetrofitError.conversionError( + url, response, new GsonConverter(new Gson()), null, conversionException); + throw new SpinnakerConversionException(retrofitError); + } catch (SpinnakerException e) { + SpinnakerException newException = e.newInstance(CUSTOM_MESSAGE); + + assertThat(newException).isInstanceOf(SpinnakerConversionException.class); + assertThat(newException).hasMessage(CUSTOM_MESSAGE); + assertThat(newException).hasCause(e); + SpinnakerConversionException spinnakerConversionException = + (SpinnakerConversionException) newException; + assertThat(spinnakerConversionException.getRetryable()).isNotNull(); + assertThat(spinnakerConversionException.getRetryable()).isFalse(); + assertThat(spinnakerConversionException.getUrl()).isEqualTo(url); + } + } +} diff --git a/kork-retrofit/src/test/kotlin/com/netflix/spinnaker/kork/retrofit/RetrofitServiceProviderTest.kt b/kork-retrofit/src/test/kotlin/com/netflix/spinnaker/kork/retrofit/RetrofitServiceProviderTest.kt index 890edbd49..a6f8e02c0 100644 --- a/kork-retrofit/src/test/kotlin/com/netflix/spinnaker/kork/retrofit/RetrofitServiceProviderTest.kt +++ b/kork-retrofit/src/test/kotlin/com/netflix/spinnaker/kork/retrofit/RetrofitServiceProviderTest.kt @@ -26,14 +26,15 @@ import com.netflix.spinnaker.config.okhttp3.DefaultOkHttpClientBuilderProvider import com.netflix.spinnaker.config.okhttp3.OkHttpClientProvider import com.netflix.spinnaker.config.okhttp3.RawOkHttpClientFactory import com.netflix.spinnaker.config.DefaultServiceClientProvider +import com.netflix.spinnaker.config.OkHttpClientComponents import com.netflix.spinnaker.kork.client.ServiceClientFactory import com.netflix.spinnaker.kork.client.ServiceClientProvider import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties -import com.netflix.spinnaker.okhttp.SpinnakerRequestInterceptor import dev.minutest.junit.JUnit5Minutests import dev.minutest.rootContext import okhttp3.OkHttpClient import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration import org.springframework.boot.test.context.assertj.AssertableApplicationContext import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.context.annotation.Bean @@ -53,6 +54,8 @@ class RetrofitServiceProviderTest : JUnit5Minutests { ApplicationContextRunner() .withConfiguration(AutoConfigurations.of( RetrofitServiceFactoryAutoConfiguration::class.java, + TaskExecutionAutoConfiguration::class.java, + OkHttpClientComponents::class.java, RetrofitConfiguration::class.java, TestConfiguration::class.java )) @@ -97,11 +100,6 @@ private open class TestConfiguration { return OkHttpClientProvider(listOf(DefaultOkHttpClientBuilderProvider(okHttpClient, OkHttpClientConfigurationProperties()))) } - @Bean - open fun spinnakerRequestInterceptor(): SpinnakerRequestInterceptor { - return SpinnakerRequestInterceptor(OkHttpClientConfigurationProperties()) - } - @Bean open fun objectMapper(): ObjectMapper { return ObjectMapper() diff --git a/kork-runtime/kork-runtime.gradle b/kork-runtime/kork-runtime.gradle index a8fd2d6af..dba679ede 100644 --- a/kork-runtime/kork-runtime.gradle +++ b/kork-runtime/kork-runtime.gradle @@ -1,4 +1,6 @@ dependencies { + runtimeOnly(platform(project(":spinnaker-dependencies"))) + // Add each included runtime project as a runtime dependency gradle.includedRuntimeProjects.each { runtimeOnly project(it) diff --git a/kork-secrets-aws/src/main/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngine.java b/kork-secrets-aws/src/main/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngine.java index 9409ddfc1..0dc6badc2 100644 --- a/kork-secrets-aws/src/main/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngine.java +++ b/kork-secrets-aws/src/main/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngine.java @@ -73,10 +73,10 @@ public class SecretsManagerSecretEngine implements SecretEngine { private final SecretsManagerClientProvider clientProvider; public SecretsManagerSecretEngine( - ObjectMapper mapper, + ObjectMapper objectMapper, UserSecretSerdeFactory userSecretSerdeFactory, SecretsManagerClientProvider clientProvider) { - this.mapper = mapper; + this.mapper = objectMapper; this.userSecretSerdeFactory = userSecretSerdeFactory; this.clientProvider = clientProvider; } diff --git a/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineIntegrationTest.java b/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineIntegrationTest.java index e0c2eef07..1add9dbc6 100644 --- a/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineIntegrationTest.java +++ b/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineIntegrationTest.java @@ -16,7 +16,7 @@ package com.netflix.spinnaker.kork.secrets.engines; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import com.amazonaws.services.secretsmanager.AWSSecretsManager; import com.amazonaws.services.secretsmanager.AWSSecretsManagerClientBuilder; diff --git a/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineTest.java b/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineTest.java index 5823a7202..8d94c1cc8 100644 --- a/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineTest.java +++ b/kork-secrets-aws/src/test/java/com/netflix/spinnaker/kork/secrets/engines/SecretsManagerSecretEngineTest.java @@ -15,8 +15,9 @@ */ package com.netflix.spinnaker.kork.secrets.engines; -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.MockitoAnnotations.initMocks; @@ -40,10 +41,8 @@ import java.nio.ByteBuffer; import java.util.List; import java.util.Map; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Spy; @@ -63,9 +62,7 @@ public class SecretsManagerSecretEngineTest { private GetSecretValueResult secretStringFileValue = new GetSecretValueResult().withSecretString("BEGIN RSA PRIVATE KEY"); - @Rule public ExpectedException exceptionRule = ExpectedException.none(); - - @Before + @BeforeEach public void setup() { ObjectMapper mapper = new ObjectMapper(); List mappers = List.of(mapper); @@ -96,9 +93,9 @@ public void decryptStringWithoutKey() { public void decryptFileWithKey() { EncryptedSecret kvSecret = EncryptedSecret.parse("encryptedFile:secrets-manager!r:us-west-2!s:private-key!k:password"); - exceptionRule.expect(InvalidSecretFormatException.class); doReturn(kvSecretValue).when(secretsManagerSecretEngine).getSecretValue(any()); - secretsManagerSecretEngine.validate(kvSecret); + assertThrows( + InvalidSecretFormatException.class, () -> secretsManagerSecretEngine.validate(kvSecret)); } @Test @@ -124,8 +121,7 @@ public void decryptStringWithBinaryResult() { EncryptedSecret kvSecret = EncryptedSecret.parse("encrypted:secrets-manager!r:us-west-2!s:test-secret!k:password"); doReturn(binarySecretValue).when(secretsManagerSecretEngine).getSecretValue(any()); - exceptionRule.expect(SecretException.class); - secretsManagerSecretEngine.decrypt(kvSecret); + assertThrows(SecretException.class, () -> secretsManagerSecretEngine.decrypt(kvSecret)); } @Test diff --git a/kork-secrets-gcp/kork-secrets-gcp.gradle b/kork-secrets-gcp/kork-secrets-gcp.gradle index 7f2f95e95..f0c4760a7 100644 --- a/kork-secrets-gcp/kork-secrets-gcp.gradle +++ b/kork-secrets-gcp/kork-secrets-gcp.gradle @@ -29,6 +29,7 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-databind" implementation "com.google.cloud:google-cloud-secretmanager" - testImplementation "junit:junit" + testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.mockito:mockito-core" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } diff --git a/kork-secrets-gcp/src/test/java/com/netflix/spinnaker/kork/secrets/engines/GoogleSecretsManagerSecretEngineTest.java b/kork-secrets-gcp/src/test/java/com/netflix/spinnaker/kork/secrets/engines/GoogleSecretsManagerSecretEngineTest.java index b39eb21a7..9a5f54177 100644 --- a/kork-secrets-gcp/src/test/java/com/netflix/spinnaker/kork/secrets/engines/GoogleSecretsManagerSecretEngineTest.java +++ b/kork-secrets-gcp/src/test/java/com/netflix/spinnaker/kork/secrets/engines/GoogleSecretsManagerSecretEngineTest.java @@ -15,7 +15,8 @@ */ package com.netflix.spinnaker.kork.secrets.engines; -import static org.junit.Assert.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.MockitoAnnotations.initMocks; @@ -25,10 +26,8 @@ import com.netflix.spinnaker.kork.secrets.EncryptedSecret; import com.netflix.spinnaker.kork.secrets.InvalidSecretFormatException; import com.netflix.spinnaker.kork.secrets.SecretException; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Spy; public class GoogleSecretsManagerSecretEngineTest { @@ -37,8 +36,6 @@ public class GoogleSecretsManagerSecretEngineTest { private GoogleSecretsManagerSecretEngine googleSecretsManagerSecretEngine = new GoogleSecretsManagerSecretEngine(); - @Rule public ExpectedException exceptionRule = ExpectedException.none(); - private final SecretPayload minioAccessKeyId = SecretPayload.newBuilder() .setData(ByteString.copyFromUtf8("{\"minioAccessKeyId\":\"minioadmin\"}")) @@ -60,7 +57,7 @@ public class GoogleSecretsManagerSecretEngineTest { private final SecretPayload plaintextSecretValue = SecretPayload.newBuilder().setData(ByteString.copyFromUtf8("my-k8s-v2-account-name")).build(); - @Before + @BeforeEach public void setup() { initMocks(this); } @@ -93,11 +90,12 @@ public void decryptFileWithKey() { EncryptedSecret kvSecret = EncryptedSecret.parse( "encryptedFile:google-secrets-manager!p:824069899151!s:spinnaker-store!k:minioAccessKeyId"); - exceptionRule.expect(InvalidSecretFormatException.class); doReturn(kvSecretValue) .when(googleSecretsManagerSecretEngine) .getSecretPayload(any(), any(), any()); - googleSecretsManagerSecretEngine.validate(kvSecret); + assertThrows( + InvalidSecretFormatException.class, + () -> googleSecretsManagerSecretEngine.validate(kvSecret)); } @Test @@ -132,8 +130,7 @@ public void decryptStringWithBinaryResult() { doReturn(binarySecretValue) .when(googleSecretsManagerSecretEngine) .getSecretPayload(any(), any(), any()); - exceptionRule.expect(SecretException.class); - googleSecretsManagerSecretEngine.decrypt(kvSecret); + assertThrows(SecretException.class, () -> googleSecretsManagerSecretEngine.decrypt(kvSecret)); } @Test @@ -144,7 +141,6 @@ public void decryptStringWithInvalidParam() { doReturn(binarySecretValue) .when(googleSecretsManagerSecretEngine) .getSecretPayload(any(), any(), any()); - exceptionRule.expect(SecretException.class); - googleSecretsManagerSecretEngine.decrypt(kvSecret); + assertThrows(SecretException.class, () -> googleSecretsManagerSecretEngine.decrypt(kvSecret)); } } diff --git a/kork-secrets/kork-secrets.gradle b/kork-secrets/kork-secrets.gradle index c08e17909..e926a4799 100644 --- a/kork-secrets/kork-secrets.gradle +++ b/kork-secrets/kork-secrets.gradle @@ -14,13 +14,17 @@ dependencies { implementation "org.yaml:snakeyaml" implementation "com.google.guava:guava" implementation "org.apache.commons:commons-lang3" + implementation "org.apache.logging.log4j:log4j-api" testImplementation "com.hubspot.jinjava:jinjava" testImplementation "org.spockframework:spock-core" + testImplementation "org.junit.jupiter:junit-jupiter-api" testRuntimeOnly "cglib:cglib-nodep" testRuntimeOnly "org.objenesis:objenesis" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" testImplementation("org.springframework:spring-test") testImplementation("org.springframework.boot:spring-boot-test") testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito:mockito-junit-jupiter") } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySource.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySource.java index cc53e57a6..23c0b4053 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySource.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySource.java @@ -16,50 +16,45 @@ package com.netflix.spinnaker.kork.secrets; -import lombok.Getter; -import lombok.Setter; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import javax.annotation.Nullable; import org.springframework.core.env.EnumerablePropertySource; -public class SecretAwarePropertySource extends EnumerablePropertySource { - @Setter @Getter private SecretManager secretManager; - - public SecretAwarePropertySource(EnumerablePropertySource source, SecretManager secretManager) { - super(source.getName(), source); - this.secretManager = secretManager; +/** + * Wraps an enumerable property source with support for decrypting {@link EncryptedSecret} URIs + * found in property values. + * + * @param underlying source of properties being wrapped + */ +@NonnullByDefault +public class SecretAwarePropertySource extends EnumerablePropertySource { + private final EnumerablePropertySource delegate; + private final SecretPropertyProcessor secretPropertyProcessor; + + SecretAwarePropertySource( + EnumerablePropertySource source, SecretPropertyProcessor secretPropertyProcessor) { + super(source.getName(), source.getSource()); + this.delegate = source; + this.secretPropertyProcessor = secretPropertyProcessor; } @Override + @Nullable public Object getProperty(String name) { - Object o = source.getProperty(name); - if (o instanceof String && EncryptedSecret.isEncryptedSecret((String) o)) { - String propertyValue = (String) o; - if (secretManager == null) { - throw new SecretException("No secret manager to decrypt value of " + name); - } - String lName = name.toLowerCase(); - if (isSamlFile(lName)) { - return "file:" + secretManager.decryptAsFile(propertyValue).toString(); - } else if (isFile(lName) || EncryptedSecret.isEncryptedFile(propertyValue)) { - return secretManager.decryptAsFile(propertyValue).toString(); - } else { - return secretManager.decrypt(propertyValue); - } - } - return o; + return secretPropertyProcessor.processPropertyValue(name, delegate.getProperty(name)); } - @Deprecated - private boolean isFile(String name) { - return name.endsWith("file") || name.endsWith("path") || name.endsWith("truststore"); + @Override + public String[] getPropertyNames() { + return delegate.getPropertyNames(); } - @Deprecated - private boolean isSamlFile(String name) { - return name.endsWith("keystore") || name.endsWith("metadataurl"); + @Override + public boolean containsProperty(String name) { + return delegate.containsProperty(name); } - @Override - public String[] getPropertyNames() { - return this.source.getPropertyNames(); + public EnumerablePropertySource getDelegate() { + return delegate; } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceRegistrar.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceRegistrar.java new file mode 100644 index 000000000..72ec22c23 --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceRegistrar.java @@ -0,0 +1,68 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.secrets; + +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.stereotype.Component; + +/** + * Handles registration of {@link SecretAwarePropertySource} wrappers during startup. This + * registration must take place after Spring has finished applying bean factory post processors and + * bean post processors to allow for {@link SecretEngine} instances to be configured as beans. + */ +@Component +@RequiredArgsConstructor +public class SecretAwarePropertySourceRegistrar + implements EnvironmentAware, BeanFactoryAware, InitializingBean, Ordered, BeanPostProcessor { + private ConfigurableEnvironment environment; + @Setter private BeanFactory beanFactory; + + @Override + public void setEnvironment(@Nonnull Environment environment) { + this.environment = (ConfigurableEnvironment) environment; + } + + @Override + public void afterPropertiesSet() throws Exception { + MutablePropertySources propertySources = environment.getPropertySources(); + SecretPropertyProcessor processor = beanFactory.getBean(SecretPropertyProcessor.class); + propertySources.stream() + .filter(EnumerablePropertySource.class::isInstance) + .map(source -> (EnumerablePropertySource) source) + .forEach( + source -> + propertySources.replace( + source.getName(), new SecretAwarePropertySource<>(source, processor))); + } + + @Override + public int getOrder() { + return LOWEST_PRECEDENCE; + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretBeanPostProcessor.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretBeanPostProcessor.java deleted file mode 100644 index 55af7acfd..000000000 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretBeanPostProcessor.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2019 Armory, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * 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. - */ - -package com.netflix.spinnaker.kork.secrets; - -import java.util.ArrayList; -import java.util.List; -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.config.BeanPostProcessor; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.core.Ordered; -import org.springframework.core.env.EnumerablePropertySource; -import org.springframework.core.env.MutablePropertySources; -import org.springframework.core.env.PropertySource; - -public class SecretBeanPostProcessor implements BeanPostProcessor, Ordered { - - private ConfigurableApplicationContext applicationContext; - private SecretManager secretManager; - - SecretBeanPostProcessor( - ConfigurableApplicationContext applicationContext, SecretManager secretManager) { - this.applicationContext = applicationContext; - this.secretManager = secretManager; - MutablePropertySources propertySources = - applicationContext.getEnvironment().getPropertySources(); - List enumerableSources = new ArrayList<>(); - - for (PropertySource ps : propertySources) { - if (ps instanceof EnumerablePropertySource) { - enumerableSources.add((EnumerablePropertySource) ps); - } - } - - for (EnumerablePropertySource s : enumerableSources) { - propertySources.replace(s.getName(), new SecretAwarePropertySource(s, secretManager)); - } - } - - @Override - public Object postProcessBeforeInitialization(Object bean, String beanName) - throws BeansException { - return bean; - } - - @Override - public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - return bean; - } - - @Override - public int getOrder() { - return Ordered.HIGHEST_PRECEDENCE + 8; - } -} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretConfiguration.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretConfiguration.java index f36ef90f5..0b924df64 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretConfiguration.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretConfiguration.java @@ -29,7 +29,6 @@ import java.util.Set; import java.util.stream.Collectors; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @@ -39,12 +38,6 @@ @ComponentScan public class SecretConfiguration { - @Bean - static SecretBeanPostProcessor secretBeanPostProcessor( - ConfigurableApplicationContext applicationContext, SecretManager secretManager) { - return new SecretBeanPostProcessor(applicationContext, secretManager); - } - @Bean public UserSecretTypeProvider defaultUserSecretTypeProvider(ResourceLoader loader) { return UserSecretTypeProvider.fromPackage(UserSecretData.class.getPackageName(), loader); diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java index 6ca7dd799..1eb550342 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java @@ -16,29 +16,28 @@ package com.netflix.spinnaker.kork.secrets; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import javax.annotation.PostConstruct; -import lombok.Getter; -import org.springframework.beans.factory.annotation.Autowired; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class SecretEngineRegistry { + private final ObjectProvider secretEngines; - @Getter private Map registeredEngines = new HashMap<>(); - - @Getter @Autowired private List secretEngineList; - - @PostConstruct - public void init() { - for (SecretEngine secretEngine : secretEngineList) { - registeredEngines.put(secretEngine.identifier(), secretEngine); - } + public List getSecretEngineList() { + return secretEngines.orderedStream().collect(Collectors.toList()); } + @Nullable public SecretEngine getEngine(String key) { - return registeredEngines.get(key); + return secretEngines + .orderedStream() + .filter(secretEngine -> secretEngine.identifier().equals(key)) + .findFirst() + .orElse(null); } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretManager.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretManager.java index ad91ad490..f26227393 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretManager.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretManager.java @@ -23,19 +23,15 @@ import java.nio.file.Path; import java.nio.file.Paths; import lombok.Getter; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class SecretManager { @Getter private final SecretEngineRegistry secretEngineRegistry; - @Autowired - SecretManager(SecretEngineRegistry secretEngineRegistry) { - this.secretEngineRegistry = secretEngineRegistry; - } - /** * Decrypt will deserialize the configValue into an EncryptedSecret object and decrypted based on * the secretEngine referenced in the configValue. diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretPropertyProcessor.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretPropertyProcessor.java new file mode 100644 index 000000000..0182b6afe --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretPropertyProcessor.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.kork.secrets; + +import java.nio.file.Path; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** Handles replacing property values with {@link EncryptedSecret} URIs with fetched secrets. */ +@Component +@RequiredArgsConstructor +public class SecretPropertyProcessor { + // special support for some SAML-related properties in Gate + private static final List SAML_FILE_PROPERTY_NAME_ENDINGS = + List.of("keystore", "metadataurl"); + // special support for properties expecting filenames + private static final List FILE_PROPERTY_NAME_ENDINGS = + List.of("file", "path", "truststore"); + + @Setter(onMethod_ = {@Autowired}) + private SecretManager secretManager; + + /** + * Examines the given property name and value for use of encrypted secrets, returning either the + * unchanged property value or the decrypted property value when encountering {@link + * EncryptedSecret} URIs. + */ + @Nullable + public Object processPropertyValue(@Nonnull String name, @Nullable Object value) { + if (!(value instanceof String)) { + return value; + } + + String string = (String) value; + if (!EncryptedSecret.isEncryptedSecret(string)) { + return string; + } + + if (secretManager == null) { + throw new SecretException("No secret manager to decrypt value of " + name); + } + + if (isSamlFilePropertyName(name)) { + Path file = secretManager.decryptAsFile(string); + return "file:" + file; + } + + if (isFilePropertyName(name) || EncryptedSecret.isEncryptedFile(string)) { + Path file = secretManager.decryptAsFile(string); + return file.toString(); + } + + return secretManager.decrypt(string); + } + + private static boolean isSamlFilePropertyName(String name) { + return SAML_FILE_PROPERTY_NAME_ENDINGS.stream() + .anyMatch(suffix -> StringUtils.endsWithIgnoreCase(name, suffix)); + } + + private static boolean isFilePropertyName(String name) { + return FILE_PROPERTY_NAME_ENDINGS.stream() + .anyMatch(suffix -> StringUtils.endsWithIgnoreCase(name, suffix)); + } +} diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/EncryptedSecretTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/EncryptedSecretTest.java index f852ff7f4..1fc6decc7 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/EncryptedSecretTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/EncryptedSecretTest.java @@ -16,17 +16,15 @@ package com.netflix.spinnaker.kork.secrets; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import org.junit.jupiter.api.Test; public class EncryptedSecretTest { - @Rule public ExpectedException exceptionRule = ExpectedException.none(); - @Test public void isEncryptedSecretShouldReturnFalse() { String secretConfig = "foo"; @@ -95,20 +93,25 @@ public void constructorTest() { @Test public void updateThrowsInvalidSecretFormatException() { - exceptionRule.expect(InvalidSecretFormatException.class); - exceptionRule.expectMessage( - "Invalid encrypted secret format, must have at least one parameter"); - EncryptedSecret encryptedSecret = new EncryptedSecret(); - encryptedSecret.update("encrypted:s3"); + Exception exception = + assertThrows( + InvalidSecretFormatException.class, () -> new EncryptedSecret().update("encrypted:s3")); + assertTrue( + exception + .getMessage() + .contains("Invalid encrypted secret format, must have at least one parameter")); } @Test public void updateThrowsInvalidSecretFormatExceptionNoKeyValuePairs() { - exceptionRule.expect(InvalidSecretFormatException.class); - exceptionRule.expectMessage( - "Invalid encrypted secret format, keys and values must be delimited by ':'"); - EncryptedSecret encryptedSecret = new EncryptedSecret(); - encryptedSecret.update("encrypted:s3!foobar"); + Exception exception = + assertThrows( + InvalidSecretFormatException.class, + () -> new EncryptedSecret().update("encrypted:s3!foobar")); + assertTrue( + exception + .getMessage() + .contains("Invalid encrypted secret format, keys and values must be delimited by ':'")); } @Test diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceTest.java index 9da5235da..cd16d7915 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretAwarePropertySourceTest.java @@ -1,45 +1,31 @@ package com.netflix.spinnaker.kork.secrets; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.springframework.core.env.EnumerablePropertySource; - +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.MapPropertySource; + +@ExtendWith(MockitoExtension.class) public class SecretAwarePropertySourceTest { - private SecretAwarePropertySource secretAwarePropertySource; - private SecretManager secretManager; - private Map testValues = new HashMap<>(); - - @Rule public ExpectedException thrown = ExpectedException.none(); + private SecretAwarePropertySource> secretAwarePropertySource; + private final SecretPropertyProcessor secretPropertyProcessor = new SecretPropertyProcessor(); + @Mock private SecretManager secretManager; + private final Map testValues = new HashMap<>(); + private final MapPropertySource propertySource = new MapPropertySource("testSource", testValues); - @Before + @BeforeEach public void setup() { - EnumerablePropertySource source = - new EnumerablePropertySource("testSource") { - @Override - public String[] getPropertyNames() { - return new String[0]; - } - - @Override - public Object getProperty(String name) { - return testValues.get(name); - } - }; - testValues.put("testSecretFile", "encrypted:noop!k:testValue"); testValues.put("testSecretPath", "encrypted:noop!k:testValue"); testValues.put("testSecretCert", "encryptedFile:noop!k:testValue"); @@ -47,10 +33,12 @@ public Object getProperty(String name) { testValues.put("testNotSoSecret", "unencrypted"); secretManager = mock(SecretManager.class); - secretAwarePropertySource = new SecretAwarePropertySource(source, secretManager); + secretPropertyProcessor.setSecretManager(secretManager); + secretAwarePropertySource = + new SecretAwarePropertySource<>(propertySource, secretPropertyProcessor); - when(secretManager.decryptAsFile(any())).thenReturn(Paths.get("decryptedFile")); - when(secretManager.decrypt(any())).thenReturn("decryptedString"); + lenient().when(secretManager.decryptAsFile(any())).thenReturn(Paths.get("decryptedFile")); + lenient().when(secretManager.decrypt(any())).thenReturn("decryptedString"); } @Test @@ -92,9 +80,12 @@ public void unencryptedPropertyShouldDoNothing() { @Test public void noSecretManagerShouldThrowException() { - secretAwarePropertySource.setSecretManager(null); - thrown.expect(SecretException.class); - thrown.expectMessage("No secret manager to decrypt value of testSecretString"); - secretAwarePropertySource.getProperty("testSecretString"); + secretPropertyProcessor.setSecretManager(null); + SecretException exception = + assertThrows( + SecretException.class, () -> secretAwarePropertySource.getProperty("testSecretString")); + assertEquals("No secret manager to decrypt value of testSecretString", exception.getMessage()); + verify(secretManager, never()).decrypt(any()); + verify(secretManager, never()).decryptAsFile(any()); } } diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretBeanPostProcessorTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretBeanPostProcessorTest.java deleted file mode 100644 index 8b6a14b76..000000000 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretBeanPostProcessorTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.netflix.spinnaker.kork.secrets; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.EnumerablePropertySource; -import org.springframework.core.env.MutablePropertySources; -import org.springframework.core.env.PropertySource; - -public class SecretBeanPostProcessorTest { - - @Mock private ConfigurableApplicationContext applicationContext; - - @Mock private ConfigurableEnvironment environment; - - private SecretBeanPostProcessor secretBeanPostProcessor; - private MutablePropertySources mutablePropertySources = new MutablePropertySources(); - - private PropertySource propertySource = - new PropertySource("testPropertySource") { - @Override - public Object getProperty(String name) { - return null; - } - }; - - private EnumerablePropertySource enumerablePropertySource = - new EnumerablePropertySource("testEnumerableSource") { - @Override - public String[] getPropertyNames() { - return new String[0]; - } - - @Override - public Object getProperty(String name) { - return null; - } - }; - - @Before - public void setup() { - mutablePropertySources.addLast(propertySource); - mutablePropertySources.addLast(enumerablePropertySource); - - MockitoAnnotations.initMocks(this); - when(applicationContext.getEnvironment()).thenReturn(environment); - when(environment.getPropertySources()).thenReturn(mutablePropertySources); - } - - @Test - public void secretManagerBeanShouldGetProcessed() { - secretBeanPostProcessor = new SecretBeanPostProcessor(applicationContext, null); - verify(applicationContext, times(1)).getEnvironment(); - } - - @Test - public void replaceEnumerableSourceWithSecretAwareSourceInSecretManagerBean() { - assertTrue( - mutablePropertySources.get("testEnumerableSource") instanceof EnumerablePropertySource); - assertFalse( - mutablePropertySources.get("testPropertySource") instanceof EnumerablePropertySource); - - secretBeanPostProcessor = new SecretBeanPostProcessor(applicationContext, null); - - assertTrue( - mutablePropertySources.get("testEnumerableSource") instanceof SecretAwarePropertySource); - assertFalse( - mutablePropertySources.get("testPropertySource") instanceof SecretAwarePropertySource); - } -} diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretManagerTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretManagerTest.java index 30a573fe2..722f02cdc 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretManagerTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretManagerTest.java @@ -16,39 +16,42 @@ package com.netflix.spinnaker.kork.secrets; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import org.junit.*; -import org.junit.Before; -import org.junit.Test; -import org.junit.rules.ExpectedException; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +@ExtendWith(MockitoExtension.class) public class SecretManagerTest { - @Mock SecretEngineRegistry secretEngineRegistry; + @Mock ObjectProvider secretEngineProvider; + + SecretEngineRegistry secretEngineRegistry; @Mock SecretEngine secretEngine; SecretManager secretManager; - @Rule public ExpectedException exceptionRule = ExpectedException.none(); - - @Before + @BeforeEach public void setup() { - MockitoAnnotations.initMocks(this); - when(secretEngineRegistry.getEngine("s3")).thenReturn(secretEngine); - when(secretEngine.identifier()).thenReturn("s3"); - secretManager = new SecretManager(secretEngineRegistry); - // secretManager.setSecretEngineRegistry(secretEngineRegistry); + secretEngineRegistry = new SecretEngineRegistry(secretEngineProvider); + lenient().when(secretEngineProvider.orderedStream()).thenReturn(Stream.of(secretEngine)); + lenient().when(secretEngine.identifier()).thenReturn("s3"); + secretManager = spy(new SecretManager(secretEngineRegistry)); } @Test @@ -60,11 +63,10 @@ public void decryptTest() throws SecretDecryptionException { @Test public void decryptSecretEngineNotFound() throws SecretDecryptionException { - when(secretEngineRegistry.getEngine("does-not-exist")).thenReturn(null); String secretConfig = "encrypted:does-not-exist!paramName:paramValue"; - exceptionRule.expect(SecretDecryptionException.class); - exceptionRule.expectMessage("Secret Engine does not exist: does-not-exist"); - secretManager.decrypt(secretConfig); + Exception exception = + assertThrows(SecretDecryptionException.class, () -> secretManager.decrypt(secretConfig)); + assertTrue(exception.getMessage().contains("Secret Engine does not exist: does-not-exist")); } @Test @@ -73,8 +75,7 @@ public void decryptInvalidParams() throws SecretDecryptionException { .when(secretEngine) .validate(any(EncryptedSecret.class)); String secretConfig = "encrypted:s3!paramName:paramValue"; - exceptionRule.expect(InvalidSecretFormatException.class); - secretManager.decrypt(secretConfig); + assertThrows(InvalidSecretFormatException.class, () -> secretManager.decrypt(secretConfig)); } @Test @@ -90,11 +91,11 @@ public void decryptFile() throws SecretDecryptionException, IOException { @Test public void decryptFileSecretEngineNotFound() throws SecretDecryptionException { - when(secretEngineRegistry.getEngine("does-not-exist")).thenReturn(null); String secretConfig = "encrypted:does-not-exist!paramName:paramValue"; - exceptionRule.expect(SecretDecryptionException.class); - exceptionRule.expectMessage("Secret Engine does not exist: does-not-exist"); - secretManager.decryptAsFile(secretConfig); + Exception exception = + assertThrows( + SecretDecryptionException.class, () -> secretManager.decryptAsFile(secretConfig)); + assertTrue(exception.getMessage().contains("Secret Engine does not exist: does-not-exist")); } @Test @@ -103,18 +104,16 @@ public void decryptFileInvalidParams() throws SecretDecryptionException { .when(secretEngine) .validate(any(EncryptedSecret.class)); String secretConfig = "encrypted:s3!paramName:paramValue"; - exceptionRule.expect(InvalidSecretFormatException.class); - secretManager.decryptAsFile(secretConfig); + assertThrows( + InvalidSecretFormatException.class, () -> secretManager.decryptAsFile(secretConfig)); } @Test public void decryptFileNoDiskSpaceMock() throws SecretDecryptionException { - SecretManager spy = spy(new SecretManager(secretEngineRegistry)); - doThrow(SecretDecryptionException.class).when(spy).createTempFile(any(), any()); - doReturn("contents").when(spy).decrypt(any()); - doCallRealMethod().when(spy).decryptAsFile(any()); - exceptionRule.expect(SecretDecryptionException.class); + doThrow(SecretDecryptionException.class).when(secretManager).createTempFile(any(), any()); + doReturn("contents".getBytes(StandardCharsets.UTF_8)).when(secretManager).decryptAsBytes(any()); + doCallRealMethod().when(secretManager).decryptAsFile(any()); String secretConfig = "encrypted:s3!paramName:paramValue"; - spy.decryptAsFile(secretConfig); + assertThrows(SecretDecryptionException.class, () -> secretManager.decryptAsFile(secretConfig)); } } diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretSessionTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretSessionTest.java index 17f7154ad..9067fa303 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretSessionTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/SecretSessionTest.java @@ -1,6 +1,6 @@ package com.netflix.spinnaker.kork.secrets; -import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.doReturn; @@ -14,8 +14,8 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -27,7 +27,7 @@ public class SecretSessionTest { private SecretManager secretManager; private SecretSession secretSession; - @Before + @BeforeEach public void setup() { MockitoAnnotations.initMocks(this); diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/engines/AbstractStorageEngineTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/engines/AbstractStorageEngineTest.java index f03130e83..0b6c66982 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/engines/AbstractStorageEngineTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/engines/AbstractStorageEngineTest.java @@ -16,20 +16,20 @@ package com.netflix.spinnaker.kork.secrets.engines; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.netflix.spinnaker.kork.secrets.EncryptedSecret; import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.util.Arrays; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; public class AbstractStorageEngineTest { AbstractStorageSecretEngine engine; - @Before + @BeforeEach public void init() { engine = new AbstractStorageSecretEngine() { diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManagerTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManagerTest.java index 8b28a9782..21e279dcf 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManagerTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManagerTest.java @@ -16,20 +16,20 @@ package com.netflix.spinnaker.kork.secrets.user; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.netflix.spinnaker.kork.secrets.EncryptedSecret; import com.netflix.spinnaker.kork.secrets.SecretConfiguration; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.junit.jupiter.SpringExtension; @SpringBootTest(classes = SecretConfiguration.class) -@RunWith(SpringJUnit4ClassRunner.class) +@ExtendWith(SpringExtension.class) public class UserSecretManagerTest { @Autowired UserSecretManager userSecretManager; diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java index 4194a5ac5..57eb2cf59 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java @@ -1,10 +1,10 @@ package com.netflix.spinnaker.kork.secrets.user; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; -import org.junit.Test; +import org.junit.jupiter.api.Test; public class UserSecretReferenceTest { diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerdeTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerdeTest.java index 749203d29..5d69c3f9a 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerdeTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerdeTest.java @@ -22,13 +22,13 @@ import com.netflix.spinnaker.kork.secrets.SecretConfiguration; import java.util.List; import java.util.Map; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.junit.jupiter.SpringExtension; -@RunWith(SpringJUnit4ClassRunner.class) +@ExtendWith(SpringExtension.class) @SpringBootTest(classes = SecretConfiguration.class) public class UserSecretSerdeTest { diff --git a/kork-security/kork-security.gradle b/kork-security/kork-security.gradle index 336ea06af..3ef147dd6 100644 --- a/kork-security/kork-security.gradle +++ b/kork-security/kork-security.gradle @@ -11,6 +11,7 @@ dependencies { api "com.fasterxml.jackson.core:jackson-annotations" implementation "com.google.guava:guava" + implementation "org.apache.logging.log4j:log4j-api" implementation "org.slf4j:slf4j-api" testImplementation "org.spockframework:spock-core" diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/AbstractPermissionEvaluator.java b/kork-security/src/main/java/com/netflix/spinnaker/security/AbstractPermissionEvaluator.java new file mode 100644 index 000000000..905622ad3 --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/AbstractPermissionEvaluator.java @@ -0,0 +1,79 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + * + */ + +package com.netflix.spinnaker.security; + +import java.io.Serializable; +import org.springframework.security.access.PermissionEvaluator; +import org.springframework.security.core.Authentication; + +/** + * Base implementation for permission evaluators that support {@link AccessControlled} domain + * objects. + */ +public abstract class AbstractPermissionEvaluator implements PermissionEvaluator { + + @Override + public boolean hasPermission( + Authentication authentication, Object targetDomainObject, Object permission) { + if (isDisabled()) { + return true; + } + if (authentication == null || targetDomainObject == null) { + return false; + } + if (SpinnakerAuthorities.isAdmin(authentication)) { + return true; + } + if (targetDomainObject instanceof AccessControlled) { + return ((AccessControlled) targetDomainObject).isAuthorized(authentication, permission); + } + return false; + } + + @Override + public boolean hasPermission( + Authentication authentication, Serializable targetId, String targetType, Object permission) { + if (isDisabled()) { + return true; + } + return hasPermission( + SpinnakerUsers.getUserId(authentication), targetId, targetType, permission); + } + + /** + * Indicates whether permission evaluation is disabled. When this is true, {@code hasPermission} + * calls should return true. This should be overridden to allow for toggling this evaluator at + * runtime. + */ + protected boolean isDisabled() { + return false; + } + + /** + * Alternative method for evaluating a permission where only the identifier of the user and target + * object is available, rather than the authenticated user and target objects themselves. + * + * @param username identifier for user to check permissions for + * @param targetId identifier of the target resource to check permissions + * @param targetType the type of the target resource being checked + * @param permission the permission being validated + * @return true if the permission is granted + */ + public abstract boolean hasPermission( + String username, Serializable targetId, String targetType, Object permission); +} diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/AccessControlled.java b/kork-security/src/main/java/com/netflix/spinnaker/security/AccessControlled.java index a01d8b5fd..58539de0c 100644 --- a/kork-security/src/main/java/com/netflix/spinnaker/security/AccessControlled.java +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/AccessControlled.java @@ -16,18 +16,26 @@ package com.netflix.spinnaker.security; +import java.util.Collection; import org.springframework.security.core.Authentication; /** * An AccessControlled object is an object that knows its own permissions and can check them against * a given user and authorization. This allows resources to support access control checks via Spring * Security against the resource object directly. + * + * @see AbstractPermissionEvaluator */ public interface AccessControlled { /** * Checks if the authenticated user has a particular authorization on this object. Note that * checking if the user is an admin should be performed by a {@link - * org.springframework.security.access.PermissionEvaluator} rather than in these domain objects. + * org.springframework.security.access.PermissionEvaluator} or by checking {@link + * SpinnakerAuthorities#isAdmin(Authentication)} rather than via this method as the admin role is + * a Spinnaker-specific role. + * + * @see Authorization + * @see SpinnakerAuthorities#hasAnyRole(Authentication, Collection) */ boolean isAuthorized(Authentication authentication, Object authorization); } diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/AuthenticatedRequestDecorator.java b/kork-security/src/main/java/com/netflix/spinnaker/security/AuthenticatedRequestDecorator.java new file mode 100644 index 000000000..61afa62a7 --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/AuthenticatedRequestDecorator.java @@ -0,0 +1,73 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.Map; +import java.util.concurrent.Callable; +import org.apache.logging.log4j.ThreadContext; + +/** + * Provides decorators for {@link Runnable} and {@link Callable} for propagating the current {@link + * ThreadContext}/{@link org.slf4j.MDC}. + */ +@NonnullByDefault +public class AuthenticatedRequestDecorator { + public static Runnable wrap(Runnable runnable) { + Map context = ThreadContext.getContext(); + // this should not be null, but sometimes it is; see: + // https://github.com/apache/logging-log4j2/issues/1426 + if (context == null) { + return runnable; + } + return () -> { + Map originalContext = ThreadContext.getContext(); + ThreadContext.clearMap(); + ThreadContext.putAll(context); + try { + runnable.run(); + } finally { + ThreadContext.clearMap(); + if (originalContext != null) { + ThreadContext.putAll(originalContext); + } + } + }; + } + + public static Callable wrap(Callable callable) { + Map context = ThreadContext.getContext(); + // this should not be null, but sometimes it is; see: + // https://github.com/apache/logging-log4j2/issues/1426 + if (context == null) { + return callable; + } + return () -> { + Map originalContext = ThreadContext.getContext(); + ThreadContext.clearMap(); + ThreadContext.putAll(context); + try { + return callable.call(); + } finally { + ThreadContext.clearMap(); + if (originalContext != null) { + ThreadContext.putAll(originalContext); + } + } + }; + } +} diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/Authorization.java b/kork-security/src/main/java/com/netflix/spinnaker/security/Authorization.java new file mode 100644 index 000000000..4f2512068 --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/Authorization.java @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security; + +import java.util.Locale; +import javax.annotation.Nullable; +import org.springframework.security.core.Authentication; + +/** + * Defines types of authorizations supported by {@link AccessControlled#isAuthorized(Authentication, + * Object)}. + */ +public enum Authorization { + READ, + WRITE, + EXECUTE, + CREATE, + ; + + public static @Nullable Authorization parse(@Nullable Object o) { + if (o == null) { + return null; + } + String name = o.toString().toUpperCase(Locale.ROOT); + try { + return valueOf(name); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/AuthorizationMapControlled.java b/kork-security/src/main/java/com/netflix/spinnaker/security/AuthorizationMapControlled.java new file mode 100644 index 000000000..d13ad4f0a --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/AuthorizationMapControlled.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + * + */ + +package com.netflix.spinnaker.security; + +import javax.annotation.Nullable; + +/** + * Common interface for access-controlled classes which use a permission map of {@link + * Authorization} enums. + */ +public interface AuthorizationMapControlled extends PermissionMapControlled { + @Nullable + @Override + default Authorization valueOf(@Nullable Object authorization) { + return Authorization.parse(authorization); + } +} diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/PermissionMapControlled.java b/kork-security/src/main/java/com/netflix/spinnaker/security/PermissionMapControlled.java new file mode 100644 index 000000000..4abe8a391 --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/PermissionMapControlled.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security; + +import com.netflix.spinnaker.kork.annotations.Alpha; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.springframework.security.core.Authentication; + +/** + * Common interface for access-controlled classes which use a permission map. + * + * @param Authorization enum type + */ +@Alpha +public interface PermissionMapControlled> + extends AccessControlled { + @Nullable + Authorization valueOf(@Nullable Object authorization); + + @Nonnull + default Map> getPermissions() { + return Map.of(); + } + + @Override + default boolean isAuthorized(Authentication authentication, Object authorization) { + Authorization auth = valueOf(authorization); + if (auth == null) { + return false; + } + Set permittedRoles = getPermissions().getOrDefault(auth, Set.of()); + return permittedRoles.isEmpty() + || SpinnakerAuthorities.hasAnyRole(authentication, permittedRoles); + } +} diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/SpinnakerAuthorities.java b/kork-security/src/main/java/com/netflix/spinnaker/security/SpinnakerAuthorities.java new file mode 100644 index 000000000..75fba07d4 --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/SpinnakerAuthorities.java @@ -0,0 +1,90 @@ +/* + * Copyright 2022 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Constants and utilities for working with Spring Security GrantedAuthority objects specific to + * Spinnaker and Fiat. Spinnaker-specific roles are represented here as granted authorities with the + * {@code SPINNAKER_} prefix. + */ +public class SpinnakerAuthorities { + private static final String ROLE_PREFIX = "ROLE_"; + + public static final String ADMIN = "SPINNAKER_ADMIN"; + /** Granted authority for Spinnaker administrators. */ + public static final GrantedAuthority ADMIN_AUTHORITY = new SimpleGrantedAuthority(ADMIN); + + /** Granted authority for anonymous users. */ + public static final GrantedAuthority ANONYMOUS_AUTHORITY = forRoleName("ANONYMOUS"); + + /** Creates a granted authority corresponding to the provided name of a role. */ + @Nonnull + public static GrantedAuthority forRoleName(@Nonnull String role) { + return new SimpleGrantedAuthority(ROLE_PREFIX + role); + } + + /** Checks if the given user is a Spinnaker admin. */ + public static boolean isAdmin(@Nullable Authentication authentication) { + return authentication != null + && authentication.getAuthorities().contains(SpinnakerAuthorities.ADMIN_AUTHORITY); + } + + /** Checks if the given user has the provided role. */ + public static boolean hasRole(@Nullable Authentication authentication, @Nonnull String role) { + return authentication != null && streamRoles(authentication).anyMatch(role::equals); + } + + /** Checks if the given user has any of the provided roles. */ + public static boolean hasAnyRole( + @Nullable Authentication authentication, @Nonnull Collection roles) { + return authentication != null && streamRoles(authentication).anyMatch(roles::contains); + } + + /** Gets the list of roles assigned to the given user. */ + @Nonnull + public static List getRoles(@Nullable Authentication authentication) { + if (authentication == null) { + return List.of(); + } + return streamRoles(authentication).distinct().collect(Collectors.toList()); + } + + @Nonnull + private static Stream streamRoles(@Nonnull Authentication authentication) { + return authentication.getAuthorities().stream() + .filter(SpinnakerAuthorities::isRole) + .map(SpinnakerAuthorities::getRole); + } + + private static boolean isRole(@Nonnull GrantedAuthority authority) { + return authority.getAuthority().startsWith(ROLE_PREFIX); + } + + private static String getRole(@Nonnull GrantedAuthority authority) { + return authority.getAuthority().substring(ROLE_PREFIX.length()); + } +} diff --git a/kork-security/src/main/java/com/netflix/spinnaker/security/SpinnakerUsers.java b/kork-security/src/main/java/com/netflix/spinnaker/security/SpinnakerUsers.java new file mode 100644 index 000000000..15c799465 --- /dev/null +++ b/kork-security/src/main/java/com/netflix/spinnaker/security/SpinnakerUsers.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + * + */ + +package com.netflix.spinnaker.security; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import javax.annotation.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** Constants and utilities related to Spinnaker users (AKA principals). */ +@NonnullByDefault +public class SpinnakerUsers { + /** String constant for the anonymous userid. */ + public static final String ANONYMOUS = "anonymous"; + + /** Gets the userid of the provided authentication token. */ + public static String getUserId(@Nullable Authentication authentication) { + return authentication != null ? authentication.getName() : ANONYMOUS; + } + + /** Gets the current Spinnaker userid. */ + public static String getCurrentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null) { + return getUserId(authentication); + } + // fall back to request header context if relevant (AuthenticatedRequestFilter) + return AuthenticatedRequest.getSpinnakerUser().orElse(ANONYMOUS); + } +} diff --git a/kork-security/src/test/groovy/com/netflix/spinnaker/security/AccessControlledSpec.groovy b/kork-security/src/test/groovy/com/netflix/spinnaker/security/AccessControlledSpec.groovy new file mode 100644 index 000000000..d0845bff9 --- /dev/null +++ b/kork-security/src/test/groovy/com/netflix/spinnaker/security/AccessControlledSpec.groovy @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security + +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.AuthenticatedPrincipal +import spock.lang.Specification + +import static groovy.lang.Tuple.tuple + +class AccessControlledSpec extends Specification { + static class TestCredentials implements AuthorizationMapControlled { + Map> permissions + } + + static class TestPermissionEvaluator extends AbstractPermissionEvaluator { + Map, Map>> targetIdTypesToUsernamePermissions = [:] + + @Override + boolean hasPermission(String username, Serializable targetId, String targetType, Object permission) { + def targetKey = tuple(targetId, targetType) + def usernamePermissions = targetIdTypesToUsernamePermissions[targetKey] + def permissions = usernamePermissions?[username] + permissions != null && permission in permissions + } + } + + static class TestUser implements AuthenticatedPrincipal { + String name + } + + void "check hasPermission for expected permissions"() { + when: + def username = 'alpha' + def user = new TestUser(name: username) + def role = 'dev' + def authentication = new TestingAuthenticationToken(user, null, [SpinnakerAuthorities.forRoleName(role)]) + def credentials = new TestCredentials(permissions: Map.of(Authorization.READ, Set.of(role), Authorization.WRITE, Set.of('ops'))) + def permissionEvaluator = new TestPermissionEvaluator() + then: + permissionEvaluator.hasPermission(authentication, credentials, Authorization.READ) + !permissionEvaluator.hasPermission(authentication, credentials, Authorization.WRITE) + !permissionEvaluator.hasPermission(null, credentials, Authorization.READ) + !permissionEvaluator.hasPermission(authentication, null, Authorization.READ) + when: + def targetId = 14 + def targetType = 'entry' + permissionEvaluator.targetIdTypesToUsernamePermissions[tuple(targetId, targetType)] = Map.of(username, Set.of(Authorization.READ)) + then: + permissionEvaluator.hasPermission(authentication, targetId, targetType, Authorization.READ) + permissionEvaluator.hasPermission(username, targetId, targetType, Authorization.READ) + } +} diff --git a/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthenticatedRequestSpec.groovy b/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthenticatedRequestSpec.groovy index 07e7704d8..cc15df260 100644 --- a/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthenticatedRequestSpec.groovy +++ b/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthenticatedRequestSpec.groovy @@ -110,4 +110,30 @@ class AuthenticatedRequestSpec extends Specification { 'X-SPINNAKER-ACCOUNTS' : Optional.empty(), 'X-SPINNAKER-MY-ATTRIBUTE': Optional.empty()] } + + void "should propagate user and headers in decorator"() { + when: + AuthenticatedRequest.clear() + AuthenticatedRequest.user = 'fry' + AuthenticatedRequest.application = 'express' + AuthenticatedRequest.requestId = '2000' + def closure = AuthenticatedRequestDecorator.wrap { + assert AuthenticatedRequest.spinnakerUser.get() == 'fry' + assert AuthenticatedRequest.spinnakerApplication.get() == 'express' + assert AuthenticatedRequest.spinnakerRequestId.get() == '2000' + } + AuthenticatedRequest.user = 'amy' + AuthenticatedRequest.application = 'mars' + AuthenticatedRequest.requestId = '3000' + closure.run() + then: + // ensure previous context restored + AuthenticatedRequest.spinnakerUser.get() == 'amy' + AuthenticatedRequest.spinnakerApplication.get() == 'mars' + AuthenticatedRequest.spinnakerRequestId.get() == '3000' + when: + AuthenticatedRequest.clear() + then: + closure.run() + } } diff --git a/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthorizationSpec.groovy b/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthorizationSpec.groovy new file mode 100644 index 000000000..7247df46f --- /dev/null +++ b/kork-security/src/test/groovy/com/netflix/spinnaker/security/AuthorizationSpec.groovy @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security + +import spock.lang.Specification + +class AuthorizationSpec extends Specification { + void "parse returns appropriate value"() { + expect: + Authorization.parse(input) == output + where: + input || output + null || null + 'read' || Authorization.READ + 'READ' || Authorization.READ + "Read" || Authorization.READ + Authorization.READ || Authorization.READ + 'write' || Authorization.WRITE + "${Authorization.EXECUTE}" || Authorization.EXECUTE + 'create' || Authorization.CREATE + 'unsupported' || null + ' read ' || null + 'query' || null + } +} diff --git a/kork-security/src/test/groovy/com/netflix/spinnaker/security/SpinnakerAuthoritiesSpec.groovy b/kork-security/src/test/groovy/com/netflix/spinnaker/security/SpinnakerAuthoritiesSpec.groovy new file mode 100644 index 000000000..c3dbe8ca3 --- /dev/null +++ b/kork-security/src/test/groovy/com/netflix/spinnaker/security/SpinnakerAuthoritiesSpec.groovy @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security + +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import spock.lang.Specification + +class SpinnakerAuthoritiesSpec extends Specification { + void "isAdmin returns expected value"() { + expect: + SpinnakerAuthorities.isAdmin(authentication) == isAdmin + where: + authentication || isAdmin + null || false + u(SpinnakerAuthorities.ADMIN_AUTHORITY) || true + u(SpinnakerAuthorities.ANONYMOUS_AUTHORITY) || false + } + + void "hasRole returns expected value"() { + expect: + SpinnakerAuthorities.hasRole(authentication, 'dev') == hasRole + where: + authentication || hasRole + null || false + u('ROLE_dev') || true + u(SpinnakerAuthorities.forRoleName('dev')) || true + u(SpinnakerAuthorities.ANONYMOUS_AUTHORITY) || false + } + + void "getRoles returns expected value"() { + expect: + SpinnakerAuthorities.getRoles(authentication) == expectedRoles + where: + authentication || expectedRoles + null || [] + u('ROLE_a', 'ROLE_b') || ['a', 'b'] + u(SpinnakerAuthorities.forRoleName('c')) || ['c'] + } + + private static Authentication u(String... authorities) { + new TestingAuthenticationToken(null, null, authorities) + } + + private static Authentication u(GrantedAuthority... authorities) { + new TestingAuthenticationToken(null, null, List.of(authorities)) + } +} diff --git a/kork-security/src/test/groovy/com/netflix/spinnaker/security/SpinnakerUsersSpec.groovy b/kork-security/src/test/groovy/com/netflix/spinnaker/security/SpinnakerUsersSpec.groovy new file mode 100644 index 000000000..0b869548e --- /dev/null +++ b/kork-security/src/test/groovy/com/netflix/spinnaker/security/SpinnakerUsersSpec.groovy @@ -0,0 +1,96 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.security + +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.AuthenticatedPrincipal +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.User +import spock.lang.Specification + +import java.security.Principal + +class SpinnakerUsersSpec extends Specification { + void "user id of null authentication returns anonymous"() { + when: + def authentication = null + then: + SpinnakerUsers.getUserId(authentication) == SpinnakerUsers.ANONYMOUS + } + + void "user id of UserDetails returns username"() { + when: + def username = 'alpha' + def user = User.withUsername(username).password('').authorities('ROLE_USER').build() + def authentication = new TestingAuthenticationToken(user, null) + then: + SpinnakerUsers.getUserId(authentication) == username + } + + void "user id of AuthenticatedPrincipal returns name"() { + when: + def username = 'beta' + def principal = new TestAuthenticatedPrincipal(name: username) + def authentication = new TestingAuthenticationToken(principal, null) + then: + SpinnakerUsers.getUserId(authentication) == username + } + + void "user id of Principal returns name"() { + when: + def username = 'gamma' + def principal = new TestPrincipal(name: username) + def authentication = new TestingAuthenticationToken(principal, null) + then: + SpinnakerUsers.getUserId(authentication) == username + } + + void "current user id is anonymous by default"() { + when: + SecurityContextHolder.context.authentication = null + AuthenticatedRequest.clear() + then: + SpinnakerUsers.currentUserId == SpinnakerUsers.ANONYMOUS + } + + void "current user id uses current security context first"() { + when: + def username = 'delta' + def principal = new TestPrincipal(name: username) + SecurityContextHolder.context.authentication = new TestingAuthenticationToken(principal, null) + AuthenticatedRequest.setUser("not $username") + then: + SpinnakerUsers.currentUserId == username + } + + void "current user id uses authenticated request second"() { + when: + def username = 'epsilon' + SecurityContextHolder.clearContext() + AuthenticatedRequest.setUser(username) + then: + SpinnakerUsers.currentUserId == username + } + + static class TestPrincipal implements Principal { + String name + } + + static class TestAuthenticatedPrincipal implements AuthenticatedPrincipal { + String name + } +} diff --git a/kork-sql-test/kork-sql-test.gradle b/kork-sql-test/kork-sql-test.gradle index 86c39965a..e2a8a0992 100644 --- a/kork-sql-test/kork-sql-test.gradle +++ b/kork-sql-test/kork-sql-test.gradle @@ -8,7 +8,6 @@ dependencies { api("org.testcontainers:postgresql") testImplementation "org.junit.jupiter:junit-jupiter-api" - testImplementation "org.junit.platform:junit-platform-runner" runtimeOnly "com.h2database:h2" diff --git a/kork-sql-test/src/main/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtil.java b/kork-sql-test/src/main/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtil.java index 1e55f132f..2ee16db68 100644 --- a/kork-sql-test/src/main/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtil.java +++ b/kork-sql-test/src/main/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtil.java @@ -223,7 +223,7 @@ public static TestDatabase initDatabase( Liquibase migrate; try { - DatabaseChangeLog changeLog = new DatabaseChangeLog(); + DatabaseChangeLog changeLog = new DatabaseChangeLog("db/changelog/"); changeLog.setChangeLogParameters( new ChangeLogParameters( diff --git a/kork-sql-test/src/test/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtilTest.java b/kork-sql-test/src/test/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtilTest.java index 8abea1029..69b603fca 100644 --- a/kork-sql-test/src/test/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtilTest.java +++ b/kork-sql-test/src/test/java/com/netflix/spinnaker/kork/sql/test/SqlTestUtilTest.java @@ -15,19 +15,16 @@ */ package com.netflix.spinnaker.kork.sql.test; -import static org.junit.Assume.assumeTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.junit.platform.runner.JUnitPlatform; -import org.junit.runner.RunWith; import org.testcontainers.DockerClientFactory; /** * Verify that SqlTestUtil can bring up database containers. Beyond testing the code, it also helps * to verify that appropriate docker images are available in CI environments. */ -@RunWith(JUnitPlatform.class) public class SqlTestUtilTest { @BeforeAll diff --git a/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/config/SqlMigrationProperties.kt b/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/config/SqlMigrationProperties.kt index 7a52c2025..e88a7c578 100644 --- a/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/config/SqlMigrationProperties.kt +++ b/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/config/SqlMigrationProperties.kt @@ -15,6 +15,8 @@ */ package com.netflix.spinnaker.kork.sql.config +import liquibase.GlobalConfiguration + /** * Defines the configuration properties for connecting to a SQL database for schema migration purposes. * @@ -23,11 +25,14 @@ package com.netflix.spinnaker.kork.sql.config * @param password The password to authenticate the [user] * @param driver The JDBC driver name * @param additionalChangeLogs A list of additional change log paths. This is useful for libraries and extensions. + * @param duplicateFileMode flag to handle if multiple files are found in the search path that have duplicate paths. */ data class SqlMigrationProperties( var jdbcUrl: String? = null, var user: String? = null, var password: String? = null, var driver: String? = null, - var additionalChangeLogs: List = mutableListOf() + var additionalChangeLogs: List = mutableListOf(), + var duplicateFileMode: GlobalConfiguration.DuplicateFileMode = GlobalConfiguration.DUPLICATE_FILE_MODE.defaultValue + ) diff --git a/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/migration/SpringLiquibaseProxy.kt b/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/migration/SpringLiquibaseProxy.kt index 3cf1a7935..574ceb708 100644 --- a/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/migration/SpringLiquibaseProxy.kt +++ b/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/migration/SpringLiquibaseProxy.kt @@ -16,6 +16,8 @@ package com.netflix.spinnaker.kork.sql.migration import com.netflix.spinnaker.kork.sql.config.SqlMigrationProperties +import liquibase.GlobalConfiguration +import liquibase.Scope import javax.sql.DataSource import liquibase.integration.spring.SpringLiquibase import org.springframework.jdbc.datasource.SingleConnectionDataSource @@ -52,18 +54,20 @@ class SpringLiquibaseProxy( super.afterPropertiesSet() // Then if anything else has been defined, do that afterwards - (sqlMigrationProperties.additionalChangeLogs + korkAdditionalChangelogs) - .map { - SpringLiquibase().apply { - changeLog = "classpath:$it" - dataSource = createDataSource() - resourceLoader = this@SpringLiquibaseProxy.resourceLoader - shouldRun = !sqlReadOnly + Scope.child(GlobalConfiguration.DUPLICATE_FILE_MODE.key, sqlMigrationProperties.duplicateFileMode) { + (sqlMigrationProperties.additionalChangeLogs + korkAdditionalChangelogs) + .map { + SpringLiquibase().apply { + changeLog = "classpath:$it" + dataSource = createDataSource() + resourceLoader = this@SpringLiquibaseProxy.resourceLoader + shouldRun = !sqlReadOnly + } } - } - .forEach { - it.afterPropertiesSet() - } + .forEach { + it.afterPropertiesSet() + } + } } private fun createDataSource(): DataSource = diff --git a/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/telemetry/HikariSpectatorMetricsTracker.kt b/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/telemetry/HikariSpectatorMetricsTracker.kt index d1b3072cd..5fd2378f6 100644 --- a/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/telemetry/HikariSpectatorMetricsTracker.kt +++ b/kork-sql/src/main/kotlin/com/netflix/spinnaker/kork/sql/telemetry/HikariSpectatorMetricsTracker.kt @@ -33,19 +33,19 @@ class HikariSpectatorMetricsTracker( private val connectionUsageId = registry.createId("sql.pool.$poolName.connectionUsageTiming") private val connectionTimeoutId = registry.createId("sql.pool.$poolName.connectionTimeout") - private val idleConnectionsId = registry.createId("sql.pool.$poolName.idle") - private val activeConnectionsId = registry.createId("sql.pool.$poolName.active") - private val totalConnectionsId = registry.createId("sql.pool.$poolName.total") - private val blockedThreadsId = registry.createId("sql.pool.$poolName.blocked") + private val idleConnectionsGauge = registry.gauge("sql.pool.$poolName.idle") + private val activeConnectionsGauge = registry.gauge("sql.pool.$poolName.active") + private val totalConnectionsGauge = registry.gauge("sql.pool.$poolName.total") + private val blockedThreadsGauge = registry.gauge("sql.pool.$poolName.blocked") /** * Record the individual pool's statistics. */ fun recordPoolStats() { - registry.gauge(idleConnectionsId).set(poolStats.idleConnections.toDouble()) - registry.gauge(activeConnectionsId).set(poolStats.activeConnections.toDouble()) - registry.gauge(totalConnectionsId).set(poolStats.totalConnections.toDouble()) - registry.gauge(blockedThreadsId).set(poolStats.pendingThreads.toDouble()) + idleConnectionsGauge.set(poolStats.idleConnections.toDouble()) + activeConnectionsGauge.set(poolStats.activeConnections.toDouble()) + totalConnectionsGauge.set(poolStats.totalConnections.toDouble()) + blockedThreadsGauge.set(poolStats.pendingThreads.toDouble()) } override fun recordConnectionAcquiredNanos(elapsedAcquiredNanos: Long) { diff --git a/kork-sql/src/test/kotlin/com/netflix/spinnaker/kork/sql/SpringStartupTests.kt b/kork-sql/src/test/kotlin/com/netflix/spinnaker/kork/sql/SpringStartupTests.kt index 080ab5153..d23df51ee 100644 --- a/kork-sql/src/test/kotlin/com/netflix/spinnaker/kork/sql/SpringStartupTests.kt +++ b/kork-sql/src/test/kotlin/com/netflix/spinnaker/kork/sql/SpringStartupTests.kt @@ -21,8 +21,8 @@ import com.netflix.spinnaker.kork.sql.health.SqlHealthIndicator import org.jooq.DSLContext import org.jooq.impl.DSL.field import org.jooq.impl.DSL.table -import org.junit.Test -import org.junit.runner.RunWith +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.getBeansOfType import org.springframework.boot.actuate.health.HealthIndicator @@ -30,19 +30,21 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Import -import org.springframework.test.context.junit4.SpringRunner +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension import strikt.api.expectThat import strikt.assertions.isA import strikt.assertions.isEqualTo import strikt.assertions.isNotNull -@RunWith(SpringRunner::class) +@ExtendWith(SpringExtension::class) @SpringBootTest( classes = [StartupTestApp::class], properties = [ "sql.enabled=true", "sql.migration.jdbcUrl=jdbc:h2:mem:test", "sql.migration.dialect=H2", + "sql.migration.duplicateFileMode=WARN", "sql.connectionPool.jdbcUrl=jdbc:h2:mem:test", "sql.connectionPool.dialect=H2" ] diff --git a/kork-sql/src/test/resources/application-test.yml b/kork-sql/src/test/resources/application-test.yml index c3b01d241..662c6420d 100644 --- a/kork-sql/src/test/resources/application-test.yml +++ b/kork-sql/src/test/resources/application-test.yml @@ -26,10 +26,12 @@ sql: jdbcUrl: "jdbc:h2:mem:test" user: password: + duplicateFileMode: WARN secondaryMigration: jdbcUrl: "jdbc:h2:mem:test" user: password: + duplicateFileMode: WARN --- spring: @@ -52,3 +54,4 @@ sql: jdbcUrl: "jdbc:h2:mem:test" user: password: + duplicateFileMode: WARN diff --git a/kork-sql/src/test/resources/db/changelog-master.yml b/kork-sql/src/test/resources/db/changelog-master.yml index daf9f00fc..3d2c63d3b 100644 --- a/kork-sql/src/test/resources/db/changelog-master.yml +++ b/kork-sql/src/test/resources/db/changelog-master.yml @@ -1 +1,21 @@ -databaseChangeLog: [] +databaseChangeLog: + - changeSet: + id: create-sample-table + author: kirangodishala + changes: + - createTable: + tableName: sample + columns: + - column: + name: id + type: boolean + constraints: + primaryKey: true + nullable: false + - modifySql: + dbms: mysql + append: + value: " engine innodb" + rollback: + - dropTable: + tableName: sample diff --git a/kork-stackdriver/kork-stackdriver.gradle b/kork-stackdriver/kork-stackdriver.gradle index ddc861667..be5035d0b 100644 --- a/kork-stackdriver/kork-stackdriver.gradle +++ b/kork-stackdriver/kork-stackdriver.gradle @@ -34,5 +34,6 @@ dependencies { compileOnly "org.springframework.boot:spring-boot-autoconfigure" testImplementation "org.mockito:mockito-core" - testImplementation "junit:junit" + testImplementation "org.junit.jupiter:junit-jupiter-api" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } diff --git a/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MetricDescriptorCacheTest.java b/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MetricDescriptorCacheTest.java index 1fc20b35e..d2115201a 100644 --- a/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MetricDescriptorCacheTest.java +++ b/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MetricDescriptorCacheTest.java @@ -16,6 +16,8 @@ package com.netflix.spectator.stackdriver; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.times; @@ -36,18 +38,14 @@ import java.util.List; import java.util.Set; import java.util.function.Predicate; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; -@RunWith(JUnit4.class) public class MetricDescriptorCacheTest { static class ReturnExecuteDescriptorArg implements Answer { private Monitoring.Projects.MetricDescriptors.Create mockCreateMethod; @@ -142,7 +140,7 @@ Set getLabelKeys(Iterable labels) { return result; } - @Before + @BeforeEach public void setup() { MockitoAnnotations.initMocks(this); when(monitoringApi.projects()).thenReturn(projectsApi); @@ -165,7 +163,7 @@ public void setup() { @Test public void descriptorTypeAreCompliant() { - Assert.assertEquals( + assertEquals( "custom.googleapis.com/TESTNAMESPACE/" + applicationName + "/idA", cache.idToDescriptorType(idA)); } @@ -196,7 +194,7 @@ public void testAddLabel() throws IOException { when(mockGetMethod.execute()).thenReturn(origDescriptor); when(mockCreateMethod.execute()).thenReturn(updatedDescriptor); - Assert.assertEquals(updatedDescriptor, cache.addLabel(type, label)); + assertEquals(updatedDescriptor, cache.addLabel(type, label)); verify(mockGetMethod, times(1)).execute(); verify(mockDeleteMethod, times(1)).execute(); verify(mockCreateMethod, times(1)).execute(); @@ -229,7 +227,7 @@ public void testAddLabelWithDeleteFailure() throws IOException { when(mockDeleteMethod.execute()).thenThrow(new IOException("Not Found")); when(mockCreateMethod.execute()).thenReturn(updatedDescriptor); - Assert.assertEquals(updatedDescriptor, cache.addLabel(type, label)); + assertEquals(updatedDescriptor, cache.addLabel(type, label)); verify(mockGetMethod, times(1)).execute(); verify(mockDeleteMethod, times(1)).execute(); verify(mockCreateMethod, times(1)).execute(); @@ -261,7 +259,7 @@ public void testAddLabelWithCreateFailure() throws IOException { when(mockGetMethod.execute()).thenReturn(origDescriptor); when(mockCreateMethod.execute()).thenThrow(new IOException("Not Found")); - Assert.assertNull(cache.addLabel(type, label)); + assertNull(cache.addLabel(type, label)); verify(mockGetMethod, times(1)).execute(); verify(mockDeleteMethod, times(1)).execute(); @@ -288,7 +286,7 @@ public void testAddLabelAlreadyExists() throws IOException { when(descriptorsApi.create(any(), any())).thenReturn(mockCreateMethod); when(mockGetMethod.execute()).thenReturn(origDescriptor); - Assert.assertEquals(origDescriptor, cache.addLabel(type, label)); + assertEquals(origDescriptor, cache.addLabel(type, label)); verify(mockGetMethod, times(1)).execute(); verify(mockDeleteMethod, times(0)).execute(); diff --git a/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MonitoredResourceBuilderTest.java b/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MonitoredResourceBuilderTest.java index 3db2699e6..9509a0f6e 100644 --- a/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MonitoredResourceBuilderTest.java +++ b/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/MonitoredResourceBuilderTest.java @@ -16,6 +16,7 @@ package com.netflix.spectator.stackdriver; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; @@ -25,18 +26,14 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; -@RunWith(JUnit4.class) public class MonitoredResourceBuilderTest { MonitoredResourceBuilder builder; - @Before + @BeforeEach public void setup() { builder = spy(new MonitoredResourceBuilder()); } @@ -59,8 +56,8 @@ public void testGceInstance() throws IOException { labels.put("zone", zone); MonitoredResource resource = builder.build(); - Assert.assertEquals("gce_instance", resource.getType()); - Assert.assertEquals(labels, resource.getLabels()); + assertEquals("gce_instance", resource.getType()); + assertEquals(labels, resource.getLabels()); } @Test @@ -72,9 +69,9 @@ public void testMatchAttribute() { + " \"region\" : \"us-east-1\"\n" + "}"; - Assert.assertEquals("the-instance", builder.matchAttribute(text, "instanceId")); - Assert.assertEquals("us-east-1", builder.matchAttribute(text, "region")); - Assert.assertEquals("", builder.matchAttribute(text, "notFound")); + assertEquals("the-instance", builder.matchAttribute(text, "instanceId")); + assertEquals("us-east-1", builder.matchAttribute(text, "region")); + assertEquals("", builder.matchAttribute(text, "notFound")); } @Test @@ -115,8 +112,8 @@ public void testEc2Instance() throws IOException { labels.put("project_id", project); MonitoredResource resource = builder.build(); - Assert.assertEquals("aws_ec2_instance", resource.getType()); - Assert.assertEquals(labels, resource.getLabels()); + assertEquals("aws_ec2_instance", resource.getType()); + assertEquals(labels, resource.getLabels()); } @Test @@ -131,7 +128,7 @@ public void testGlobal() throws IOException { labels.put("project_id", project); MonitoredResource resource = builder.build(); - Assert.assertEquals("global", resource.getType()); - Assert.assertEquals(labels, resource.getLabels()); + assertEquals("global", resource.getType()); + assertEquals(labels, resource.getLabels()); } } diff --git a/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/StackdriverWriterTest.java b/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/StackdriverWriterTest.java index 82e449f86..ce1424f5e 100644 --- a/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/StackdriverWriterTest.java +++ b/kork-stackdriver/src/test/java/com/netflix/spectator/stackdriver/StackdriverWriterTest.java @@ -16,6 +16,8 @@ package com.netflix.spectator.stackdriver; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.doReturn; @@ -45,18 +47,14 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -@RunWith(JUnit4.class) public class StackdriverWriterTest { static class TestableStackdriverWriter extends StackdriverWriter { public TestableStackdriverWriter(ConfigParams params) { @@ -160,7 +158,7 @@ Set getLabelKeys(Iterable labels) { return result; } - @Before + @BeforeEach public void setup() { MockitoAnnotations.initMocks(this); when(monitoringApi.projects()).thenReturn(projectsApi); @@ -201,7 +199,7 @@ public void testConfigParamsDefaultInstanceId() { .setApplicationName(applicationName) .setMeasurementFilter(allowAll) .build(); - Assert.assertTrue(!config.getInstanceId().isEmpty()); + assertTrue(!config.getInstanceId().isEmpty()); } TimeSeries makeTimeSeries(MetricDescriptor descriptor, Id id, double value, String time) { @@ -280,10 +278,10 @@ public void testAddMeasurementsToTimeSeries() { descriptorRegistrySpy.addExtraTimeSeriesLabel( MetricDescriptorCache.INSTANCE_LABEL, INSTANCE_ID); - Assert.assertEquals( + assertEquals( makeTimeSeries(descriptorA, idAXY, 1, timeA), writer.measurementToTimeSeries(descriptorA.getType(), testRegistry, timerA, measureAXY)); - Assert.assertEquals( + assertEquals( makeTimeSeries(descriptorB, idBXY, 20.1, timeB), writer.measurementToTimeSeries(descriptorB.getType(), testRegistry, timerB, measureBXY)); } @@ -311,7 +309,7 @@ public void writeRegistryWithSmallRegistry() throws IOException { ArgumentCaptor.forClass(CreateTimeSeriesRequest.class); verify(timeseriesApi, times(1)).create(eq("projects/test-project"), captor.capture()); // A, B, timer count and totalTime. - Assert.assertEquals(4, captor.getValue().getTimeSeries().size()); + assertEquals(4, captor.getValue().getTimeSeries().size()); } @Test @@ -372,8 +370,8 @@ public boolean matches(CreateTimeSeriesRequest obj) { spy.writeRegistry(registry); verify(mockCreateMethod, times(2)).execute(); - Assert.assertEquals(1, match200.found); - Assert.assertEquals(1, match1.found); + assertEquals(1, match200.found); + assertEquals(1, match1.found); } @Test @@ -385,6 +383,6 @@ public void writeRegistryWithTimer() throws IOException { // If we get the expected result then we matched the expected descriptors, // which means the transforms occurred as expected. List tsList = writer.registryToTimeSeries(testRegistry); - Assert.assertEquals(2, tsList.size()); + assertEquals(2, tsList.size()); } } diff --git a/kork-swagger/kork-swagger.gradle b/kork-swagger/kork-swagger.gradle index 0ba8870b2..5d57e7d5c 100644 --- a/kork-swagger/kork-swagger.gradle +++ b/kork-swagger/kork-swagger.gradle @@ -7,7 +7,6 @@ dependencies { implementation "com.google.guava:guava" implementation "org.springframework.boot:spring-boot-autoconfigure" - implementation "io.springfox:springfox-swagger2" - implementation "io.springfox:springfox-swagger-ui" + implementation "io.springfox:springfox-boot-starter" } diff --git a/kork-swagger/src/main/java/com/netflix/spinnaker/config/SwaggerConfig.java b/kork-swagger/src/main/java/com/netflix/spinnaker/config/SwaggerConfig.java index 54823325c..662e45b2f 100644 --- a/kork-swagger/src/main/java/com/netflix/spinnaker/config/SwaggerConfig.java +++ b/kork-swagger/src/main/java/com/netflix/spinnaker/config/SwaggerConfig.java @@ -16,13 +16,10 @@ package com.netflix.spinnaker.config; -import static com.google.common.base.Predicates.or; - -import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; +import java.util.function.Predicate; import javax.annotation.Nullable; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -32,11 +29,9 @@ import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.service.ApiInfo; import springfox.documentation.spi.DocumentationType; -import springfox.documentation.spring.web.paths.AbstractPathProvider; +import springfox.documentation.spring.web.paths.DefaultPathProvider; import springfox.documentation.spring.web.plugins.Docket; -import springfox.documentation.swagger2.annotations.EnableSwagger2; -@EnableSwagger2 @Configuration @ConditionalOnProperty("swagger.enabled") @ConfigurationProperties(prefix = "swagger") @@ -54,7 +49,7 @@ public class SwaggerConfig { @Bean public Docket gateApi() { return new Docket(DocumentationType.SWAGGER_2) - .pathProvider(new BasePathProvider(basePath, documentationPath)) + .pathProvider(new BasePathProvider(documentationPath)) .select() .apis(RequestHandlerSelectors.any()) .paths(paths()) @@ -80,7 +75,7 @@ private static Class getClassIfPresent(String name) { } private Predicate paths() { - return or(patterns.stream().map(PathSelectors::regex).collect(Collectors.toList())); + return patterns.stream().map(PathSelectors::regex).reduce((x, y) -> x.or(y)).get(); } private ApiInfo apiInfo() { @@ -123,20 +118,13 @@ public String getDocumentationPath() { return documentationPath; } - public class BasePathProvider extends AbstractPathProvider { - private String basePath; + public class BasePathProvider extends DefaultPathProvider { private String documentationPath; - private BasePathProvider(String basePath, String documentationPath) { - this.basePath = basePath; + private BasePathProvider(String documentationPath) { this.documentationPath = documentationPath; } - @Override - protected String applicationPath() { - return basePath; - } - @Override protected String getDocumentationPath() { return documentationPath; diff --git a/kork-tomcat/kork-tomcat.gradle b/kork-tomcat/kork-tomcat.gradle index 8dedbdb51..30a305248 100644 --- a/kork-tomcat/kork-tomcat.gradle +++ b/kork-tomcat/kork-tomcat.gradle @@ -8,6 +8,7 @@ dependencies { implementation("com.netflix.spectator:spectator-api") implementation("com.google.guava:guava") implementation(project(":kork-core")) + implementation(project(":kork-crypto")) testImplementation "org.springframework.boot:spring-boot-starter-test" } diff --git a/kork-tomcat/src/main/java/com/netflix/spinnaker/kork/tomcat/TomcatConfigurationProperties.java b/kork-tomcat/src/main/java/com/netflix/spinnaker/kork/tomcat/TomcatConfigurationProperties.java index a5eaf94c8..1f890711a 100644 --- a/kork-tomcat/src/main/java/com/netflix/spinnaker/kork/tomcat/TomcatConfigurationProperties.java +++ b/kork-tomcat/src/main/java/com/netflix/spinnaker/kork/tomcat/TomcatConfigurationProperties.java @@ -17,6 +17,7 @@ package com.netflix.spinnaker.kork.tomcat; +import com.netflix.spinnaker.kork.crypto.CipherSuites; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -31,27 +32,9 @@ public class TomcatConfigurationProperties { private String relaxedQueryCharacters = ""; private String relaxedPathCharacters = ""; - private List tlsVersions = new ArrayList<>(Arrays.asList("TLSv1.2", "TLSv1.1")); - - // Defaults from https://wiki.mozilla.org/Security/Server_Side_TLS#Modern_compatibility - // with some extra ciphers (non SHA384/256) to support TLSv1.1 and some non EC ciphers - private List cipherSuites = - new ArrayList<>( - Arrays.asList( - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", - "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", - "TLS_DHE_RSA_WITH_AES_128_CBC_SHA")); + private List tlsVersions = new ArrayList<>(Arrays.asList("TLSv1.3", "TLSv1.2")); + + private List cipherSuites = CipherSuites.getRecommendedCiphers(); private Boolean rejectIllegalHeader; diff --git a/kork-web/kork-web.gradle b/kork-web/kork-web.gradle index 21e45ae69..313f38191 100644 --- a/kork-web/kork-web.gradle +++ b/kork-web/kork-web.gradle @@ -1,5 +1,6 @@ apply plugin: "java-library" apply plugin: "groovy" +apply from: "$rootDir/gradle/lombok.gradle" sourceSets { main { @@ -12,10 +13,12 @@ dependencies { api(platform(project(":spinnaker-dependencies"))) api project(":kork-core") + api project(":kork-crypto") api project(":kork-security") api project(":kork-exceptions") api "org.codehaus.groovy:groovy" api "org.springframework.boot:spring-boot-starter-web" + api "org.springframework.boot:spring-boot-starter-webflux" api "org.springframework.boot:spring-boot-starter-security" api "org.springframework.security:spring-security-core" api "com.netflix.spectator:spectator-api" @@ -46,6 +49,7 @@ dependencies { testImplementation "org.spockframework:spock-spring" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "com.netflix.spectator:spectator-reg-micrometer" + testImplementation testFixtures(project(":kork-crypto")) testRuntimeOnly "cglib:cglib-nodep" testRuntimeOnly "org.objenesis:objenesis" } diff --git a/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttp3ClientConfiguration.groovy b/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttp3ClientConfiguration.groovy index ce4d9635e..f38ca837a 100644 --- a/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttp3ClientConfiguration.groovy +++ b/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttp3ClientConfiguration.groovy @@ -19,7 +19,7 @@ package com.netflix.spinnaker.config import com.netflix.spinnaker.okhttp.SpinnakerRequestHeaderInterceptor import okhttp3.Dispatcher import okhttp3.logging.HttpLoggingInterceptor -import org.springframework.beans.factory.annotation.Value +import org.springframework.beans.factory.ObjectFactory import static com.google.common.base.Preconditions.checkState import com.netflix.spinnaker.okhttp.OkHttp3MetricsInterceptor @@ -48,40 +48,49 @@ import java.util.concurrent.TimeUnit class OkHttp3ClientConfiguration { private final OkHttpClientConfigurationProperties okHttpClientConfigurationProperties private final OkHttp3MetricsInterceptor okHttp3MetricsInterceptor + private final ObjectFactory httpClientBuilderFactory /** * Logging level for retrofit2 client calls */ - private final HttpLoggingInterceptor.Level retrofit2LogLevel; + private final HttpLoggingInterceptor.Level retrofit2LogLevel /** * {@link okhttp3.Interceptor} which adds spinnaker auth headers to requests when retrofit2 client used */ - private final SpinnakerRequestHeaderInterceptor spinnakerRequestHeaderInterceptor; + private final SpinnakerRequestHeaderInterceptor spinnakerRequestHeaderInterceptor @Autowired - OkHttp3ClientConfiguration(OkHttpClientConfigurationProperties okHttpClientConfigurationProperties, OkHttp3MetricsInterceptor okHttp3MetricsInterceptor, - HttpLoggingInterceptor.Level retrofit2LogLevel, SpinnakerRequestHeaderInterceptor spinnakerRequestHeaderInterceptor) { + OkHttp3ClientConfiguration(OkHttpClientConfigurationProperties okHttpClientConfigurationProperties, + OkHttp3MetricsInterceptor okHttp3MetricsInterceptor, + HttpLoggingInterceptor.Level retrofit2LogLevel, + SpinnakerRequestHeaderInterceptor spinnakerRequestHeaderInterceptor, + ObjectFactory httpClientBuilderFactory) { this.okHttpClientConfigurationProperties = okHttpClientConfigurationProperties this.okHttp3MetricsInterceptor = okHttp3MetricsInterceptor this.retrofit2LogLevel = retrofit2LogLevel this.spinnakerRequestHeaderInterceptor = spinnakerRequestHeaderInterceptor + this.httpClientBuilderFactory = httpClientBuilderFactory } public OkHttp3ClientConfiguration(OkHttpClientConfigurationProperties okHttpClientConfigurationProperties, OkHttp3MetricsInterceptor okHttp3MetricsInterceptor) { - this.okHttpClientConfigurationProperties = okHttpClientConfigurationProperties - this.okHttp3MetricsInterceptor = okHttp3MetricsInterceptor + this(okHttpClientConfigurationProperties, okHttp3MetricsInterceptor, null, null, + { new OkHttpClient.Builder() }) } public OkHttp3ClientConfiguration(OkHttpClientConfigurationProperties okHttpClientConfigurationProperties) { - this.okHttpClientConfigurationProperties = okHttpClientConfigurationProperties + this(okHttpClientConfigurationProperties, null) } /** * @return OkHttpClient w/ key and trust stores */ OkHttpClient.Builder create() { + if (okHttpClientConfigurationProperties.refreshableKeys.enabled) { + // already configured via OkHttpClientCustomizer beans + return httpClientBuilderFactory.object + } OkHttpClient.Builder okHttpClientBuilder = createBasicClient() @@ -100,6 +109,10 @@ class OkHttp3ClientConfiguration { * @return OkHttpClient with SpinnakerRequestHeaderInterceptor as initial interceptor w/ key and trust stores */ OkHttpClient.Builder createForRetrofit2() { + if (okHttpClientConfigurationProperties.refreshableKeys.enabled) { + // already configured via OkHttpClientCustomizer beans + return httpClientBuilderFactory.object + } OkHttpClient.Builder okHttpClientBuilder = createBasicClient() diff --git a/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttpClientComponents.groovy b/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttpClientComponents.groovy deleted file mode 100644 index 64f8b041e..000000000 --- a/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttpClientComponents.groovy +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2016 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * 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. - */ - -package com.netflix.spinnaker.config - -import com.netflix.spectator.api.Registry -import com.netflix.spinnaker.okhttp.OkHttp3MetricsInterceptor -import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties -import com.netflix.spinnaker.okhttp.OkHttpMetricsInterceptor -import com.netflix.spinnaker.okhttp.SpinnakerRequestHeaderInterceptor -import com.netflix.spinnaker.okhttp.SpinnakerRequestInterceptor -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -import javax.inject.Provider - -@Configuration -@EnableConfigurationProperties([OkHttpClientConfigurationProperties, OkHttpMetricsInterceptorProperties]) -class OkHttpClientComponents { - @Bean - SpinnakerRequestInterceptor spinnakerRequestInterceptor(OkHttpClientConfigurationProperties okHttpClientConfigurationProperties) { - return new SpinnakerRequestInterceptor(okHttpClientConfigurationProperties) - } - - @Bean - SpinnakerRequestHeaderInterceptor spinnakerRequestHeaderInterceptor(OkHttpClientConfigurationProperties okHttpClientConfigurationProperties) { - return new SpinnakerRequestHeaderInterceptor(okHttpClientConfigurationProperties) - } - - @Bean - OkHttpMetricsInterceptor okHttpMetricsInterceptor( - Provider registry, - OkHttpMetricsInterceptorProperties okHttpMetricsInterceptorProperties) { - - return new OkHttpMetricsInterceptor(registry, okHttpMetricsInterceptorProperties) - } - - @Bean - OkHttp3MetricsInterceptor okHttp3MetricsInterceptor( - Provider registry, - OkHttpMetricsInterceptorProperties okHttpMetricsInterceptorProperties) { - - return new OkHttp3MetricsInterceptor(registry, okHttpMetricsInterceptorProperties) - } -} diff --git a/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttpClientComponents.java b/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttpClientComponents.java new file mode 100644 index 000000000..e187e079d --- /dev/null +++ b/kork-web/src/main/groovy/com/netflix/spinnaker/config/OkHttpClientComponents.java @@ -0,0 +1,277 @@ +/* + * Copyright 2016 Netflix, Inc.; Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.config; + +import brave.http.HttpTracing; +import brave.okhttp3.TracingInterceptor; +import com.netflix.spectator.api.Registry; +import com.netflix.spinnaker.config.okhttp3.OkHttpClientCustomizer; +import com.netflix.spinnaker.kork.crypto.PasswordProvider; +import com.netflix.spinnaker.kork.crypto.SecureRandomBuilder; +import com.netflix.spinnaker.kork.crypto.StandardCrypto; +import com.netflix.spinnaker.kork.crypto.TrustStores; +import com.netflix.spinnaker.kork.crypto.X509Identity; +import com.netflix.spinnaker.kork.crypto.X509IdentitySource; +import com.netflix.spinnaker.okhttp.OkHttp3MetricsInterceptor; +import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties; +import com.netflix.spinnaker.okhttp.OkHttpMetricsInterceptor; +import com.netflix.spinnaker.okhttp.SpinnakerRequestHeaderInterceptor; +import com.netflix.spinnaker.okhttp.SpinnakerRequestInterceptor; +import com.netflix.spinnaker.retrofit.Retrofit2ConfigurationProperties; +import com.netflix.spinnaker.retrofit.RetrofitConfigurationProperties; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import javax.inject.Provider; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import lombok.RequiredArgsConstructor; +import okhttp3.ConnectionPool; +import okhttp3.ConnectionSpec; +import okhttp3.Dispatcher; +import okhttp3.OkHttpClient; +import okhttp3.logging.HttpLoggingInterceptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.task.TaskExecutorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.core.task.support.ExecutorServiceAdapter; +import org.springframework.util.CollectionUtils; + +/** Provides OkHttpClient beans. */ +@Configuration(proxyBeanMethods = false) +@RequiredArgsConstructor +@EnableConfigurationProperties({ + OkHttpClientConfigurationProperties.class, + OkHttpMetricsInterceptorProperties.class, + RetrofitConfigurationProperties.class, + Retrofit2ConfigurationProperties.class +}) +public class OkHttpClientComponents { + private final Provider registryProvider; + private final OkHttpClientConfigurationProperties clientProperties; + private final OkHttpMetricsInterceptorProperties metricsProperties; + private final Retrofit2ConfigurationProperties retrofit2Properties; + + @Bean + public SpinnakerRequestInterceptor spinnakerRequestInterceptor() { + return new SpinnakerRequestInterceptor(clientProperties); + } + + @Bean + public SpinnakerRequestHeaderInterceptor spinnakerRequestHeaderInterceptor() { + return new SpinnakerRequestHeaderInterceptor(clientProperties); + } + + @Bean + public OkHttpMetricsInterceptor okHttpMetricsInterceptor() { + return new OkHttpMetricsInterceptor(registryProvider, metricsProperties); + } + + @Bean + public OkHttp3MetricsInterceptor okHttp3MetricsInterceptor() { + return new OkHttp3MetricsInterceptor(registryProvider, metricsProperties); + } + + /** Adds a metrics interceptor to clients. */ + @Bean + public OkHttpClientCustomizer metricsInterceptorCustomizer( + OkHttp3MetricsInterceptor metricsInterceptor) { + return builder -> builder.addInterceptor(metricsInterceptor); + } + + @Bean + public OkHttpClientCustomizer requestHeaderInterceptorCustomizer( + SpinnakerRequestHeaderInterceptor headerInterceptor) { + return builder -> builder.addInterceptor(headerInterceptor); + } + + /** + * Adds an HTTP tracing interceptor to clients if enabled. + * + * @see HttpTracing + */ + @Bean + @ConditionalOnBean(HttpTracing.class) + public OkHttpClientCustomizer tracingInterceptorCustomizer(HttpTracing httpTracing) { + return builder -> builder.addNetworkInterceptor(TracingInterceptor.create(httpTracing)); + } + + /** + * Configures a common dispatcher for clients. + * + * @see org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration + */ + @Bean + public Dispatcher okhttpDispatcher(TaskExecutorBuilder taskExecutorBuilder) { + var dispatcher = new Dispatcher(new ExecutorServiceAdapter(taskExecutorBuilder.build())); + dispatcher.setMaxRequests(clientProperties.getMaxRequests()); + dispatcher.setMaxRequestsPerHost(clientProperties.getMaxRequestsPerHost()); + return dispatcher; + } + + /** Configures request dispatching for clients. */ + @Bean + public OkHttpClientCustomizer dispatcherCustomizer(Dispatcher dispatcher) { + return builder -> builder.dispatcher(dispatcher); + } + + /** + * Configures connection pooling for clients. + * + * @see ConnectionPool + */ + @Bean + public OkHttpClientCustomizer connectionPoolCustomizer() { + var poolProperties = clientProperties.getConnectionPool(); + var connectionPool = + new ConnectionPool( + poolProperties.getMaxIdleConnections(), + poolProperties.getKeepAliveDurationMs(), + TimeUnit.MILLISECONDS); + return builder -> builder.connectionPool(connectionPool); + } + + /** + * Configures connection specifications allowed for clients. + * + * @see ConnectionSpec + */ + @Bean + public OkHttpClientCustomizer connectionSpecsCustomizer() { + var connectionSpecBuilder = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS); + var cipherSuites = clientProperties.getCipherSuites(); + if (!CollectionUtils.isEmpty(cipherSuites)) { + connectionSpecBuilder.cipherSuites(cipherSuites.toArray(String[]::new)); + } + var tlsVersions = clientProperties.getTlsVersions(); + if (!CollectionUtils.isEmpty(tlsVersions)) { + connectionSpecBuilder.tlsVersions(tlsVersions.toArray(String[]::new)); + } + var tlsConnectionSpec = connectionSpecBuilder.build(); + var connectionSpecs = List.of(tlsConnectionSpec, ConnectionSpec.CLEARTEXT); + return builder -> builder.connectionSpecs(connectionSpecs); + } + + /** + * Configures an {@link SSLContext} for clients. + * + * @see X509IdentitySource + * @see X509TrustManager + * @see SecureRandom + * @see javax.net.ssl.SSLSocketFactory + */ + @Bean + public OkHttpClientCustomizer sslContextCustomizer() + throws IOException, GeneralSecurityException { + var identity = loadKeyStore(); + var trustManager = loadTrustStore(); + var secureRandom = loadSecureRandom(); + SSLContext context; + if (identity != null) { + context = identity.createSSLContext(trustManager, secureRandom); + } else { + context = StandardCrypto.getTLSContext(); + context.init(null, new TrustManager[] {trustManager}, secureRandom); + } + return builder -> builder.sslSocketFactory(context.getSocketFactory(), trustManager); + } + + @Nullable + private X509Identity loadKeyStore() { + File keyStoreFile = clientProperties.getKeyStore(); + if (keyStoreFile == null) { + return null; + } + PasswordProvider passwordProvider = () -> clientProperties.getKeyStorePassword().toCharArray(); + return X509IdentitySource.fromKeyStore( + keyStoreFile.toPath(), clientProperties.getKeyStoreType(), passwordProvider) + .refreshable(clientProperties.getRefreshableKeys().getRefreshPeriod()); + } + + private X509TrustManager loadTrustStore() throws IOException, GeneralSecurityException { + File trustStoreFile = clientProperties.getTrustStore(); + if (trustStoreFile == null) { + return TrustStores.getSystemTrustManager(); + } + try (var stream = new FileInputStream(trustStoreFile)) { + KeyStore truststore = KeyStore.getInstance(clientProperties.getTrustStoreType()); + truststore.load(stream, clientProperties.getTrustStorePassword().toCharArray()); + return TrustStores.loadTrustManager(truststore); + } + } + + private SecureRandom loadSecureRandom() { + try { + return SecureRandom.getInstance(clientProperties.getSecureRandomInstanceType()); + } catch (NoSuchAlgorithmException ignored) { + } + try { + return SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException ignored) { + } + return SecureRandomBuilder.create().withPersonalizationString("OkHttp3").build(); + } + + /** Configures connection timeout settings for clients. */ + @Bean + public OkHttpClientCustomizer connectionTimeoutsCustomizer() { + var connectTimeout = Duration.ofMillis(clientProperties.getConnectTimeoutMs()); + var readTimeout = Duration.ofMillis(clientProperties.getReadTimeoutMs()); + var retryOnConnectionFailure = clientProperties.isRetryOnConnectionFailure(); + return builder -> + builder + .connectTimeout(connectTimeout) + .readTimeout(readTimeout) + .retryOnConnectionFailure(retryOnConnectionFailure); + } + + @Bean + public OkHttpClientCustomizer httpLoggingCustomizer() { + return builder -> + builder.addInterceptor( + new HttpLoggingInterceptor().setLevel(retrofit2Properties.getLogLevel())); + } + + /** + * Prototype bean for client builders. These prototypes come preconfigured with all registered + * client customizers applied. + * + * @see OkHttpClientCustomizer + */ + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + public OkHttpClient.Builder okHttpClientBuilder( + ObjectProvider customizers) { + var builder = new OkHttpClient.Builder(); + customizers.orderedStream().forEachOrdered(customizer -> customizer.customize(builder)); + return builder; + } +} diff --git a/kork-web/src/main/groovy/com/netflix/spinnaker/config/RetrofitConfiguration.groovy b/kork-web/src/main/groovy/com/netflix/spinnaker/config/RetrofitConfiguration.groovy index ce56be15d..92c03400a 100644 --- a/kork-web/src/main/groovy/com/netflix/spinnaker/config/RetrofitConfiguration.groovy +++ b/kork-web/src/main/groovy/com/netflix/spinnaker/config/RetrofitConfiguration.groovy @@ -19,13 +19,11 @@ package com.netflix.spinnaker.config import com.netflix.spinnaker.retrofit.Retrofit2ConfigurationProperties import com.netflix.spinnaker.retrofit.RetrofitConfigurationProperties import okhttp3.logging.HttpLoggingInterceptor -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import retrofit.RestAdapter @Configuration -@EnableConfigurationProperties([RetrofitConfigurationProperties, Retrofit2ConfigurationProperties]) class RetrofitConfiguration { @Bean RestAdapter.LogLevel retrofitLogLevel(RetrofitConfigurationProperties retrofitConfigurationProperties) { diff --git a/kork-web/src/main/groovy/com/netflix/spinnaker/okhttp/OkHttpClientConfigurationProperties.groovy b/kork-web/src/main/groovy/com/netflix/spinnaker/okhttp/OkHttpClientConfigurationProperties.groovy index 5eeb82feb..12a2390dc 100644 --- a/kork-web/src/main/groovy/com/netflix/spinnaker/okhttp/OkHttpClientConfigurationProperties.groovy +++ b/kork-web/src/main/groovy/com/netflix/spinnaker/okhttp/OkHttpClientConfigurationProperties.groovy @@ -16,8 +16,10 @@ package com.netflix.spinnaker.okhttp +import com.netflix.spinnaker.kork.crypto.CipherSuites import groovy.transform.AutoClone import groovy.transform.Canonical +import java.time.Duration import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.NestedConfigurationProperty @@ -42,23 +44,8 @@ class OkHttpClientConfigurationProperties { String secureRandomInstanceType = "NativePRNGNonBlocking" // TLS1.1 isn't supported in newer JVMs... do NOT try to add back - it's also insecure - List tlsVersions = ["TLSv1.2", "TLSv1.3"] - //Defaults from https://wiki.mozilla.org/Security/Server_Side_TLS#Modern_compatibility - // with some extra ciphers (non SHA384/256) to support TLSv1.1 and some non EC ciphers - List cipherSuites = [ - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" - ] + List tlsVersions = ["TLSv1.3", "TLSv1.2"] + List cipherSuites = CipherSuites.recommendedCiphers /** * Provide backwards compatibility for 'okHttpClient.connectTimoutMs' @@ -78,4 +65,17 @@ class OkHttpClientConfigurationProperties { boolean retryOnConnectionFailure = true + /** + * Configuration properties for supporting refreshable keys. When this is enabled, the configured + * keystore file will be periodically reloaded if the file changes. + */ + @Canonical + static class RefreshableKeys { + boolean enabled = false + Duration refreshPeriod = Duration.ofMinutes(30) + } + + @NestedConfigurationProperty + final RefreshableKeys refreshableKeys = new RefreshableKeys() + } diff --git a/kork-web/src/main/java/com/netflix/spinnaker/config/AuthenticatedRequestConfiguration.java b/kork-web/src/main/java/com/netflix/spinnaker/config/AuthenticatedRequestConfiguration.java new file mode 100644 index 000000000..fff05537f --- /dev/null +++ b/kork-web/src/main/java/com/netflix/spinnaker/config/AuthenticatedRequestConfiguration.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.config; + +import com.netflix.spinnaker.security.AuthenticatedRequest; +import com.netflix.spinnaker.security.AuthenticatedRequestDecorator; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.ClientRequest; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** Configures hooks for propagating authenticated request headers. */ +@Configuration +public class AuthenticatedRequestConfiguration { + + private static final String KEY = AuthenticatedRequestConfiguration.class.getName(); + + @PostConstruct + public void registerHook() { + Schedulers.onScheduleHook(KEY, AuthenticatedRequestDecorator::wrap); + } + + @PreDestroy + public void unregisterHook() { + Schedulers.resetOnScheduleHook(KEY); + } + + /** + * Configures an exchange filter function to propagate authenticated request headers into the + * requests created by {@linkplain org.springframework.web.reactive.function.client.WebClient web + * clients}. + */ + @Bean + public WebClientCustomizer authenticationHeadersPropagator() { + return webClientBuilder -> + webClientBuilder.filter( + (request, next) -> + Mono.deferContextual( + context -> { + Map authenticationHeaders = context.get(KEY); + ClientRequest authenticatedRequest = + ClientRequest.from(request) + .headers(httpHeaders -> httpHeaders.setAll(authenticationHeaders)) + .build(); + return next.exchange(authenticatedRequest); + }) + .contextWrite(context -> context.put(KEY, getAuthenticationHeaders()))); + } + + private static Map getAuthenticationHeaders() { + Map headers = new HashMap<>(); + AuthenticatedRequest.getAuthenticationHeaders() + .forEach( + (headerName, value) -> + value.ifPresent(headerValue -> headers.put(headerName, headerValue))); + return headers; + } +} diff --git a/kork-web/src/main/java/com/netflix/spinnaker/config/okhttp3/OkHttpClientCustomizer.java b/kork-web/src/main/java/com/netflix/spinnaker/config/okhttp3/OkHttpClientCustomizer.java new file mode 100644 index 000000000..0b0401efb --- /dev/null +++ b/kork-web/src/main/java/com/netflix/spinnaker/config/okhttp3/OkHttpClientCustomizer.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.config.okhttp3; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import okhttp3.OkHttpClient; +import org.springframework.beans.factory.ObjectProvider; + +/** + * Beans of this type may customize a {@link OkHttpClient.Builder} prototype. + * + * @see RefreshableOkHttpClientBuilderProvider + * @see com.netflix.spinnaker.config.OkHttpClientComponents#okHttpClientBuilder(ObjectProvider) + */ +@NonnullByDefault +@FunctionalInterface +public interface OkHttpClientCustomizer { + void customize(OkHttpClient.Builder builder); +} diff --git a/kork-web/src/main/java/com/netflix/spinnaker/config/okhttp3/RefreshableOkHttpClientBuilderProvider.java b/kork-web/src/main/java/com/netflix/spinnaker/config/okhttp3/RefreshableOkHttpClientBuilderProvider.java new file mode 100644 index 000000000..790b32e17 --- /dev/null +++ b/kork-web/src/main/java/com/netflix/spinnaker/config/okhttp3/RefreshableOkHttpClientBuilderProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.config.okhttp3; + +import com.netflix.spinnaker.config.ServiceEndpoint; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import lombok.RequiredArgsConstructor; +import okhttp3.OkHttpClient; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@Order(0) +@ConditionalOnProperty("ok-http-client.refreshable-keys.enabled") +@NonnullByDefault +@RequiredArgsConstructor +public class RefreshableOkHttpClientBuilderProvider implements OkHttpClientBuilderProvider { + private final ObjectFactory builderFactory; + + @Override + public OkHttpClient.Builder get(ServiceEndpoint service) { + return builderFactory.getObject(); + } +} diff --git a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/MdcCopyingAsyncTaskExecutor.java b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/MdcCopyingAsyncTaskExecutor.java index f73421ed9..a75b902bc 100644 --- a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/MdcCopyingAsyncTaskExecutor.java +++ b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/MdcCopyingAsyncTaskExecutor.java @@ -16,11 +16,10 @@ package com.netflix.spinnaker.kork.web.context; -import java.util.Map; +import com.netflix.spinnaker.security.AuthenticatedRequestDecorator; import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.Future; -import org.slf4j.MDC; import org.springframework.core.task.AsyncTaskExecutor; /** @@ -45,26 +44,11 @@ public MdcCopyingAsyncTaskExecutor(AsyncTaskExecutor asyncTaskExecutor) { } private Runnable wrapWithContext(final Runnable task) { - Map contextMap = MDC.getCopyOfContextMap(); - return () -> { - if (contextMap == null) { - MDC.clear(); - } else { - MDC.setContextMap(contextMap); - } - task.run(); - }; + return AuthenticatedRequestDecorator.wrap(task); } private Callable wrapWithContext(final Callable callable) { - Map contextMap = MDC.getCopyOfContextMap(); - if (contextMap == null) { - return callable; - } - return () -> { - MDC.setContextMap(contextMap); - return callable.call(); - }; + return AuthenticatedRequestDecorator.wrap(callable); } @Override diff --git a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/RequestContext.java b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/RequestContext.java index dc20ec791..1e5750513 100644 --- a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/RequestContext.java +++ b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/context/RequestContext.java @@ -46,6 +46,14 @@ default Optional getApplication() { return get(Header.APPLICATION); } + default Optional getAccount() { + return get(Header.ACCOUNT); + } + + default Optional getCloudProvider() { + return get(Header.CLOUD_PROVIDER); + } + default Optional getExecutionType() { return get(Header.EXECUTION_TYPE); } @@ -83,6 +91,14 @@ default void setApplication(String value) { set(Header.APPLICATION, value); } + default void setAccount(String value) { + set(Header.ACCOUNT, value); + } + + default void setCloudProvider(String value) { + set(Header.CLOUD_PROVIDER, value); + } + default void setExecutionType(String value) { set(Header.EXECUTION_TYPE, value); } diff --git a/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/MetricsInterceptorMicrometerTest.java b/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/MetricsInterceptorMicrometerTest.java index d9bebfb17..34ca7b644 100644 --- a/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/MetricsInterceptorMicrometerTest.java +++ b/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/MetricsInterceptorMicrometerTest.java @@ -31,7 +31,7 @@ import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.method.HandlerMethod; diff --git a/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/SpinnakerRequestHeaderInterceptorTest.groovy b/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/SpinnakerRequestHeaderInterceptorTest.groovy index bc67f98d6..59cce3aba 100644 --- a/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/SpinnakerRequestHeaderInterceptorTest.groovy +++ b/kork-web/src/test/groovy/com/netflix/spinnaker/kork/web/interceptors/SpinnakerRequestHeaderInterceptorTest.groovy @@ -22,13 +22,14 @@ import com.netflix.spinnaker.okhttp.SpinnakerRequestHeaderInterceptor import com.netflix.spinnaker.security.AuthenticatedRequest import okhttp3.Interceptor import okhttp3.Request +import org.mockito.MockedStatic import org.mockito.Mockito import spock.lang.Specification class SpinnakerRequestHeaderInterceptorTest extends Specification { def "request contains authorization header"() { - Mockito.mockStatic(AuthenticatedRequest.class) + MockedStatic mockAuthenticatedRequest = Mockito.mockStatic(AuthenticatedRequest.class) Map> authHeaders = new HashMap>(){} authHeaders.put(Header.USER.getHeader(), Optional.of("some user")) authHeaders.put(Header.ACCOUNTS.getHeader(), Optional.of("Some ACCOUNTS")) @@ -49,5 +50,8 @@ class SpinnakerRequestHeaderInterceptorTest extends Specification { then: "the expected authorization header is added to the request before proceeding" 1 * chain.proceed({ Request request -> request.headers(Header.USER.getHeader()) == ["some user"] && request.headers(Header.ACCOUNTS.getHeader()) == ["Some ACCOUNTS"]}) + + cleanup: + mockAuthenticatedRequest.close() } } diff --git a/kork-web/src/test/java/com/netflix/spinnaker/config/okhttp3/RefreshableOkHttpClientBuilderProviderTest.java b/kork-web/src/test/java/com/netflix/spinnaker/config/okhttp3/RefreshableOkHttpClientBuilderProviderTest.java new file mode 100644 index 000000000..963e5c29e --- /dev/null +++ b/kork-web/src/test/java/com/netflix/spinnaker/config/okhttp3/RefreshableOkHttpClientBuilderProviderTest.java @@ -0,0 +1,250 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +package com.netflix.spinnaker.config.okhttp3; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.netflix.spinnaker.config.DefaultServiceEndpoint; +import com.netflix.spinnaker.config.MetricsEndpointConfiguration; +import com.netflix.spinnaker.config.ServiceEndpoint; +import com.netflix.spinnaker.kork.crypto.StandardCrypto; +import com.netflix.spinnaker.kork.crypto.TrustStores; +import com.netflix.spinnaker.kork.crypto.X509Identity; +import com.netflix.spinnaker.kork.crypto.X509IdentitySource; +import com.netflix.spinnaker.kork.crypto.test.CertificateIdentity; +import com.netflix.spinnaker.kork.crypto.test.TestCrypto; +import com.netflix.spinnaker.kork.metrics.SpectatorConfiguration; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.time.Duration; +import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; + +class RefreshableOkHttpClientBuilderProviderTest { + + Path truststore; + HttpsServer server; + ServiceEndpoint serviceEndpoint; + HttpUrl testUrl; + CertificateIdentity clientIdentity; + + @BeforeEach + void setUp() throws Exception { + var ca = CertificateIdentity.generateSelfSigned(); + + // set up a truststore file for later + truststore = Files.createTempFile("ca", ".p12"); + ca.saveAsPKCS12(truststore, "guest".toCharArray()); + + // preload the trust store into a trust manager directly from the certificate + KeyStore ks = StandardCrypto.getPKCS12KeyStore(); + ks.load(null, null); + ks.setCertificateEntry(X509Identity.generateAlias(ca.getCertificate()), ca.getCertificate()); + X509TrustManager trustManager = TrustStores.loadTrustManager(ks); + + // both client and server keys will require signatures and key agreements for TLS usage + var tlsKeyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyAgreement); + + // generate server keypair + var serverKeyPair = TestCrypto.generateKeyPair(); + var serverPublicKey = serverKeyPair.getPublic(); + var serverPrivateKey = serverKeyPair.getPrivate(); + + // set up extension requests for localhost (both a CN and a SAN DNS name) + var serverName = new X500Principal("CN=localhost"); + var serverAlternativeName = new DERSequence(new GeneralName(GeneralName.dNSName, "localhost")); + var serverExtensions = new ExtensionsGenerator(); + serverExtensions.addExtension(Extension.keyUsage, true, tlsKeyUsage); + serverExtensions.addExtension( + Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth)); + serverExtensions.addExtension(Extension.subjectAlternativeName, false, serverAlternativeName); + + // request CA signature and store as PEM files + var serverCertificationRequest = + new JcaPKCS10CertificationRequestBuilder(serverName, serverPublicKey) + .addAttribute( + PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, serverExtensions.generate()) + .build(CertificateIdentity.signerFrom(serverPrivateKey)); + var serverIdentity = + CertificateIdentity.fromCredentials( + serverPrivateKey, ca.signCertificationRequest(serverCertificationRequest)); + var serverKeyFile = Files.createTempFile("server", ".key"); + var serverCertFile = Files.createTempFile("server", ".crt"); + serverIdentity.saveAsPEM(serverKeyFile, serverCertFile); + + // prepare SSLContext for server + var serverIdentitySource = X509IdentitySource.fromPEM(serverKeyFile, serverCertFile); + var serverSSLContext = + serverIdentitySource.refreshable(Duration.ofMinutes(15)).createSSLContext(trustManager); + + // set up HTTPS server + int port = findAvailablePort(); + var address = new InetSocketAddress("localhost", port); + server = HttpsServer.create(address, 0); + + // server settings for later use with OkHttpClient + serviceEndpoint = + new DefaultServiceEndpoint("server", String.format("https://localhost:%d/", port), true); + testUrl = + new HttpUrl.Builder() + .scheme("https") + .host("localhost") + .port(port) + .addPathSegment("test") + .build(); + + // set up and start HTTPS server with test endpoint + server.setHttpsConfigurator(new HttpsConfigurator(serverSSLContext)); + server.createContext( + "/test", + exchange -> { + var response = "It works!\n".getBytes(StandardCharsets.UTF_8); + try (var out = exchange.getResponseBody()) { + exchange.sendResponseHeaders(200, response.length); + out.write(response); + } + }); + server.start(); + + // generate client keypair + var clientKeyPair = TestCrypto.generateKeyPair(); + var clientPublicKey = clientKeyPair.getPublic(); + var clientPrivateKey = clientKeyPair.getPrivate(); + + // as a client certificate, we'll pretend to be spinnaker@localhost + var clientName = new X500Principal("CN=spinnaker, O=spinnaker"); + var clientAlternativeName = + new DERSequence(new GeneralName(GeneralName.rfc822Name, "spinnaker@localhost")); + var clientExtensions = new ExtensionsGenerator(); + clientExtensions.addExtension(Extension.keyUsage, true, tlsKeyUsage); + clientExtensions.addExtension( + Extension.extendedKeyUsage, false, new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth)); + clientExtensions.addExtension(Extension.subjectAlternativeName, false, clientAlternativeName); + + // get certificate signed by CA + var clientCertificationRequest = + new JcaPKCS10CertificationRequestBuilder(clientName, clientPublicKey) + .addAttribute( + PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, clientExtensions.generate()) + .build(CertificateIdentity.signerFrom(clientPrivateKey)); + clientIdentity = + CertificateIdentity.fromCredentials( + clientPrivateKey, ca.signCertificationRequest(clientCertificationRequest)); + } + + @AfterEach + void tearDown() { + server.stop(1); + } + + @Test + void smokeTest() throws Exception { + // prepare client keystore + Path keystore = Files.createTempFile("identity", ".p12"); + char[] password = TestCrypto.generatePassword(30); + clientIdentity.saveAsPKCS12(keystore, password); + + new ApplicationContextRunner() + // set up minimal properties to enable refreshable keys (and therefore + // RefreshableOkHttpClientBuilderProvider as the highest priority + // OkHttpClientBuilderProvider) + .withPropertyValues( + "ok-http-client.refreshable-keys.enabled=true", + "ok-http-client.trust-store=" + truststore, + "ok-http-client.trust-store-password=guest", + "ok-http-client.key-store=" + keystore, + "ok-http-client.key-store-password=" + new String(password)) + // set up minimal spring boot auto configs expected + .withConfiguration( + AutoConfigurations.of( + TaskExecutionAutoConfiguration.class, + JacksonAutoConfiguration.class, + MetricsAutoConfiguration.class, + CompositeMeterRegistryAutoConfiguration.class)) + // set up minimal kork-web config classes + .withUserConfiguration(TestConfig.class) + .run( + context -> { + OkHttpClientBuilderProvider provider = + context + .getBeanProvider(OkHttpClientBuilderProvider.class) + .orderedStream() + .filter(p -> p.supports(serviceEndpoint)) + .findFirst() + .orElseThrow(() -> new AssertionFailedError("No client provider found")); + assertThat(provider).isInstanceOf(RefreshableOkHttpClientBuilderProvider.class); + OkHttpClient client = provider.get(serviceEndpoint).build(); + Call call = client.newCall(new Request.Builder().get().url(testUrl).build()); + try (Response response = call.execute()) { + ResponseBody body = response.body(); + assertThat(body).isNotNull(); + assertThat(body.string()).isEqualTo("It works!\n"); + } + }); + } + + @Import(SpectatorConfiguration.class) + @ComponentScan( + basePackages = "com.netflix.spinnaker.config", + excludeFilters = + @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = MetricsEndpointConfiguration.class)) + static class TestConfig {} + + private static int findAvailablePort() throws IOException { + try (var socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } + } +} diff --git a/settings.gradle b/settings.gradle index c25bf9008..40df92e74 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,8 +23,6 @@ if (spinnakerGradleVersion.endsWith('-SNAPSHOT')) { } } -enableFeaturePreview("VERSION_ORDERING_V2") - include( "spinnaker-dependencies", "kork-actuator", @@ -39,6 +37,7 @@ include( "kork-core-tck", "kork-credentials", "kork-credentials-api", + "kork-crypto", "kork-eureka", "kork-exceptions", "kork-expressions", diff --git a/spinnaker-dependencies/spinnaker-dependencies.gradle b/spinnaker-dependencies/spinnaker-dependencies.gradle index 40f48dd37..4aa52a369 100644 --- a/spinnaker-dependencies/spinnaker-dependencies.gradle +++ b/spinnaker-dependencies/spinnaker-dependencies.gradle @@ -8,40 +8,31 @@ ext { versions = [ arrow : "0.13.2", aws : "1.12.399", - bouncycastle : "1.70", + awsv2 : "2.19.0", + bouncycastle : "1.77", brave : "5.12.3", gcp : "25.3.0", - groovy : "2.5.15", - jooq : "3.13.6", jsch : "0.1.54", jschAgentProxy : "0.0.9", - // spring boot 2.5.14 specifies logback 1.2.11, but a rosco test hung with - // 1.2.11 from https://jira.qos.ch/browse/LOGBACK-1623 so stick with 1.2.10 - // until 1.2.12 appears. - logback : "1.2.10", protobuf : "3.21.12", okhttp : "2.7.5", // CVE-2016-2402 - okhttp3 : "3.14.9", + okhttp3 : "4.9.3", openapi : "1.3.9", // this needs to be kept in sync with spring boot as it pulls in the spring-boot-dependencies BOM - //Spring boot 2.5.14 upgrade brings io.rest-assured 4.3.3 as transitive dependency. - //io.rest-assured 4.3.x require groovy 3.0.2. So, pinning the nearest version - //using groovy 2.x. This can be removed with groovy 3.x upgrade. - //[https://github.com/rest-assured/rest-assured/blob/9b683130c93188cabdef850e89d0c9417d847a17/changelog.txt#L200] - restassured : "4.2.0", retrofit : "1.9.0", retrofit2 : "2.8.1", spectator : "1.0.6", spek : "1.1.5", spek2 : "2.0.9", - springBoot : "2.5.14", + springBoot : "2.5.15", springCloud : "2020.0.6", - springfoxSwagger : "2.9.2", - swagger : "1.5.20", //this should stay in sync with what springfoxSwagger expects + springfoxSwagger : "3.0.0", + swagger : "1.5.20", //this should stay in sync with what springfoxSwagger expects. // Spring boot 2.4.13 brings in 9.0.55. Spring boot 2.5.14 brings in // 9.0.63. Use 9.0.69 to resolve CVE-2022-42252 and CVE-2022-45143. Spring // boot 2.6.14 and 2.7.6 bring in 9.0.69. - tomcat : "9.0.69" + // https://tomcat.apache.org/security-9.html for latest security fixes + tomcat : "9.0.81" ] } @@ -56,48 +47,29 @@ dependencies { */ // Log4shell safeguard. Per analysis, log4j-core is not included in dependencies, but this would prevent transitive inclusion of it by extension // platforms. Doing 2.16.0 which completely removes message lookups AND sets jndi to disabled by default + // 2.16.0 is subject to CVE-2021-45105. 2.17.0 is subject to CVE-2021-44832, so use >= 2.17.1. + api(platform("org.apache.logging.log4j:log4j-bom:2.20.0")) - api(platform("org.apache.logging.log4j:log4j-bom:2.16.0")) - - //Upgrade of spring boot 2.5.x brings groovy 3.x as transitive dependency. - //To avoid transitive upgrade of groovy, pinning it with enforcedPlatform() closure. - //It forces version for internal submodules of kork as well as for all the consumer spinnaker services. - api(enforcedPlatform("org.codehaus.groovy:groovy-bom:${versions.groovy}")){ - force = true - } - api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.4.3")) + api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.5.2")) //kotlinVersion comes from gradle.properties since we have kotlin code in // this project and need to configure gradle plugins etc. api(platform("org.jetbrains.kotlin:kotlin-bom:$kotlinVersion")) - api(platform("org.junit:junit-bom:5.6.3")) - api(platform("com.fasterxml.jackson:jackson-bom:2.12.7.20221012")) api(platform("io.zipkin.brave:brave-bom:${versions.brave}")) + api(platform("org.junit:junit-bom:5.8.2")) // remove with spring boot >= 2.6.2 api(platform("org.springframework.boot:spring-boot-dependencies:${versions.springBoot}")) api(platform("com.amazonaws:aws-java-sdk-bom:${versions.aws}")) api(platform("com.google.protobuf:protobuf-bom:${versions.protobuf}")) api(platform("com.google.cloud:libraries-bom:${versions.gcp}")) - api(platform("com.google.cloud:google-cloud-secretmanager:2.1.7")) + api(platform("software.amazon.awssdk:bom:${versions.awsv2}")) api(platform("org.springframework.cloud:spring-cloud-dependencies:${versions.springCloud}")) api(platform("io.strikt:strikt-bom:0.31.0")) - api(platform("org.spockframework:spock-bom:1.3-groovy-2.5")) - api(platform("com.oracle.oci.sdk:oci-java-sdk-bom:1.5.17")) + api(platform("org.spockframework:spock-bom:2.0-groovy-3.0")) + api(platform("com.oracle.oci.sdk:oci-java-sdk-bom:3.21.0")) api(platform("org.testcontainers:testcontainers-bom:1.16.2")) api(platform("io.arrow-kt:arrow-stack:${versions.arrow}")) constraints { api("cglib:cglib-nodep:3.3.0") - //A bug is reported in 1.2.11 and fixed in 1.2.12. - //So pinning the version to 1.2.10 untill required package is released. - //[https://jira.qos.ch/browse/LOGBACK-1623] - api("ch.qos.logback:logback-core:${versions.logback}"){ - force = true - } - api("ch.qos.logback:logback-classic:${versions.logback}"){ - force = true - } - api("ch.qos.logback:logback-classic:${versions.logback}"){ - force = true - } api("com.amazonaws:aws-java-sdk:${versions.aws}") api("com.google.api-client:google-api-client:1.30.10") // TODO: Track update for CVE-2020-7692, reanalysis pending. api("com.google.apis:google-api-services-admin-directory:directory_v1-rev105-1.25.0") @@ -106,12 +78,12 @@ dependencies { api("com.google.apis:google-api-services-iam:v1-rev267-1.25.0") api("com.google.apis:google-api-services-monitoring:v3-rev477-1.25.0") api("com.google.apis:google-api-services-storage:v1-rev141-1.25.0") + api("com.google.cloud:google-cloud-secretmanager:2.3.10") api("com.google.code.findbugs:jsr305:3.0.2") api("com.google.guava:guava:30.0-jre") - // JinJava 2.5.3 has a bad bug: https://github.com/HubSpot/jinjava/issues/429 - api("com.hubspot.jinjava:jinjava:2.5.2") + api("com.hubspot.jinjava:jinjava:2.7.1") api("com.jakewharton.retrofit:retrofit1-okhttp3-client:1.1.0") - api("com.jcraft.jsch:${versions.jsch}") + api("com.jcraft:jsch:${versions.jsch}") api("com.jcraft:jsch.agentproxy.connector-factory:${versions.jschAgentProxy}") api("com.jcraft:jsch.agentproxy.jsch:${versions.jschAgentProxy}") api("com.natpryce:hamkrest:1.4.2.2") @@ -130,7 +102,6 @@ dependencies { api("com.netflix.spectator:spectator-web-spring:${versions.spectator}") api("com.netflix.spectator:spectator-reg-micrometer:${versions.spectator}") api("com.nimbusds:nimbus-jose-jwt:7.9") - api("io.spinnaker.embedded-redis:embedded-redis:0.9.0") api("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") api("com.nhaarman:mockito-kotlin:1.6.0") api("com.ninja-squad:springmockk:2.0.3") @@ -161,29 +132,21 @@ dependencies { api("de.huxhorn.sulky:de.huxhorn.sulky.ulid:8.2.0") api("dev.minutest:minutest:1.13.0") api("io.mockk:mockk:1.10.5") - api("io.rest-assured:rest-assured:${versions.restassured}"){ - force = true - } - api("io.rest-assured:rest-assured-common:${versions.restassured}"){ - force = true - } - api("io.rest-assured:json-path:${versions.restassured}"){ - force = true - } - api("io.rest-assured:xml-path:${versions.restassured}"){ - force = true - } - api("io.springfox:springfox-swagger-ui:${versions.springfoxSwagger}") + api("io.springfox:springfox-boot-starter:${versions.springfoxSwagger}") api("io.springfox:springfox-swagger2:${versions.springfoxSwagger}") api("io.swagger:swagger-annotations:${versions.swagger}") api("javax.annotation:javax.annotation-api:1.3.2") api("javax.xml.bind:jaxb-api:2.3.1") - api("mysql:mysql-connector-java:8.0.20") api("net.logstash.logback:logstash-logback-encoder:4.11") - api("net.minidev:json-smart:2.4.1") // TODO: remove this with upgrade of spring-boot version to 2.5.0 or above api("org.apache.commons:commons-exec:1.3") - api("org.bouncycastle:bcpkix-jdk15on:${versions.bouncycastle}") - api("org.bouncycastle:bcprov-jdk15on:${versions.bouncycastle}") + // from BC 1.71, module names changed from *-jdk15on to *-jdk18on + // due to this change, some of the modules in downstream services like clouddriver, gate would fall back to + // lower versions(<1.70) as transitive dependencies. So to make them use BC >=1.74(CVE free versions), + // the following dependencies are upgraded: oci-java-sdk-bom, google-cloud-secretmanager + // and in some cases *-jdk15on libraries are excluded so as to use the available *-jdk18on or *-jdk15to18 + // some of the modules would still use <1.70 as they can't be upgraded without upgrading spring boot + api("org.bouncycastle:bcpkix-jdk18on:${versions.bouncycastle}") + api("org.bouncycastle:bcprov-jdk18on:${versions.bouncycastle}") api("org.jetbrains:annotations:19.0.0") api("org.spekframework.spek2:spek-dsl-jvm:${versions.spek2}") api("org.spekframework.spek2:spek-runner-junit5:${versions.spek2}") @@ -191,24 +154,22 @@ dependencies { api("org.jetbrains.spek:spek-junit-platform-engine:${versions.spek}") api("org.jetbrains.spek:spek-junit-platform-runner:${versions.spek}") api("org.jetbrains.spek:spek-subject-extension:${versions.spek}") - api("org.jooq:jooq:${versions.jooq}") - api("org.jooq:jooq-kotlin:${versions.jooq}") - - // The liquibase version starting from 4.0.0 till 4.12.0 - // has an issue w.r.t parsing the changelog file, - // if found at multiple places within the classpath - // https://github.com/liquibase/liquibase/issues/2818 - // This duplicate changelog issue is fixed in 4.13.0, - // but identified another issue that gets introduced in 4.13.0. - // This issue(https://github.com/liquibase/liquibase/issues/3091) - // hinders the migration of sql scripts available in orca - // (https://github.com/spinnaker/orca/tree/master/orca-sql/src/main/resources/db/changelog), - // containing `afterColumn` construct, with a validation error for postgresql. So pin with 3.10.3 - api("org.liquibase:liquibase-core:3.10.3"){ - force = true + api("org.liquibase:liquibase-core"){ + version { + strictly "4.24.0" + } } api("org.objenesis:objenesis:2.5.1") - api("org.pf4j:pf4j:3.2.0") + api("org.pf4j:pf4j:3.10.0") + // pf4j:3.10.0 brings in slf4j-api:2.0.6 which is not compatible with logback 1.2.x. + // And the upgraded logback version(1.3.8) is becoming incompatible with SpringBoot's LogbackLoggingSystem: + // java.lang.NoClassDefFoundError at LogbackLoggingSystem.java:293 + // Hence pinning slf4j-api at 1.7.36 which spring boot 2.5.15 brings in. + api("org.slf4j:slf4j-api"){ + version { + strictly("1.7.36") + } + } api("org.pf4j:pf4j-update:2.3.0") // snakeyaml 1.29 fails to parse yaml (including some k8s manifests), so @@ -220,24 +181,21 @@ dependencies { // when we upgrade to spring boot 2.6.x. It's safe to upgrade beyond 1.29 // with spring boot >= 2.6.12. See // https://github.com/spring-projects/spring-boot/issues/32228#issue-136185850.0. - api("org.yaml:snakeyaml:1.27") + // making it strict as some of the modules have it resolved to higher versions (ex: kork-secrets-gcp to 1.30) + api("org.yaml:snakeyaml") { + version { + strictly "1.27" + } + } api("org.springdoc:springdoc-openapi-webmvc-core:${versions.openapi}") api("org.springdoc:springdoc-openapi-kotlin:${versions.openapi}") api("org.springframework.boot:spring-boot-configuration-processor:${versions.springBoot}") api("org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.3.12.RELEASE") api("org.springframework.security.extensions:spring-security-saml-dsl-core:1.0.5.RELEASE") api("org.springframework.security.extensions:spring-security-saml2-core:1.0.9.RELEASE") - api("org.testng:testng:7.4.0") // TODO: remove this with upgrade of spring-boot version to 2.5.0 or with upgrade of groovy-all to 3.0.8 api("org.threeten:threeten-extra:1.0") api("org.apache.tomcat.embed:tomcat-embed-core:${versions.tomcat}") api("org.apache.tomcat.embed:tomcat-embed-el:${versions.tomcat}") api("org.apache.tomcat.embed:tomcat-embed-websocket:${versions.tomcat}") - api('org.apache.ant:ant:1.10.12') { - because "1.9.15 is resolved transitively through Groovy, removes CVE-2021-36374, CVE-2021-36373, CVE-2020-11979, CVE-2020-15250" - } - api('org.apache.ant:ant-launcher:1.10.12'){ - because "1.9.15 is resolved transitively through Groovy, removes CVE-2021-36374, CVE-2021-36373, CVE-2020-11979, CVE-2020-15250" - } - } }