diff --git a/.envrc b/.envrc index 8b77fd60b6..566339cd1f 100644 --- a/.envrc +++ b/.envrc @@ -3,5 +3,6 @@ if has nix then unset GOPATH # Required for editors to discover the go-tools in the flake. use flake + layout python python3 layout ruby fi \ No newline at end of file diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000000..bfbfb52caa --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,4 @@ +sonar.sources = src/ +sonar.exclusions = **/*test.go +sonar.test.exclusions = **/*test.go # This line is needed as well! + diff --git a/Gemfile.lock b/Gemfile.lock index 4ae7f09ab4..0dfdacd9e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,4 +73,4 @@ DEPENDENCIES standard BUNDLED WITH - 2.1.4 + 2.4.10 diff --git a/Makefile b/Makefile index d8d4379918..44124c04a8 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,9 @@ MAKEFLAGS = -s go_modules:= $(shell find . -maxdepth 3 -name "*.mod" -exec dirname {} \; | sed 's|\./src/||' | sort) all_modules:= $(go_modules) db scheduler .SHELLFLAGS := -eu -o pipefail -c ${SHELLFLAGS} -MVN_OPTS="-Dmaven.test.skip=true" +MVN_OPTS="-Dmaven.test.skip=true -Dmaven.plugin.validation=VERBOSE" OS:=$(shell . /etc/lsb-release &>/dev/null && echo $${DISTRIB_ID} || uname ) db_type:=postgres -DB_HOST:=localhost DBURL := $(shell case "${db_type}" in\ (postgres) printf "postgres://postgres:postgres@${DB_HOST}/autoscaler?sslmode=disable"; ;; \ (mysql) printf "root@tcp(${DB_HOST})/autoscaler?tls=false"; ;; esac) @@ -25,6 +24,7 @@ DEST?=build export BUILDIN_MODE?=false export DEBUG?=false export ACCEPTANCE_TESTS_FILE?=${DEST}/app-autoscaler-acceptance-tests-v${VERSION}.tgz +export DB_HOST:=localhost $(shell mkdir -p target) $(shell mkdir -p build) @@ -123,11 +123,14 @@ test: test-autoscaler test-scheduler test-changelog test-changeloglockcleaner te test-autoscaler: check-db_type init init-db test-certs @echo " - using DBURL=${DBURL} OPTS=${OPTS}" @make -C src/autoscaler test DBURL="${DBURL}" OPTS="${OPTS}" -test-autoscaler-suite: check-db_type init init-db test-certs + +# ⚠ The target dependencies "autoscaler" and "scheduler" are needed by the integration tests. +# TODO: Introduce make-target for the .war-file and the needed autoscaler-files instead? +test-autoscaler-suite: check-db_type init init-db test-certs autoscaler scheduler @echo " - using DBURL=${DBURL} TEST=${TEST} OPTS=${OPTS}" @make -C src/autoscaler testsuite TEST=${TEST} DBURL="${DBURL}" OPTS="${OPTS}" + test-scheduler: check-db_type init init-db test-certs - @export DB_HOST=${DB_HOST}; \ cd src && mvn test --no-transfer-progress -Dspring.profiles.include=${db_type} && cd .. test-changelog: init @make -C src/changelog test diff --git a/README.md b/README.md index 31ce351bab..f3f136e9bf 100644 --- a/README.md +++ b/README.md @@ -350,17 +350,18 @@ cf delete-service-broker autoscaler ## Monitoring the service -The app-autoscaler provides a number of health endpoints that are available externally that can be used to check the state of each component. Each health endpoint is protected with basic auth (apart from the api server), the usernames are listed in the table below, but the passwords are available in credhub. - -Component | Health URL | Username | Password Key | ---------- | -----------| ---------| -------------| -eventgenerator|https://autoscaler-eventgenerator.((system_domain))/health|eventgenerator|/autoscaler_eventgenerator_health_password| -metricsforwarder|https://autoscaler-metricsforwarder.((system_domain))/health|metricsforwarder|/autoscaler_metricsforwarder_health_password| -metricsgateway|https://autoscaler-metricsgateway.((system_domain))/health|metricsgateway|/autoscaler_metricsgateway_health_password| -metricsserver|https://autoscaler-metricsserver.((system_domain))/health|metricsserver|/autoscaler_metricsserver_health_password| -scalingengine|https://autoscaler-scalingengine.((system_domain))/health|scalingengine|/autoscaler_scalingengine_health_password| -operator|https://autoscaler-operator.((system_domain))/health|operator|/autoscaler_operator_health_password| -scheduler|https://autoscaler-scheduler.((system_domain))/health|scheduler|/autoscaler_scheduler_health_password| +The app-autoscaler provides a number of health endpoints that are available externally that can be used to check the state of each component. Each health endpoint is protected with basic auth, the usernames are listed in the table below, but the passwords are available in credhub. + +Component | Health URL | Username | Password Key | +--------- |--------------------------------------------------------------------------|------------------|----------------------------------------------| +apiserver | https://autoscaler-apiserver.((system_domain))/health/liveness | apiserver | /autoscaler_api_server_health_password | +eventgenerator| https://autoscaler-eventgenerator.((system_domain))/health/liveness | eventgenerator | /autoscaler_eventgenerator_health_password | +metricsforwarder| https://autoscaler-metricsforwarder.((system_domain))/health/liveness | metricsforwarder | /autoscaler_metricsforwarder_health_password | +metricsgateway| https://autoscaler-metricsgateway.((system_domain))/health/liveness | metricsgateway | /autoscaler_metricsgateway_health_password | +metricsserver| https://autoscaler-metricsserver.((system_domain))/health/liveness | metricsserver | /autoscaler_metricsserver_health_password | +scalingengine| https://autoscaler-scalingengine.((system_domain))/health/liveness | scalingengine | /autoscaler_scalingengine_health_password | +operator| https://autoscaler-operator.((system_domain))/health/liveness | operator | /autoscaler_operator_health_password | +scheduler| https://autoscaler-scheduler.((system_domain))/health/liveness | scheduler | /autoscaler_scheduler_health_password | These endpoints can be disabled by using the ops file `example/operations/disable-basicauth-on-health-endpoints.yml` diff --git a/ci/autoscaler/scripts/run-acceptance-tests.sh b/ci/autoscaler/scripts/run-acceptance-tests.sh index 19a32bc282..3262a3fe6a 100755 --- a/ci/autoscaler/scripts/run-acceptance-tests.sh +++ b/ci/autoscaler/scripts/run-acceptance-tests.sh @@ -37,32 +37,31 @@ fi pushd "${autoscaler_dir}/src/acceptance" >/dev/null cat > acceptance_config.json <7Vxbl6K4Fv4t8+BaMw/W4iIqj3XR7tOnerpn7J4z/XRWhKiZRsIE8DK/fnYgIJBoWSpYttZDCbmzv+ydfYOW+ThfvWMomH2kLvZahuauWuZTyzB0TTPgh5es0xJD64mSKSOuaLUpGJF/cNZVlMbExWGpYUSpF5GgXOhQ38dOVCpDjNFludmEeuVZAzTFUsHIQZ5c+j/iRrO0tG9pm/L3mExn2cwdTdTMUdZYFIQz5NJlocgctMxHRmmUXs1Xj9jj1MvokvYbbqnNF8awH+3TYW3/0/7jr+DbB7vb6T/bf4brX39r6wKNMFpnT4xdIIC4pSya0Sn1kTfYlD4wGvsu5sNqcLdp80xpAIU6FP6Fo2gt0ERxRKFoFs09UQsrZus/Rf/k5hu/ubOy26dVsfJpnd2tSMS76Xewr9LbQke42/TjN8VunzEjcxxhJspk6gmChjRmDt5BMlvsQsSmONpF2rQdJ2dhAoHNO0xhNWwNDRj2UEQW5f2GxLad5u02yMKFAPc1QKfjLpAXi5neY+TBFjW0EWYLoIvR9eBxHsb8asqv+lpfl3ZHGfvljER4FKCEYEsQAWWctxIZ5ovwaidZRK2V8ZMQIGZP3C833KhnLDYrcGJXq4mShkTJj4j4u+movTk66h0DuOa8lLQkSmbEm1B40CK9un/HNKtoh4lcuYcGej9YJfTJ6jOaj5wZdmMvgQPNOTn9cRgkDRKcCFBazAVLT6dLe0pIAYGjMhxhxOh3/Eg9yqWJT30uFCfE8ypFyCNTH24dgI0LngcOF4Gz5V5UzInrJhJVhX95h5xgC5iaVdoCbRl/ONhk/O268O9K+L8bfPlx6a9bFVHWlwHoKvjPrIv+veumf8eUj5JG6a+b51C+Dtd8+qfWaETXz5Qk4l7g1LErOGkVAFLVS/SqYJAv43BY+opjaTgT2tIwYHyCGY7DPThlfw5gGE41NE6G4sAG/OmS57UeWtYTHwuQTE++VzHhCRgn54BMcNl76mBGXZxj74TIg73n4/B6AVJJNl1rEiC9c2VnS6+MgN1TaNfNHi6ydv00eB58GfzAIPQrNo51dhSMN+Vf6b3kYOGGUIZxC6iS/B2lM+hCyX/RXWKcWrk4jntk2ySzF12y2G6aTtCceOvUOM1NT9HgA44eGCI+nEvaR+rTan3LgLVqc6gJU0ZR2LXZIgKGlYvY3gPG9JVdxsj5Pk22XNtJoeeLZ9Pxz4ZlpWsqXvyyZZbhArAYoiAIOeV7D3D1fyBxD2YahsIaDws2d7qe8hoT9QorShOa722f/5iHum6opFnuKC+Ks05t4kx2ZF2ZOOtdpjiTTf2MY8O174w2/Hk1zFX2heWa8NlMGl02Oz9/vSKNWS3emlWZZbPyqiAw9DMDYMoAPFMgz4yGMPV9GpA60BVzDow8NMbeQ65fFY6iYfJ3yBF1uDlkWrKM6zUJr21sVaqPDvg8xiwh1UUFdc60P/JcDFmb7CriP7Vpk7Z9Fm1SJBFkmQF7ZRBkGmiuju6XsMDREwuB870OhdS+UIVUO68lcbk4vq20Els2LAbzMXY5SlLkvZoY8YXOHcTF/iOQExGfR+u1nydwqsPvKGDEn/4i7ZKmEye6VSWps6elUFvahK5w7X7yuT/owx8f4f9nRp0k+qGx2PeBiKkvqkJ8yr1B0ZLCf5dMJjg9PbUAuOpCzMAiUxr1qlLdSuhYV0XA6lKVvwzotxUbOcan+eK/3c502iNP58znksimIO5WSnZ6uZmXhUr6DTKUkpayWtpQRteJKdmzGs3pUtLSlGj5WhXf6FxXTtdRm8CQNkFXl9nJNBVb4BRpXcotcHmh36Mg0DuSRLOb86MoEbAkajeWV1TRS/ci5LH5Qt0qALqmV0i7JWVIGsuo5B7JI6Wa/CmSj5TQqeKOw8vQx04hzqoxL01TOClqcssr8ZDtmwvIojhOnlmSXtFt0jWvhKF/DolWU9zxZRFZ9hcoCaJ4C2W7btuAd2DXIm9JFLckinMdKLpRFWZmo2kUauP9R0qjOIk407N3O9+0PNNlr8vFp1GcmL36qtTjRjW27JXWC4rin9j+VIu4Zt2Tsh/oykDoG+eGQPbDXHcyxXEA96uSzlYEgupKp1ADfBY3z0aLuLNto6xJdLT+C6pEcld9F3/fQE4euc+j9d+yYbdG7k+po1yEzaXIXAfWXpCQAEu6UDHmawmT6Kpy/zxzPqtPWXld1E7Bs+JLH2KSVq5IFCHdwS9bOVy7AyFullg8i7Id6qDMmtDJJMS1uAuzxN7LS6N4PaPrb47Rm0q9UE8uJwzLjF6NXFZDZ8VTX/PImCGxsuuRCr0XpILx5mWA7GZ7ezk4RylfXVsyMxXatdLMzJNlTp81oElkv2Xh1KqDd6VAX09TbINGrSxD5RJ6wVm8b07zEw4YBu7czseSC/hmuSkS+KoZEgoXla3YM1Zte0a2zF/cM9WogpnGEjrit9dLQwqaFEBpoyDwcDtchxGep42rMZO0rh0TdTClWjrCU8rl3Nf/qNv/TscUNMbk+tNqPcV+ev11HPtRrB5zSBjiBw7igZ19FvHEKHFf1eM99haYb2Go+xXHWN0rhPHaIRiokwI9C7yaJSeJihQnXuNTNkdeoW6BGEHwC/yCopjxjxjubOegYFuTpdiVvLIj+ELzQEXHrM3DXPx0kXpSFsw4cdKkqrSMy4O2YO37ZIxJVKghwLS+mEfLHjSpiRgMNYHRs3mEB5nzUPKNxcIkS8rc8rLyseBJxt8JDMfHTEVRW3Bhqd1B4bR0pS52KANNjfrtaEac78mHQJLuxCcRyahTbVtAcme7wnJK7SYeRVGVOC4JAw+ts+YeSfSDn8icn+/I3xprrGa3SUUDo9XXWrapaPvARZ+LJygGgZIQCCUSKXP0adh3xeHPv5DJFwRGQAS6c2I0AMb8qWe42CUs5UFuiTtypeYmuG6C6ya4LklwvTrT4cbPN36+8fOb5edmFZG2os3XEOcaROwXdQuH+hMyjdMH4G2428NJ1ZO0fVE3WZJoRuMoASEkDvyiGFr5nHP4AHevNIJfcFNVX/E7geVpVr1WliKbtavn9mnZb1Wb9an67G7V4+u79/zj6Rt/QIFSZeNeTbdD0sALRLF2mOOHOmIz67/6NcktCd1Sani/8g6YNNDBn6WE281n4NPmm6/pm4N/AQ== \ No newline at end of file diff --git a/docs/images/autocaler-scheduler-apisv1.drawio.svg b/docs/images/autocaler-scheduler-apisv1.drawio.svg new file mode 100644 index 0000000000..00a3973f04 --- /dev/null +++ b/docs/images/autocaler-scheduler-apisv1.drawio.svg @@ -0,0 +1,4 @@ + + + +
Health Server
8081
Health Server...
Main Server
8080
Main Server...
Scheduler  Service
Scheduler  Service
GET
GET
GET
GET
/health/prometheus
/health/prometheus
/health/liveness
/health/liveness
GET
GET
DELETE
DELETE
/v1/apps/{app_id}/schedules
/v1/apps/{app_id}/schedules
/v1/syncSchedules
/v1/syncSchedules
PUT
PUT
PUT
PUT
Localhost:8081/health/prometheus
Localhost:8...
Current
Current
Embedded 
Tomcat Container (from Spring)
Embedded...
One JVM Process running
on two different ports
One JVM Process running...
Health Server
8081
Health Server...
Main Server
8080
Main Server...
Scheduler  Service
Scheduler  Service
GET
GET
/
/
DELETE
DELETE
/v1/apps/{app_id}/schedules
/v1/apps/{app_id}/schedules
/v1/syncSchedules
/v1/syncSchedules
PUT
PUT
PUT
PUT
Localhost:8081/health/prometheus
Localhost:8...
provisioned by spring
provisioned by spring
provisioned by
 prometheus library
provisioned by...
Embedded 
Tomcat Container (from Spring)
Embedded...
One JVM Process running
on two different ports
One JVM Process running...
Deprecated 
Deprecated 
   –  By default, all health endpoints are protected for the health server
   -  Use the unprotected configuration to call the endpoints without basic authentication.
–  By default, all health endpoints are protected for the health se...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/example/operation/disable-basicauth-on-health-endpoints.yml b/example/operation/disable-basicauth-on-health-endpoints.yml index 84d8104883..9390774f25 100644 --- a/example/operation/disable-basicauth-on-health-endpoints.yml +++ b/example/operation/disable-basicauth-on-health-endpoints.yml @@ -27,9 +27,6 @@ - type: remove path: /instance_groups/name=asactors/jobs/name=scalingengine/properties/autoscaler/scalingengine/health/password -- type: remove - path: /instance_groups/name=asactors/jobs/name=scheduler/properties/autoscaler/scheduler/health/basicAuthEnabled - - type: remove path: /instance_groups/name=asactors/jobs/name=scheduler/properties/autoscaler/scheduler/health/username diff --git a/jobs/golangapiserver/spec b/jobs/golangapiserver/spec index 960071ce96..d5471350f1 100644 --- a/jobs/golangapiserver/spec +++ b/jobs/golangapiserver/spec @@ -77,7 +77,35 @@ properties: autoscaler.apiserver.public_api.server.server_key: description: "PEM-encoded server key" autoscaler.apiserver.health.port: - default: 1080 + default: 6206 + autoscaler.apiserver.health.username: + default: "" + description: | + Username used for basic access authentication to connect to the protected health-endpoints. + Alternative of setting username_hash. + autoscaler.apiserver.health.username_hash: + default: "" + description: | + Hash-Value of the username used for basic access authentication to connect to the protected health-endpoints. + Alternative of setting the username. + autoscaler.apiserver.health.password: + default: "" + description: | + Password used for basic access authentication to connect to the protected health-endpoints. + Prefer usage of password_hash instead. + autoscaler.apiserver.health.password_hash: + default: "" + description: | + Hash-Value of the password used for basic access authentication to connect to the protected health-endpoints. + More secure alternative of setting the password. Set to `""` if you don't want to use it. + autoscaler.apiserver.health.unprotected_endpoints: + description: | + List of all health-endpoints, that run _without_ basic access authentication. + Valid endpoints are /debug/pprof, /health, /health/liveness, /health/prometheus, /health/readiness + default: [] # protect everything + autoscaler.apiserver.health.readiness_enabled: + default: false + description: "Set to true if you want to enable the endpoint on /health/readiness" autoscaler.apiserver.use_buildin_mode: default: true description: "" diff --git a/jobs/golangapiserver/templates/apiserver.yml.erb b/jobs/golangapiserver/templates/apiserver.yml.erb index 87a02f4671..31bd3f5b0d 100644 --- a/jobs/golangapiserver/templates/apiserver.yml.erb +++ b/jobs/golangapiserver/templates/apiserver.yml.erb @@ -96,6 +96,12 @@ use_buildin_mode: <%= p("autoscaler.apiserver.use_buildin_mode") %> health: port: <%= p("autoscaler.apiserver.health.port") %> + username: <%= p("autoscaler.apiserver.health.username") %> + username_hash: <%= p("autoscaler.apiserver.health.username_hash") %> + password: <%= p("autoscaler.apiserver.health.password") %> + password_hash: <%= p("autoscaler.apiserver.health.password_hash") %> + unprotected_endpoints: <%= p("autoscaler.apiserver.health.unprotected_endpoints") %> + readiness_enabled: <%= p("autoscaler.apiserver.health.readiness_enabled") %> db: policy_db: diff --git a/jobs/metricsforwarder/spec b/jobs/metricsforwarder/spec index c8623fc0d5..cca2f91f6c 100644 --- a/jobs/metricsforwarder/spec +++ b/jobs/metricsforwarder/spec @@ -133,7 +133,6 @@ properties: autoscaler.metricsforwarder.policy_poller_interval: description: "The time interval to refresh cached policies from policy database" default: 60s - autoscaler.metricsforwarder.health.port: description: "The listening port of health endpoint" default: 6403 @@ -143,6 +142,14 @@ properties: autoscaler.metricsforwarder.health.password: description: "the password of health endpoint" default: '' + autoscaler.metricsforwarder.health.unprotected_endpoints: + description: | + List of all health-endpoints, that run _without_ basic access authentication. + Valid endpoints are /debug/pprof, /health, /health/liveness, /health/prometheus, /health/readiness + default: [] # protect everything + autoscaler.metricsforwarder.health.readiness_enabled: + default: false + description: "Set to true if you want to enable the endpoint on /health/readiness" autoscaler.metricsforwarder.rate_limit.valid_duration: description: "The rate limit evaluation duration" default: 1s diff --git a/jobs/metricsforwarder/templates/metricsforwarder.yml.erb b/jobs/metricsforwarder/templates/metricsforwarder.yml.erb index 33c04fac6b..33c7d8c895 100644 --- a/jobs/metricsforwarder/templates/metricsforwarder.yml.erb +++ b/jobs/metricsforwarder/templates/metricsforwarder.yml.erb @@ -32,10 +32,6 @@ end db_url end - -########################################### -# Template Main # -########################################### job_name = 'metricsforwarder' policy_db_url = build_db_url('policy_db', job_name) if p("autoscaler.storedprocedure_db.address") != '' @@ -43,7 +39,9 @@ end end %> - +########################################### +# Template Main # +########################################### server: port: <%= p("autoscaler.metricsforwarder.server.port") %> logging: @@ -74,7 +72,8 @@ health: port: <%= p("autoscaler.metricsforwarder.health.port") %> username: <%= p("autoscaler.metricsforwarder.health.username") %> password: <%= p("autoscaler.metricsforwarder.health.password") %> - + unprotected_endpoints: <%= p("autoscaler.metricsforwarder.health.unprotected_endpoints") %> + readiness_enabled: <%= p("autoscaler.metricsforwarder.health.readiness_enabled") %> rate_limit: valid_duration: <%= p("autoscaler.metricsforwarder.rate_limit.valid_duration") %> max_amount: <%= p("autoscaler.metricsforwarder.rate_limit.max_amount") %> diff --git a/jobs/metricsgateway/spec b/jobs/metricsgateway/spec index 69ee2302f7..3fad63bd91 100644 --- a/jobs/metricsgateway/spec +++ b/jobs/metricsgateway/spec @@ -117,3 +117,12 @@ properties: autoscaler.metricsgateway.health.password: description: "the password of health endpoint" default: '' + autoscaler.metricsgateway.health.unprotected_endpoints: + description: | + List of all health-endpoints, that run _without_ basic access authentication. + Valid endpoints are /debug/pprof, /health, /health/liveness, /health/prometheus, /health/readiness + default: [] # protect everything + autoscaler.metricsgateway.health.readiness_enabled: + default: false + description: "Set to true if you want to enable the endpoint on /health/readiness" + diff --git a/jobs/metricsgateway/templates/metricsgateway.yml.erb b/jobs/metricsgateway/templates/metricsgateway.yml.erb index 87611139b0..09d0f8d60c 100644 --- a/jobs/metricsgateway/templates/metricsgateway.yml.erb +++ b/jobs/metricsgateway/templates/metricsgateway.yml.erb @@ -32,20 +32,16 @@ end db_url end - -########################################### -# Template Main # -########################################### job_name = 'metricsgateway' policy_db_url = build_db_url('policy_db', job_name) metricsserver_sorted_instances=link("metricsserver").instances.sort_by {|i|i.address} metricsserver_addrs=metricsserver_sorted_instances.map{|i| "'wss://#{i.address}:#{link("metricsserver").p('autoscaler.metricsserver.collector.port')}'"} metricsserver_nodeAddrs="[" + metricsserver_addrs.join(",") +"]" - %> - - +########################################### +# Template Main # +########################################### logging: level: <%= p("autoscaler.metricsgateway.logging.level") %> envelop_chan_size: <%= p("autoscaler.metricsgateway.envelop_chan_size") %> @@ -80,3 +76,6 @@ health: port: <%= p("autoscaler.metricsgateway.health.port") %> username: <%= p("autoscaler.metricsgateway.health.username") %> password: <%= p("autoscaler.metricsgateway.health.password") %> + unprotected_endpoints: <%= p("autoscaler.metricsgateway.health.unprotected_endpoints") %> + readiness_enabled: <%= p("autoscaler.metricsgateway.health.readiness_enabled") %> + diff --git a/jobs/metricsserver/spec b/jobs/metricsserver/spec index 52a15be819..98b44113f4 100644 --- a/jobs/metricsserver/spec +++ b/jobs/metricsserver/spec @@ -156,6 +156,14 @@ properties: autoscaler.metricsserver.health.password: description: "the password of health endpoint" default: '' + autoscaler.metricsserver.health.unprotected_endpoints: + description: | + List of all health-endpoints, that run _without_ basic access authentication. + Valid endpoints are /debug/pprof, /health, /health/liveness, /health/prometheus, /health/readiness + default: [] # protect everything + autoscaler.metricsserver.health.readiness_enabled: + default: false + description: "Set to true if you want to enable the endpoint on /health/readiness" autoscaler.changeloglock_timeout_seconds: default: 180 description: "Liquibase changelog lock timeout duration in seconds" diff --git a/jobs/metricsserver/templates/metricsserver.yml.erb b/jobs/metricsserver/templates/metricsserver.yml.erb index c149de2377..d2b70b86ce 100644 --- a/jobs/metricsserver/templates/metricsserver.yml.erb +++ b/jobs/metricsserver/templates/metricsserver.yml.erb @@ -32,10 +32,6 @@ end db_url end - -########################################### -# Template Main # -########################################### job_name = 'metricsserver' instance_metrics_db_url = build_db_url('instancemetrics_db', job_name) policy_db_url = build_db_url('policy_db', job_name) @@ -43,10 +39,11 @@ end sorted_instances=link("metricsserver").instances.sort_by {|i|i.address} nodeIndex=sorted_instances.index(sorted_instances.find{|i|i.id == spec.id}) addrs=sorted_instances.map{|i| "'#{i.address}'"} - nodeAddrs="[" + addrs.join(",") +"]" - + nodeAddrs="[" + addrs.join(",") +"]" %> - +########################################### +# Template Main # +########################################### logging: level: <%= p("autoscaler.metricsserver.logging.level") %> http_client_timeout: <%= p("autoscaler.metricsserver.http_client_timeout") %> @@ -89,6 +86,9 @@ health: port: <%= p("autoscaler.metricsserver.health.port") %> username: <%= p("autoscaler.metricsserver.health.username") %> password: <%= p("autoscaler.metricsserver.health.password") %> + unprotected_endpoints: <%= p("autoscaler.metricsserver.health.unprotected_endpoints") %> + readiness_enabled: <%= p("autoscaler.metricsserver.health.readiness_enabled") %> + diff --git a/jobs/operator/spec b/jobs/operator/spec index d093fb08bb..f210c2797e 100644 --- a/jobs/operator/spec +++ b/jobs/operator/spec @@ -317,6 +317,14 @@ properties: autoscaler.operator.health.password: description: "the password of health endpoint" default: '' + autoscaler.operator.health.unprotected_endpoints: + description: | + List of all health-endpoints, that run _without_ basic access authentication. + Valid endpoints are /debug/pprof, /health, /health/liveness, /health/prometheus, /health/readiness + default: [] # protect everything + autoscaler.operator.health.readiness_enabled: + default: false + description: "Set to true if you want to enable the endpoint on /health/readiness" autoscaler.changeloglock_timeout_seconds: default: 180 description: "Liquibase changelog lock timeout duration in seconds" diff --git a/jobs/operator/templates/operator.yml.erb b/jobs/operator/templates/operator.yml.erb index 20119d01ad..aef7d42fc4 100644 --- a/jobs/operator/templates/operator.yml.erb +++ b/jobs/operator/templates/operator.yml.erb @@ -32,10 +32,6 @@ end db_url end - -########################################### -# Template Main # -########################################### job_name = 'operator' policy_db_url = build_db_url('policy_db', job_name) instance_metrics_db_url = build_db_url('instancemetrics_db', job_name) @@ -43,7 +39,9 @@ end scaling_engine_db_url = build_db_url('scalingengine_db', job_name) lock_db_url = build_db_url('lock_db', job_name) %> - +########################################### +# Template Main # +########################################### cf: api: <%= p("autoscaler.cf.api") %> client_id: <%= p("autoscaler.cf.client_id") %> @@ -54,14 +52,15 @@ cf: idle_connection_timeout_ms: <%= p("autoscaler.cf.idle_connection_timeout_ms") %> max_idle_conns_per_host_ms: <%= p("autoscaler.cf.max_idle_conns_per_host_ms") %> - - logging: level: <%= p("autoscaler.operator.logging.level") %> + health: port: <%= p("autoscaler.operator.health.port") %> username: <%= p("autoscaler.operator.health.username") %> password: <%= p("autoscaler.operator.health.password") %> + unprotected_endpoints: <%= p("autoscaler.operator.health.unprotected_endpoints") %> + readiness_enabled: <%= p("autoscaler.operator.health.readiness_enabled") %> http_client_timeout: <%= p("autoscaler.operator.http_client_timeout") %> instance_metrics_db: db: diff --git a/jobs/scalingengine/spec b/jobs/scalingengine/spec index 878c830ae2..27b1438cc4 100644 --- a/jobs/scalingengine/spec +++ b/jobs/scalingengine/spec @@ -175,6 +175,14 @@ properties: autoscaler.scalingengine.health.password: description: "the password of health endpoint" default: '' + autoscaler.scalingengine.health.unprotected_endpoints: + description: | + List of all health-endpoints, that run _without_ basic access authentication. + Valid endpoints are /debug/pprof, /health, /health/liveness, /health/prometheus, /health/readiness + default: [] # protect everything + autoscaler.scalingengine.health.readiness_enabled: + default: false + description: "Set to true if you want to enable the endpoint on /health/readiness" autoscaler.scalingengine.defaultCoolDownSecs: description: "Default value for cool_down_secs" default: 300 diff --git a/jobs/scalingengine/templates/scalingengine.yml.erb b/jobs/scalingengine/templates/scalingengine.yml.erb index ff559b6267..86f8b044d8 100644 --- a/jobs/scalingengine/templates/scalingengine.yml.erb +++ b/jobs/scalingengine/templates/scalingengine.yml.erb @@ -32,15 +32,14 @@ end db_url end - -########################################### -# Template Main # -########################################### job_name = 'scalingengine' scaling_engine_db_url = build_db_url('scalingengine_db', job_name) policy_db_url = build_db_url('policy_db', job_name) - scheduler_db_url = build_db_url('scheduler_db', job_name)%> - + scheduler_db_url = build_db_url('scheduler_db', job_name) +%> +########################################### +# Template Main # +########################################### cf: api: <%= p("autoscaler.cf.api") %> client_id: <%= p("autoscaler.cf.client_id") %> @@ -66,6 +65,8 @@ health: port: <%= p("autoscaler.scalingengine.health.port") %> username: <%= p("autoscaler.scalingengine.health.username") %> password: <%= p("autoscaler.scalingengine.health.password") %> + unprotected_endpoints: <%= p("autoscaler.scalingengine.health.unprotected_endpoints") %> + readiness_enabled: <%= p("autoscaler.scalingengine.health.readiness_enabled") %> db: policy_db: diff --git a/jobs/scheduler/spec b/jobs/scheduler/spec index 01cf63ccf4..5c5108b8fb 100644 --- a/jobs/scheduler/spec +++ b/jobs/scheduler/spec @@ -119,9 +119,6 @@ properties: autoscaler.scheduler.health.port: description: "the listening port of health endpoint" default: 6204 - autoscaler.scheduler.health.basicAuthEnabled: - description: "if true, basic auth is enabled on the endpoint" - default: false autoscaler.scheduler.health.username: description: "the username to protect the health endpoint" default: '' @@ -131,7 +128,7 @@ properties: autoscaler.scheduler.health.unprotected_endpoints: description: | List of all health-endpoints, that run _without_ basic access authentication. - Valid endpoints are /health/liveness, /health/prometheus + Valid endpoints are /health, /health/liveness, /health/prometheus default: [] # protect everything autoscaler.changeloglock_timeout_seconds: default: 180 diff --git a/jobs/scheduler/templates/scheduler.yml.erb b/jobs/scheduler/templates/scheduler.yml.erb index 6c31f15a5e..1ca2feee1a 100644 --- a/jobs/scheduler/templates/scheduler.yml.erb +++ b/jobs/scheduler/templates/scheduler.yml.erb @@ -134,7 +134,6 @@ scheduler: password: <%=p('autoscaler.scheduler.health.password') %> port: <%=p('autoscaler.scheduler.health.port') %> username: <%=p('autoscaler.scheduler.health.username') %> - basicAuthEnabled: <%=p('autoscaler.scheduler.health.basicAuthEnabled') %> unprotected_endpoints: <%=p('autoscaler.scheduler.health.unprotected_endpoints') %> ############################################################ # Server SSL keys diff --git a/operations/disable-basicauth-on-health-endpoints.yml b/operations/disable-basicauth-on-health-endpoints.yml index 8d7c5de06c..3ca97e88c7 100644 --- a/operations/disable-basicauth-on-health-endpoints.yml +++ b/operations/disable-basicauth-on-health-endpoints.yml @@ -27,9 +27,6 @@ - type: remove path: /instance_groups/name=scalingengine/jobs/name=scalingengine/properties/autoscaler/scalingengine/health/password -- type: remove - path: /instance_groups/name=scheduler/jobs/name=scheduler/properties/autoscaler/scheduler/health/basicAuthEnabled - - type: remove path: /instance_groups/name=scheduler/jobs/name=scheduler/properties/autoscaler/scheduler/health/username diff --git a/spec/fixtures/apiserver.yml b/spec/fixtures/apiserver.yml index d28bffdd0d..fae7a7c13b 100644 --- a/spec/fixtures/apiserver.yml +++ b/spec/fixtures/apiserver.yml @@ -57,4 +57,10 @@ autoscaler: apiserver: broker: server: - dashboard_redirect_uri: https://application-autoscaler-dashboard.cf.domain \ No newline at end of file + dashboard_redirect_uri: https://application-autoscaler-dashboard.cf.domain + health: + username: "basic_auth_username" + username_hash: "" + password: "basic_auth_secret" + password_hash: "" + unprotected_endpoints: [] \ No newline at end of file diff --git a/spec/fixtures/metricsforwarder.yml b/spec/fixtures/metricsforwarder.yml index e47d23e453..6c5fac0b16 100644 --- a/spec/fixtures/metricsforwarder.yml +++ b/spec/fixtures/metricsforwarder.yml @@ -20,4 +20,9 @@ autoscaler: client_id: client_id secret: uaa_secret uaa_api: https://login.cf.domain/uaa - grant_type: ALLOW_ALL \ No newline at end of file + grant_type: ALLOW_ALL + metricsforwarder: + health: + username: "basic_auth_username" + password: "basic_auth_secret" + unprotected_endpoints: [] \ No newline at end of file diff --git a/spec/fixtures/metricsgateway.yml b/spec/fixtures/metricsgateway.yml index 3ea7c70188..4e9e4a99ee 100644 --- a/spec/fixtures/metricsgateway.yml +++ b/spec/fixtures/metricsgateway.yml @@ -24,4 +24,8 @@ autoscaler: metricsgateway: nozzle: rlp_addr: - some_addr \ No newline at end of file + some_addr + health: + username: "basic_auth_username" + password: "basic_auth_secret" + unprotected_endpoints: [] \ No newline at end of file diff --git a/spec/fixtures/metricsserver.yml b/spec/fixtures/metricsserver.yml index bcb0079d59..c88d51b8ef 100644 --- a/spec/fixtures/metricsserver.yml +++ b/spec/fixtures/metricsserver.yml @@ -36,4 +36,9 @@ autoscaler: client_id: client_id secret: uaa_secret uaa_api: https://login.cf.domain/uaa - grant_type: ALLOW_ALL \ No newline at end of file + grant_type: ALLOW_ALL + metricsserver: + health: + username: "basic_auth_username" + password: "basic_auth_secret" + unprotected_endpoints: [] \ No newline at end of file diff --git a/spec/fixtures/operator.yml b/spec/fixtures/operator.yml index 8f85c4c253..6108a2dae7 100644 --- a/spec/fixtures/operator.yml +++ b/spec/fixtures/operator.yml @@ -81,4 +81,9 @@ autoscaler: client_id: client_id secret: uaa_secret uaa_api: https://login.cf.domain/uaa - grant_type: ALLOW_ALL \ No newline at end of file + grant_type: ALLOW_ALL + operator: + health: + username: "basic_auth_username" + password: "basic_auth_secret" + unprotected_endpoints: [] \ No newline at end of file diff --git a/spec/fixtures/scalingengine.yml b/spec/fixtures/scalingengine.yml index f8d171de06..0c6009ce1d 100644 --- a/spec/fixtures/scalingengine.yml +++ b/spec/fixtures/scalingengine.yml @@ -94,4 +94,9 @@ autoscaler: auth_endpoint: https://login.cf.domain client_id: "client_id" secret: "uaa_secret" - uaa_api: https://login.cf.domain/uaa \ No newline at end of file + uaa_api: https://login.cf.domain/uaa + scalingengine: + health: + username: "basic_auth_username" + password: "basic_auth_secret" + unprotected_endpoints: [] \ No newline at end of file diff --git a/spec/jobs/golangapiserver/apiserver_spec.rb b/spec/jobs/golangapiserver/apiserver_spec.rb index a0e9966f78..31fbf55b44 100644 --- a/spec/jobs/golangapiserver/apiserver_spec.rb +++ b/spec/jobs/golangapiserver/apiserver_spec.rb @@ -156,6 +156,33 @@ end end + context "health_server" do + it "default port exist" do + expect(rendered_template["health"]["port"]).to eq(6206) + end + + it "credentials are defined" do + expect(rendered_template["health"]["username"]).to eq("basic_auth_username") + expect(rendered_template["health"]["password"]).to eq("basic_auth_secret") + end + + it "readiness enabled is set to false by default" do + expect(rendered_template["health"]["readiness_enabled"]).to eq(false) + end + + it "unprotected_endpoint config is empty by default" do + expect(rendered_template["health"]["unprotected_endpoints"]).to match_array([]) + end + + it "has valid endpoints in unprotected_endpoint config" do + properties["autoscaler"]["apiserver"]["health"]["unprotected_endpoints"] = %w[/debug/pprof /health/liveness /health/prometheus /health/readiness] + expect(rendered_template["health"]["unprotected_endpoints"]).to contain_exactly("/health/liveness", + "/health/prometheus", + "/health/readiness", + "/debug/pprof") + end + end + context "cred_helper_impl" do it "has a cred helper impl by default" do expect(rendered_template).to include( diff --git a/spec/jobs/metricsforwarder/metricsforwarder_spec.rb b/spec/jobs/metricsforwarder/metricsforwarder_spec.rb index 6d7e24ef69..33a8fab62b 100644 --- a/spec/jobs/metricsforwarder/metricsforwarder_spec.rb +++ b/spec/jobs/metricsforwarder/metricsforwarder_spec.rb @@ -94,5 +94,32 @@ end end end + + context "health_server" do + it "default port exist" do + expect(rendered_template["health"]["port"]).to eq(6403) + end + + it "credentials are defined" do + expect(rendered_template["health"]["username"]).to eq("basic_auth_username") + expect(rendered_template["health"]["password"]).to eq("basic_auth_secret") + end + + it "readiness enabled is set to false by default" do + expect(rendered_template["health"]["readiness_enabled"]).to eq(false) + end + + it "unprotected_endpoint config is empty by default" do + expect(rendered_template["health"]["unprotected_endpoints"]).to match_array([]) + end + + it "has valid endpoints in unprotected_endpoint config" do + properties["autoscaler"]["metricsforwarder"]["health"]["unprotected_endpoints"] = %w[/debug/pprof /health/liveness /health/prometheus /health/readiness] + expect(rendered_template["health"]["unprotected_endpoints"]).to contain_exactly("/health/liveness", + "/health/prometheus", + "/health/readiness", + "/debug/pprof") + end + end end end diff --git a/spec/jobs/metricsgateway/metricsgateway_spec.rb b/spec/jobs/metricsgateway/metricsgateway_spec.rb index 924942d083..01e35b0fd0 100644 --- a/spec/jobs/metricsgateway/metricsgateway_spec.rb +++ b/spec/jobs/metricsgateway/metricsgateway_spec.rb @@ -13,43 +13,7 @@ let(:rendered_template) { YAML.safe_load(template.render(properties, consumes: links)) } context "config/metricsgateway.yml" do - it "does not set username nor password if not configured" do - properties["autoscaler"]["metricsgateway"] = { - "health" => { - "port" => 1234 - }, - "nozzle" => { - "rlp_addr" => "localhost" - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234} - ) - end - - it "check metricsgateway basic auth username and password" do - properties["autoscaler"]["metricsgateway"] = { - "health" => { - "port" => 1234, - "username" => "test-user", - "password" => "test-user-password" - }, - "nozzle" => { - "rlp_addr" => "localhost" - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234, - "username" => "test-user", - "password" => "test-user-password"} - ) - end - - context "apiserver uses tls" do + context "metricsgateway uses tls" do context "policy_db" do it "includes the ca, cert and key in url when configured" do rendered_template["app_manager"]["policy_db"]["url"].tap do |url| @@ -65,5 +29,32 @@ end end end + + context "health_server" do + it "default port exist" do + expect(rendered_template["health"]["port"]).to eq(6503) + end + + it "credentials are defined" do + expect(rendered_template["health"]["username"]).to eq("basic_auth_username") + expect(rendered_template["health"]["password"]).to eq("basic_auth_secret") + end + + it "readiness enabled is set to false by default" do + expect(rendered_template["health"]["readiness_enabled"]).to eq(false) + end + + it "unprotected_endpoint config is empty by default" do + expect(rendered_template["health"]["unprotected_endpoints"]).to match_array([]) + end + + it "has valid endpoints in unprotected_endpoint config" do + properties["autoscaler"]["metricsgateway"]["health"]["unprotected_endpoints"] = %w[/debug/pprof /health/liveness /health/prometheus /health/readiness] + expect(rendered_template["health"]["unprotected_endpoints"]).to contain_exactly("/health/liveness", + "/health/prometheus", + "/health/readiness", + "/debug/pprof") + end + end end end diff --git a/spec/jobs/metricsserver/metricsserver_spec.rb b/spec/jobs/metricsserver/metricsserver_spec.rb index 6841bfb8e0..e1a43b6707 100644 --- a/spec/jobs/metricsserver/metricsserver_spec.rb +++ b/spec/jobs/metricsserver/metricsserver_spec.rb @@ -13,36 +13,6 @@ let(:rendered_template) { YAML.safe_load(template.render(properties, consumes: links)) } context "config/metricsserver.yml" do - it "does not set username nor password if not configured" do - properties["autoscaler"]["metricsserver"] = { - "health" => { - "port" => 1234 - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234} - ) - end - - it "check metricsserver basic auth username and password" do - properties["autoscaler"]["metricsserver"] = { - "health" => { - "port" => 1234, - "username" => "test-user", - "password" => "test-user-password" - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234, - "username" => "test-user", - "password" => "test-user-password"} - ) - end - context "uses tls" do context "policy_db" do it "includes the ca, cert and key in url when configured" do @@ -74,5 +44,32 @@ end end end + + context "health_server" do + it "default port exist" do + expect(rendered_template["health"]["port"]).to eq(6303) + end + + it "credentials are defined" do + expect(rendered_template["health"]["username"]).to eq("basic_auth_username") + expect(rendered_template["health"]["password"]).to eq("basic_auth_secret") + end + + it "readiness enabled is set to false by default" do + expect(rendered_template["health"]["readiness_enabled"]).to eq(false) + end + + it "unprotected_endpoint config is empty by default" do + expect(rendered_template["health"]["unprotected_endpoints"]).to match_array([]) + end + + it "has valid endpoints in unprotected_endpoint config" do + properties["autoscaler"]["metricsserver"]["health"]["unprotected_endpoints"] = %w[/debug/pprof /health/liveness /health/prometheus /health/readiness] + expect(rendered_template["health"]["unprotected_endpoints"]).to contain_exactly("/health/liveness", + "/health/prometheus", + "/health/readiness", + "/debug/pprof") + end + end end end diff --git a/spec/jobs/operator/operator_spec.rb b/spec/jobs/operator/operator_spec.rb index bd2f966447..b561d0d776 100644 --- a/spec/jobs/operator/operator_spec.rb +++ b/spec/jobs/operator/operator_spec.rb @@ -12,36 +12,6 @@ let(:rendered_template) { YAML.safe_load(template.render(properties)) } context "config/operator.yml" do - it "does not set username nor password if not configured" do - properties["autoscaler"]["operator"] = { - "health" => { - "port" => 1234 - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234} - ) - end - - it "check operator basic auth username and password" do - properties["autoscaler"]["operator"] = { - "health" => { - "port" => 1234, - "username" => "test-user", - "password" => "test-user-password" - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234, - "username" => "test-user", - "password" => "test-user-password"} - ) - end - context "uses tls" do context "policy_db" do it "includes the ca, cert and key in url when configured" do @@ -118,5 +88,32 @@ end end end + + context "health_server" do + it "default port exist" do + expect(rendered_template["health"]["port"]).to eq(6208) + end + + it "credentials are defined" do + expect(rendered_template["health"]["username"]).to eq("basic_auth_username") + expect(rendered_template["health"]["password"]).to eq("basic_auth_secret") + end + + it "readiness enabled is set to false by default" do + expect(rendered_template["health"]["readiness_enabled"]).to eq(false) + end + + it "unprotected_endpoint config is empty by default" do + expect(rendered_template["health"]["unprotected_endpoints"]).to match_array([]) + end + + it "has valid endpoints in unprotected_endpoint config" do + properties["autoscaler"]["operator"]["health"]["unprotected_endpoints"] = %w[/debug/pprof /health/liveness /health/prometheus /health/readiness] + expect(rendered_template["health"]["unprotected_endpoints"]).to contain_exactly("/health/liveness", + "/health/prometheus", + "/health/readiness", + "/debug/pprof") + end + end end end diff --git a/spec/jobs/scalingengine/scalingengine_spec.rb b/spec/jobs/scalingengine/scalingengine_spec.rb index 5195d53e6a..535194c4c9 100644 --- a/spec/jobs/scalingengine/scalingengine_spec.rb +++ b/spec/jobs/scalingengine/scalingengine_spec.rb @@ -12,38 +12,6 @@ let(:rendered_template) { YAML.safe_load(template.render(properties)) } context "config/scalingengine.yml" do - context "scalingengine" do - it "does not set username nor password if not configured" do - properties["autoscaler"]["scalingengine"] = { - "health" => { - "port" => 1234 - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234} - ) - end - - it "check scalingengine basic auth username and password" do - properties["autoscaler"]["scalingengine"] = { - "health" => { - "port" => 1234, - "username" => "test-user", - "password" => "test-user-password" - } - } - - expect(rendered_template["health"]) - .to include( - {"port" => 1234, - "username" => "test-user", - "password" => "test-user-password"} - ) - end - end - context "uses tls" do context "policy_db" do it "includes the ca, cert and key in url when configured" do @@ -90,5 +58,32 @@ end end end + + context "health_server" do + it "default port exist" do + expect(rendered_template["health"]["port"]).to eq(6204) + end + + it "credentials are defined" do + expect(rendered_template["health"]["username"]).to eq("basic_auth_username") + expect(rendered_template["health"]["password"]).to eq("basic_auth_secret") + end + + it "readiness enabled is set to false by default" do + expect(rendered_template["health"]["readiness_enabled"]).to eq(false) + end + + it "unprotected_endpoint config is empty by default" do + expect(rendered_template["health"]["unprotected_endpoints"]).to match_array([]) + end + + it "has valid endpoints in unprotected_endpoint config" do + properties["autoscaler"]["scalingengine"]["health"]["unprotected_endpoints"] = %w[/debug/pprof /health/liveness /health/prometheus /health/readiness] + expect(rendered_template["health"]["unprotected_endpoints"]).to contain_exactly("/health/liveness", + "/health/prometheus", + "/health/readiness", + "/debug/pprof") + end + end end end diff --git a/spec/jobs/scheduler/scheduler_spec.rb b/spec/jobs/scheduler/scheduler_spec.rb index 818e54f4bb..cf96782be1 100644 --- a/spec/jobs/scheduler/scheduler_spec.rb +++ b/spec/jobs/scheduler/scheduler_spec.rb @@ -28,7 +28,6 @@ "port" => 1234, "username" => nil, "password" => nil, - "basicAuthEnabled" => false, "unprotected_endpoints" => [] } }} @@ -53,7 +52,6 @@ "port" => 1234, "username" => "test-user", "password" => "test-user-password", - "basicAuthEnabled" => false, "unprotected_endpoints" => ["/health/liveness"] } }} diff --git a/src/acceptance/api/api_suite_test.go b/src/acceptance/api/api_suite_test.go index c3dbf22e91..58d73b9e3f 100644 --- a/src/acceptance/api/api_suite_test.go +++ b/src/acceptance/api/api_suite_test.go @@ -20,7 +20,7 @@ import ( ) const ( - HealthPath = "/health" + LivenessPath = "/health/liveness" AggregatedMetricPath = "/v1/apps/{appId}/aggregated_metric_histories/{metric_type}" HistoryPath = "/v1/apps/{appId}/scaling_histories" ) @@ -94,8 +94,7 @@ var _ = BeforeSuite(func() { }, Timeout: 30 * time.Second, } - - healthURL = fmt.Sprintf("%s%s", cfg.ASApiEndpoint, HealthPath) + healthURL = fmt.Sprintf("%s%s", cfg.ASApiEndpoint, LivenessPath) policyURL = fmt.Sprintf("%s%s", cfg.ASApiEndpoint, strings.Replace(PolicyPath, "{appId}", appGUID, -1)) metricURL = fmt.Sprintf("%s%s", cfg.ASApiEndpoint, strings.Replace(metricURL, "{appId}", appGUID, -1)) aggregatedMetricURL = strings.Replace(AggregatedMetricPath, "{metric_type}", "memoryused", -1) diff --git a/src/acceptance/api/basic_auth_test.go b/src/acceptance/api/basic_auth_test.go index 9ba4ea8dee..4ee8fa8af4 100644 --- a/src/acceptance/api/basic_auth_test.go +++ b/src/acceptance/api/basic_auth_test.go @@ -1,6 +1,7 @@ package api_test import ( + "fmt" "net/http" "strings" @@ -8,23 +9,56 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("AutoScaler Basic Auth Tests", func() { +var _ = Describe("AutoScaler Health Endpoints with Basic Auth", func() { - urlfor := func(name string) func() string { - return func() string { return strings.Replace(healthURL, cfg.ServiceName, cfg.ServiceName+"-"+name, 1) } + urlfor := func(name string, path string) func() string { + return func() string { + if path != "" { + healthURL = fmt.Sprintf("%s%s", cfg.ASApiEndpoint, path) + } + healthURL := strings.Replace(healthURL, cfg.ServiceName, cfg.ServiceName+"-"+name, 1) + return healthURL + } } - DescribeTable("basic auth tests", + DescribeTable("Basic Auth Credentials not provided", func(url func() string, statusCode func() int) { Expect(Get(url())).To(Equal(statusCode()), "to get status code %d when getting %s", statusCode(), url()) }, - Entry("API Server", func() string { return healthURL }, func() int { return 200 }), - Entry("Eventgenerator", urlfor("eventgenerator"), getStatus), - Entry("Scaling Engine", urlfor("scalingengine"), getStatus), - Entry("Operator", urlfor("operator"), getStatus), - Entry("Metrics Forwarder", urlfor("metricsforwarder"), getStatus), - Entry("Scheduler", urlfor("scheduler"), getStatus), + Entry("API Server", urlfor("apiserver", ""), getStatus), + Entry("Eventgenerator", urlfor("eventgenerator", ""), getStatus), + Entry("Scaling Engine", urlfor("scalingengine", ""), getStatus), + Entry("Operator", urlfor("operator", ""), getStatus), + Entry("Metrics Forwarder", urlfor("metricsforwarder", ""), getStatus), + Entry("Scheduler", urlfor("scheduler", ""), getStatus), ) + DescribeTable("Basic Auth Credentials Provided", + + func(url func() string, statusCode func() int) { + cfg.HealthEndpointsBasicAuthEnabled = true + Expect(Get(url())).To(Equal(statusCode()), "to get status code %d when getting %s", statusCode(), url()) + }, + Entry("API Server", urlfor("apiserver", ""), getStatus), + Entry("Eventgenerator", urlfor("eventgenerator", ""), getStatus), + Entry("Scaling Engine", urlfor("scalingengine", ""), getStatus), + Entry("Operator", urlfor("operator", ""), getStatus), + Entry("Metrics Forwarder", urlfor("metricsforwarder", ""), getStatus), + Entry("Scheduler", urlfor("scheduler", ""), getStatus), + ) + + DescribeTable("Liveness with Basic Auth Credentials Provided", + + func(url func() string, statusCode func() int) { + cfg.HealthEndpointsBasicAuthEnabled = true + Expect(Get(url())).To(Equal(statusCode()), "to get status code %d when getting %s", statusCode(), url()) + }, + Entry("API Server", urlfor("apiserver", "/health"), getStatus), + Entry("Eventgenerator", urlfor("eventgenerator", "/health"), getStatus), + Entry("Scaling Engine", urlfor("scalingengine", "/health"), getStatus), + Entry("Operator", urlfor("operator", "/health"), getStatus), + Entry("Metrics Forwarder", urlfor("metricsforwarder", "/health"), getStatus), + Entry("Scheduler", urlfor("scheduler", "/health"), getStatus), + ) }) func getStatus() int { diff --git a/src/autoscaler/Makefile b/src/autoscaler/Makefile index 5b1edeb755..2beefc0e8b 100644 --- a/src/autoscaler/Makefile +++ b/src/autoscaler/Makefile @@ -44,8 +44,8 @@ ginkgo_check: @ current_version=$(shell ginkgo version | cut -d " " -f 3 | sed -E 's/([0-9]+\.[0-9]+)\..*/\1/');\ expected_version=$(shell grep "ginkgo" "../../.tool-versions" | cut -d " " -f 2 | sed -E 's/([0-9]+\.[0-9]+)\..*/\1/');\ if [ "$${current_version}" != "$${expected_version}" ]; then \ - echo "WARNING: Expected to have ginkgo version '$${expected_version}.x' but we have $(shell ginkgo version)";\ - fi + echo "WARNING: Expected to have ginkgo version '$${expected_version}.x' but we have $(shell ginkgo version)";\ + fi test: ginkgo_check @echo "Running tests" diff --git a/src/autoscaler/api/brokerserver/broker_server.go b/src/autoscaler/api/brokerserver/broker_server.go index 8c1750ba01..bc40fcc7c0 100644 --- a/src/autoscaler/api/brokerserver/broker_server.go +++ b/src/autoscaler/api/brokerserver/broker_server.go @@ -8,9 +8,6 @@ import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/api/broker" - "code.cloudfoundry.org/app-autoscaler/src/autoscaler/helpers/handlers" - "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" - "code.cloudfoundry.org/app-autoscaler/src/autoscaler/cf" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/cred_helper" @@ -47,23 +44,19 @@ type basicAuthenticationMiddleware struct { func (bam *basicAuthenticationMiddleware) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/health" { - next.ServeHTTP(w, r) - return - } username, password, authOK := r.BasicAuth() - crenditialFoundFlag := false + credentialFound := false for _, brokerCredential := range bam.brokerCredentials { usernameHashResult := bcrypt.CompareHashAndPassword(brokerCredential.BrokerUsernameHash, []byte(username)) passwordHashResult := bcrypt.CompareHashAndPassword(brokerCredential.BrokerPasswordHash, []byte(password)) if authOK && usernameHashResult == nil && passwordHashResult == nil { - crenditialFoundFlag = true + credentialFound = true break } } - if !crenditialFoundFlag { + if !credentialFound { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } @@ -129,8 +122,6 @@ func NewBrokerServer(logger lager.Logger, conf *config.Config, bindingdb db.Bind r.Use(httpStatusCollectMiddleware.Collect) brokerapi.AttachRoutes(r, autoscalerBroker, logger.Session("broker_handler")) - r.HandleFunc(routes.BrokerHealthPath, GetHealth) - var addr string if os.Getenv("APP_AUTOSCALER_TEST_RUN") == "true" { addr = fmt.Sprintf("localhost:%d", conf.BrokerServer.Port) @@ -166,7 +157,3 @@ func restrictToMaxBcryptLength(logger lager.Logger, brokerCredential config.Brok return brokerCredential } - -func GetHealth(w http.ResponseWriter, _ *http.Request) { - handlers.WriteJSONResponse(w, http.StatusOK, []byte(`{"alive":"true"}`)) -} diff --git a/src/autoscaler/api/brokerserver/broker_server_test.go b/src/autoscaler/api/brokerserver/broker_server_test.go index bd2d543a67..8d61a66ed3 100644 --- a/src/autoscaler/api/brokerserver/broker_server_test.go +++ b/src/autoscaler/api/brokerserver/broker_server_test.go @@ -132,22 +132,4 @@ var _ = Describe("BrokerServer", func() { Expect(rsp.StatusCode).To(Equal(http.StatusNotFound)) }) }) - - Context("when requesting the health endpoint", func() { - BeforeEach(func() { - serverUrl.Path = "/health" - }) - JustBeforeEach(func() { - req, err := http.NewRequest(http.MethodGet, serverUrl.String(), nil) - Expect(err).NotTo(HaveOccurred()) - - rsp, err = httpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should get 200", func() { - Expect(rsp.StatusCode).To(Equal(http.StatusOK)) - }) - }) - }) diff --git a/src/autoscaler/api/cmd/api/api_test.go b/src/autoscaler/api/cmd/api/api_test.go index 65a18001d6..c6b03a30eb 100644 --- a/src/autoscaler/api/cmd/api/api_test.go +++ b/src/autoscaler/api/cmd/api/api_test.go @@ -7,6 +7,8 @@ import ( "os" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" + . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/testhelpers" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/api/config" @@ -200,11 +202,15 @@ var _ = Describe("Api", func() { }) }) }) - Describe("when Health server is ready to serve RESTful API", func() { + + Describe("when Health server is ready to serve RESTful API without basic Auth", func() { BeforeEach(func() { basicAuthConfig := cfg basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" + basicAuthConfig.Health.ReadinessCheckEnabled = true + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PprofPath, routes.PrometheusPath} runner.configPath = writeConfig(&basicAuthConfig).Name() runner.Start() }) @@ -212,9 +218,10 @@ var _ = Describe("Api", func() { runner.Interrupt() Eventually(runner.Session, 5).Should(Exit(0)) }) - Context("when a request to query health comes", func() { + Context("when a request to query health/prometheus comes without credentials", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d", healthport)) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(url) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -225,26 +232,47 @@ var _ = Describe("Api", func() { Expect(healthData).To(ContainSubstring("go_goroutines")) Expect(healthData).To(ContainSubstring("go_memstats_alloc_bytes")) rsp.Body.Close() + }) + }) + Context("when a request to /health/liveness comes with without credentials", func() { + It("should return 200", func() { + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) + Expect(err).NotTo(HaveOccurred()) + + req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) + rsp, err := healthHttpClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(rsp.StatusCode).To(Equal(http.StatusOK)) }) }) }) Describe("when Health server is ready to serve RESTful API with basic Auth", func() { BeforeEach(func() { + basicAuthConfig := cfg + basicAuthConfig.Health.HealthCheckUsername = "correct_username" + basicAuthConfig.Health.HealthCheckPassword = "correct_password" + + cfg = basicAuthConfig // Setting password only for `basicAuthConfig` is not sufficient, + // since the server-process does not use that configuration. + // Alternatively, basiAuthConfig could be just a pointer. + runner.configPath = writeConfig(&cfg).Name() runner.Start() }) AfterEach(func() { runner.Interrupt() Eventually(runner.Session, 5).Should(Exit(0)) }) + Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) - req.SetBasicAuth("wrongusername", "wrongpassword") + req.SetBasicAuth("wrong_username", "wrong_password") rsp, err := healthHttpClient.Do(req) Expect(err).ToNot(HaveOccurred()) @@ -254,8 +282,8 @@ var _ = Describe("Api", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) @@ -265,6 +293,7 @@ var _ = Describe("Api", func() { Expect(rsp.StatusCode).To(Equal(http.StatusOK)) }) }) + }) Describe("can start with default plugin", func() { diff --git a/src/autoscaler/api/cmd/api/main.go b/src/autoscaler/api/cmd/api/main.go index 510b61207e..19ad3d5d32 100644 --- a/src/autoscaler/api/cmd/api/main.go +++ b/src/autoscaler/api/cmd/api/main.go @@ -110,8 +110,13 @@ func main() { promRegistry := prometheus.NewRegistry() healthendpoint.RegisterCollectors(promRegistry, prometheusCollectors, true, logger.Session("golangapiserver-prometheus")) - publicApiHttpServer := createApiServer(conf, logger, policyDb, credentialProvider, checkBindingFunc, cfClient, httpStatusCollector, bindingDB) - healthServer, err := healthendpoint.NewServerWithBasicAuth(conf.Health, []healthendpoint.Checker{}, logger.Session("health-server"), promRegistry, time.Now) + publicApiHttpServer := createApiServer( + conf, logger, policyDb, credentialProvider, checkBindingFunc, cfClient, httpStatusCollector, + bindingDB) + + healthServer, err := healthendpoint.NewServerWithBasicAuth( + conf.Health, []healthendpoint.Checker{}, logger.Session("health-server"), promRegistry, + time.Now) if err != nil { logger.Fatal("Failed to create health server:", err) os.Exit(1) @@ -137,9 +142,15 @@ func main() { logger.Info("exited") } -func createApiServer(conf *config.Config, logger lager.Logger, policyDb *sqldb.PolicySQLDB, credentialProvider cred_helper.Credentials, checkBindingFunc api.CheckBindingFunc, cfClient cf.CFClient, httpStatusCollector healthendpoint.HTTPStatusCollector, bindingDB db.BindingDB) ifrit.Runner { - rateLimiter := ratelimiter.DefaultRateLimiter(conf.RateLimit.MaxAmount, conf.RateLimit.ValidDuration, logger.Session("api-ratelimiter")) - publicApiHttpServer, err := publicapiserver.NewPublicApiServer(logger.Session("public_api_http_server"), conf, policyDb, credentialProvider, checkBindingFunc, cfClient, httpStatusCollector, rateLimiter, bindingDB) +func createApiServer(conf *config.Config, logger lager.Logger, policyDb *sqldb.PolicySQLDB, + credentialProvider cred_helper.Credentials, checkBindingFunc api.CheckBindingFunc, + cfClient cf.CFClient, httpStatusCollector healthendpoint.HTTPStatusCollector, + bindingDB db.BindingDB) ifrit.Runner { + rateLimiter := ratelimiter.DefaultRateLimiter( + conf.RateLimit.MaxAmount, conf.RateLimit.ValidDuration, logger.Session("api-ratelimiter")) + publicApiHttpServer, err := publicapiserver.NewPublicApiServer( + logger.Session("public_api_http_server"), conf, policyDb, credentialProvider, checkBindingFunc, + cfClient, httpStatusCollector, rateLimiter, bindingDB) if err != nil { logger.Error("failed to create public api http server", err) os.Exit(1) diff --git a/src/autoscaler/api/config/config_test.go b/src/autoscaler/api/config/config_test.go index 3674cb7b9b..4306afbc8d 100644 --- a/src/autoscaler/api/config/config_test.go +++ b/src/autoscaler/api/config/config_test.go @@ -4,6 +4,7 @@ import ( "bytes" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/testhelpers" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/api/config" @@ -113,6 +114,8 @@ var _ = Describe("Config", func() { }, )) Expect(conf.CredHelperImpl).To(Equal("default")) + Expect(conf.Health.UnprotectedEndpoints).To( + ContainElements("/health/liveness", "/health/prometheus", "/debug/pprof")) }) }) @@ -257,6 +260,11 @@ rate_limit: conf.RateLimit.ValidDuration = 1 * time.Second conf.CredHelperImpl = "path/to/plugin" + + conf.Health = models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}, + } }) JustBeforeEach(func() { err = conf.Validate() diff --git a/src/autoscaler/api/config/testdata/valid_config.yml b/src/autoscaler/api/config/testdata/valid_config.yml index f2168ecba5..d51a1659a8 100644 --- a/src/autoscaler/api/config/testdata/valid_config.yml +++ b/src/autoscaler/api/config/testdata/valid_config.yml @@ -31,6 +31,8 @@ db: catalog_schema_path: '../schemas/catalog.schema.json' catalog_path: '../exampleconfig/catalog-example.json' policy_schema_path: '../exampleconfig/policy.schema.json' +health: + unprotected_endpoints: ["/health/liveness", "/health/prometheus", "/debug/pprof"] scheduler: scheduler_url: https://localhost:8083 tls: diff --git a/src/autoscaler/api/publicapiserver/public_api_handler.go b/src/autoscaler/api/publicapiserver/public_api_handler.go index 52376e43fa..77aca0735f 100644 --- a/src/autoscaler/api/publicapiserver/public_api_handler.go +++ b/src/autoscaler/api/publicapiserver/public_api_handler.go @@ -325,13 +325,6 @@ func (h *PublicApiHandler) GetApiInfo(w http.ResponseWriter, _ *http.Request, _ } } -func (h *PublicApiHandler) GetHealth(w http.ResponseWriter, _ *http.Request, _ map[string]string) { - _, err := w.Write([]byte(`{"alive":"true"}`)) - if err != nil { - h.logger.Error("failed-to-write-body", err) - } -} - func (h *PublicApiHandler) CreateCredential(w http.ResponseWriter, r *http.Request, vars map[string]string) { appId := vars["appId"] if appId == "" { diff --git a/src/autoscaler/api/publicapiserver/public_api_handler_test.go b/src/autoscaler/api/publicapiserver/public_api_handler_test.go index 1d45e71b13..f1b2950635 100644 --- a/src/autoscaler/api/publicapiserver/public_api_handler_test.go +++ b/src/autoscaler/api/publicapiserver/public_api_handler_test.go @@ -113,17 +113,6 @@ var _ = Describe("PublicApiHandler", func() { }) }) - Describe("GetHealth", func() { - JustBeforeEach(func() { - handler.GetHealth(resp, req, map[string]string{}) - }) - Context("When GetHealth is called", func() { - It("succeeds with 200", func() { - Expect(resp.Code).To(Equal(http.StatusOK)) - Expect(resp.Body.String()).To(Equal(`{"alive":"true"}`)) - }) - }) - }) Describe("GetScalingPolicy", func() { JustBeforeEach(func() { handler.GetScalingPolicy(resp, req, pathVariables) diff --git a/src/autoscaler/api/publicapiserver/public_api_server.go b/src/autoscaler/api/publicapiserver/public_api_server.go index ee68a646c0..557d6cdfd7 100644 --- a/src/autoscaler/api/publicapiserver/public_api_server.go +++ b/src/autoscaler/api/publicapiserver/public_api_server.go @@ -35,41 +35,11 @@ func NewPublicApiServer(logger lager.Logger, conf *config.Config, policydb db.Po mw := NewMiddleware(logger, cfclient, checkBindingFunc, conf.APIClientId) rateLimiterMiddleware := ratelimiter.NewRateLimiterMiddleware("appId", rateLimiter, logger.Session("api-ratelimiter-middleware")) httpStatusCollectMiddleware := healthendpoint.NewHTTPStatusCollectMiddleware(httpStatusCollector) - r := routes.ApiOpenRoutes() - r.Use(httpStatusCollectMiddleware.Collect) - r.Get(routes.PublicApiInfoRouteName).Handler(VarsFunc(pah.GetApiInfo)) - r.Get(routes.PublicApiHealthRouteName).Handler(VarsFunc(pah.GetHealth)) - - rp := routes.ApiRoutes() - rp.Use(rateLimiterMiddleware.CheckRateLimit) - rp.Use(mw.HasClientToken) - rp.Use(mw.Oauth) - rp.Use(httpStatusCollectMiddleware.Collect) - rp.Get(routes.PublicApiScalingHistoryRouteName).Handler(VarsFunc(pah.GetScalingHistories)) - rp.Get(routes.PublicApiAggregatedMetricsHistoryRouteName).Handler(VarsFunc(pah.GetAggregatedMetricsHistories)) - - rpolicy := routes.ApiPolicyRoutes() - rpolicy.Use(rateLimiterMiddleware.CheckRateLimit) - rpolicy.Use(mw.HasClientToken) - rpolicy.Use(mw.Oauth) - if !conf.UseBuildInMode { - rpolicy.Use(mw.CheckServiceBinding) - } - rpolicy.Use(httpStatusCollectMiddleware.Collect) - rpolicy.Get(routes.PublicApiGetPolicyRouteName).Handler(VarsFunc(pah.GetScalingPolicy)) - rpolicy.Get(routes.PublicApiAttachPolicyRouteName).Handler(VarsFunc(pah.AttachScalingPolicy)) - rpolicy.Get(routes.PublicApiDetachPolicyRouteName).Handler(VarsFunc(pah.DetachScalingPolicy)) - rcredential := routes.ApiCredentialRoutes() - rcredential.Use(rateLimiterMiddleware.CheckRateLimit) - if !conf.UseBuildInMode { - rcredential.Use(mw.RejectCredentialOperationInServiceOffering) - } - rcredential.Use(httpStatusCollectMiddleware.Collect) - rcredential.Use(mw.HasClientToken) - rcredential.Use(mw.Oauth) - rcredential.Get(routes.PublicApiCreateCredentialRouteName).Handler(VarsFunc(pah.CreateCredential)) - rcredential.Get(routes.PublicApiDeleteCredentialRouteName).Handler(VarsFunc(pah.DeleteCredential)) + r := createApiOpenRoutes(httpStatusCollectMiddleware, pah) + createApiRoutes(rateLimiterMiddleware, mw, httpStatusCollectMiddleware, pah) + createApiPolicyRoutes(conf.UseBuildInMode, rateLimiterMiddleware, mw, httpStatusCollectMiddleware, pah) + createApiCredentialsRoutes(conf.UseBuildInMode, rateLimiterMiddleware, mw, httpStatusCollectMiddleware, pah) var addr string if os.Getenv("APP_AUTOSCALER_TEST_RUN") == "true" { @@ -95,3 +65,74 @@ func NewPublicApiServer(logger lager.Logger, conf *config.Config, policydb db.Po logger.Info("public-api-http-server-created", lager.Data{"serverConfig": conf.PublicApiServer}) return runner, nil } + +func createApiOpenRoutes( + httpStatusCollectMiddleware *healthendpoint.HTTPStatusCollectMiddleware, + publicApiHandler *PublicApiHandler) *mux.Router { + r := routes.ApiOpenRoutes() + r.Use(httpStatusCollectMiddleware.Collect) + r.Get(routes.PublicApiInfoRouteName).Handler(VarsFunc(publicApiHandler.GetApiInfo)) + + return r +} + +func createApiRoutes( + rateLimiterMiddleware *ratelimiter.RateLimiterMiddleware, + middleWare *Middleware, + httpStatusCollectMiddleware *healthendpoint.HTTPStatusCollectMiddleware, + publicApiHandler *PublicApiHandler) *mux.Router { + rp := routes.ApiRoutes() + rp.Use(rateLimiterMiddleware.CheckRateLimit) + rp.Use(middleWare.HasClientToken) + rp.Use(middleWare.Oauth) + rp.Use(httpStatusCollectMiddleware.Collect) + rp.Get(routes.PublicApiScalingHistoryRouteName).Handler(VarsFunc(publicApiHandler.GetScalingHistories)) + rp.Get(routes.PublicApiAggregatedMetricsHistoryRouteName).Handler(VarsFunc(publicApiHandler.GetAggregatedMetricsHistories)) + + return rp +} + +func createApiPolicyRoutes( + isBuildInMode bool, + rateLimiterMiddleware *ratelimiter.RateLimiterMiddleware, + middleWare *Middleware, + httpStatusCollectMiddleware *healthendpoint.HTTPStatusCollectMiddleware, + publicApiHandler *PublicApiHandler) *mux.Router { + rpolicy := routes.ApiPolicyRoutes() + + rpolicy.Use(rateLimiterMiddleware.CheckRateLimit) + rpolicy.Use(middleWare.HasClientToken) + rpolicy.Use(middleWare.Oauth) + if !isBuildInMode { + rpolicy.Use(middleWare.CheckServiceBinding) + } + rpolicy.Use(httpStatusCollectMiddleware.Collect) + + rpolicy.Get(routes.PublicApiGetPolicyRouteName).Handler(VarsFunc(publicApiHandler.GetScalingPolicy)) + rpolicy.Get(routes.PublicApiAttachPolicyRouteName).Handler(VarsFunc(publicApiHandler.AttachScalingPolicy)) + rpolicy.Get(routes.PublicApiDetachPolicyRouteName).Handler(VarsFunc(publicApiHandler.DetachScalingPolicy)) + + return rpolicy +} + +func createApiCredentialsRoutes( + isBuildInMode bool, + rateLimiterMiddleware *ratelimiter.RateLimiterMiddleware, + middleWare *Middleware, + httpStatusCollectMiddleware *healthendpoint.HTTPStatusCollectMiddleware, + publicApiHandler *PublicApiHandler) *mux.Router { + rcredential := routes.ApiCredentialRoutes() + + rcredential.Use(rateLimiterMiddleware.CheckRateLimit) + if !isBuildInMode { + rcredential.Use(middleWare.RejectCredentialOperationInServiceOffering) + } + rcredential.Use(httpStatusCollectMiddleware.Collect) + rcredential.Use(middleWare.HasClientToken) + rcredential.Use(middleWare.Oauth) + + rcredential.Get(routes.PublicApiCreateCredentialRouteName).Handler(VarsFunc(publicApiHandler.CreateCredential)) + rcredential.Get(routes.PublicApiDeleteCredentialRouteName).Handler(VarsFunc(publicApiHandler.DeleteCredential)) + + return rcredential +} diff --git a/src/autoscaler/api/publicapiserver/public_api_server_test.go b/src/autoscaler/api/publicapiserver/public_api_server_test.go index 9636bf151b..19c65ad494 100644 --- a/src/autoscaler/api/publicapiserver/public_api_server_test.go +++ b/src/autoscaler/api/publicapiserver/public_api_server_test.go @@ -506,17 +506,13 @@ var _ = Describe("PublicApiServer", func() { }) }) }) + Describe("UnProtected Routes", func() { Context("when calling info endpoint", func() { It("should succeed", func() { verifyResponse(httpClient, serverUrl, "/v1/info", nil, http.MethodGet, "", http.StatusOK) }) }) - Context("when calling health endpoint", func() { - It("should succeed", func() { - verifyResponse(httpClient, serverUrl, "/health", nil, http.MethodGet, "", http.StatusOK) - }) - }) }) Context("when requesting non existing path", func() { diff --git a/src/autoscaler/eventgenerator/cmd/eventgenerator/eventgenerator_test.go b/src/autoscaler/eventgenerator/cmd/eventgenerator/eventgenerator_test.go index 17546744fb..6557f9e351 100644 --- a/src/autoscaler/eventgenerator/cmd/eventgenerator/eventgenerator_test.go +++ b/src/autoscaler/eventgenerator/cmd/eventgenerator/eventgenerator_test.go @@ -15,6 +15,7 @@ import ( "time" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" rpc "code.cloudfoundry.org/go-log-cache/rpc/logcache_v1" "code.cloudfoundry.org/go-loggregator/v9/rpc/loggregator_v2" @@ -158,15 +159,17 @@ var _ = Describe("Eventgenerator", func() { basicAuthConfig := conf basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" - runner.configPath = writeConfig(&basicAuthConfig).Name() + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} + runner.configPath = writeConfig(&basicAuthConfig).Name() runner.Start() - }) - Context("when a request to query health comes", func() { + Context("when a request to the prometheus-endpoint comes", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/health", healthport)) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(url) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -177,7 +180,6 @@ var _ = Describe("Eventgenerator", func() { Expect(healthData).To(ContainSubstring("go_goroutines")) Expect(healthData).To(ContainSubstring("go_memstats_alloc_bytes")) rsp.Body.Close() - }) }) }) @@ -188,8 +190,8 @@ var _ = Describe("Eventgenerator", func() { }) Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -202,41 +204,8 @@ var _ = Describe("Eventgenerator", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) - Expect(err).NotTo(HaveOccurred()) - - req.SetBasicAuth(conf.Health.HealthCheckUsername, conf.Health.HealthCheckPassword) - - rsp, err := healthHttpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - Expect(rsp.StatusCode).To(Equal(http.StatusOK)) - }) - }) - }) - - Describe("when Health server is ready to serve RESTful API with basic Auth", func() { - BeforeEach(func() { - runner.Start() - }) - Context("when username and password are incorrect for basic authentication during health check", func() { - It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) - Expect(err).NotTo(HaveOccurred()) - - req.SetBasicAuth("wrongusername", "wrongpassword") - - rsp, err := healthHttpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - Expect(rsp.StatusCode).To(Equal(http.StatusUnauthorized)) - }) - }) - - Context("when username and password are correct for basic authentication during health check", func() { - It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(conf.Health.HealthCheckUsername, conf.Health.HealthCheckPassword) diff --git a/src/autoscaler/eventgenerator/config/config_test.go b/src/autoscaler/eventgenerator/config/config_test.go index 463f1651d4..35f902729e 100644 --- a/src/autoscaler/eventgenerator/config/config_test.go +++ b/src/autoscaler/eventgenerator/config/config_test.go @@ -3,6 +3,8 @@ package config_test import ( "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/db" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/eventgenerator/config" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/helpers" @@ -43,6 +45,7 @@ server: node_index: 1 health: port: 9999 + unprotected_endpoints: ["/health/liveness", "/health/prometheus", "/health/readiness", "/debug/pprof"] db: policy_db: url: postgres://postgres:password@localhost/autoscaler?sslmode=disable @@ -104,7 +107,8 @@ circuitBreaker: NodeIndex: 1, }, Health: models.HealthConfig{ - Port: 9999, + Port: 9999, + UnprotectedEndpoints: []string{"/health/liveness", "/health/prometheus", "/health/readiness", "/debug/pprof"}, }, DB: DBConfig{ PolicyDB: db.DatabaseConfig{ @@ -229,7 +233,8 @@ defaultBreachDurationSecs: 600 TLS: models.TLSCerts{}, }, Health: models.HealthConfig{ - Port: 8081, + Port: 8081, + UnprotectedEndpoints: nil, }, DB: DBConfig{ PolicyDB: db.DatabaseConfig{ @@ -1107,6 +1112,8 @@ health: DefaultBreachDurationSecs: 600, DefaultStatWindowSecs: 300, HttpClientTimeout: 10 * time.Second, + Health: models.HealthConfig{UnprotectedEndpoints: []string{routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, } }) diff --git a/src/autoscaler/healthendpoint/health_readiness_test.go b/src/autoscaler/healthendpoint/health_readiness_test.go index d552880454..b1fb7c2476 100644 --- a/src/autoscaler/healthendpoint/health_readiness_test.go +++ b/src/autoscaler/healthendpoint/health_readiness_test.go @@ -8,6 +8,8 @@ import ( "sync/atomic" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" "github.com/pkg/errors" @@ -53,6 +55,7 @@ var _ = Describe("Health Readiness", func() { config.HealthCheckPassword = "test-user-password" config.HealthCheckPasswordHash = "" config.HealthCheckUsernameHash = "" + config.UnprotectedEndpoints = []string{} config.ReadinessCheckEnabled = true checkers = []healthendpoint.Checker{} tmsttr := time.Now() @@ -61,10 +64,10 @@ var _ = Describe("Health Readiness", func() { JustBeforeEach(func() { var err error - healthRoute, err = healthendpoint.NewHealthRouter(config, checkers, logger, prometheus.NewRegistry(), func() time.Time { return *timesetter }) + healthRoute, err = healthendpoint.NewHealthRouterWithBasicAuth(config, checkers, logger, + prometheus.NewRegistry(), func() time.Time { return *timesetter }) Expect(err).ShouldNot(HaveOccurred()) }) - Context("Authentication parameter checks", func() { When("username and password are defined", func() { BeforeEach(func() { @@ -77,7 +80,7 @@ var _ = Describe("Health Readiness", func() { It("should require basic auth", func() { apitest.New(). Handler(healthRoute). - Get("/health"). + Get(routes.PrometheusPath). Expect(t). Status(http.StatusUnauthorized). End() @@ -95,7 +98,7 @@ var _ = Describe("Health Readiness", func() { It("should require basic auth", func() { apitest.New(). Handler(healthRoute). - Get("/health"). + Get(routes.PrometheusPath). Expect(t). Status(http.StatusUnauthorized). End() @@ -108,23 +111,57 @@ var _ = Describe("Health Readiness", func() { BeforeEach(func() { config.HealthCheckUsername = "" config.HealthCheckPassword = "" + config.UnprotectedEndpoints = []string{"", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} }) - When("Prometheus Health endpoint is called", func() { + When("Prometheus Health or / endpoint is called", func() { It("should respond OK", func() { - apitest.New(). + apitest.New().Debug(). Handler(healthRoute). - Get("/anything"). + Get(routes.PrometheusPath). Expect(t). Status(http.StatusOK). Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8"). End() }) + It("should respond with 404", func() { + apitest.New().Debug(). + Handler(healthRoute). + Get("/"). + Expect(t). + Status(http.StatusNotFound). + End() + }) + }) + When("/health/liveness endpoint is called", func() { + It("should response OK", func() { + apitest.New().Debug(). + Handler(healthRoute). + Get(routes.LivenessPath). + Expect(t). + Status(http.StatusOK). + Header("Content-Type", "application/json"). + Body(`{"overall_status" : "UP", "checks" : [] }`). + End() + }) + }) + When("/health endpoint is called", func() { + It("should response with liveness", func() { + apitest.New().Debug(). + Handler(healthRoute). + Get(routes.LivenessBasePath). + Expect(t). + Status(http.StatusOK). + Header("Content-Type", "application/json"). + Body(`{"overall_status" : "UP", "checks" : [] }`). + End() + }) }) When("/health/readiness endpoint is called", func() { It("should response OK", func() { - apitest.New(). + apitest.New().Debug(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). Expect(t). Status(http.StatusOK). Header("Content-Type", "application/json"). @@ -134,14 +171,12 @@ var _ = Describe("Health Readiness", func() { }) When("readiness is disabled", func() { BeforeEach(func() { config.ReadinessCheckEnabled = false }) - - It("should respond Prometheus Health endpoint", func() { - apitest.New(). + It("should respond with 404", func() { + apitest.New().Debug(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). Expect(t). - Status(http.StatusOK). - Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8"). + Status(http.StatusNotFound). End() }) }) @@ -149,11 +184,22 @@ var _ = Describe("Health Readiness", func() { Context("with basic auth configured", func() { When("Readiness endpoint is called without basic auth", func() { + It("should return 401", func() { + apitest.New(). + Handler(healthRoute). + Get(routes.ReadinessPath). + Expect(t). + Status(http.StatusUnauthorized). + End() + }) + }) + When("Readiness endpoint is called with basic auth", func() { Context("and without checkers", func() { It("should have json response", func() { apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). Header("Content-Type", "application/json"). @@ -172,11 +218,12 @@ var _ = Describe("Health Readiness", func() { It("should have database check passing", func() { apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). Header("Content-Type", "application/json"). - Body(`{ + Body(`{ "overall_status" : "UP", "checks" : [ {"name": "policy", "type": "database", "status": "UP" } ] }`). @@ -185,7 +232,8 @@ var _ = Describe("Health Readiness", func() { It("should cache health result", func() { apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). End() @@ -194,7 +242,8 @@ var _ = Describe("Health Readiness", func() { timesetter = &(tmsttr) apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). End() @@ -203,7 +252,8 @@ var _ = Describe("Health Readiness", func() { It("should expire the cache entry after 30 seconds", func() { apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). End() @@ -212,33 +262,31 @@ var _ = Describe("Health Readiness", func() { timesetter = &(tmsttr) apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). End() Expect(pinger.counter).To(Equal(2)) }) }) - Context("and a checker is supplied but readiness is disabled", func() { + Context("but readiness is disabled", func() { BeforeEach(func() { - checkers = []healthendpoint.Checker{healthendpoint.DbChecker("policy", &testPinger{error: nil})} config.ReadinessCheckEnabled = false }) - It("should respond with 401 due fallthough to Prometheus health", func() { + It("should respond with 404", func() { apitest.New().Debug(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). Expect(t). - Status(http.StatusUnauthorized). + Status(http.StatusNotFound). End() }) }) Context("and two checkers and one is failing", func() { - BeforeEach(func() { - dbUpFunc := healthendpoint.DbChecker("policy", &testPinger{error: nil}) dbDownFunc := healthendpoint.DbChecker("instance-db", &testPinger{error: errors.Errorf("DB is DOWN")}) @@ -250,13 +298,14 @@ var _ = Describe("Health Readiness", func() { It("should have overall status down", func() { apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). Header("Content-Type", "application/json"). - Body(`{ + Body(`{ "overall_status" : "DOWN", - "checks" : [ + "checks" : [ {"name": "policy", "type": "database", "status": "UP" }, {"name": "instance-db", "type": "database", "status": "DOWN" }, {"name": "instance", "type": "server", "status": "DOWN" } @@ -264,7 +313,7 @@ var _ = Describe("Health Readiness", func() { End() }) }) - When("There are many requests at the same time", func() { + Context("There are many requests at the same time", func() { counter := int32(0) numThreads := 100 BeforeEach(func() { @@ -284,7 +333,8 @@ var _ = Describe("Health Readiness", func() { mu.RLock() apitest.New(). Handler(healthRoute). - Get("/health/readiness"). + Get(routes.ReadinessPath). + BasicAuth("test-user-name", "test-user-password"). Expect(t). Status(http.StatusOK). End() @@ -297,27 +347,73 @@ var _ = Describe("Health Readiness", func() { }) }) }) + When("Liveness endpoint is called without basic auth", func() { + It("should return 401", func() { + apitest.New(). + Handler(healthRoute). + Get(routes.LivenessPath). + Expect(t). + Status(http.StatusUnauthorized). + End() + }) + }) + When("Liveness endpoint is called with basic auth", func() { + Context("and without checkers", func() { + It("should have json response", func() { + apitest.New(). + Handler(healthRoute). + Get(routes.LivenessPath). + BasicAuth("test-user-name", "test-user-password"). + Expect(t). + Status(http.StatusOK). + Header("Content-Type", "application/json"). + Body(`{"overall_status" : "UP", "checks" : [] }`). + End() + }) + It("should have json response", func() { + apitest.New(). + Handler(healthRoute). + Get(routes.LivenessBasePath). + BasicAuth("test-user-name", "test-user-password"). + Expect(t). + Status(http.StatusOK). + Header("Content-Type", "application/json"). + Body(`{"overall_status" : "UP", "checks" : [] }`). + End() + }) + }) + + }) When("Prometheus Health endpoint is called", func() { It("should require basic auth", func() { apitest.New(). Handler(healthRoute). - Get("/health"). + Get(routes.PrometheusPath). Expect(t). Status(http.StatusUnauthorized). End() }) }) - When("Default endpoint is called", func() { + When("/any endpoint is called", func() { It("should require basic auth", func() { apitest.New(). Handler(healthRoute). Get("/any"). Expect(t). - Status(http.StatusUnauthorized). + Status(http.StatusNotFound). + End() + }) + }) + When("Default / endpoint is called", func() { + It("should require basic auth", func() { + apitest.New(). + Handler(healthRoute). + Get("/"). + Expect(t). + Status(http.StatusNotFound). End() }) }) - }) Context("pprof endpoint", func() { @@ -325,14 +421,16 @@ var _ = Describe("Health Readiness", func() { BeforeEach(func() { config.HealthCheckUsername = "" config.HealthCheckPassword = "" + config.UnprotectedEndpoints = []string{"", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} }) - It("should not be available", func() { + It("should work", func() { apitest.New(). Handler(healthRoute). Get("/debug/pprof"). Expect(t). Assert(assertBody(func(body string) bool { - return Expect(body).To(Not(ContainSubstring("Types of profiles available"))) + return Expect(body).To(ContainSubstring("Types of profiles available")) })). Status(http.StatusOK). End() @@ -360,7 +458,7 @@ var _ = Describe("Health Readiness", func() { testPprofEndpoint(healthRoute, "", "Types of profiles available", t) }) By("returning the command line", func() { - testPprofEndpoint(healthRoute, "/cmdline", "test", t) + testPprofEndpoint(healthRoute, "/cmdline", "healthendpoint", t) }) By("dumping the goroutines", func() { testPprofEndpoint(healthRoute, "/goroutine?debug=2", "goroutine 1", t) diff --git a/src/autoscaler/healthendpoint/server.go b/src/autoscaler/healthendpoint/server.go index 6ab165237d..0267281bb8 100644 --- a/src/autoscaler/healthendpoint/server.go +++ b/src/autoscaler/healthendpoint/server.go @@ -7,6 +7,10 @@ import ( "os" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/metricsforwarder/server/common" @@ -14,12 +18,13 @@ import ( "code.cloudfoundry.org/lager/v3" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/tedsuo/ifrit" "github.com/tedsuo/ifrit/http_server" "golang.org/x/crypto/bcrypt" ) +var basicAuthErrorMsg = "basic authentication required for endpoint %s, but credentials not set up properly" + // basic authentication credentials struct type basicAuthenticationMiddleware struct { usernameHash []byte @@ -31,7 +36,8 @@ func (bam *basicAuthenticationMiddleware) middleware(next http.Handler) http.Han return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, authOK := r.BasicAuth() - if !authOK || bcrypt.CompareHashAndPassword(bam.usernameHash, []byte(username)) != nil || bcrypt.CompareHashAndPassword(bam.passwordHash, []byte(password)) != nil { + if !authOK || bcrypt.CompareHashAndPassword(bam.usernameHash, []byte(username)) != nil || + bcrypt.CompareHashAndPassword(bam.passwordHash, []byte(password)) != nil { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } @@ -40,9 +46,11 @@ func (bam *basicAuthenticationMiddleware) middleware(next http.Handler) http.Han } // NewServerWithBasicAuth open the healthcheck port with basic authentication. -// Make sure that username and password is not empty -func NewServerWithBasicAuth(conf models.HealthConfig, healthCheckers []Checker, logger lager.Logger, gatherer prometheus.Gatherer, time func() time.Time) (ifrit.Runner, error) { - healthRouter, err := NewHealthRouter(conf, healthCheckers, logger, gatherer, time) +// Make sure that username and password is not empty. +// Parameter `healthCheckers` determines the information provided by the readiness-endpoint. +func NewServerWithBasicAuth(conf models.HealthConfig, healthCheckers []Checker, logger lager.Logger, + gatherer prometheus.Gatherer, time func() time.Time) (ifrit.Runner, error) { + healthRouter, err := NewHealthRouterWithBasicAuth(conf, healthCheckers, logger, gatherer, time) if err != nil { return nil, err } @@ -57,48 +65,97 @@ func NewServerWithBasicAuth(conf models.HealthConfig, healthCheckers []Checker, return http_server.New(addr, healthRouter), nil } -func NewHealthRouter(conf models.HealthConfig, healthCheckers []Checker, logger lager.Logger, gatherer prometheus.Gatherer, time func() time.Time) (*mux.Router, error) { - var healthRouter *mux.Router - var err error - username := conf.HealthCheckUsername - password := conf.HealthCheckPassword - usernameHash := conf.HealthCheckUsernameHash - passwordHash := conf.HealthCheckPasswordHash - if username == "" && password == "" && usernameHash == "" && passwordHash == "" { - //when username and password are not set then don't use basic authentication - healthRouter = mux.NewRouter() - if conf.ReadinessCheckEnabled { - healthRouter.Handle("/health/readiness", common.VarsFunc(readiness(healthCheckers, time))) - } - healthRouter.PathPrefix("").Handler(promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{})) - } else { - healthRouter, err = healthBasicAuthRouter(conf, healthCheckers, logger, gatherer, time) +// NewHealthRouterWithBasicAuth Make sure that username and password is not empty. +// Parameter `healthCheckers` determines the information provided by the readiness-endpoint. +func NewHealthRouterWithBasicAuth(conf models.HealthConfig, healthCheckers []Checker, logger lager.Logger, + gatherer prometheus.Gatherer, time func() time.Time) (*mux.Router, error) { + router := mux.NewRouter() + authMiddleware, err := createBasicAuthMiddleware(logger, conf.HealthCheckUsernameHash, + conf.HealthCheckUsername, conf.HealthCheckPasswordHash, conf.HealthCheckPassword) + if err != nil { + return nil, err + } + + err = addLivelinessHandlers(conf, router, time, authMiddleware) + if err != nil { + return nil, err + } + + if conf.ReadinessCheckEnabled { + err = addReadinessHandler(conf, router, authMiddleware, healthCheckers, time) if err != nil { return nil, err } } - return healthRouter, nil -} -func healthBasicAuthRouter(conf models.HealthConfig, healthCheckers []Checker, logger lager.Logger, gatherer prometheus.Gatherer, time func() time.Time) (*mux.Router, error) { - basicAuthentication, err := createBasicAuthMiddleware(logger, conf.HealthCheckUsernameHash, conf.HealthCheckUsername, conf.HealthCheckPasswordHash, conf.HealthCheckPassword) + err = addPprofHandlers(conf, router, authMiddleware) if err != nil { return nil, err } - promHandler := promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{}) - // /health - router := mux.NewRouter() - // unauthenticated paths - if conf.ReadinessCheckEnabled { - router.Handle("/health/readiness", common.VarsFunc(readiness(healthCheckers, time))) + err = addPrometheusHandler(router, conf, authMiddleware, gatherer) + if err != nil { + return nil, err } - //authenticated paths - health := router.Path("/health").Subrouter() - health.Use(basicAuthentication.middleware) - pprofRouter := router.PathPrefix("/debug/pprof").Subrouter() - pprofRouter.Use(basicAuthentication.middleware) + return router, nil +} + +// Adds addLivelinessHandlers responds to the "/health" and "/health/liveness" endpoints. +// By default, endpoints are protected by Basic authentication. However, endpoints can be accessed +// without basic authentication by defining UnprotectedEndpoints in models.HealthConfig.UnprotectedEndpoints. +// +// addLivelinessHandlers Returns an error in case BasicAuth is required but the configuration is not set up properly. +func addLivelinessHandlers(conf models.HealthConfig, mainRouter *mux.Router, time func() time.Time, + authMiddleware *basicAuthenticationMiddleware) error { + livenessHandler := common.VarsFunc(readiness([]Checker{}, time)) + livenessRouter := mainRouter.PathPrefix(routes.LivenessBasePath).Subrouter() + + if endpointsNeedsProtection(routes.LivenessPath, conf) { + if !conf.BasicAuthPossible() { + return fmt.Errorf(basicAuthErrorMsg, routes.LivenessPath) + } + livenessRouter.Use(authMiddleware.middleware) + } + livenessRouter.Handle("", livenessHandler) + livenessRouter.Handle("/liveness", livenessHandler) + + return nil +} + +// Adds a readiness handler on the path READINESS_PATH and adds authentication middleware +// for BasicAuth, if and only if READINESS_PATH is not included in the models.HealthConfig.UnprotectedEndpoints. +// +// Returns an error in case BasicAuth is required but the configuration is not set up properly. +func addReadinessHandler(conf models.HealthConfig, mainRouter *mux.Router, + authMiddleware *basicAuthenticationMiddleware, healthCheckers []Checker, time func() time.Time, +) error { + readinessRouter := mainRouter.PathPrefix("/health").Subrouter() + if endpointsNeedsProtection(routes.ReadinessPath, conf) { + readinessRouter.Use(authMiddleware.middleware) + if !conf.BasicAuthPossible() { + return fmt.Errorf(basicAuthErrorMsg, routes.ReadinessPath) + } + } + readinessRouter.Handle("/readiness", common.VarsFunc(readiness(healthCheckers, time))) + return nil +} + +// Adds a pprof handler on the path PPROF_PATH featuring several endpoints. +// Adds authentication middleware for BasiAuth, if and only if PPROF_PATH +// is not excluded in the models.HealthConfig.UnprotectedEndpoints. +// +// Returns an error in case BasicAuth is required but the configuration is not set up properly. +func addPprofHandlers(conf models.HealthConfig, mainRouter *mux.Router, + authMiddleware *basicAuthenticationMiddleware) error { + pprofRouter := mainRouter.PathPrefix(routes.PprofPath).Subrouter() + + if endpointsNeedsProtection(routes.PprofPath, conf) { + if !conf.BasicAuthPossible() { + return fmt.Errorf(basicAuthErrorMsg, routes.PprofPath) + } + pprofRouter.Use(authMiddleware.middleware) + } pprofRouter.HandleFunc("/cmdline", pprof.Cmdline) pprofRouter.HandleFunc("/profile", pprof.Profile) @@ -106,11 +163,38 @@ func healthBasicAuthRouter(conf models.HealthConfig, healthCheckers []Checker, l pprofRouter.HandleFunc("/trace", pprof.Trace) pprofRouter.PathPrefix("").HandlerFunc(pprof.Index) - everything := router.PathPrefix("").Subrouter() - everything.Use(basicAuthentication.middleware) - everything.PathPrefix("").Handler(promHandler) + return nil +} - return router, nil +// Adds a prometheus handler on the path PROMETHEUS_PATH and adds authentication middleware +// for BasicAuth, if and only if PROMETHEUS_PATH is not excluded in the models.HealthConfig. +// +// Returns an error in case BasicAuth is required but the configuration is not set up properly. +func addPrometheusHandler(mainRouter *mux.Router, conf models.HealthConfig, + authMiddleware *basicAuthenticationMiddleware, gatherer prometheus.Gatherer) error { + promHandler := promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{}) + + prometheusRouter := mainRouter.PathPrefix(routes.PrometheusPath).Subrouter() + if endpointsNeedsProtection(routes.PrometheusPath, conf) { + prometheusRouter.Use(authMiddleware.middleware) + if !conf.BasicAuthPossible() { + return fmt.Errorf(basicAuthErrorMsg, routes.PrometheusPath) + } + } + prometheusRouter.Path("").Handler(promHandler) + return nil +} + +func endpointsNeedsProtection(path string, conf models.HealthConfig) bool { + result := true + for _, p := range conf.UnprotectedEndpoints { + if p == path { + result = false + break + } + } + + return result } func createBasicAuthMiddleware(logger lager.Logger, usernameHash string, username string, passwordHash string, password string) (*basicAuthenticationMiddleware, error) { diff --git a/src/autoscaler/integration/components_test.go b/src/autoscaler/integration/components_test.go index 79419cb79b..29fd282560 100644 --- a/src/autoscaler/integration/components_test.go +++ b/src/autoscaler/integration/components_test.go @@ -10,7 +10,9 @@ import ( msConfig "code.cloudfoundry.org/app-autoscaler/src/autoscaler/metricsserver/config" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" opConfig "code.cloudfoundry.org/app-autoscaler/src/autoscaler/operator/config" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" seConfig "code.cloudfoundry.org/app-autoscaler/src/autoscaler/scalingengine/config" + "github.com/go-sql-driver/mysql" "fmt" "net/url" @@ -20,7 +22,6 @@ import ( "strings" "time" - "github.com/go-sql-driver/mysql" . "github.com/onsi/gomega" "github.com/tedsuo/ifrit/ginkgomon_v2" "gopkg.in/yaml.v3" @@ -213,6 +214,9 @@ func (components *Components) PrepareGolangApiServerConfig(dbURI string, publicA Logging: helpers.LoggingConfig{ Level: LOGLEVEL, }, + Health: models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, PublicApiServer: apiConfig.ServerConfig{ Port: publicApiPort, TLS: models.TLSCerts{ @@ -350,7 +354,10 @@ server.ssl.enabled-protocols=TLSv1,TLSv1.1,TLSv1.2 server.ssl.ciphers=TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_CBC_SHA256,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_RSA_WITH_AES_128_GCM_SHA256,TLS_RSA_WITH_AES_128_CBC_SHA256,TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_3DES_EDE_CBC_SHA,TLS_ECDHE_RSA_WITH_RC4_128_SHA,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,SSL_RSA_WITH_RC4_128_SHA server.port=%d -scheduler.healthserver.port=0 +# Health Server +scheduler.healthserver.port=7000 +scheduler.healthserver.username=test-user +scheduler.healthserver.password=test-password client.httpClientTimeout=%d #Quartz org.quartz.scheduler.instanceName=app-autoscaler @@ -391,6 +398,9 @@ func (components *Components) PrepareEventGeneratorConfig(dbUri string, port int Logging: helpers.LoggingConfig{ Level: LOGLEVEL, }, + Health: models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, Server: egConfig.ServerConfig{ Port: port, TLS: models.TLSCerts{ @@ -453,6 +463,9 @@ func (components *Components) PrepareScalingEngineConfig(dbURI string, port int, ClientID: "admin", Secret: "admin", }, + Health: models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, Server: seConfig.ServerConfig{ Port: port, TLS: models.TLSCerts{ @@ -488,6 +501,9 @@ func (components *Components) PrepareOperatorConfig(dbURI string, ccUAAURL strin Logging: helpers.LoggingConfig{ Level: LOGLEVEL, }, + Health: models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, CF: cf.Config{ API: ccUAAURL, ClientID: "admin", @@ -555,6 +571,9 @@ func (components *Components) PrepareMetricsGatewayConfig(dbURI string, metricSe Logging: helpers.LoggingConfig{ Level: LOGLEVEL, }, + Health: models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, EnvelopChanSize: 500, NozzleCount: 1, MetricServerAddrs: metricServerAddresses, @@ -598,6 +617,9 @@ func (components *Components) PrepareMetricsServerConfig(dbURI string, httpClien Logging: helpers.LoggingConfig{ Level: LOGLEVEL, }, + Health: models.HealthConfig{ + UnprotectedEndpoints: []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath}}, HttpClientTimeout: httpClientTimeout, NodeAddrs: []string{"localhost"}, NodeIndex: 0, diff --git a/src/autoscaler/integration/integration_operator_others_test.go b/src/autoscaler/integration/integration_operator_others_test.go index 4998a7c8d1..18e4ef2e61 100644 --- a/src/autoscaler/integration/integration_operator_others_test.go +++ b/src/autoscaler/integration/integration_operator_others_test.go @@ -42,16 +42,23 @@ var _ = Describe("Integration_Operator_Others", func() { tmpDir) startGolangApiServer() - scalingEngineConfPath = components.PrepareScalingEngineConfig(dbUrl, components.Ports[ScalingEngine], fakeCCNOAAUAA.URL(), defaultHttpClientTimeout, tmpDir) + scalingEngineConfPath = components.PrepareScalingEngineConfig( + dbUrl, components.Ports[ScalingEngine], fakeCCNOAAUAA.URL(), defaultHttpClientTimeout, + tmpDir) startScalingEngine() - schedulerConfPath = components.PrepareSchedulerConfig(dbUrl, fmt.Sprintf("https://127.0.0.1:%d", components.Ports[ScalingEngine]), tmpDir, defaultHttpClientTimeout) + schedulerConfPath = components.PrepareSchedulerConfig( + dbUrl, fmt.Sprintf("https://127.0.0.1:%d", components.Ports[ScalingEngine]), tmpDir, + defaultHttpClientTimeout) startScheduler() - }) JustBeforeEach(func() { - operatorConfPath = components.PrepareOperatorConfig(dbUrl, fakeCCNOAAUAA.URL(), fmt.Sprintf("https://127.0.0.1:%d", components.Ports[ScalingEngine]), fmt.Sprintf("https://127.0.0.1:%d", components.Ports[Scheduler]), 10*time.Second, 1*24*time.Hour, defaultHttpClientTimeout, tmpDir) + operatorConfPath = components.PrepareOperatorConfig( + dbUrl, fakeCCNOAAUAA.URL(), fmt.Sprintf("https://127.0.0.1:%d", + components.Ports[ScalingEngine]), + fmt.Sprintf("https://127.0.0.1:%d", components.Ports[Scheduler]), + 10*time.Second, 1*24*time.Hour, defaultHttpClientTimeout, tmpDir) startOperator() }) @@ -65,9 +72,7 @@ var _ = Describe("Integration_Operator_Others", func() { }) Describe("Synchronizer", func() { - Describe("Synchronize the active schedules to scaling engine", func() { - Context("ScalingEngine Server is down when active_schedule changes", func() { JustBeforeEach(func() { stopScalingEngine() @@ -107,11 +112,9 @@ var _ = Describe("Integration_Operator_Others", func() { Consistently(func() bool { return activeScheduleExists(testAppId) }). WithTimeout(10 * time.Second). WithPolling(1 * time.Second).Should(BeTrue()) - }) It("should delete an active schedule in scaling engine after restart", func() { - By("ensure scaling server is down when the active schedule is deleted from scheduler") //TODO there is a better check than waiting 80 seconds for consecutive errors. Consistently(func() error { @@ -128,7 +131,6 @@ var _ = Describe("Integration_Operator_Others", func() { WithPolling(5*time.Second). ShouldNot(BeTrue(), "Active schedule should be removed after restart") }) - }) }) }) @@ -244,7 +246,7 @@ var _ = Describe("Integration_Operator_Others", func() { }) - It("opeator should remove the staled records ", func() { + It("operator should remove the staled records ", func() { Eventually(func() bool { return getAppInstanceMetricTotalCount(testAppId) == 0 && getScalingHistoryTotalCount(testAppId) == 0 && getScalingHistoryTotalCount(testAppId) == 0 diff --git a/src/autoscaler/integration/integration_suite_test.go b/src/autoscaler/integration/integration_suite_test.go index df6a388270..2d3780d4ee 100644 --- a/src/autoscaler/integration/integration_suite_test.go +++ b/src/autoscaler/integration/integration_suite_test.go @@ -451,7 +451,8 @@ func deleteSchedule(appId string) (*http.Response, error) { func getActiveSchedule(appId string) (*http.Response, error) { By("getActiveSchedule") - req, err := http.NewRequest("GET", fmt.Sprintf("https://127.0.0.1:%d/v1/apps/%s/active_schedules", components.Ports[ScalingEngine], appId), strings.NewReader("")) + url := fmt.Sprintf("https://127.0.0.1:%d/v1/apps/%s/active_schedules", components.Ports[ScalingEngine], appId) + req, err := http.NewRequest("GET", url, strings.NewReader("")) Expect(err).NotTo(HaveOccurred()) req.Header.Set("Content-Type", "application/json") return httpClient.Do(req) diff --git a/src/autoscaler/metricsforwarder/cmd/metricsforwarder/metricsforwarder_test.go b/src/autoscaler/metricsforwarder/cmd/metricsforwarder/metricsforwarder_test.go index c9da9aaf03..8a7eea8ee3 100644 --- a/src/autoscaler/metricsforwarder/cmd/metricsforwarder/metricsforwarder_test.go +++ b/src/autoscaler/metricsforwarder/cmd/metricsforwarder/metricsforwarder_test.go @@ -11,6 +11,7 @@ import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/db" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "github.com/onsi/gomega/gbytes" . "github.com/onsi/gomega/gexec" @@ -139,18 +140,19 @@ var _ = Describe("Metricsforwarder", func() { Describe("when Health server is ready to serve RESTful API", func() { BeforeEach(func() { - basicAuthConfig := cfg basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} runner.configPath = writeConfig(&basicAuthConfig).Name() runner.Start() - }) - Context("when a request to query health comes", func() { + Context("when a request to query prometheus comes", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d", healthport)) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(url) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -174,9 +176,9 @@ var _ = Describe("Metricsforwarder", func() { }) Context("when username and password are incorrect for basic authentication during health check", func() { - It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + It("should return 401 for liveness-path", func() { + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -188,9 +190,9 @@ var _ = Describe("Metricsforwarder", func() { }) Context("when username and password are correct for basic authentication during health check", func() { - It("should return 200 for /health", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + It("should return 200 for liveness-path", func() { + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) @@ -200,8 +202,12 @@ var _ = Describe("Metricsforwarder", func() { Expect(rsp.StatusCode).To(Equal(http.StatusOK)) }) It("should return 200 for /health/readiness", func() { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health/readiness", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.ReadinessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) + + req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) + rsp, err := healthHttpClient.Do(req) Expect(err).ToNot(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) @@ -223,8 +229,8 @@ var _ = Describe("Metricsforwarder", func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -237,8 +243,8 @@ var _ = Describe("Metricsforwarder", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) diff --git a/src/autoscaler/metricsforwarder/config/config_test.go b/src/autoscaler/metricsforwarder/config/config_test.go index 7f5ee97b3a..eabf50bc77 100644 --- a/src/autoscaler/metricsforwarder/config/config_test.go +++ b/src/autoscaler/metricsforwarder/config/config_test.go @@ -6,6 +6,7 @@ import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/db" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/metricsforwarder/config" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -67,6 +68,7 @@ db: connection_max_lifetime: 60s health: port: 9999 + unprotected_endpoints: ["/health/liveness", "/health/readiness", "/health/prometheus", "/debug/pprof"] cred_helper_impl: default `) }) @@ -75,6 +77,8 @@ cred_helper_impl: default Expect(conf.Server.Port).To(Equal(8081)) Expect(conf.Logging.Level).To(Equal("debug")) Expect(conf.Health.Port).To(Equal(9999)) + Expect(conf.Health.UnprotectedEndpoints).To( + ContainElements("/health/liveness", "/health/readiness", "/health/prometheus", "/debug/pprof")) Expect(conf.LoggregatorConfig.MetronAddress).To(Equal("127.0.0.1:3457")) Expect(conf.Db[db.PolicyDb]).To(Equal( db.DatabaseConfig{ @@ -288,6 +292,9 @@ rate_limit: conf.Server.Port = 8081 conf.Logging.Level = "debug" conf.Health.Port = 8081 + conf.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} + conf.LoggregatorConfig.MetronAddress = "127.0.0.1:3458" conf.LoggregatorConfig.TLS.CACertFile = "../testcerts/ca.crt" conf.LoggregatorConfig.TLS.CertFile = "../testcerts/client.crt" diff --git a/src/autoscaler/metricsgateway/cmd/metricsgateway/metricsgateway_test.go b/src/autoscaler/metricsgateway/cmd/metricsgateway/metricsgateway_test.go index 00ca8d4fba..f51fa79bec 100644 --- a/src/autoscaler/metricsgateway/cmd/metricsgateway/metricsgateway_test.go +++ b/src/autoscaler/metricsgateway/cmd/metricsgateway/metricsgateway_test.go @@ -7,6 +7,7 @@ import ( "os" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gbytes" @@ -112,12 +113,15 @@ var _ = Describe("Metricsgateway", func() { basicAuthConfig := conf basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} runner.configPath = writeConfig(&basicAuthConfig).Name() }) - Context("when a request to query health comes", func() { + Context("when a request to query prometheus comes", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/health", healthport)) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(url) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -136,8 +140,8 @@ var _ = Describe("Metricsgateway", func() { Describe("when Health server is ready to serve RESTful API with basic Auth", func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -150,8 +154,8 @@ var _ = Describe("Metricsgateway", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(conf.Health.HealthCheckUsername, conf.Health.HealthCheckPassword) @@ -167,8 +171,8 @@ var _ = Describe("Metricsgateway", func() { Describe("when Health server is ready to serve RESTful API with basic Auth", func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -181,8 +185,8 @@ var _ = Describe("Metricsgateway", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(conf.Health.HealthCheckUsername, conf.Health.HealthCheckPassword) diff --git a/src/autoscaler/metricsgateway/config/config_test.go b/src/autoscaler/metricsgateway/config/config_test.go index 4218d9b3bf..6218f8be67 100644 --- a/src/autoscaler/metricsgateway/config/config_test.go +++ b/src/autoscaler/metricsgateway/config/config_test.go @@ -7,6 +7,7 @@ import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/helpers" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/metricsgateway/config" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -78,6 +79,7 @@ nozzle: shard_id: autoscaler health: port: 8081 + unprotected_endpoints: ["/health/liveness", "/health/readiness", "/health/prometheus", "/debug/pprof"] `) }) It("returns the config", func() { @@ -110,7 +112,8 @@ health: CACertFile: "autoscaler_ca.cert", })) Expect(conf.Health.Port).To(Equal(8081)) - + Expect(conf.Health.UnprotectedEndpoints).To( + ContainElements("/health/liveness", "/health/readiness", "/health/prometheus", "/debug/pprof")) }) }) @@ -844,6 +847,8 @@ health: }, Health: models.HealthConfig{ Port: 8081, + UnprotectedEndpoints: []string{"/", routes.LivenessPath, routes.ReadinessPath, routes.PrometheusPath, + routes.PprofPath}, }, } }) diff --git a/src/autoscaler/metricsserver/cmd/metricsserver/metricsserver_test.go b/src/autoscaler/metricsserver/cmd/metricsserver/metricsserver_test.go index eab57b378a..0df3e4e2bc 100644 --- a/src/autoscaler/metricsserver/cmd/metricsserver/metricsserver_test.go +++ b/src/autoscaler/metricsserver/cmd/metricsserver/metricsserver_test.go @@ -6,6 +6,7 @@ import ( "net/http" "os" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -103,28 +104,30 @@ var _ = Describe("MetricsServer", func() { }) It("returns with a 200", func() { - rsp, err := httpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/apps/an-app-id/metric_histories/a-metric-type", msPort)) + metricsHistoryUrl := fmt.Sprintf( + "http://127.0.0.1:%d/v1/apps/an-app-id/metric_histories/a-metric-type", msPort) + rsp, err := httpClient.Get(metricsHistoryUrl) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) rsp.Body.Close() }) }) - }) - Describe("when Health server is ready to serve RESTful API", func() { + Describe("when Health server is ready to serve RESTful API without basic auth", func() { BeforeEach(func() { - basicAuthConfig := cfg basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} runner.configPath = writeConfig(&basicAuthConfig).Name() runner.Start() - }) - Context("when a request to query health comes", func() { + Context("when a request to query prometheus comes", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d", healthport)) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(url) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -135,7 +138,6 @@ var _ = Describe("MetricsServer", func() { Expect(healthData).To(ContainSubstring("go_goroutines")) Expect(healthData).To(ContainSubstring("go_memstats_alloc_bytes")) rsp.Body.Close() - }) }) }) @@ -147,8 +149,8 @@ var _ = Describe("MetricsServer", func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + livenessUrl := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, livenessUrl, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -161,8 +163,8 @@ var _ = Describe("MetricsServer", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + livenessUrl := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, livenessUrl, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) @@ -173,40 +175,5 @@ var _ = Describe("MetricsServer", func() { }) }) }) - - Describe("when Health server is ready to serve RESTful API with basic Auth", func() { - BeforeEach(func() { - runner.Start() - }) - - Context("when username and password are incorrect for basic authentication during health check", func() { - It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) - Expect(err).NotTo(HaveOccurred()) - - req.SetBasicAuth("wrongusername", "wrongpassword") - - rsp, err := healthHttpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - Expect(rsp.StatusCode).To(Equal(http.StatusUnauthorized)) - }) - }) - - Context("when username and password are correct for basic authentication during health check", func() { - It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) - Expect(err).NotTo(HaveOccurred()) - - req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) - - rsp, err := healthHttpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - Expect(rsp.StatusCode).To(Equal(http.StatusOK)) - }) - }) - }) - //TODO : Add test cases for testing WebServer endpoints }) diff --git a/src/autoscaler/metricsserver/config/config_test.go b/src/autoscaler/metricsserver/config/config_test.go index 7dca81895e..b928491be0 100644 --- a/src/autoscaler/metricsserver/config/config_test.go +++ b/src/autoscaler/metricsserver/config/config_test.go @@ -9,6 +9,7 @@ import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/db" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/metricsserver/config" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" ) var _ = Describe("Config", func() { @@ -86,6 +87,7 @@ server: ca_file: /var/vcap/jobs/autoscaler/config/certs/ca.crt health: port: 9999 + unprotected_endpoints: [] `) }) @@ -131,7 +133,7 @@ health: Expect(conf.Server.TLS.CACertFile).To(Equal("/var/vcap/jobs/autoscaler/config/certs/ca.crt")) Expect(conf.Health.Port).To(Equal(9999)) - + Expect(conf.Health.UnprotectedEndpoints).To(BeEmpty()) }) }) @@ -180,6 +182,7 @@ db: Expect(conf.Server.Port).To(Equal(DefaultHTTPServerPort)) Expect(conf.Health.Port).To(Equal(DefaultHealthPort)) + Expect(conf.Health.UnprotectedEndpoints).To(BeNil()) }) }) @@ -212,6 +215,8 @@ db: conf.Collector.EnvelopeChannelSize = 300 conf.Collector.MetricChannelSize = 300 conf.Health.Port = 8081 + conf.Health.UnprotectedEndpoints = []string{ + "/", routes.LivenessPath, routes.PrometheusPath, routes.PprofPath} }) JustBeforeEach(func() { diff --git a/src/autoscaler/models/health.go b/src/autoscaler/models/health.go index 30d073e7ce..c3ef396693 100644 --- a/src/autoscaler/models/health.go +++ b/src/autoscaler/models/health.go @@ -2,20 +2,29 @@ package models import ( "fmt" + "strings" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" "golang.org/x/crypto/bcrypt" ) type HealthConfig struct { - Port int `yaml:"port"` - HealthCheckUsername string `yaml:"username"` - HealthCheckUsernameHash string `yaml:"username_hash"` - HealthCheckPassword string `yaml:"password"` - HealthCheckPasswordHash string `yaml:"password_hash"` - ReadinessCheckEnabled bool `yaml:"readiness_enabled"` + Port int `yaml:"port"` + HealthCheckUsername string `yaml:"username"` + HealthCheckUsernameHash string `yaml:"username_hash"` + HealthCheckPassword string `yaml:"password"` + HealthCheckPasswordHash string `yaml:"password_hash"` + ReadinessCheckEnabled bool `yaml:"readiness_enabled"` + UnprotectedEndpoints []string `yaml:"unprotected_endpoints"` } -var ErrConfiguration = fmt.Errorf("configuration error") +var ErrConfiguration = fmt.Errorf("health configuration error") + +func (c *HealthConfig) BasicAuthPossible() bool { + usernameVerifiable := c.HealthCheckUsername != "" || c.HealthCheckUsernameHash != "" + passwordVerifiable := c.HealthCheckPassword != "" || c.HealthCheckPasswordHash != "" + return usernameVerifiable && passwordVerifiable +} func (c *HealthConfig) Validate() error { if c.HealthCheckUsername != "" && c.HealthCheckUsernameHash != "" { @@ -38,13 +47,40 @@ func (c *HealthConfig) Validate() error { } } - if c.HealthCheckUsername == "" && c.HealthCheckPassword != "" { - return fmt.Errorf("%w: healthcheck username is empty", ErrConfiguration) + if c.basicAuthIntended() && !c.BasicAuthPossible() { + protectedHealthEndpoints := c.protectedHealthEndpoints() + msg := + "some endpoints configured to use basic auth but, credentials not properly set up\n" + + "\tprotected endpoints according to health-configuration: " + + strings.Join(protectedHealthEndpoints, ", ") + return fmt.Errorf("%w: %s", ErrConfiguration, msg) + } + + return nil +} + +func (c *HealthConfig) basicAuthIntended() bool { + return len(c.protectedHealthEndpoints()) > 0 +} + +func (c *HealthConfig) protectedHealthEndpoints() []string { + var protectedEndpoints []string + + allEndpointsList := []string{"/", routes.LivenessPath, routes.PrometheusPath, routes.PprofPath} + if c.ReadinessCheckEnabled { + allEndpointsList = append(allEndpointsList, routes.ReadinessPath) + } + + unprotectedEndpointsSet := make(map[string]bool, len(c.UnprotectedEndpoints)) + for _, endpoint := range c.UnprotectedEndpoints { + unprotectedEndpointsSet[endpoint] = true } - if c.HealthCheckUsername != "" && c.HealthCheckPassword == "" { - return fmt.Errorf("%w: healthcheck password is empty", ErrConfiguration) + for _, endpoint := range allEndpointsList { + if _, endpointIsUnprotected := unprotectedEndpointsSet[endpoint]; !endpointIsUnprotected { + protectedEndpoints = append(protectedEndpoints, endpoint) + } } - return nil + return protectedEndpoints } diff --git a/src/autoscaler/models/health_test.go b/src/autoscaler/models/health_test.go index eb2c9799ff..0823220a67 100644 --- a/src/autoscaler/models/health_test.go +++ b/src/autoscaler/models/health_test.go @@ -84,7 +84,7 @@ password_hash: password_hash err = healthConfig.Validate() Expect(err).To(HaveOccurred()) Expect(errors.Is(err, models.ErrConfiguration)).To(BeTrue()) - Expect(err.Error()).To(Equal("configuration error: both healthcheck password and healthcheck password_hash are provided, please provide only one of them")) + Expect(err.Error()).To(Equal("health configuration error: both healthcheck password and healthcheck password_hash are provided, please provide only one of them")) }) }) }) diff --git a/src/autoscaler/operator/cmd/operator/operator_test.go b/src/autoscaler/operator/cmd/operator/operator_test.go index 4a13cda514..aee48bad82 100644 --- a/src/autoscaler/operator/cmd/operator/operator_test.go +++ b/src/autoscaler/operator/cmd/operator/operator_test.go @@ -7,6 +7,8 @@ import ( "os" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gbytes" @@ -134,6 +136,8 @@ var _ = Describe("Operator", Serial, func() { secondRunner.startCheck = "" cfg.Health.HealthCheckUsername = "" cfg.Health.HealthCheckPassword = "" + cfg.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} cfg.Health.Port = 9000 + GinkgoParallelProcess() secondRunner.configPath = writeConfig(&cfg).Name() secondRunner.Start() @@ -149,15 +153,13 @@ var _ = Describe("Operator", Serial, func() { Consistently(secondRunner.Session.Buffer, 5*time.Second).ShouldNot(Say("operator.successfully-acquired-lock")) By("checking the health endpoint of the standing-by instance") - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/health", cfg.Health.Port)) + rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d%s", cfg.Health.Port, routes.LivenessPath)) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) - }) }) Context("When more than one instances of operator try to get the lock simultaneously", func() { - var runnerAcquiredLock bool BeforeEach(func() { @@ -340,13 +342,14 @@ var _ = Describe("Operator", Serial, func() { }) }) - Describe("when Health server is ready to serve RESTful API", func() { + Describe("when Health server is ready to serve RESTful API without basic Auth", func() { BeforeEach(func() { basicAuthConfig := cfg basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} runner.configPath = writeConfig(&basicAuthConfig).Name() - runner.Start() Eventually(runner.Session.Buffer, 2*time.Second).Should(Say("operator.started")) }) @@ -355,9 +358,10 @@ var _ = Describe("Operator", Serial, func() { runner.ClearLockDatabase() }) - Context("when a request to query health comes", func() { + Context("when a request to query prometheus comes", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d", healthport)) + prometheusUrl := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(prometheusUrl) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -387,48 +391,8 @@ var _ = Describe("Operator", Serial, func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) - Expect(err).NotTo(HaveOccurred()) - - req.SetBasicAuth("wrongusername", "wrongpassword") - - rsp, err := healthHttpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - Expect(rsp.StatusCode).To(Equal(http.StatusUnauthorized)) - }) - }) - - Context("when username and password are correct for basic authentication during health check", func() { - It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) - Expect(err).NotTo(HaveOccurred()) - - req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) - - rsp, err := healthHttpClient.Do(req) - Expect(err).ToNot(HaveOccurred()) - Expect(rsp.StatusCode).To(Equal(http.StatusOK)) - }) - }) - }) - - Describe("when Health server is ready to serve RESTful API with basic Auth", func() { - BeforeEach(func() { - runner.Start() - - Eventually(runner.Session.Buffer, 2*time.Second).Should(Say("operator.started")) - }) - - AfterEach(func() { - runner.ClearLockDatabase() - }) - - Context("when username and password are incorrect for basic authentication during health check", func() { - It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + livenessUrl := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, livenessUrl, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -441,8 +405,9 @@ var _ = Describe("Operator", Serial, func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { + livenessUrl := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + req, err := http.NewRequest(http.MethodGet, livenessUrl, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(cfg.Health.HealthCheckUsername, cfg.Health.HealthCheckPassword) diff --git a/src/autoscaler/operator/config/config_test.go b/src/autoscaler/operator/config/config_test.go index 7ab76e9c43..f638fb85bd 100644 --- a/src/autoscaler/operator/config/config_test.go +++ b/src/autoscaler/operator/config/config_test.go @@ -4,6 +4,7 @@ import ( "bytes" "time" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/testhelpers" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/db" @@ -241,8 +242,10 @@ scheduler: conf.AppSyncer.DB.URL = "postgres://pqgotest:password@exampl.com/pqgotest" conf.DBLock.DB.URL = "postgres://pqgotest:password@exampl.com/pqgotest" conf.HttpClientTimeout = 10 * time.Second - conf.Health.Port = 8081 + conf.Health.Port = 8081 + conf.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.PrometheusPath, routes.PprofPath} }) JustBeforeEach(func() { diff --git a/src/autoscaler/routes/routes.go b/src/autoscaler/routes/routes.go index 7812af7050..b5adbb75df 100644 --- a/src/autoscaler/routes/routes.go +++ b/src/autoscaler/routes/routes.go @@ -29,8 +29,6 @@ const ( SyncActiveSchedulesPath = "/v1/syncSchedules" SyncActiveSchedulesRouteName = "SyncActiveSchedules" - BrokerHealthPath = "/health" - EnvelopePath = "/v1/envelopes" EnvelopeReportRouteName = "ReportEnvelope" CustomMetricsPath = "/v1/apps/{appid}/metrics" @@ -39,11 +37,8 @@ const ( UpdateScheduleRouteName = "UpdateSchedule" DeleteScheduleRouteName = "DeleteSchedule" - PublicApiScalingHistoryPath = "/{appId}/scaling_histories" - PublicApiScalingHistoryRouteName = "GetPublicApiScalingHistories" - - PublicApiMetricsHistoryPath = "/{appId}/metric_histories/{metricType}" - + PublicApiScalingHistoryPath = "/{appId}/scaling_histories" + PublicApiScalingHistoryRouteName = "GetPublicApiScalingHistories" PublicApiAggregatedMetricsHistoryPath = "/{appId}/aggregated_metric_histories/{metricType}" PublicApiAggregatedMetricsHistoryRouteName = "GetPublicApiAggregatedMetricsHistories" @@ -58,9 +53,11 @@ const ( PublicApiInfoPath = "/v1/info" PublicApiInfoRouteName = "GetPublicApiInfo" - - PublicApiHealthPath = "/health" - PublicApiHealthRouteName = "GetPublicApiHealth" + LivenessPath = "/health/liveness" + LivenessBasePath = "/health" + ReadinessPath = "/health/readiness" + PrometheusPath = "/health/prometheus" + PprofPath = "/debug/pprof" ) type AutoScalerRoute struct { @@ -110,7 +107,6 @@ func newRouters() *AutoScalerRoute { instance.schedulerRoutes.Path(SchedulePath).Methods(http.MethodPut).Name(UpdateScheduleRouteName) instance.schedulerRoutes.Path(SchedulePath).Methods(http.MethodDelete).Name(DeleteScheduleRouteName) instance.apiOpenRoutes.Path(PublicApiInfoPath).Methods(http.MethodGet).Name(PublicApiInfoRouteName) - instance.apiOpenRoutes.Path(PublicApiHealthPath).Methods(http.MethodGet).Name(PublicApiHealthRouteName) instance.apiRoutes = instance.apiOpenRoutes.PathPrefix("/v1/apps").Subrouter() instance.apiRoutes.Path(PublicApiScalingHistoryPath).Methods(http.MethodGet).Name(PublicApiScalingHistoryRouteName) diff --git a/src/autoscaler/routes/routes_test.go b/src/autoscaler/routes/routes_test.go index cd5169d3a7..5bb26a0dd6 100644 --- a/src/autoscaler/routes/routes_test.go +++ b/src/autoscaler/routes/routes_test.go @@ -51,14 +51,6 @@ var _ = Describe("Routes", func() { Expect(path.Path).To(Equal("/v1/info")) }) }) - - Context("PublicApiHealthRouteName", func() { - It("should return the correct path", func() { - path, err := routes.ApiOpenRoutes().Get(routes.PublicApiHealthRouteName).URLPath() - Expect(err).NotTo(HaveOccurred()) - Expect(path.Path).To(Equal("/health")) - }) - }) }) Describe("ApiRoutes", func() { diff --git a/src/autoscaler/scalingengine/cmd/scalingengine/scalingengine_test.go b/src/autoscaler/scalingengine/cmd/scalingengine/scalingengine_test.go index 2a460bc924..60fe48dc50 100644 --- a/src/autoscaler/scalingengine/cmd/scalingengine/scalingengine_test.go +++ b/src/autoscaler/scalingengine/cmd/scalingengine/scalingengine_test.go @@ -6,6 +6,7 @@ import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/cf" "code.cloudfoundry.org/app-autoscaler/src/autoscaler/models" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -149,7 +150,6 @@ var _ = Describe("Main", func() { }) Describe("when http server is ready to serve RESTful API", func() { - JustBeforeEach(func() { Eventually(runner.Session.Buffer, 2).Should(gbytes.Say("scalingengine.started")) }) @@ -206,6 +206,8 @@ var _ = Describe("Main", func() { basicAuthConfig := conf basicAuthConfig.Health.HealthCheckUsername = "" basicAuthConfig.Health.HealthCheckPassword = "" + basicAuthConfig.Health.UnprotectedEndpoints = []string{"/", routes.LivenessPath, + routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath} runner.configPath = writeConfig(&basicAuthConfig).Name() }) @@ -215,7 +217,8 @@ var _ = Describe("Main", func() { Context("when a request to query health comes", func() { It("returns with a 200", func() { - rsp, err := healthHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d", healthport)) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.PrometheusPath) + rsp, err := healthHttpClient.Get(url) Expect(err).NotTo(HaveOccurred()) Expect(rsp.StatusCode).To(Equal(http.StatusOK)) raw, _ := io.ReadAll(rsp.Body) @@ -239,8 +242,8 @@ var _ = Describe("Main", func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -253,8 +256,8 @@ var _ = Describe("Main", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(conf.Health.HealthCheckUsername, conf.Health.HealthCheckPassword) @@ -273,8 +276,8 @@ var _ = Describe("Main", func() { Context("when username and password are incorrect for basic authentication during health check", func() { It("should return 401", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth("wrongusername", "wrongpassword") @@ -287,8 +290,8 @@ var _ = Describe("Main", func() { Context("when username and password are correct for basic authentication during health check", func() { It("should return 200", func() { - - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", healthport), nil) + url := fmt.Sprintf("http://127.0.0.1:%d%s", healthport, routes.LivenessPath) + req, err := http.NewRequest(http.MethodGet, url, nil) Expect(err).NotTo(HaveOccurred()) req.SetBasicAuth(conf.Health.HealthCheckUsername, conf.Health.HealthCheckPassword) diff --git a/src/autoscaler/scalingengine/config/config_test.go b/src/autoscaler/scalingengine/config/config_test.go index eab991c81e..c1a449665a 100644 --- a/src/autoscaler/scalingengine/config/config_test.go +++ b/src/autoscaler/scalingengine/config/config_test.go @@ -2,6 +2,7 @@ package config_test import ( "code.cloudfoundry.org/app-autoscaler/src/autoscaler/db" + "code.cloudfoundry.org/app-autoscaler/src/autoscaler/routes" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/scalingengine/config" . "code.cloudfoundry.org/app-autoscaler/src/autoscaler/testhelpers" @@ -56,6 +57,8 @@ var _ = Describe("Config", func() { Expect(conf.Server.TLS.CACertFile).To(Equal("/var/vcap/jobs/autoscaler/config/certs/ca.crt")) Expect(conf.Health.Port).To(Equal(9999)) + Expect(conf.Health.UnprotectedEndpoints).To( + ContainElements("/health/liveness", "/health/readiness", "/health/prometheus", "/debug/pprof")) Expect(conf.Logging.Level).To(Equal("debug")) Expect(conf.DB.PolicyDB).To(Equal( @@ -201,6 +204,9 @@ health: conf.DefaultCoolDownSecs = 300 conf.LockSize = 32 conf.HttpClientTimeout = 10 * time.Second + conf.Health.UnprotectedEndpoints = []string{ + "/", routes.LivenessPath, routes.ReadinessPath, routes.PrometheusPath, routes.PprofPath, + } }) JustBeforeEach(func() { diff --git a/src/autoscaler/scalingengine/config/testdata/valid.yml b/src/autoscaler/scalingengine/config/testdata/valid.yml index 2aef197852..0425f27751 100644 --- a/src/autoscaler/scalingengine/config/testdata/valid.yml +++ b/src/autoscaler/scalingengine/config/testdata/valid.yml @@ -11,6 +11,7 @@ server: ca_file: /var/vcap/jobs/autoscaler/config/certs/ca.crt health: port: 9999 + unprotected_endpoints: ["/health/liveness", "/health/readiness", "/health/prometheus", "/debug/pprof"] logging: level: DeBug diff --git a/src/scheduler/pom.xml b/src/scheduler/pom.xml index c3fc793e41..44703f76f2 100644 --- a/src/scheduler/pom.xml +++ b/src/scheduler/pom.xml @@ -90,6 +90,11 @@ commons-dbcp2 2.9.0 + + commons-codec + commons-codec + 1.15 + org.apache.httpcomponents.client5 httpclient5 diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/SchedulerApplication.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/SchedulerApplication.java index 1dc61c5d57..27270599b2 100644 --- a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/SchedulerApplication.java +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/SchedulerApplication.java @@ -1,6 +1,5 @@ package org.cloudfoundry.autoscaler.scheduler; -import org.cloudfoundry.autoscaler.scheduler.conf.MetricsConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; @@ -21,10 +20,8 @@ import org.springframework.boot.autoconfigure.transaction.jta.JtaAutoConfiguration; import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.event.EventListener; -@ConfigurationPropertiesScan(basePackageClasses = MetricsConfig.class) @SpringBootApplication( exclude = { AopAutoConfiguration.class, diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/EmbeddedTomcatConfiguration.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/EmbeddedTomcatConfiguration.java new file mode 100644 index 0000000000..cb4c8643cb --- /dev/null +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/EmbeddedTomcatConfiguration.java @@ -0,0 +1,38 @@ +package org.cloudfoundry.autoscaler.scheduler.conf; + +import java.util.ArrayList; +import java.util.List; +import org.apache.catalina.connector.Connector; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class EmbeddedTomcatConfiguration { + + final HealthServerConfiguration healthServerConfig; + + public EmbeddedTomcatConfiguration(HealthServerConfiguration healthServerConfig) { + this.healthServerConfig = healthServerConfig; + } + + @Bean + public TomcatServletWebServerFactory servletContainer() { + TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory(); + Connector[] additionalConnectors = this.additionalConnector(); + if (additionalConnectors != null && additionalConnectors.length > 0) { + tomcat.addAdditionalTomcatConnectors(additionalConnectors); + } + return tomcat; + } + + private Connector[] additionalConnector() { + List result = new ArrayList<>(); + Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol"); + connector.setScheme("http"); + connector.setPort(healthServerConfig.getPort()); + + result.add(connector); + return result.toArray(new Connector[] {}); + } +} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthServerConfiguration.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthServerConfiguration.java new file mode 100644 index 0000000000..7833602b7f --- /dev/null +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthServerConfiguration.java @@ -0,0 +1,82 @@ +package org.cloudfoundry.autoscaler.scheduler.conf; + +import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.cloudfoundry.autoscaler.scheduler.util.health.EndpointsEnum; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; + +@ConfigurationProperties(prefix = "scheduler.healthserver") +@Data +@Component +@AllArgsConstructor +@NoArgsConstructor +public class HealthServerConfiguration { + private String username; + private String password; + private Integer port; + private Set unprotectedEndpoints; + + @PostConstruct + public void init() { + + validatePort(); + validateConfiguredEndpoints(); + + // We need the username and password in health configuration if and only if + // - atleast one endpoints exists in the unprotectedEndpoints configuration + // - and the unprotectedEndpoints is empty => all endpoints are protected + boolean basicAuthEnabled = + (unprotectedEndpoints != null || ObjectUtils.isEmpty(unprotectedEndpoints)); + if (basicAuthEnabled + && (this.username == null + || this.password == null + || this.username.isEmpty() + || this.password.isEmpty())) { + throw new IllegalArgumentException( + "Health Server Basic Auth Username or password is not set"); + } + } + + private void validatePort() { + Optional healthPortOptional = Optional.ofNullable(this.port); + if (!healthPortOptional.isPresent() || healthPortOptional.get() == 0) { + throw new IllegalArgumentException( + "Health Configuration: health server port not defined or set to unsupported port-number" + + " `0`"); + } + } + + private void validateConfiguredEndpoints() { + + Map invalidEndpointsMap = new HashMap<>(); + if (unprotectedEndpoints == null) { + return; + } + for (String unprotectedEndpoint : unprotectedEndpoints) { + + if (!EndpointsEnum.isValidEndpoint(unprotectedEndpoint)) { + invalidEndpointsMap.put(unprotectedEndpoint, true); + } + } + if (!ObjectUtils.isEmpty(invalidEndpointsMap)) { + throw new IllegalArgumentException( + "Health configuration: invalid unprotectedEndpoints provided: " + + invalidEndpointsMap + + "\n" + + "validate endpoints are: " + + EndpointsEnum.displayAllEndpointValues()); + } + } + + public boolean isUnprotectedByConfiguration(String requestURI) { + return this.getUnprotectedEndpoints().contains(requestURI); + } +} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfig.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfig.java deleted file mode 100644 index 58e81f2d35..0000000000 --- a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfig.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.cloudfoundry.autoscaler.scheduler.conf; - -import com.sun.net.httpserver.BasicAuthenticator; -import io.prometheus.client.exporter.HTTPServer; -import io.prometheus.client.exporter.HTTPServer.Builder; -import java.io.IOException; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class MetricsConfig { - - @Bean(destroyMethod = "close") - HTTPServer metricsServer(MetricsConfiguration config) throws IOException { - Builder builder = new Builder().withPort(config.getPort()); - if (config.isBasicAuthEnabled()) { - builder.withAuthenticator( - new BasicAuthenticator("/") { - @Override - public boolean checkCredentials(String username, String password) { - return config.getUsername().equals(username) && config.getPassword().equals(password); - } - }); - } - return builder.build(); - } -} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfiguration.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfiguration.java deleted file mode 100644 index 7d9d230487..0000000000 --- a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfiguration.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.cloudfoundry.autoscaler.scheduler.conf; - -import jakarta.annotation.PostConstruct; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@ConfigurationProperties(prefix = "scheduler.healthserver") -@Data -@Component -@AllArgsConstructor -@NoArgsConstructor -public class MetricsConfiguration { - private String username; - private String password; - private int port; - private boolean basicAuthEnabled = false; - - @PostConstruct - public void init() { - if (this.basicAuthEnabled - && (this.username == null - || this.password == null - || this.username.isEmpty() - || this.password.isEmpty())) { - throw new IllegalStateException("Heath Server Basic Auth Username or password is not set"); - } - } -} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthConfig.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/PrometheusConfig.java similarity index 97% rename from src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthConfig.java rename to src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/PrometheusConfig.java index 14dc39887b..e9c6d995b8 100644 --- a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthConfig.java +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/conf/PrometheusConfig.java @@ -9,7 +9,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -public class HealthConfig { +public class PrometheusConfig { @Bean public DbStatusCollector dbStatusCollector( diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/BasicAuthenticationFilter.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/BasicAuthenticationFilter.java new file mode 100644 index 0000000000..978a2a25c9 --- /dev/null +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/BasicAuthenticationFilter.java @@ -0,0 +1,155 @@ +package org.cloudfoundry.autoscaler.scheduler.rest; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.cloudfoundry.autoscaler.scheduler.util.health.EndpointsEnum; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; + +@Slf4j +@Component +@Order(2) +public class BasicAuthenticationFilter implements Filter { + private static final String WWW_AUTHENTICATE_VALUE = "Basic"; + + final HealthServerConfiguration healthServerConfiguration; + + public BasicAuthenticationFilter(HealthServerConfiguration healthServerConfiguration) { + this.healthServerConfiguration = healthServerConfiguration; + } + + @Override + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; + HttpServletResponse httpResponse = (HttpServletResponse) servletResponse; + Set unprotectedEndpointsConfig = healthServerConfiguration.getUnprotectedEndpoints(); + + if (!httpRequest.getRequestURI().contains("/health")) { + log.debug("Not a health request: " + httpRequest.getRequestURI()); + chain.doFilter(servletRequest, servletResponse); + return; + } + final boolean allEndpointsRequireAuthorization = + ObjectUtils.isEmpty(unprotectedEndpointsConfig); + + if (allEndpointsRequireAuthorization) { // case 1 ; config [] + allowAuthenticatedRequest(chain, httpRequest, httpResponse); + + } else if (!ObjectUtils.isEmpty(unprotectedEndpointsConfig)) { + /* + // case 2.1 ; config ["/health/liveness"] + here is – by configuration – one protected endpoint "/health/prometheus" and one unprotected "/health/liveness". + The user is authenticated. + The user queries on "/health/prometheus". + Expected behaviour: The request will be handled successfully. + */ + + // IMPORTANT: Match the configured health endpoints with the allowed list of endpoints to + // cover + // BasicAuthenticationFilterTest#denyHealthRequestWithWrongUnprotectedEndpoints() + // Suggestion: THe following block should be part of HealthConfiguration. + // Move this block to Health Configuration/Or adjust test + if (!EndpointsEnum.configuredEndpointsExists( + healthServerConfiguration.getUnprotectedEndpoints())) { + log.error("Health Configuration: Invalid endpoints defined"); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, WWW_AUTHENTICATE_VALUE); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + boolean isEndpointRequireAuthentication = + isEndpointRequireAuthentication(httpRequest.getRequestURI()); + if (isEndpointRequireAuthentication) { + allowAuthenticatedRequest(chain, httpRequest, httpResponse); + } else { // RequestURI() does not need authentication ( + // Case 2.2 ; config ["/health/liveness", "/health/prometheus"] + chain.doFilter(servletRequest, servletResponse); + } + } + } + + private void allowAuthenticatedRequest( + FilterChain filterChain, HttpServletRequest httpRequest, HttpServletResponse httpResponse) + throws IOException, ServletException { + final String authorizationHeader = httpRequest.getHeader("Authorization"); + + if (authorizationHeader == null) { + log.error("Basic authentication not provided with the request"); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, WWW_AUTHENTICATE_VALUE); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String[] tokens = decodeAndGetAuthTokens(authorizationHeader); + if (tokens.length != 2) { + log.error("Malformed authorization header"); + httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + if (isUserAuthenticated(authorizationHeader)) { + // allow access to health endpoints + filterChain.doFilter(httpRequest, httpResponse); + } else { + log.error( + "Health configuration: Basic auth is required to access protectedEndpoints: " + + httpRequest.getRequestURI() + + " \nValid unprotected endpoints are: " + + healthServerConfiguration.getUnprotectedEndpoints()); + httpResponse.setHeader(HttpHeaders.WWW_AUTHENTICATE, WWW_AUTHENTICATE_VALUE); + httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } + } + + private boolean isEndpointRequireAuthentication(String requestURI) { + boolean isProtected = EndpointsEnum.isValidEndpoint(requestURI); + boolean isUnprotectedByConfiguration = + healthServerConfiguration.isUnprotectedByConfiguration(requestURI); + + return isProtected && !isUnprotectedByConfiguration; + } + + private boolean isUserAuthenticated(String authorization) { + + if (authorization == null) { + log.error("Basic authentication not provided with the request"); + return false; + } + + String[] tokens = decodeAndGetAuthTokens(authorization); + if (tokens.length != 2) { + log.error("Malformed authorization header"); + return false; + } + String username = tokens[0]; + String password = tokens[1]; + return areBasicAuthCredentialsCorrect(username, password); + } + + private static String[] decodeAndGetAuthTokens(String authorization) { + String base64Credentials = authorization.substring(WWW_AUTHENTICATE_VALUE.length()).trim(); + byte[] credDecoded = Base64.decodeBase64(base64Credentials); + String credentials = new String(credDecoded); + String[] tokens = credentials.split(":"); + return tokens; + } + + private boolean areBasicAuthCredentialsCorrect(String username, String password) { + return healthServerConfiguration.getUsername().equals(username) + && healthServerConfiguration.getPassword().equals(password); + } +} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/MultiPortFilter.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/MultiPortFilter.java new file mode 100644 index 0000000000..f3bbdf87a3 --- /dev/null +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/MultiPortFilter.java @@ -0,0 +1,53 @@ +package org.cloudfoundry.autoscaler.scheduler.rest; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@Order(1) +public class MultiPortFilter implements Filter { + HealthServerConfiguration healthServerConfiguration; + + public MultiPortFilter(HealthServerConfiguration healthServerConfiguration) { + this.healthServerConfiguration = healthServerConfiguration; + } + + @Override + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; + HttpServletResponse httpResponse = (HttpServletResponse) servletResponse; + + if (isMainRequest(httpRequest)) { + log.debug("Main server request received on port " + httpRequest.getLocalPort()); + filterChain.doFilter(servletRequest, servletResponse); + } else if (isHealthRequest(httpRequest)) { + log.debug("Health server request received on port " + httpRequest.getLocalPort()); + filterChain.doFilter(servletRequest, servletResponse); + } else { + httpResponse.sendError(HttpServletResponse.SC_NOT_FOUND, "Health endpoints do not exist"); + } + } + + private boolean isHealthRequest(HttpServletRequest httpRequest) { + return httpRequest.getLocalPort() == healthServerConfiguration.getPort() + && httpRequest.getRequestURI().contains("health"); + } + + private boolean isMainRequest(HttpServletRequest httpRequest) { + return httpRequest.getLocalPort() != healthServerConfiguration.getPort() + && !httpRequest.getRequestURI().contains("health"); + } +} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/health/HealthRestController.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/health/HealthRestController.java new file mode 100644 index 0000000000..1667a9246e --- /dev/null +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/rest/health/HealthRestController.java @@ -0,0 +1,37 @@ +package org.cloudfoundry.autoscaler.scheduler.rest.health; + +import io.prometheus.client.CollectorRegistry; +import io.prometheus.client.exporter.common.TextFormat; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(value = {"/health"}) +public class HealthRestController { + @GetMapping(value = "/prometheus") + public ResponseEntity getPrometheusMetrics() throws IOException { + + final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + try (OutputStreamWriter writer = new OutputStreamWriter(stream)) { + TextFormat.write004(writer, CollectorRegistry.defaultRegistry.metricFamilySamples()); + } + return new ResponseEntity<>(stream.toString(), HttpStatus.OK); + } + + @GetMapping(value = {"", "/liveness"}) + public ResponseEntity> getLiveness() { + Map body = new HashMap<>(); + body.put("status", "Up"); + return new ResponseEntity<>(body, HttpStatus.OK); + } +} diff --git a/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/util/health/EndpointsEnum.java b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/util/health/EndpointsEnum.java new file mode 100644 index 0000000000..21b98b9809 --- /dev/null +++ b/src/scheduler/src/main/java/org/cloudfoundry/autoscaler/scheduler/util/health/EndpointsEnum.java @@ -0,0 +1,51 @@ +package org.cloudfoundry.autoscaler.scheduler.util.health; + +import java.util.Set; + +public enum EndpointsEnum { + PROMETHEUS("/health/prometheus"), + LIVENESS("/health/liveness"); + + private final String url; + + EndpointsEnum(String url) { + this.url = url; + } + + public String getUrl() { + return url; + } + + public static EndpointsEnum valueOfEndpoint(String url) { + for (EndpointsEnum endpoint : values()) { + if (endpoint.url.equals(url)) { + return endpoint; + } + } + throw new IllegalArgumentException("Enum for " + url); + } + + public static boolean isValidEndpoint(String url) { + EndpointsEnum endpointsEnum = valueOfEndpoint(url); + if (endpointsEnum != null) { + return true; + } + return false; + } + + public static boolean configuredEndpointsExists(Set userDefindHealthEndpoints) { + + for (String configuredEndpoint : userDefindHealthEndpoints) { + return isValidEndpoint(configuredEndpoint); + } + return false; + } + + public static String displayAllEndpointValues() { + String endpointValues = EndpointsEnum.values()[0].getUrl(); + for (int i = 1; i < EndpointsEnum.values().length; i++) { + endpointValues += "," + EndpointsEnum.values()[i].getUrl(); + } + return endpointValues; + } +} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthServerConfigurationTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthServerConfigurationTest.java new file mode 100644 index 0000000000..24dfec8606 --- /dev/null +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/conf/HealthServerConfigurationTest.java @@ -0,0 +1,96 @@ +package org.cloudfoundry.autoscaler.scheduler.conf; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.runner.RunWith; +import org.mockito.internal.util.collections.Sets; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +class HealthServerConfigurationTest { + @Test + void givenUnprotectedEndpointListAndUsernameOrPasswordIsNull() { + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration(null, null, 8081, Sets.newSet("test_endpoint")).init()); + } + + @Test + void givenUnprotectedEndpointListAndUsernameOrPasswordIsEmpty() { + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration("", "", 8081, Sets.newSet("test_endpoint")).init()); + } + + @Test + void givenEmptyUnprotectedEndpointListAndUsernameOrPasswordIsNull() { + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration(null, null, 8081, Set.of()).init()); + } + + @Test + void givenEmptyUnprotectedEndpointListAndUsernameOrPasswordIsEmpty() { + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration("", "", 8081, Set.of()).init()); + } + + @Test + void givenUnprotectedEndpointListIsNullThenBasicAuthRequired() { + assertDoesNotThrow( + () -> new HealthServerConfiguration("test-user", "test-password", 8081, null).init()); + } + + @Test + void givenEmptyUnprotectedEndpointListWithUsernameOrPassword() { + assertDoesNotThrow( + () -> new HealthServerConfiguration("some-user", "some-test", 8081, Set.of()).init()); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "0", ""}) + public void givenInvalidPortThrowsException(String healthPort) { + + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration("", "", Integer.parseInt(healthPort), Set.of()).init()); + } + + @Test + void givenValidReturnsPort() { + HealthServerConfiguration healthServerConfiguration = + new HealthServerConfiguration("s", "s", 888, Set.of()); + healthServerConfiguration.init(); + assertEquals(healthServerConfiguration.getPort(), 888); + } + + @Test + void givenEmptyPortThrowsException() { + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration("", "", null, Set.of()).init()); + } + + @Test + void givenInvalidConfiguredEndpointsThrowsException() { + assertThrows( + IllegalArgumentException.class, + () -> new HealthServerConfiguration("", "", 8888, Set.of("/info")).init()); + } + + @Test + void givenValidConfiguredEndpoints() { + assertDoesNotThrow( + () -> + new HealthServerConfiguration( + "some-user", "some-password", 8888, Set.of("/health/prometheus")) + .init()); + } +} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfigurationTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfigurationTest.java deleted file mode 100644 index 4cb3d4b2e4..0000000000 --- a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/conf/MetricsConfigurationTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.cloudfoundry.autoscaler.scheduler.conf; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.junit.jupiter.api.Test; - -class MetricsConfigurationTest { - @Test - void givenBasicAuthEnableAndUsernameOrPasswordIsNull() { - assertThrows( - IllegalStateException.class, () -> new MetricsConfiguration(null, null, 8081, true).init()); - } - - @Test - void givenBasicAuthEnableAndUsernameOrPasswordIsEmpty() { - assertThrows( - IllegalStateException.class, () -> new MetricsConfiguration("", "", 8081, true).init()); - } -} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/health/SchedulerHealthDefaultEndpointTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/health/SchedulerHealthDefaultEndpointTest.java deleted file mode 100644 index ca7d587399..0000000000 --- a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/health/SchedulerHealthDefaultEndpointTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.cloudfoundry.autoscaler.scheduler.health; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.cloudfoundry.autoscaler.scheduler.conf.MetricsConfiguration; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.ResponseEntity; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.junit4.SpringRunner; - -@RunWith(SpringRunner.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@DirtiesContext(classMode = ClassMode.BEFORE_CLASS) -public class SchedulerHealthDefaultEndpointTest { - - @Autowired private TestRestTemplate restTemplate; - - @Autowired private MetricsConfiguration metricsConfig; - - @Test - public void metricsShouldBeAvailable() { - ResponseEntity response = this.restTemplate.getForEntity(metricsUrl(), String.class); - assertThat(response.getStatusCode().value()) - .describedAs("Http status code should be OK") - .isEqualTo(200); - } - - private String metricsUrl() { - return "http://localhost:" + metricsConfig.getPort() + "/metrics"; - } -} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/BasicAuthenticationFilterTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/BasicAuthenticationFilterTest.java new file mode 100644 index 0000000000..e2482fc3b3 --- /dev/null +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/BasicAuthenticationFilterTest.java @@ -0,0 +1,180 @@ +package org.cloudfoundry.autoscaler.scheduler.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import java.io.IOException; +import java.util.Set; +import org.apache.commons.codec.binary.Base64; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +public class BasicAuthenticationFilterTest { + private static final String username = "user"; + private static final String password = "pw"; + + private MockHttpServletRequest req; + private MockHttpServletResponse res; + private FilterChain filterChainMock; + + @Before + public void setUp() { + req = new MockHttpServletRequest(); + res = new MockHttpServletResponse(); + filterChainMock = Mockito.mock(FilterChain.class); + req.setLocalPort(8081); + } + + @Test + public void allowRequestIfPort8081andURIContainHealthWithoutUnprotectedEndpoints() + throws IOException, ServletException { + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration(username, password, 8081, Set.of()); + + req.setRequestURI("some/health/uri"); + String auth = username + ":" + password; + req.addHeader("Authorization", "Basic " + Base64.encodeBase64String(auth.getBytes())); + + BasicAuthenticationFilter filter = new BasicAuthenticationFilter(healthServerConfig); + + filter.doFilter(req, res, filterChainMock); + assertEquals(200, res.getStatus()); + Mockito.verify(filterChainMock, Mockito.times(1)).doFilter(req, res); + } + + @Test + public void denyHealthRequesWithAllSecuredEndpointsAndInvalidCredentials() + throws ServletException, IOException { + + req.setRequestURI("some/health/uri"); + + String auth = username + ":" + password; + req.addHeader("Authorization", "Basic " + Base64.encodeBase64String(auth.getBytes())); + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration("", "", 8081, Set.of()); + BasicAuthenticationFilter userPwNullFilter = new BasicAuthenticationFilter(healthServerConfig); + userPwNullFilter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(0)).doFilter(req, res); + assertEquals(401, res.getStatus()); + assertEquals(res.getHeader("WWW-Authenticate"), "Basic"); + + res = new MockHttpServletResponse(); + healthServerConfig = new HealthServerConfiguration(username, password, 8081, Set.of()); + BasicAuthenticationFilter wrongCredsFilter = new BasicAuthenticationFilter(healthServerConfig); + req.removeHeader("Authorization"); + String wrongCreds = "wrong-user:pw"; + req.addHeader("Authorization", "Basic " + Base64.encodeBase64String(wrongCreds.getBytes())); + wrongCredsFilter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(0)).doFilter(req, res); + assertEquals(401, res.getStatus()); + assertEquals(res.getHeader("WWW-Authenticate"), "Basic"); + + res = new MockHttpServletResponse(); + healthServerConfig = new HealthServerConfiguration(username, password, 8081, Set.of()); + BasicAuthenticationFilter noAuthHeaderFilter = + new BasicAuthenticationFilter(healthServerConfig); + req.removeHeader("Authorization"); + noAuthHeaderFilter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(0)).doFilter(req, res); + assertEquals(401, res.getStatus()); + assertEquals(res.getHeader("WWW-Authenticate"), "Basic"); + } + + @Test + public void denyHealthRequestIfAuthHeaderIsInvalid() throws IOException, ServletException { + + req.setRequestURI("some/health/uri"); + + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration(username, password, 8081, Set.of()); + BasicAuthenticationFilter malformedHeaderFilter = + new BasicAuthenticationFilter(healthServerConfig); + req.removeHeader("Authorization"); + String malformedCreds = "some-malformed-creds"; + req.addHeader("Authorization", "Basic " + Base64.encodeBase64String(malformedCreds.getBytes())); + malformedHeaderFilter.doFilter(req, res, filterChainMock); + + assertEquals(400, res.getStatus()); + assertNull(res.getHeader("WWW-Authenticate")); + } + + @Test + public void allowRequestIfPort8081andURIContainHealthWithUnprotectedEndpoints() + throws IOException, ServletException { + + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration(username, password, 8081, Set.of("/health/liveness")); + req.setRequestURI("/health/liveness"); + String auth = username + ":" + password; + req.addHeader("Authorization", "Basic " + Base64.encodeBase64String(auth.getBytes())); + + BasicAuthenticationFilter filter = new BasicAuthenticationFilter(healthServerConfig); + filter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(1)).doFilter(req, res); + } + + @ParameterizedTest + @ValueSource(strings = {"/health/prometheus", "/health/liveness", "/health/wrong-endpoint"}) + public void denyHealthRequestsWithNoUnprotectedEndpointsConfigThenBasicAuthRequired( + String requestURI) throws IOException, ServletException { + req = new MockHttpServletRequest(); + res = new MockHttpServletResponse(); + filterChainMock = Mockito.mock(FilterChain.class); + req.setLocalPort(8081); + + req.setRequestURI(requestURI); + + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration(username, password, 8081, Set.of()); + BasicAuthenticationFilter basicAuthenticationFilter = + new BasicAuthenticationFilter(healthServerConfig); + + basicAuthenticationFilter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(0)).doFilter(req, res); + assertEquals(401, res.getStatus()); + assertEquals(res.getHeader("WWW-Authenticate"), "Basic"); + } + + @Test + public void denyHealthRequestIfBasicAuthRequired() throws IOException, ServletException { + + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration(username, password, 8081, Set.of("/health/prometheus")); + req.setRequestURI("/health/liveness"); + + BasicAuthenticationFilter noBasicAuthFilter = new BasicAuthenticationFilter(healthServerConfig); + noBasicAuthFilter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(0)).doFilter(req, res); + assertEquals(401, res.getStatus()); + assertEquals(res.getHeader("WWW-Authenticate"), "Basic"); + } + + @Test + public void basicAuthFilterNotAppliedIfNoHealthRequest() throws IOException, ServletException { + req.setLocalPort(8080); + req.setRequestURI("/routeTo8080"); + + HealthServerConfiguration healthServerConfig = + new HealthServerConfiguration(username, password, 8081, Set.of("/health/wrong-endpoint")); + BasicAuthenticationFilter filter = new BasicAuthenticationFilter(healthServerConfig); + filter.doFilter(req, res, filterChainMock); + + Mockito.verify(filterChainMock, Mockito.times(1)).doFilter(req, res); + assertEquals(200, res.getStatus()); + assertEquals(8080, req.getLocalPort()); + } +} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/MultiPortFilterTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/MultiPortFilterTest.java new file mode 100644 index 0000000000..d3af6fc432 --- /dev/null +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/MultiPortFilterTest.java @@ -0,0 +1,92 @@ +package org.cloudfoundry.autoscaler.scheduler.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import java.io.IOException; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +public class MultiPortFilterTest { + + private MockHttpServletRequest req; + private MockHttpServletResponse res; + + @MockBean private FilterChain filterChainMock; + + @Before + public void setUp() { + req = new MockHttpServletRequest(); + res = new MockHttpServletResponse(); + } + + @Test + public void shouldRespondTo8080IfURLContainsPort8080() throws IOException, ServletException { + + req.setLocalPort(8080); + + MultiPortFilter filter = new MultiPortFilter(new HealthServerConfiguration("", "", 8081, null)); + filter.doFilter(req, res, filterChainMock); + assertEquals(8080, req.getLocalPort()); + assertEquals(200, res.getStatus()); + } + + @Test + public void shouldRespond8080IfSchedulersURL() throws IOException, ServletException { + + req.setLocalPort(8080); + + req.setRequestURI("/v1/syncSchedules"); + + req.setMethod("PUT"); + MultiPortFilter filter = new MultiPortFilter(new HealthServerConfiguration("", "", 8081, null)); + filter.doFilter(req, res, filterChainMock); + assertEquals(8080, req.getLocalPort()); + assertEquals(200, res.getStatus()); + } + + @Test + public void allowRequestIfPort8081WithHealthEndpoint() throws IOException, ServletException { + + req.setLocalPort(8081); + + req.setRequestURI("/health/"); + + MultiPortFilter filter = new MultiPortFilter(new HealthServerConfiguration("", "", 8081, null)); + filter.doFilter(req, res, filterChainMock); + assertEquals(8081, req.getLocalPort()); + assertEquals(200, res.getStatus()); + } + + @Test + public void shouldRespond404IfPort8081WithNoHealthEndpoint() + throws IOException, ServletException { + req.setLocalPort(8081); + + MultiPortFilter filter = new MultiPortFilter(new HealthServerConfiguration("", "", 8081, null)); + filter.doFilter(req, res, null); + assertEquals(8081, req.getLocalPort()); + assertEquals(404, res.getStatus()); + } + + @Test + public void shouldRespond404IfPort8081WithNoHealthEndpointButWithSchedules() + throws IOException, ServletException { + req.setLocalPort(8081); + req.setRequestURI("/v1/syncSchedules"); + req.setMethod("PUT"); + + MultiPortFilter filter = new MultiPortFilter(new HealthServerConfiguration("", "", 8081, null)); + filter.doFilter(req, res, filterChainMock); + assertEquals(8081, req.getLocalPort()); + assertEquals(404, res.getStatus()); + } +} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointMixedAuthTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointMixedAuthTest.java new file mode 100644 index 0000000000..6413eab638 --- /dev/null +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointMixedAuthTest.java @@ -0,0 +1,58 @@ +package org.cloudfoundry.autoscaler.scheduler.rest.healthControllerTest; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.cloudfoundry.autoscaler.scheduler.util.HealthUtils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.BEFORE_CLASS) +@ActiveProfiles("HealthAuth") +@TestPropertySource( + properties = "scheduler.healthserver.unprotectedEndpoints=" + "/health/liveness") +public class HealthEndpointMixedAuthTest { + @Autowired private TestRestTemplate restTemplate; + @Autowired private HealthServerConfiguration healthServerConfig; + + /* + // case 2.1 ; config ["/health/liveness"] + here is – by configuration – one protected endpoint "/health/prometheus" and one unprotected "/health/liveness". + The user is authenticated. + The user queries on "/health/prometheus". + Expected behaviour: The request will be handled successfully. + */ + @Test + public void givenLivenessUnprotectedAndUserIsAuthenticatedShouldReturnPrometheusWith200() + throws MalformedURLException, URISyntaxException { + + ResponseEntity prometheusResponse = + this.restTemplate + .withBasicAuth("prometheus", "someHash") + .getForEntity(HealthUtils.prometheusMetricsUrl().toURI(), String.class); + + assertThat(prometheusResponse.getStatusCode().value()) + .describedAs("Http status code should be OK") + .isEqualTo(200); + assertThat(prometheusResponse.getHeaders().getContentType()) + .isEqualTo(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8)); + assertThat(prometheusResponse.getBody()) + .contains("autoscaler_scheduler_data_source_initial_size 0.0"); + } +} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointWithBasicAuthTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointWithBasicAuthTest.java new file mode 100644 index 0000000000..8feddbb531 --- /dev/null +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointWithBasicAuthTest.java @@ -0,0 +1,152 @@ +package org.cloudfoundry.autoscaler.scheduler.rest.healthControllerTest; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.cloudfoundry.autoscaler.scheduler.util.HealthUtils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.BEFORE_CLASS) +@ActiveProfiles("HealthAuth") +@TestPropertySource(properties = "scheduler.healthserver.unprotectedEndpoints=") +public class HealthEndpointWithBasicAuthTest { + + @Autowired private TestRestTemplate restTemplate; + + @Autowired private HealthServerConfiguration healthServerConfig; + + @Test + public void givenCorrectCredentialsPrometheusShouldBeAvailable() + throws MalformedURLException, URISyntaxException { + + ResponseEntity response = + this.restTemplate + .withBasicAuth("prometheus", "someHash") + .getForEntity(HealthUtils.prometheusMetricsUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be OK") + .isEqualTo(200); + String result = response.toString(); + assertThat(result) + .contains("jvm_info") + .contains("jvm_buffer_pool_used_bytes") + .contains("jvm_buffer_pool_capacity_bytes") + .contains("jvm_buffer_pool_used_buffers") + .contains("jvm_gc_collection_seconds_count") + .contains("jvm_gc_collection_seconds_sum") + .contains("jvm_classes_loaded") + .contains("jvm_classes_loaded_total") + .contains("jvm_classes_unloaded_total") + .contains("jvm_threads") + .contains("jvm_memory_bytes") + .contains("jvm_memory_pool_bytes") + .contains("autoscaler_scheduler_data_source") + .contains("autoscaler_scheduler_policy_db_data_source"); + } + + @Test + public void givenRootEndpointThenMetricsShouldNotBeAvailable() { + ResponseEntity response = this.restTemplate.getForEntity("/", String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be Not Found") + .isEqualTo(404); + } + + @Test + public void givenIncorrectCredentialsShouldReturn401() + throws MalformedURLException, URISyntaxException { + ResponseEntity response = + this.restTemplate + .withBasicAuth("bad", "auth") + .getForEntity(HealthUtils.prometheusMetricsUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be Unauthorized") + .isEqualTo(401); + } + + @Test + public void givenNoCredentialsShouldReturn401() throws MalformedURLException, URISyntaxException { + ResponseEntity response = + this.restTemplate.getForEntity(HealthUtils.prometheusMetricsUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + public void givenCorrectPasswordAndWrongUsernameFailsWith401() + throws MalformedURLException, URISyntaxException { + ResponseEntity response = + this.restTemplate + .withBasicAuth("bad", "someHash") + .getForEntity(HealthUtils.prometheusMetricsUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + public void givenCorrectCredentialsLivenessShouldBeAvailable() + throws MalformedURLException, URISyntaxException { + + ResponseEntity response = + this.restTemplate + .withBasicAuth("prometheus", "someHash") + .getForEntity(HealthUtils.livenessUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be OK") + .isEqualTo(200); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(response.getBody()).isEqualTo("{\"status\":\"Up\"}"); + } + + @Test + public void givenCorrectCredentialsLivenessBaseUrlShouldBeAvailable() + throws MalformedURLException, URISyntaxException { + + ResponseEntity response = + this.restTemplate + .withBasicAuth("prometheus", "someHash") + .getForEntity(HealthUtils.baseLivenessUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be OK") + .isEqualTo(200); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(response.getBody()).isEqualTo("{\"status\":\"Up\"}"); + } + + @Test + public void givenNoCredentialsLivenessShouldReturn401() + throws MalformedURLException, URISyntaxException { + + ResponseEntity response = + this.restTemplate.getForEntity(HealthUtils.livenessUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be Unauthorized") + .isEqualTo(401); + } + + @Test + public void givenIncorrectCredentialsShouldLivenessReturn401() + throws MalformedURLException, URISyntaxException { + ResponseEntity response = + this.restTemplate + .withBasicAuth("bad", "auth") + .getForEntity(HealthUtils.livenessUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be Unauthorized") + .isEqualTo(401); + } +} diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/health/SchedulerHealthEndpointTest.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointWithoutBasicAuthTest.java similarity index 53% rename from src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/health/SchedulerHealthEndpointTest.java rename to src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointWithoutBasicAuthTest.java index 89a3589f6e..07fa4b30e5 100644 --- a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/health/SchedulerHealthEndpointTest.java +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/rest/healthControllerTest/HealthEndpointWithoutBasicAuthTest.java @@ -1,42 +1,63 @@ -package org.cloudfoundry.autoscaler.scheduler.health; +package org.cloudfoundry.autoscaler.scheduler.rest.healthControllerTest; import static org.assertj.core.api.Assertions.assertThat; -import org.cloudfoundry.autoscaler.scheduler.conf.MetricsConfiguration; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.cloudfoundry.autoscaler.scheduler.util.HealthUtils; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit4.SpringRunner; @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @DirtiesContext(classMode = ClassMode.BEFORE_CLASS) @ActiveProfiles("HealthAuth") -public class SchedulerHealthEndpointTest { - +@TestPropertySource( + properties = + "scheduler.healthserver.unprotectedEndpoints=" + "/health/liveness,/health/prometheus") +public class HealthEndpointWithoutBasicAuthTest { @Autowired private TestRestTemplate restTemplate; - @Autowired private MetricsConfiguration metricsConfig; + @Autowired private HealthServerConfiguration healthServerConfig; @Test - public void givenCorrectCredentialsStandardMetricsShouldBeAvailable() { + public void givenUnprotectedConfigsShouldLivenessReturn200() + throws MalformedURLException, URISyntaxException { ResponseEntity response = - this.restTemplate - .withBasicAuth("prometheus", "someHash") - .getForEntity(metricsUrl(), String.class); + this.restTemplate.getForEntity(HealthUtils.livenessUrl().toURI(), String.class); assertThat(response.getStatusCode().value()) .describedAs("Http status code should be OK") .isEqualTo(200); - String result = response.toString(); - assertThat(result) + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(response.getBody()).isEqualTo("{\"status\":\"Up\"}"); + } + + @Test + public void givenUnprotectedConfigsShouldPrometheusReturn200() + throws MalformedURLException, URISyntaxException { + + ResponseEntity response = + this.restTemplate.getForEntity(HealthUtils.prometheusMetricsUrl().toURI(), String.class); + assertThat(response.getStatusCode().value()) + .describedAs("Http status code should be OK") + .isEqualTo(200); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8)); + assertThat(response.toString()) .contains("jvm_info") .contains("jvm_buffer_pool_used_bytes") .contains("jvm_buffer_pool_capacity_bytes") @@ -52,28 +73,4 @@ public void givenCorrectCredentialsStandardMetricsShouldBeAvailable() { .contains("autoscaler_scheduler_data_source") .contains("autoscaler_scheduler_policy_db_data_source"); } - - @Test - public void givenIncorrectCredentials401ResultShouldBeGiven() { - ResponseEntity response = - this.restTemplate.withBasicAuth("bad", "auth").getForEntity(metricsUrl(), String.class); - assertThat(response.getStatusCode().value()).isEqualTo(401); - } - - private String metricsUrl() { - return "http://localhost:" + metricsConfig.getPort() + "/metrics"; - } - - @Test - public void givenNoCredentials401ResultShouldBeGiven() { - ResponseEntity response = this.restTemplate.getForEntity(metricsUrl(), String.class); - assertThat(response.getStatusCode().value()).isEqualTo(401); - } - - @Test - public void givenCorrectPasswordAndWrongUsernameFailsWith401() { - ResponseEntity response = - this.restTemplate.withBasicAuth("bad", "someHash").getForEntity(metricsUrl(), String.class); - assertThat(response.getStatusCode().value()).isEqualTo(401); - } } diff --git a/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/util/HealthUtils.java b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/util/HealthUtils.java new file mode 100644 index 0000000000..3ddb92ba11 --- /dev/null +++ b/src/scheduler/src/test/java/org/cloudfoundry/autoscaler/scheduler/util/HealthUtils.java @@ -0,0 +1,28 @@ +package org.cloudfoundry.autoscaler.scheduler.util; + +import java.net.MalformedURLException; +import java.net.URL; +import org.cloudfoundry.autoscaler.scheduler.conf.HealthServerConfiguration; +import org.springframework.stereotype.Component; + +@Component +public class HealthUtils { + + static HealthServerConfiguration healthServerConfig; + + private HealthUtils(HealthServerConfiguration healthServerConfig) { + this.healthServerConfig = healthServerConfig; + } + + public static URL livenessUrl() throws MalformedURLException { + return new URL("http://localhost:" + healthServerConfig.getPort() + "/health/liveness"); + } + + public static URL prometheusMetricsUrl() throws MalformedURLException { + return new URL("http://localhost:" + healthServerConfig.getPort() + "/health/prometheus"); + } + + public static URL baseLivenessUrl() throws MalformedURLException { + return new URL("http://localhost:" + healthServerConfig.getPort() + "/health"); + } +} diff --git a/src/scheduler/src/test/resources/application-HealthAuth.yml b/src/scheduler/src/test/resources/application-HealthAuth.yml index 061f9dc033..abc7c73e44 100644 --- a/src/scheduler/src/test/resources/application-HealthAuth.yml +++ b/src/scheduler/src/test/resources/application-HealthAuth.yml @@ -1,5 +1,4 @@ scheduler: healthserver: - basicAuthEnabled: true username: prometheus password: "someHash" \ No newline at end of file diff --git a/style-guide/checkstyle_suppressions.xml b/style-guide/checkstyle_suppressions.xml index 1eff6a5a2f..c80c6e58c1 100644 --- a/style-guide/checkstyle_suppressions.xml +++ b/style-guide/checkstyle_suppressions.xml @@ -5,12 +5,14 @@ "https://checkstyle.org/dtds/suppressions_1_2.dtd"> - - - + + + + + diff --git a/style-guide/google-format-ci-v0.1.sh b/style-guide/google-format-ci-v0.1.sh index f2249bc9cf..fd1a99d2c2 100755 --- a/style-guide/google-format-ci-v0.1.sh +++ b/style-guide/google-format-ci-v0.1.sh @@ -8,13 +8,16 @@ GOOGLE_JAR_VERSION=${GOOGLE_JAR_VERSION:-"1.11.0"} GOOGLE_JAR_NAME=${GOOGLE_JAR_NAME:-"google-java-format-${GOOGLE_JAR_VERSION}-all-deps.jar"} ! [ -e "$GOOGLE_JAR_NAME" ] && \ curl -fLJO "https://github.com/google/google-java-format/releases/download/v$GOOGLE_JAR_VERSION/$GOOGLE_JAR_NAME" + +# List all java formatting issues (dryRun)- Autofix is possible by using --replace flag +# shellcheck disable=SC2046 files_to_be_formatted=$(java \ --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ - -jar "${GOOGLE_JAR_NAME}" -n --skip-javadoc-formatting $(find . -name '*.java') ) + -jar "${GOOGLE_JAR_NAME}" --dry-run --skip-javadoc-formatting $(find . -name '*.java') ) if [ -n "$files_to_be_formatted" ]; then diff --git a/templates/app-autoscaler.yml b/templates/app-autoscaler.yml index 957cd5a88f..0fea044cfc 100644 --- a/templates/app-autoscaler.yml +++ b/templates/app-autoscaler.yml @@ -246,6 +246,10 @@ instance_groups: properties: autoscaler: apiserver: + health: + port: &apiServerHealthPort 6206 + username: api_server + password: ((autoscaler_api_server_health_password)) public_api: server: port: &publicApiServerPort 6101 @@ -322,6 +326,13 @@ instance_groups: component: autoscaler_service_broker uris: - *servicebroker_public_domain + - name: autoscaler_api_server_health + registration_interval: 20s + port: *apiServerHealthPort + tags: + component: api_server + uris: + - ((deployment_name))-apiserver.((system_domain)) # Scheduler Instance Group - name: scheduler @@ -341,8 +352,7 @@ instance_groups: scheduler_db: *database scheduler: health: - port: &schedulerHealthPort 6202 - basicAuthEnabled: true + port: &schedulerHealthPort 6204 username: scheduler password: ((autoscaler_scheduler_health_password)) job_reschedule_interval_millisecond: 10000 @@ -709,6 +719,8 @@ variables: type: password options: length: 128 +- name: autoscaler_api_server_health_password + type: password - name: autoscaler_metricsforwarder_health_password type: password - name: autoscaler_metricsgateway_health_password