diff --git a/.github/workflows/testing-pr.yml b/.github/workflows/testing-pr.yml index 132092477..0549516e9 100644 --- a/.github/workflows/testing-pr.yml +++ b/.github/workflows/testing-pr.yml @@ -51,7 +51,6 @@ jobs: RELEASE=${RELEASE} make pipe-image-ci - name: Build and Push UI Image - continue-on-error: true # Workaround until the generation is fixed id: build-ui run: | cd ${{ github.workspace }} @@ -121,6 +120,7 @@ jobs: echo "Git hash: ${{ github.sha }}" echo ">>>>" + BRANCH=${{ github.event.pull_request.head.ref }} make bootstrap RELEASE=${RELEASE} make build-edgecluster-compact RELEASE=${RELEASE} make deploy-pipe-edgecluster-compact-ci diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48cf1804e..ccd89eb43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - files: \.(css|js|md|markdown|json) id: prettier repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.0 + rev: v2.7.1 - hooks: - id: seed-isort-config repo: https://github.com/asottile/seed-isort-config diff --git a/Makefile b/Makefile index 935efde4d..0f0ae7b09 100644 --- a/Makefile +++ b/Makefile @@ -193,10 +193,10 @@ clean-ci: kcli delete vm -y $(EDGE_NAME)-m0 $(EDGE_NAME)-m1 $(EDGE_NAME)-m2 $(EDGE_NAME)-w0; \ list=$$(tkn pr ls -n edgecluster-deployer |grep -i running | cut -d' ' -f1); \ for i in ${list}; do tkn pr cancel $${i} -n edgecluster-deployer; done; \ - oc delete --ignore-not-found=true managedcluster $(EDGE_NAME); \ list=$$($ oc get bmh -n $(EDGE_NAME) --no-headers|awk '{print $$1}'); \ for i in $${list}; do oc patch -n $(EDGE_NAME) bmh $${i} --type json -p '[ { "op": "remove", "path": "/metadata/finalizers" } ]'; done; \ list=$$(oc get secret -n $(EDGE_NAME) --no-headers |grep bmc|awk '{print $$1}'); \ for i in $${list}; do oc patch -n $(EDGE_NAME) secret $${i} --type json -p '[ { "op": "remove", "path": "/metadata/finalizers" } ]'; done; \ + oc delete --ignore-not-found=true managedcluster $(EDGE_NAME); \ oc delete --ignore-not-found=true ns $(EDGE_NAME); \ oc rollout restart -n openshift-machine-api deployment/metal3; diff --git a/deploy-disconnected-registry/common.sh b/deploy-disconnected-registry/common.sh index ac96f3836..e5966e781 100755 --- a/deploy-disconnected-registry/common.sh +++ b/deploy-disconnected-registry/common.sh @@ -75,16 +75,22 @@ function trust_internal_registry() { MYREGISTRY=$(oc --kubeconfig=${KBKNFG} get route -n ztpfw-registry ztpfw-registry-quay -o jsonpath='{.spec.host}') fi + export PATH_CA_CERT="/etc/pki/ca-trust/source/anchors/internal-registry-${clus}.crt" echo ">>>> Trusting internal registry: ${1}" echo ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>" echo ">> Kubeconfig: ${KBKNFG}" echo ">> Mode: ${1}" echo ">> Cluster: ${clus}" - ## Update trusted CA from Helper - #TODO after sync pull secret global because crictl can't use flags and uses the generic with https://access.redhat.com/solutions/4902871 - export CA_CERT_DATA=$(oc --kubeconfig=${KBKNFG} get secret -n openshift-ingress router-certs-default -o go-template='{{index .data "tls.crt"}}') - export PATH_CA_CERT="/etc/pki/ca-trust/source/anchors/internal-registry-${clus}.crt" - echo ">> Cert: ${PATH_CA_CERT}" + + if [[ ${CUSTOM_REGISTRY} == "false" ]]; then + ## Update trusted CA from Helper + #TODO after sync pull secret global because crictl can't use flags and uses the generic with https://access.redhat.com/solutions/4902871 + export CA_CERT_DATA=$(oc --kubeconfig=${KBKNFG} get secret -n openshift-ingress router-certs-default -o go-template='{{index .data "tls.crt"}}') + echo ">> Cert: ${PATH_CA_CERT}" + else + export CA_CERT_DATA=$(openssl s_client -connect ${LOCAL_REG} -showcerts "${PATH_CA_CERT}" @@ -116,7 +122,6 @@ source ${WORKDIR}/shared-utils/common.sh echo ">>>> Get the pull secret from hub to file pull-secret" echo ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>" -export REGISTRY=ztpfw-registry export AUTH_SECRET=../${SHARED_DIR}/htpasswd export REGISTRY_MANIFESTS=manifests export QUAY_MANIFESTS=quay-manifests @@ -139,7 +144,11 @@ if [[ ${1} == "hub" ]]; then export SOURCE_REGISTRY="quay.io" export SOURCE_INDEX="registry.redhat.io/redhat/redhat-operator-index:v${OC_OCP_VERSION_MIN}" export CERTIFIED_SOURCE_INDEX="registry.redhat.io/redhat/certified-operator-index:v${OC_OCP_VERSION_MIN}" - export DESTINATION_REGISTRY="$(oc --kubeconfig=${KUBECONFIG_HUB} get route -n ${REGISTRY} ${REGISTRY} -o jsonpath={'.status.ingress[0].host'})" + if [[ ${CUSTOM_REGISTRY} == "false" ]]; then + export DESTINATION_REGISTRY="$(oc --kubeconfig=${KUBECONFIG_HUB} get route -n ${REGISTRY} ${REGISTRY} -o jsonpath={'.status.ingress[0].host'})" + else + export DESTINATION_REGISTRY=${LOCAL_REG} + fi # OLM ## NS where the OLM images will be mirrored export OLM_DESTINATION_REGISTRY_IMAGE_NS=olm diff --git a/deploy-disconnected-registry/deploy.sh b/deploy-disconnected-registry/deploy.sh index 52b56782f..72f035d73 100755 --- a/deploy-disconnected-registry/deploy.sh +++ b/deploy-disconnected-registry/deploy.sh @@ -38,48 +38,6 @@ function extract_kubeconfig() { oc --kubeconfig=${KUBECONFIG_HUB} get secret -n $edgecluster $edgecluster-admin-kubeconfig -o jsonpath='{.data.kubeconfig}' | base64 -d >${EDGE_KUBECONFIG} } -function side_evict_error() { - - KUBEC=${1} - echo ">> Looking for eviction errors" - status='SchedulingDisabled' - - conflicting_node="$(oc --kubeconfig=${KUBEC} get node --no-headers | grep ${status} | cut -f1 -d\ )" - - if [[ -z ${conflicting_node} ]]; then - echo "No masters on ${status}" - else - conflicting_daemon_pod=$(oc --kubeconfig=${KUBEC} get pod -n openshift-machine-config-operator -o wide --no-headers | grep daemon | grep ${conflicting_node} | cut -f1 -d\ ) - - # Check if conflicting_daemon_pod is not empty - if [[ -z ${conflicting_daemon_pod} ]]; then - echo "No conflicting daemon pod exists in ${conflicting_node}" - else - pattern_1="$(oc --kubeconfig=${KUBEC} logs -n openshift-machine-config-operator ${conflicting_daemon_pod} -c machine-config-daemon | grep drain.go | grep evicting | tail -1 | grep pods)" - pattern_2="$(oc --kubeconfig=${KUBEC} logs -n openshift-machine-config-operator ${conflicting_daemon_pod} -c machine-config-daemon | grep drain.go | grep "Draining failed" | tail -1 | grep pod)" - - for log_entry in "${pattern_1}" "${pattern_2}"; do - if [[ -z ${log_entry} ]]; then - echo "No Conflicting LogEntry on ${conflicting_daemon_pod}" - else - echo ">> Conflicting LogEntry Found!!" - pod=$(echo ${log_entry##*pods/} | cut -d\" -f2) - conflicting_ns=$(oc --kubeconfig=${KUBEC} get pod -A | grep ${pod} | cut -f1 -d\ ) - - echo ">> Clean Eviction triggered info: " - echo NODE: ${conflicting_node} - echo DAEMON: ${conflicting_daemon_pod} - echo NS: ${conflicting_ns} - echo LOG: ${log_entry} - echo POD: ${pod} - - oc --kubeconfig=${KUBEC} delete pod -n ${conflicting_ns} ${pod} --force --grace-period=0 - fi - done - fi - fi -} - function check_mcp() { echo Mode: ${1} @@ -239,16 +197,30 @@ function deploy_registry() { } +function custom_registry() { + trust_internal_registry 'hub' + check_mcp 'hub' + render_file manifests/machine-config-certs-worker.yaml 'hub' + render_file manifests/machine-config-certs-master.yaml 'hub' + check_resource "mcp" "master" "Updated" "default" "${KUBECONFIG_HUB}" + +} + if [[ ${1} == 'hub' ]]; then + if ! ./verify.sh 'hub'; then - deploy_registry 'hub' - trust_internal_registry 'hub' - check_resource "deployment" "${REGISTRY}" "Available" "${REGISTRY}" "${KUBECONFIG_HUB}" - check_mcp 'hub' - render_file manifests/machine-config-certs-master.yaml 'hub' - render_file manifests/machine-config-certs-worker.yaml 'hub' - check_resource "mcp" "master" "Updated" "default" "${KUBECONFIG_HUB}" - check_resource "deployment" "${REGISTRY}" "Available" "${REGISTRY}" "${KUBECONFIG_HUB}" + if [[ ${CUSTOM_REGISTRY} == "false" ]]; then + deploy_registry 'hub' + trust_internal_registry 'hub' + check_resource "deployment" "${REGISTRY}" "Available" "${REGISTRY}" "${KUBECONFIG_HUB}" + check_mcp 'hub' + render_file manifests/machine-config-certs-worker.yaml 'hub' + render_file manifests/machine-config-certs-master.yaml 'hub' + check_resource "mcp" "master" "Updated" "default" "${KUBECONFIG_HUB}" + check_resource "deployment" "${REGISTRY}" "Available" "${REGISTRY}" "${KUBECONFIG_HUB}" + else + custom_registry 'hub' + fi else echo ">>>> This step to deploy registry on Hub is not neccesary, everything looks ready" fi diff --git a/deploy-disconnected-registry/ocp-sync.sh b/deploy-disconnected-registry/ocp-sync.sh index 51646e3d5..0059a3701 100755 --- a/deploy-disconnected-registry/ocp-sync.sh +++ b/deploy-disconnected-registry/ocp-sync.sh @@ -42,10 +42,15 @@ function mirror_ocp() { echo ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>" echo - ####### WORKAROUND: Newer versions of podman/buildah try to set overlayfs mount options when - ####### using the vfs driver, and this causes errors. - export STORAGE_DRIVER=vfs - sed -i '/^mountopt =.*/d' /etc/containers/storage.conf + if [[ ${CUSTOM_REGISTRY} == "true" ]]; then + echo "Checking Private registry creds" + if [[ ! $(podman login ${LOCAL_REG} --authfile ${PULL_SECRET}) ]]; then + echo "ERROR: Failed to login to ${LOCAL_REG}, please check Pull Secret" + exit 1 + else + echo "Login successfully to ${LOCAL_REG}" + fi + fi ####### # Empty log file @@ -82,10 +87,13 @@ if [[ ${1} == 'hub' ]]; then trust_internal_registry 'hub' if ! ./verify_ocp_sync.sh 'hub'; then - oc create namespace ${REGISTRY} -o yaml --dry-run=client | oc apply -f - - export REGISTRY_NAME="$(oc get route -n ${REGISTRY} ${REGISTRY} -o jsonpath={'.status.ingress[0].host'})" - ${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} # to create a merge with the registry original adding the registry auth entry + if [[ ${CUSTOM_REGISTRY} == "false" ]]; then + oc create namespace ${REGISTRY} -o yaml --dry-run=client | oc apply -f - + # TODO: commented out the next line seems not needed + # export REGISTRY_NAME="$(oc get route -n ${REGISTRY} ${REGISTRY} -o jsonpath={'.status.ingress[0].host'})" + fi + registry_login ${DESTINATION_REGISTRY} mirror_ocp 'hub' 'hub' else echo ">>>> This step to mirror ocp is not neccesary, everything looks ready: ${1}" diff --git a/deploy-disconnected-registry/olm-sync.sh b/deploy-disconnected-registry/olm-sync.sh index 7980a3471..eb10ee0a0 100755 --- a/deploy-disconnected-registry/olm-sync.sh +++ b/deploy-disconnected-registry/olm-sync.sh @@ -41,10 +41,14 @@ function prepare_env() { function check_registry() { REG=${1} + if [[ ${CUSTOM_REGISTRY} == "true" ]]; then + COMMAND="" + else + COMMAND="--username ${REG_US} --password ${REG_PASS}" + fi for a in {1..30}; do - skopeo login ${REG} --authfile=${PULL_SECRET} --username ${REG_US} --password ${REG_PASS} - if [[ $? -eq 0 ]]; then + if [[ $(skopeo login ${REG} --authfile=${PULL_SECRET} ${COMMAND}) ]]; then echo "Registry: ${REG} available" break fi @@ -67,11 +71,9 @@ function mirror() { fi echo ">>>> Podman Login into Source Registry: ${SOURCE_REGISTRY}" - ${PODMAN_LOGIN_CMD} ${SOURCE_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} - ${PODMAN_LOGIN_CMD} ${SOURCE_REGISTRY} -u ${REG_US} -p ${REG_PASS} + registry_login ${SOURCE_REGISTRY} echo ">>>> Podman Login into Destination Registry: ${DESTINATION_REGISTRY}" - ${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} - ${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} + registry_login ${DESTINATION_REGISTRY} if [ ! -f ~/.docker/config.json ]; then echo "INFO: missing ~/.docker/config.json config" @@ -256,11 +258,9 @@ function mirror_certified() { fi echo ">>>> Podman Login into Source Registry: ${SOURCE_REGISTRY}" - ${PODMAN_LOGIN_CMD} ${SOURCE_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} - ${PODMAN_LOGIN_CMD} ${SOURCE_REGISTRY} -u ${REG_US} -p ${REG_PASS} + registry_login ${SOURCE_REGISTRY} echo ">>>> Podman Login into Destination Registry: ${DESTINATION_REGISTRY}" - ${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} - ${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} + registry_login ${DESTINATION_REGISTRY} if [ ! -f ~/.docker/config.json ]; then echo "INFO: missing ~/.docker/config.json config" diff --git a/deploy-disconnected-registry/update-global-pullsecret.sh b/deploy-disconnected-registry/update-global-pullsecret.sh index 443cbccd7..8224d574d 100755 --- a/deploy-disconnected-registry/update-global-pullsecret.sh +++ b/deploy-disconnected-registry/update-global-pullsecret.sh @@ -42,7 +42,7 @@ function prepare_env() { if [[ ${1} == 'hub' ]]; then prepare_env 'hub' - ${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} + registry_login ${DESTINATION_REGISTRY} oc --kubeconfig=${KUBECONFIG_HUB} set data secret/pull-secret -n openshift-config --from-file=.dockerconfigjson=${PULL_SECRET} elif [[ ${1} == "edgecluster" ]]; then diff --git a/deploy-disconnected-registry/verify.sh b/deploy-disconnected-registry/verify.sh index 88804c6c5..95c74cc5e 100755 --- a/deploy-disconnected-registry/verify.sh +++ b/deploy-disconnected-registry/verify.sh @@ -19,13 +19,18 @@ elif [[ ${1} == 'edgecluster' ]]; then TG_KUBECONFIG=${EDGE_KUBECONFIG} fi -if [[ $(oc --kubeconfig=${TG_KUBECONFIG} get ns | grep ${REGISTRY} | wc -l) -eq 0 || $(oc --kubeconfig=${TG_KUBECONFIG} get -n ztpfw-registry deployment ztpfw-registry -ojsonpath='{.status.availableReplicas}') -eq 0 ]]; then - #namespace or resources does not exist. Launching the step to create it... - exit 1 -fi +if [[ ${CUSTOM_REGISTRY} == "false" ]]; then + if [[ $(oc --kubeconfig=${TG_KUBECONFIG} get ns | grep ${REGISTRY} | wc -l) -eq 0 || $(oc --kubeconfig=${TG_KUBECONFIG} get -n ztpfw-registry deployment ztpfw-registry -ojsonpath='{.status.availableReplicas}') -eq 0 ]]; then + #namespace or resources does not exist. Launching the step to create it... + exit 1 + fi -if [[ $(oc get --kubeconfig=${TG_KUBECONFIG} route -n ${REGISTRY} --no-headers | wc -l) -lt 1 ]]; then - exit 2 + if [[ $(oc get --kubeconfig=${TG_KUBECONFIG} route -n ${REGISTRY} --no-headers | wc -l) -lt 1 ]]; then + exit 2 + fi +else + # Running with Custom registry + exit 10 fi exit 0 diff --git a/deploy-disconnected-registry/verify_ocp_sync.sh b/deploy-disconnected-registry/verify_ocp_sync.sh index a166f7d2f..4784d3690 100755 --- a/deploy-disconnected-registry/verify_ocp_sync.sh +++ b/deploy-disconnected-registry/verify_ocp_sync.sh @@ -19,8 +19,7 @@ elif [[ ${1} == 'edgecluster' ]]; then TARGET_KUBECONFIG=${EDGE_KUBECONFIG} fi -echo "Logging into ${DESTINATION_REGISTRY}" -${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} # to create a merge with the registry original adding the registry auth entry +registry_login ${DESTINATION_REGISTRY} if [[ $(oc --kubeconfig=${TARGET_KUBECONFIG} adm release info "${DESTINATION_REGISTRY}"/"${OCP_DESTINATION_REGISTRY_IMAGE_NS}":"${OC_OCP_TAG}" --registry-config="${PULL_SECRET}" | wc -l) -gt 1 ]]; then ## line 1 == error line. If found image should show more information (>1 line) #Everyting is ready exit 0 diff --git a/deploy-disconnected-registry/verify_olm_sync.sh b/deploy-disconnected-registry/verify_olm_sync.sh index 704d1097f..f5caa263c 100755 --- a/deploy-disconnected-registry/verify_olm_sync.sh +++ b/deploy-disconnected-registry/verify_olm_sync.sh @@ -41,7 +41,7 @@ elif [[ ${1} == 'edgecluster' ]]; then fi echo ">>>> Verifying OLM Sync: ${1}" -${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} +registry_login ${DESTINATION_REGISTRY} for packagemanifest in $(oc --kubeconfig=${TGT_KUBECONFIG} get packagemanifest -n openshift-marketplace -o name ${PACKAGES_FORMATED}); do for package in $(oc --kubeconfig=${TGT_KUBECONFIG} get ${packagemanifest} -o jsonpath='{.status.channels[*].currentCSVDesc.relatedImages}' | sed "s/ /\n/g" | tr -d '[],' | sed 's/"/ /g'); do echo "Verify Package: ${package}" @@ -51,7 +51,7 @@ for packagemanifest in $(oc --kubeconfig=${TGT_KUBECONFIG} get packagemanifest - done echo ">>>> Verifying Certified OLM Sync: ${1}" -${PODMAN_LOGIN_CMD} ${DESTINATION_REGISTRY} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} +registry_login ${DESTINATION_REGISTRY} for packagemanifest in $(oc --kubeconfig=${TGT_KUBECONFIG} get packagemanifest -n openshift-marketplace -o name ${CERTIFIED_PACKAGES_FORMATED}); do for package in $(oc --kubeconfig=${TGT_KUBECONFIG} get ${packagemanifest} -o jsonpath='{.status.channels[*].currentCSVDesc.relatedImages}' | sed "s/ /\n/g" | tr -d '[],' | sed 's/"/ /g'); do echo "Verify Package: ${package}" diff --git a/deploy-edgecluster/configure_disconnected.sh b/deploy-edgecluster/configure_disconnected.sh index ed1f3b883..64683edf2 100755 --- a/deploy-edgecluster/configure_disconnected.sh +++ b/deploy-edgecluster/configure_disconnected.sh @@ -170,44 +170,11 @@ function icsp_maker() { RAW_SRC=${entry%%=*} RAW_DST=${entry##*=} SRC_IMG="${RAW_SRC%%@*}" - DST_IMG="${RAW_DST%%:*}" + DST_IMG="${RAW_DST}" add_icsp_entry ${SRC_IMG} ${DST_IMG} done <${MAP_FILE} } -function side_evict_error() { - - KUBEC=${1} - echo ">> Looking for eviction errors" - pattern='SchedulingDisabled' - - conflicting_node="$(oc --kubeconfig=${KUBEC} get node --no-headers | grep ${pattern} | cut -f1 -d\ )" - - if [[ -z ${conflicting_node} ]]; then - echo "No masters on ${pattern}" - else - conflicting_daemon_pod=$(oc --kubeconfig=${KUBEC} get pod -n openshift-machine-config-operator -o wide --no-headers | grep daemon | grep ${conflicting_node} | cut -f1 -d\ ) - log_entry="$(oc --kubeconfig=${KUBEC} logs -n openshift-machine-config-operator ${conflicting_daemon_pod} -c machine-config-daemon | grep drain.go | grep evicting | tail -1 | grep pods)" - - if [[ -z ${log_entry} ]]; then - echo "No Conflicting LogEntry on ${conflicting_daemon_pod}" - else - echo ">> Conflicting LogEntry Found!!" - pod=$(echo ${log_entry##*pods/} | cut -d\" -f2) - conflicting_ns=$(oc --kubeconfig=${KUBEC} get pod -A | grep ${pod} | cut -f1 -d\ ) - - echo ">> Clean Eviction triggered info: " - echo NODE: ${conflicting_node} - echo DAEMON: ${conflicting_daemon_pod} - echo NS: ${conflicting_ns} - echo LOG: ${log_entry} - echo POD: ${pod} - - oc --kubeconfig=${KUBEC} delete pod -n ${conflicting_ns} ${pod} - fi - fi -} - function wait_for_mcp_ready() { # This function waits for the MCP to be ready # It will wait for the MCP to be ready for the given number of seconds diff --git a/deploy-hub-configs/deploy.sh b/deploy-hub-configs/deploy.sh index c5829c764..a417a0bbc 100755 --- a/deploy-hub-configs/deploy.sh +++ b/deploy-hub-configs/deploy.sh @@ -22,12 +22,21 @@ if ./verify.sh; then sed -i "s/HTTPD_SERVICE/${HTTPSERVICE}/g" 04-agent-service-config.yml pull=$(oc get secret -n openshift-config pull-secret -ojsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq -c) echo -n " .dockerconfigjson: "\'$pull\' >>05-pullsecrethub.yml - REGISTRY=ztpfw-registry - LOCAL_REG="$(oc get route -n ${REGISTRY} ${REGISTRY} -o jsonpath={'.status.ingress[0].host'})" #TODO change it to use the global common variable importing here the source commons + if [[ ${CUSTOM_REGISTRY} == "false" ]]; then + REGISTRY=ztpfw-registry + LOCAL_REG="$(oc get route -n ${REGISTRY} ${REGISTRY} -o jsonpath={'.status.ingress[0].host'})" #TODO change it to use the global common variable importing here the source commons + fi sed -i "s/CHANGEDOMAIN/${LOCAL_REG}/g" registryconf.txt - CABUNDLE=$(oc get cm -n openshift-image-registry kube-root-ca.crt --template='{{index .data "ca.crt"}}') - echo " ca-bundle.crt: |" >>01_Mirror_ConfigMap.yml - echo -n "${CABUNDLE}" | sed "s/^/ /" >>01_Mirror_ConfigMap.yml + if [[ ${CUSTOM_REGISTRY} == "true" ]]; then + export CA_CERT_DATA=$(openssl s_client -connect ${LOCAL_REG} -showcerts >01_Mirror_ConfigMap.yml + echo " ca-bundle.crt: |" >>01_Mirror_ConfigMap.yml + echo -n "${CA_CERT_DATA}" | sed "s/^/ /" >>01_Mirror_ConfigMap.yml + else + CABUNDLE=$(oc get cm -n openshift-image-registry kube-root-ca.crt --template='{{index .data "ca.crt"}}') + echo " ca-bundle.crt: |" >>01_Mirror_ConfigMap.yml + echo -n "${CABUNDLE}" | sed "s/^/ /" >>01_Mirror_ConfigMap.yml + fi echo "" >>01_Mirror_ConfigMap.yml cat registryconf.txt >>01_Mirror_ConfigMap.yml NEWTAG=${LOCAL_REG}/ocp4/openshift4:${OC_OCP_TAG} diff --git a/deploy-ui/manifests/deployment.yaml b/deploy-ui/manifests/deployment.yaml index 4cd07ba0e..bea24c3ef 100644 --- a/deploy-ui/manifests/deployment.yaml +++ b/deploy-ui/manifests/deployment.yaml @@ -35,15 +35,14 @@ spec: value: "3000" - name: FRONTEND_URL value: "$UI_APP_URL" - # Let's compose it via env variables - #- name: CLUSTER_API_URL - # value: https://kubernetes.default.svc:443 - name: OAUTH2_CLIENT_ID value: "ztpfwoauth" - name: OAUTH2_REDIRECT_URL value: "$UI_APP_URL/login/callback" - name: OAUTH2_CLIENT_SECRET value: "ztpfwoauthsecret" + - name: API_LOGGING_ENABLED + value: "false" # or "true" livenessProbe: failureThreshold: 1 httpGet: diff --git a/documentation/ztp-for-factories/images/225_OpenShift_Installing_Clusters_0422_network.png b/documentation/ztp-for-factories/images/225_OpenShift_Installing_Clusters_0422_network.png index 83b2ab8ff..ce45eb996 100644 Binary files a/documentation/ztp-for-factories/images/225_OpenShift_Installing_Clusters_0422_network.png and b/documentation/ztp-for-factories/images/225_OpenShift_Installing_Clusters_0422_network.png differ diff --git a/examples/config.yaml b/examples/config.yaml index 315700b3d..21f43323b 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -2,6 +2,8 @@ config: OC_OCP_VERSION: "4.10.6" OC_ACM_VERSION: "2.4" OC_ODF_VERSION: "4.9" + # optional use your own registry + REGISTRY: "myregistry.domain.local:5000" edgeclusters: - edgecluster1-name: diff --git a/images/Containerfile.UI b/images/Containerfile.UI index 6b4ff69a5..63ad7eed8 100644 --- a/images/Containerfile.UI +++ b/images/Containerfile.UI @@ -11,10 +11,15 @@ RUN curl -L --output /yarn.rpm $(cat /yarn.url) RUN dnf install -y /yarn.rpm WORKDIR /app + COPY ./ui/frontend ./frontend COPY ./ui/backend ./backend COPY ./ui/package.json ./ui/yarn.lock ./ +# just to get latest SHA at build time +COPY ./.git ./.git +RUN cd ./frontend ; yarn get-sha ; cd ../ ; rm -rf ./git + RUN yarn clean # Reduce flakiness. The NPM registry network operations can fail sometime ... RUN yarn install || (sleep 5 ; yarn install) || (sleep 5 ; yarn install) diff --git a/images/Containerfile.pipeline b/images/Containerfile.pipeline index 7243e6a93..cd989db69 100644 --- a/images/Containerfile.pipeline +++ b/images/Containerfile.pipeline @@ -10,7 +10,7 @@ RUN curl -k -s https://mirror.openshift.com/pub/openshift-v4/clients/ocp/latest/ curl -k -s https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/latest/opm-linux.tar.gz | tar xvz -C /usr/bin && \ chmod +x /usr/bin/oc /usr/bin/opm /usr/bin/kubectl /usr/bin/jq /usr/bin/yq -RUN dnf install -y bind-utils openssh-clients httpd-tools conmon skopeo podman gettext fuse-overlayfs iputils nmap-ncat --setopt=install_weak_deps=False && \ +RUN dnf install -y bind-utils openssl openssh-clients httpd-tools conmon skopeo podman gettext fuse-overlayfs iputils nmap-ncat --setopt=install_weak_deps=False && \ dnf clean all && rm -rf /var/cache/yum COPY . /opt/ztp diff --git a/shared-utils/common.sh b/shared-utils/common.sh index 979eecb8f..acad62677 100755 --- a/shared-utils/common.sh +++ b/shared-utils/common.sh @@ -3,6 +3,19 @@ # EDGECLUSTERS_FILE variable must be exported in the environment #set -x +function registry_login() { + ####### WORKAROUND: Newer versions of podman/buildah try to set overlayfs mount options when + ####### using the vfs driver, and this causes errors. + export STORAGE_DRIVER=vfs + sed -i '/^mountopt =.*/d' /etc/containers/storage.conf + + if [[ ${CUSTOM_REGISTRY} == "true" ]]; then + ${PODMAN_LOGIN_CMD} ${1} --authfile=${PULL_SECRET} + else + ${PODMAN_LOGIN_CMD} ${1} -u ${REG_US} -p ${REG_PASS} --authfile=${PULL_SECRET} + ${PODMAN_LOGIN_CMD} ${1} -u ${REG_US} -p ${REG_PASS} + fi +} function check_resource() { # 1 - Resource type: "deployment" @@ -251,6 +264,48 @@ function grab_api_ingress() { export EDGE_INGRESS_IP="$(dig @${HUB_NODE_IP} +short ${REGISTRY_URL}.${EDGE_INGRESS_NAME})" } +function side_evict_error() { + + KUBEC=${1} + echo ">> Looking for eviction errors" + status='SchedulingDisabled' + + conflicting_node="$(oc --kubeconfig=${KUBEC} get node --no-headers | grep ${status} | cut -f1 -d\ )" + + if [[ -z ${conflicting_node} ]]; then + echo "No masters on ${status}" + else + conflicting_daemon_pod=$(oc --kubeconfig=${KUBEC} get pod -n openshift-machine-config-operator -o wide --no-headers | grep daemon | grep ${conflicting_node} | cut -f1 -d\ ) + + # Check if conflicting_daemon_pod is not empty + if [[ -z ${conflicting_daemon_pod} ]]; then + echo "No conflicting daemon pod exists in ${conflicting_node}" + else + pattern_1="$(oc --kubeconfig=${KUBEC} logs -n openshift-machine-config-operator ${conflicting_daemon_pod} -c machine-config-daemon | grep drain.go | grep evicting | tail -1 | grep pods)" + pattern_2="$(oc --kubeconfig=${KUBEC} logs -n openshift-machine-config-operator ${conflicting_daemon_pod} -c machine-config-daemon | grep drain.go | grep "Draining failed" | tail -1 | grep pod)" + + for log_entry in "${pattern_1}" "${pattern_2}"; do + if [[ -z ${log_entry} ]]; then + echo "No Conflicting LogEntry on ${conflicting_daemon_pod}" + else + echo ">> Conflicting LogEntry Found!!" + pod=$(echo ${log_entry##*pods/} | cut -d\" -f2) + conflicting_ns=$(oc --kubeconfig=${KUBEC} get pod -A | grep ${pod} | cut -f1 -d\ ) + + echo ">> Clean Eviction triggered info: " + echo NODE: ${conflicting_node} + echo DAEMON: ${conflicting_daemon_pod} + echo NS: ${conflicting_ns} + echo LOG: ${log_entry} + echo POD: ${pod} + + oc --kubeconfig=${KUBEC} delete pod -n ${conflicting_ns} ${pod} --force --grace-period=0 + fi + done + fi + fi +} + # EDGECLUSTERS_FILE variable must be exported in the environment export KUBECONFIG_HUB=${KUBECONFIG} @@ -318,3 +373,13 @@ if [[ -n ${PRESERVE_SECRET:-false} ]]; then fi export ALLEDGECLUSTERS=$(yq e '(.edgeclusters[] | keys)[]' ${EDGECLUSTERS_FILE}) + +export EDGECLUSTERS_REGISTRY=$(yq eval ".config.REGISTRY" ${EDGECLUSTERS_FILE} || null) +if [[ ${EDGECLUSTERS_REGISTRY} == "" || ${EDGECLUSTERS_REGISTRY} == null ]]; then + export CUSTOM_REGISTRY=false + export REGISTRY=ztpfw-registry +else + export CUSTOM_REGISTRY=true + REGISTRY=$(echo ${EDGECLUSTERS_REGISTRY} | cut -d"." -f1) + LOCAL_REG=${EDGECLUSTERS_REGISTRY} +fi diff --git a/ui/backend/package.json b/ui/backend/package.json index 9a84a3f91..e25038651 100644 --- a/ui/backend/package.json +++ b/ui/backend/package.json @@ -31,7 +31,7 @@ "@types/node-fetch": "2.x", "@typescript-eslint/eslint-plugin": "^5.10.1", "@typescript-eslint/parser": "^5.10.1", - "eslint": "^8.7.0", + "eslint": "^8.18.0", "prettier": "^2.5.1", "ts-node-dev": "^1.1.8", "typescript": "^4.5.5" diff --git a/ui/backend/src/common/domains.ts b/ui/backend/src/common/domains.ts new file mode 100644 index 000000000..6b06627d3 --- /dev/null +++ b/ui/backend/src/common/domains.ts @@ -0,0 +1,12 @@ +export const ZTPFW_UI_ROUTE_PREFIX = 'edge-cluster-setup'; +export const OAUTH_ROUTE_PREFIX = 'oauth-openshift'; +export const OAUTH_NAMESPACE = 'openshift-authentication'; + +export const getApiDomain = (domain: string) => `api.${domain}`; +export const getIngressDomain = (domain: string) => `apps.${domain}`; + +export const getConsoleDomain = (domain: string) => + `console-openshift-console.${getIngressDomain(domain)}`; +export const getOauthDomain = (domain: string) => `oauth-openshift.${getIngressDomain(domain)}`; +export const getZtpfwDomain = (domain: string) => + `${ZTPFW_UI_ROUTE_PREFIX}.${getIngressDomain(domain)}`; diff --git a/ui/backend/src/common/index.ts b/ui/backend/src/common/index.ts index 4661fc58f..16118554f 100644 --- a/ui/backend/src/common/index.ts +++ b/ui/backend/src/common/index.ts @@ -1,3 +1,4 @@ export * from './types'; export * from './validation'; export * from './resources'; +export * from './domains'; diff --git a/ui/backend/src/common/resources/ingress.ts b/ui/backend/src/common/resources/ingress.ts index 1d3c8c7f3..04b8c25aa 100644 --- a/ui/backend/src/common/resources/ingress.ts +++ b/ui/backend/src/common/resources/ingress.ts @@ -1,3 +1,4 @@ +import { OAUTH_NAMESPACE, OAUTH_ROUTE_PREFIX } from '../domains'; import { Metadata } from './metadata'; import { IResource } from './resource'; @@ -26,4 +27,30 @@ export interface Ingress extends IResource { domain?: string; componentRoutes?: ComponentRoute[]; }; + status?: { + componentRoutes?: { + name: string; + namespace: string; + defaultHostname?: string; + currentHostnames?: string[]; + }[]; + }; } + +export const getDomainFromPrefix = (prefix: string, domain?: string) => { + let result = domain?.trim(); + if (result?.startsWith(prefix)) { + result = result.substring(prefix.length); + } + return result; +}; + +export const getClusterDomainFromComponentRoutes = (ingress?: Ingress) => { + const defaultDomain = getDomainFromPrefix('apps.', ingress?.spec?.domain); + + const currentHostnames = ingress?.status?.componentRoutes?.find( + (cr) => cr.name === OAUTH_ROUTE_PREFIX && cr.namespace === OAUTH_NAMESPACE, + )?.currentHostnames; + + return getDomainFromPrefix(`${OAUTH_ROUTE_PREFIX}.apps.`, currentHostnames?.[0]) || defaultDomain; +}; diff --git a/ui/backend/src/common/types.ts b/ui/backend/src/common/types.ts index 12685aa22..19ebf6a87 100644 --- a/ui/backend/src/common/types.ts +++ b/ui/backend/src/common/types.ts @@ -1,4 +1,14 @@ export type TlsCertificate = { 'tls.crt': string; 'tls.key': string; + + 'tls.crt.filename'?: string; + 'tls.key.filename'?: string; +}; + +export type ChangeDomainInputType = { + clusterDomain?: string; + customCerts?: { + [key: /* ~ domain */ string]: TlsCertificate; + }; }; diff --git a/ui/backend/src/constants.ts b/ui/backend/src/constants.ts index 45e0f2954..6b70a834e 100644 --- a/ui/backend/src/constants.ts +++ b/ui/backend/src/constants.ts @@ -2,8 +2,6 @@ export const TLS_SECRET_NAMESPACE = 'openshift-config'; // Route-prefix for this application. // TODO: Make it dynamic, this might vary over deployments -export const ZTPFW_UI_ROUTE_PREFIX = 'edge-cluster-setup'; -export const OAUTH_ROUTE_PREFIX = 'oauth-openshift'; export const ZTPFW_ROUTE_NAME = 'ztpfw-ui'; export const ZTPFW_DEPLOYMENT_NAME = 'ztpfw-ui'; diff --git a/ui/backend/src/endpoints/changeDomain.ts b/ui/backend/src/endpoints/changeDomain.ts index 57d35c3c2..02e7ea513 100644 --- a/ui/backend/src/endpoints/changeDomain.ts +++ b/ui/backend/src/endpoints/changeDomain.ts @@ -1,12 +1,23 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ import { Request, Response } from 'express'; -import { Route } from '../common'; +import { + Route, + ChangeDomainInputType, + getApiDomain, + getIngressDomain, + TlsCertificate, + getConsoleDomain, + getOauthDomain, + getZtpfwDomain, + ZTPFW_UI_ROUTE_PREFIX, + OAUTH_NAMESPACE, + getClusterDomainFromComponentRoutes, +} from '../common'; import { ZTPFW_DEPLOYMENT_NAME, ZTPFW_NAMESPACE, ZTPFW_OAUTHCLIENT_NAME, ZTPFW_ROUTE_NAME, - ZTPFW_UI_ROUTE_PREFIX, } from '../constants'; import { DNS_NAME_REGEX, PatchType, ComponentRoute } from '../frontend-shared'; import { getToken, PostResponse, unauthorized } from '../k8s'; @@ -21,15 +32,24 @@ import { validateInput } from './utils'; const logger = console; -const createSelfSignedTlsSecret = async ( +const createTlsSecret = async ( res: Response, token: string, domain: string, namePrefix: string, + customCerts: ChangeDomainInputType['customCerts'] = {}, ): Promise => { - const certificate = await generateCertificate(res, domain); - if (!certificate) { - return undefined; + let certificate: TlsCertificate | undefined = customCerts[domain]; + + // if not provided, so generate self-signed one + if (certificate) { + logger.debug('Custom certificate provided for domain: ', domain); + } else { + certificate = await generateCertificate(res, domain); + + if (!certificate) { + return undefined; + } } const certSecret = await createCertSecret(res, token, namePrefix, certificate); @@ -43,6 +63,7 @@ const createSelfSignedTlsSecret = async ( const updateIngressComponentRoutes = async ( res: Response, token: string, + customCerts: ChangeDomainInputType['customCerts'], componentRoutes: ComponentRoute[], // Following type will not be hardcoded forever, the ZTPFW hostname might varry over different deployments routeName: 'console' | 'oauth-openshift' | 'edge-cluster-setup', @@ -50,7 +71,8 @@ const updateIngressComponentRoutes = async ( namePrefix: string, routeNamespace: string, ): Promise => { - const secretName = await createSelfSignedTlsSecret(res, token, domain, namePrefix); + logger.debug(`updateIngressComponentRoutes called for ${routeName} and domain ${domain}`); + const secretName = await createTlsSecret(res, token, domain, namePrefix, customCerts); if (secretName) { const route = componentRoutes.find((r) => r.name === routeName); if (route) { @@ -81,22 +103,20 @@ const updateIngressComponentRoutes = async ( const updateSingleRoute = async ( token: string, - oldIngressDomain: string, - ingressDomain: string, + ztpfwDomain: string, route: Route, ): Promise | void> => { if (route.spec?.host) { - const newHost = route.spec.host.replace(oldIngressDomain, ingressDomain); - if (newHost === route.spec.host) { + if (ztpfwDomain === route.spec.host) { logger.debug( - `No change for the ${route.metadata.namespace}/${route.metadata.name} route, keeping host: "${newHost}".`, + `No change for the ${route.metadata.namespace}/${route.metadata.name} route, keeping host: "${route.spec.host}".`, ); } else { const patch: PatchType[] = [ { op: 'replace', path: '/spec/host', - value: newHost, + value: ztpfwDomain, }, ]; @@ -107,7 +127,7 @@ const updateSingleRoute = async ( patch, ).then((r) => { logger.debug( - `Route ${route.metadata.namespace}/${route.metadata.name} is patched, new host: ${newHost}`, + `Route ${route.metadata.namespace}/${route.metadata.name} is patched, new host: ${ztpfwDomain}`, ); return r; }); @@ -212,13 +232,7 @@ const updateZtpfwDeployment = async ( } }; -const updateZtpfwUI = async ( - token: string, - // ztpfwUiTlsSecretName: string, - ztpfwDomain: string, - ingressDomain: string, - oldIngressDomain = '', -) => { +const updateZtpfwUI = async (token: string, ztpfwDomain: string) => { // route try { const route = await getRoute(token, { name: ZTPFW_ROUTE_NAME, namespace: ZTPFW_NAMESPACE }); @@ -226,7 +240,7 @@ const updateZtpfwUI = async ( // Make a copy to be able to make livenessProbe requests from browser (new route hots CORS issue) await backupRoute(token, route); - await updateSingleRoute(token, oldIngressDomain, ingressDomain, route); + await updateSingleRoute(token, ztpfwDomain, route); logger.debug('ZTPFW UI Route patched'); } catch (e) { logger.error('Failed to patch ZTPFW UI Route: ', e); @@ -239,6 +253,26 @@ const updateZtpfwUI = async ( await updateZtpfwDeployment(token, ztpfwDomain, newOauthRedirectUri); }; +const isDomainChanged = ( + res: Response, + apiDomain: string, + newDomain: string, + oldDomain?: string, +) => { + if (oldDomain === newDomain) { + logger.info( + 'Domain stays unchanged (based on the Ingress config), skipping persistence of it.', + ); + res.writeHead(200).end(); // All good + return false; + } + logger.debug( + `About to change domain from "${oldDomain}" to "${newDomain}" (api: "${apiDomain}")`, + ); + + return true; +}; + /** * Will perform cluster domain change. * Intentionally executed on the backend to decrease risks of network issues during the complex flow. @@ -267,36 +301,37 @@ const updateZtpfwUI = async ( * - side-effect: our pod is terminated (consequence of the Deployment resource change) * */ -const changeDomainImpl = async (res: Response, token: string, _domain?: string): Promise => { - const domain = validateInput(DNS_NAME_REGEX, _domain); - logger.debug('ChangeDomain endpoint called, domain:', domain); +const changeDomainImpl = async ( + res: Response, + token: string, + input: ChangeDomainInputType, +): Promise => { + const clusterDomain = validateInput(DNS_NAME_REGEX, input.clusterDomain); + logger.debug('ChangeDomain endpoint called, domain:', clusterDomain); - if (!domain) { + if (!clusterDomain) { res.writeHead(422).end(); return; } - /* TODO: avoid auto-generating of self-signed certificates if the user has provided them */ - const ingress = await getIngressConfig(token); - const oldIngressDomain = ingress.spec?.domain; - const apiDomain = `api.${domain}`; - const ingressDomain = `apps.${domain}`; + const oldIngressDomain = getIngressDomain(getClusterDomainFromComponentRoutes(ingress) || ''); + const apiDomain = getApiDomain(clusterDomain); + const ingressDomain = getIngressDomain(clusterDomain); - if (ingressDomain === ingress?.spec?.domain) { - logger.info( - 'Domain stays unchanged (based on the Ingress config), skipping persistence of it.', - ); - res.writeHead(200).end(); // All good + if (!isDomainChanged(res, apiDomain, ingressDomain, oldIngressDomain)) { return; } - logger.debug( - `About to change domain from "${oldIngressDomain}" to "${ingressDomain}" (api: "${apiDomain}")`, - ); // Prepare patch to change API certificate (apiserver/cluster resource) - will be executed at the end of the flow - const apiCertSecretName = await createSelfSignedTlsSecret(res, token, apiDomain, 'api-secret-'); + const apiCertSecretName = await createTlsSecret( + res, + token, + apiDomain, + 'api-secret-', + input.customCerts, + ); if (!apiCertSecretName) { return; } @@ -314,14 +349,15 @@ const changeDomainImpl = async (res: Response, token: string, _domain?: string): }; // Prepare ingress /cluster resource patch - will be executed at the end of the flow - const consoleDomain = `console-openshift-console.${ingressDomain}`; - const oauthDomain = `oauth-openshift.${ingressDomain}`; - const ztpfwDomain = `${ZTPFW_UI_ROUTE_PREFIX}.${ingressDomain}`; + const consoleDomain = getConsoleDomain(clusterDomain); + const oauthDomain = getOauthDomain(clusterDomain); + const ztpfwDomain = getZtpfwDomain(clusterDomain); const componentRoutes = ingress?.spec?.componentRoutes || []; const consoleTlsSecretName = await updateIngressComponentRoutes( res, token, + input.customCerts, componentRoutes, 'console', consoleDomain, @@ -331,15 +367,17 @@ const changeDomainImpl = async (res: Response, token: string, _domain?: string): const oauthTlsSecretName = await updateIngressComponentRoutes( res, token, + input.customCerts, componentRoutes, 'oauth-openshift', oauthDomain, 'oauth-secret-', - 'openshift-authentication', + OAUTH_NAMESPACE, ); const ztpfwUiTlsSecretName = await updateIngressComponentRoutes( res, token, + input.customCerts, componentRoutes, ZTPFW_UI_ROUTE_PREFIX, ztpfwDomain, @@ -353,11 +391,11 @@ const changeDomainImpl = async (res: Response, token: string, _domain?: string): } const ingressPatches: PatchType[] = [ - { - op: ingress?.spec?.domain ? 'replace' : 'add', - path: '/spec/domain', - value: ingressDomain, - }, + // { Do not change domain but add a new one + // op: ingress?.spec?.domain ? 'replace' : 'add', + // path: '/spec/domain', + // value: ingressDomain, + // }, { op: ingress?.spec?.componentRoutes ? 'replace' : 'add', path: '/spec/componentRoutes', @@ -404,12 +442,7 @@ const changeDomainImpl = async (res: Response, token: string, _domain?: string): } // This will terminate our pod - await updateZtpfwUI( - token, - /*ztpfwUiTlsSecretName,*/ ztpfwDomain, - ingressDomain, - oldIngressDomain, - ); + await updateZtpfwUI(token, ztpfwDomain); res.writeHead(200).end(); // All good }; @@ -428,8 +461,8 @@ export function changeDomain(req: Request, res: Response): void { .on('end', async () => { try { const data: string = Buffer.concat(body).toString(); - const encoded = JSON.parse(data) as { domain?: string }; - await changeDomainImpl(res, token, encoded?.domain); + const encoded = JSON.parse(data) as ChangeDomainInputType; + await changeDomainImpl(res, token, encoded); } catch (e) { logger.error('Failed to parse input for changeDomain'); res.writeHead(422).end(); diff --git a/ui/backend/src/endpoints/generateCertificate.ts b/ui/backend/src/endpoints/generateCertificate.ts index d1fa8bf7e..d4bab34e3 100644 --- a/ui/backend/src/endpoints/generateCertificate.ts +++ b/ui/backend/src/endpoints/generateCertificate.ts @@ -24,9 +24,9 @@ export const generateCertificate = async ( const delimiter = '-----generateCertificateDelimiter-----'; try { - const { stdout: _stdout } = await exec( - `/usr/bin/openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout ${keyFile} -out ${certFile} -subj "/CN=${domain}" -addext "subjectAltName = DNS:${domain}" && cat ${keyFile} && echo ${delimiter} && cat ${certFile}`, - ); + const command = `/usr/bin/openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout ${keyFile} -out ${certFile} -subj "/CN=${domain}" -addext "subjectAltName = DNS:${domain}" && cat ${keyFile} && echo ${delimiter} && cat ${certFile}`; + logger.debug('Executing: ', command); + const { stdout: _stdout } = await exec(command); rmdirSync(tmpdir, { recursive: true, maxRetries: 5 }); const stdout = _stdout?.toString(); @@ -66,6 +66,22 @@ export const createCertSecret = async ( const object = cloneDeep(TLS_SECRET); object.metadata.generateName = namePrefix; object.data = certificate; + object.data = { + 'tls.crt': certificate['tls.crt'], + 'tls.key': certificate['tls.key'], + }; + + if (!object.data['tls.crt'] || !object.data['tls.key']) { + res + .writeHead( + 400, + `Can not create ${namePrefix} TLS secret in the ${ + TLS_SECRET.metadata.namespace || '' + } namespace, missing either tls.crt or tls.key.`, + ) + .end(); + return; + } // TODO: what about clean-up? const response = await createSecret(token, object); @@ -74,6 +90,13 @@ export const createCertSecret = async ( return response.body; } + logger.error( + `Can not create ${namePrefix} TLS secret in the ${ + TLS_SECRET.metadata.namespace || '' + } namespace.`, + ' Response: ', + response, + ); res .writeHead( response.statusCode, @@ -83,6 +106,13 @@ export const createCertSecret = async ( ) .end(); } catch (e) { + logger.error( + `Can not create ${namePrefix} TLS secret in the ${ + TLS_SECRET.metadata.namespace || '' + } namespace. Internal error.`, + ' Error: ', + e, + ); res .writeHead( 500, diff --git a/ui/backend/src/endpoints/proxy.ts b/ui/backend/src/endpoints/proxy.ts index ab90a4d7a..d150e6779 100644 --- a/ui/backend/src/endpoints/proxy.ts +++ b/ui/backend/src/endpoints/proxy.ts @@ -6,6 +6,7 @@ import { URL } from 'url'; import { notFound, unauthorized, getToken, respondInternalServerError } from '../k8s'; import { getClusterApiUrl } from '../k8s/utils'; +import { logRequestProxy, logResponseProxy } from '../logging'; const logger = console; @@ -49,9 +50,11 @@ export function proxy(req: Request, res: Response): void { headers, rejectUnauthorized: false, }; + logRequestProxy(options); pipeline( req, request(options, (response) => { + logResponseProxy(response); if (!response) return notFound(req, res); const responseHeaders: OutgoingHttpHeaders = {}; for (const header of proxyResponseHeaders) { diff --git a/ui/backend/src/k8s/fetch-retry.ts b/ui/backend/src/k8s/fetch-retry.ts index 67b8ccfcf..2fad16e41 100644 --- a/ui/backend/src/k8s/fetch-retry.ts +++ b/ui/backend/src/k8s/fetch-retry.ts @@ -1,4 +1,5 @@ import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch'; +import { logRequestError /*, logResponse, logRequest */ } from '../logging'; export function fetchRetry( url: RequestInfo, @@ -20,7 +21,9 @@ export function fetchRetry( return new Promise(function (resolve, reject) { async function fetchAttempt() { try { + // logRequest(url, init); const response = await fetch(url, init); + // logResponse(response, url); switch (response.status) { case 429: // Too Many Requests { @@ -53,6 +56,7 @@ export function fetchRetry( resolve(response); } } catch (err) { + logRequestError(err); if (err instanceof Error) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access switch ((err as any).code) { diff --git a/ui/backend/src/k8s/json-request.ts b/ui/backend/src/k8s/json-request.ts index 115175c92..7b24315e5 100644 --- a/ui/backend/src/k8s/json-request.ts +++ b/ui/backend/src/k8s/json-request.ts @@ -1,6 +1,7 @@ import { constants } from 'http2'; import { Agent } from 'https'; import { HeadersInit } from 'node-fetch'; +import { logRequestResponse } from '../logging'; import { fetchRetry } from './fetch-retry'; const { HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_AUTHORIZATION, HTTP2_HEADER_ACCEPT } = constants; @@ -10,9 +11,12 @@ const agent = new Agent({ rejectUnauthorized: false }); export function jsonRequest(url: string, token?: string): Promise { const headers: HeadersInit = { [HTTP2_HEADER_ACCEPT]: 'application/json' }; if (token) headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}`; - return fetchRetry(url, { headers, agent, compress: true }).then( - (response) => response.json() as unknown as Promise, - ); + const request = { headers, agent, compress: true }; + return fetchRetry(url, request).then(async (response) => { + const result = (await response.json()) as unknown as Promise; + logRequestResponse('GET', url, request, result); + return result; + }); } export interface PostResponse { @@ -30,17 +34,21 @@ export function jsonPost( [HTTP2_HEADER_CONTENT_TYPE]: 'application/json', }; if (token) headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}`; - return fetchRetry(url, { + + const request = { method: 'POST', headers, agent, body: JSON.stringify(body), compress: true, - }).then(async (response) => { + }; + + return fetchRetry(url, request).then(async (response) => { const result = { statusCode: response.status, body: (await response.json()) as unknown as T, }; + logRequestResponse('POST', url, request, result); return result; }); } @@ -59,38 +67,41 @@ export function jsonPatch( headers['Content-Type'] = 'application/merge-patch+json'; } - return fetchRetry(url, { + const request = { method: 'PATCH', headers, agent, body: JSON.stringify(patches), compress: true, - }).then(async (response) => { + }; + + return fetchRetry(url, request).then(async (response) => { const result = { statusCode: response.status, body: (await response.json()) as unknown as T, }; + + logRequestResponse('PATCH', url, request, result); return result; }); } -export function jsonDelete( - url: string, - token: string, -): Promise> { +export function jsonDelete(url: string, token: string): Promise> { const headers: HeadersInit = {}; headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}`; - return fetchRetry(url, { + const request = { method: 'DELETE', headers, agent, compress: true, - }).then(async (response) => { + }; + return fetchRetry(url, request).then(async (response) => { const result = { statusCode: response.status, body: (await response.json()) as unknown as T, }; + logRequestResponse('DELETE', url, request, result); return result; }); -} \ No newline at end of file +} diff --git a/ui/backend/src/k8s/oauth.ts b/ui/backend/src/k8s/oauth.ts index 45f6a64ca..7e98b8735 100644 --- a/ui/backend/src/k8s/oauth.ts +++ b/ui/backend/src/k8s/oauth.ts @@ -8,8 +8,8 @@ import { deleteCookie } from './cookies'; import { jsonRequest } from './json-request'; import { getToken, K8S_ACCESS_TOKEN_COOKIE } from './token'; import { redirect, respondInternalServerError, unauthorized } from './respond'; -import { OAUTH_ROUTE_PREFIX, ZTPFW_UI_ROUTE_PREFIX } from '../constants'; import { setDead } from '../endpoints'; +import { OAUTH_ROUTE_PREFIX, ZTPFW_UI_ROUTE_PREFIX } from '../common'; const logger = console; @@ -146,8 +146,8 @@ export async function logout(req: Request, res: Response): Promise { const host = req.headers.host; - deleteCookie(res, { cookie: 'connect.sid' }); deleteCookie(res, { cookie: K8S_ACCESS_TOKEN_COOKIE }); + deleteCookie(res, { cookie: 'connect.sid' }); deleteCookie(res, { cookie: '_oauth_proxy', domain: `.${host || ''}` }); res.writeHead(200).end(); } diff --git a/ui/backend/src/logging.ts b/ui/backend/src/logging.ts new file mode 100644 index 000000000..bf224ed73 --- /dev/null +++ b/ui/backend/src/logging.ts @@ -0,0 +1,76 @@ +import { IncomingMessage } from 'http'; +import { RequestOptions } from 'https'; +import { RequestInfo, RequestInit, Response } from 'node-fetch'; + +const isAPILoggingEnabled = (): boolean => process.env.API_LOGGING_ENABLED === 'true'; + +export const logRequest = (url: RequestInfo, init?: RequestInit) => { + if (isAPILoggingEnabled()) { + if (!url?.toString()?.endsWith('/apis')) { + console.log('API Request:\n', url, '\ninit:\n', init); + } + } +}; + +export const logResponse = (response: Response, url?: RequestInfo) => { + if (isAPILoggingEnabled()) { + if (!url?.toString()?.endsWith('/apis')) { + // skip logging livenessProbe + console.log('API Response:\n', response); + } + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const logRequestError = (err: any) => { + if (isAPILoggingEnabled()) { + console.log('Request error: \n', err); + } +}; + +export const logRequestProxy = (options: RequestOptions) => { + if (isAPILoggingEnabled()) { + console.log('Proxied API Request:\n', options); + } +}; + +export const logResponseProxy = (response: IncomingMessage) => { + if (isAPILoggingEnabled()) { + console.log( + 'Proxied API Response (skipping data):\nstatusCode: ', + response['statusCode'], + '\nstatusMessage: ', + response['statusMessage'], + '\noriginal request:\n', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + response['_httpMessage'], + '\nrawHeaders: ', + response['rawHeaders'], + '\n', + ); + } +}; + +export const logRequestResponse = ( + method: string, + url: RequestInfo, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response?: any, +) => { + if (isAPILoggingEnabled()) { + try { + console.log( + `----- ${method} API request to: ${url.toString() || ''}: \n`, + request, + '\nresponse:\n', + response, + '\n----------', + ); + } catch (err) { + console.error(`Error during logging ${method || ''} API request to: ${url.toString() || ''}`); + } + } +}; diff --git a/ui/backend/yarn.lock b/ui/backend/yarn.lock index 71df9d223..46c8f52ea 100644 --- a/ui/backend/yarn.lock +++ b/ui/backend/yarn.lock @@ -2,19 +2,19 @@ # yarn lockfile v1 -"@eslint/eslintrc@^1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.0.5.tgz#33f1b838dbf1f923bfa517e008362b78ddbbf318" - integrity sha512-BLxsnmK3KyPunz5wmCCpqy0YelEoxxGmH73Is+Z74oOTMtExcjkr3dDR6quwrjh1YspA8DH9gnX1o069KiS9AQ== +"@eslint/eslintrc@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" + integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.2.0" - globals "^13.9.0" - ignore "^4.0.6" + espree "^9.3.2" + globals "^13.15.0" + ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.0" - minimatch "^3.0.4" + minimatch "^3.1.2" strip-json-comments "^3.1.1" "@humanwhocodes/config-array@^0.9.2": @@ -386,15 +386,15 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-jsx@^5.3.1: +acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.7.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" - integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== +acorn@^8.7.1: + version "8.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" + integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== aggregate-error@^3.1.0: version "3.1.0" @@ -825,10 +825,10 @@ eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.0.tgz#c1f6ea30ac583031f203d65c73e723b01298f153" - integrity sha512-aWwkhnS0qAXqNOgKOK0dJ2nvzEbhEvpy8OlJ9kZ0FeZnA6zpjv1/Vei+puGFFX7zkPCkHHXb7IDX3A+7yPrRWg== +eslint-scope@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" + integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" @@ -845,17 +845,22 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.1.0, eslint-visitor-keys@^3.2.0: +eslint-visitor-keys@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.2.0.tgz#6fbb166a6798ee5991358bc2daa1ba76cc1254a1" integrity sha512-IOzT0X126zn7ALX0dwFiUQEdsfzrm4+ISsQS8nukaJXwEyYKRSnEIIDULYg1mCtGp7UUXgfGl7BIolXREQK+XQ== -eslint@^8.7.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.7.0.tgz#22e036842ee5b7cf87b03fe237731675b4d3633c" - integrity sha512-ifHYzkBGrzS2iDU7KjhCAVMGCvF6M3Xfs8X8b37cgrUlDt6bWRTpRh6T/gtSXv1HJ/BUGgmjvNvOEGu85Iif7w== +eslint-visitor-keys@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" + integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== + +eslint@^8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.18.0.tgz#78d565d16c993d0b73968c523c0446b13da784fd" + integrity sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA== dependencies: - "@eslint/eslintrc" "^1.0.5" + "@eslint/eslintrc" "^1.3.0" "@humanwhocodes/config-array" "^0.9.2" ajv "^6.10.0" chalk "^4.0.0" @@ -863,17 +868,17 @@ eslint@^8.7.0: debug "^4.3.2" doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.1.0" + eslint-scope "^7.1.1" eslint-utils "^3.0.0" - eslint-visitor-keys "^3.2.0" - espree "^9.3.0" + eslint-visitor-keys "^3.3.0" + espree "^9.3.2" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" - globals "^13.6.0" + globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -882,7 +887,7 @@ eslint@^8.7.0: json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" - minimatch "^3.0.4" + minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" regexpp "^3.2.0" @@ -891,14 +896,14 @@ eslint@^8.7.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^9.2.0, espree@^9.3.0: - version "9.3.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.0.tgz#c1240d79183b72aaee6ccfa5a90bc9111df085a8" - integrity sha512-d/5nCsb0JcqsSEeQzFZ8DH1RmxPcglRWh24EFTlUEmCKoehXGdpsx0RkHDubqUI8LSAIKMQp4r9SzQ3n+sm4HQ== +espree@^9.3.2: + version "9.3.2" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596" + integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA== dependencies: - acorn "^8.7.0" - acorn-jsx "^5.3.1" - eslint-visitor-keys "^3.1.0" + acorn "^8.7.1" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" esquery@^1.4.0: version "1.4.0" @@ -1187,10 +1192,10 @@ glob@^7.0.0, glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -globals@^13.6.0, globals@^13.9.0: - version "13.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.12.0.tgz#4d733760304230a0082ed96e21e5c565f898089e" - integrity sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg== +globals@^13.15.0: + version "13.15.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac" + integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog== dependencies: type-fest "^0.20.2" @@ -1298,11 +1303,6 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== - ignore@^5.1.8, ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" @@ -1616,6 +1616,13 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + minimist@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" diff --git a/ui/frontend/package.json b/ui/frontend/package.json index a339d3eea..a09acf8fc 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -13,12 +13,13 @@ "@types/node": "^16.7.13", "@types/react": "^17.0.20", "@types/react-dom": "^17.0.9", + "buffer": "^6.0.3", "file-saver": "^2.0.5", "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2", "react-router-dom": "^6.2.1", - "react-scripts": "5.0.0", + "react-scripts": "5.0.1", "typescript": "^4.4.2", "web-vitals": "^2.1.0" }, @@ -29,6 +30,7 @@ "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "jest-fetch-mock": "^3.0.3", + "jest-location-mock": "^1.0.9", "prettier": "^2.5.1", "string.prototype.replaceall": "^1.0.6" }, @@ -36,6 +38,7 @@ "start": "HTTPS=true SSL_CRT_FILE=${TLS_CERT_FILE} SSL_KEY_FILE=${TLS_KEY_FILE} react-scripts start", "build": "react-scripts build", "backend-common": "rm -rf src/copy-backend-common ; cp -r ../backend/src/common src/copy-backend-common", + "get-sha": "SHA=$(git rev-parse HEAD) ; echo ${SHA} ; echo \"GIT_BUILD_SHA = '${SHA}';\" >> src/sha.ts", "prebuild": "yarn backend-common", "prestart": "yarn backend-common", "pretest": "yarn backend-common", diff --git a/ui/frontend/src/components/ContentTwoCols/ContentTwoCols.tsx b/ui/frontend/src/components/ContentTwoCols/ContentTwoCols.tsx index 650510c6b..87949f443 100644 --- a/ui/frontend/src/components/ContentTwoCols/ContentTwoCols.tsx +++ b/ui/frontend/src/components/ContentTwoCols/ContentTwoCols.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { Grid, GridItem } from '@patternfly/react-core'; +import { Grid, GridItem, gridSpans } from '@patternfly/react-core'; import './ContentTwoCols.css'; export const ContentTwoCols: React.FC<{ left: React.ReactNode; right: React.ReactNode; -}> = ({ left, right }) => ( + spanLeft?: gridSpans; + spanRight?: gridSpans; +}> = ({ left, right, spanLeft = 6, spanRight = 6 }) => ( - {left} - {right} + {left} + {right} ); diff --git a/ui/frontend/src/components/DomainCertificatesDecisionPage/DomainCertificatesDecision.tsx b/ui/frontend/src/components/DomainCertificatesDecisionPage/DomainCertificatesDecision.tsx new file mode 100644 index 000000000..c03420497 --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesDecisionPage/DomainCertificatesDecision.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Radio, Split, SplitItem, Stack, StackItem, Title } from '@patternfly/react-core'; + +type AutomaticManualDecisionProps = { + id?: string; + isAutomatic: boolean; + setAutomatic: (isAutomatic: boolean) => void; +}; + +export const AutomaticManualDecision: React.FC = ({ + id, + isAutomatic, + setAutomatic, +}) => ( + + {/* TODO: Improve positioning on the Wizard's page */} + + setAutomatic(true)} + /> + + + setAutomatic(false)} + /> + + +); + +export const DomainCertificatesDecision: React.FC = (props) => { + return ( + + + How do you want to assign certificates to your domain? + + + Choose whether you want to automatically generate and assign self-signed PEM certificates + for your custom domain. + + + + + + ); +}; diff --git a/ui/frontend/src/components/DomainCertificatesDecisionPage/DomainCertificatesDecisionPage.tsx b/ui/frontend/src/components/DomainCertificatesDecisionPage/DomainCertificatesDecisionPage.tsx new file mode 100644 index 000000000..f3dedafb7 --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesDecisionPage/DomainCertificatesDecisionPage.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { Page } from '../Page'; +import { ContentThreeRows } from '../ContentThreeRows'; +import { WizardProgress, WizardStepType } from '../WizardProgress'; +import { useWizardProgressContext } from '../WizardProgress/WizardProgressContext'; +import { WizardFooter } from '../WizardFooter'; +import { DomainCertificatesDecision } from './DomainCertificatesDecision'; +import { useK8SStateContext } from '../K8SStateContext'; + +export const DomainCertificatesDecisionPage: React.FC = () => { + const { setActiveStep } = useWizardProgressContext(); + const { domainValidation: validation, customCerts } = useK8SStateContext(); + const [isAutomatic, setAutomatic] = React.useState( + () => Object.keys(customCerts || {}).length === 0, + ); + + // No special step for that + React.useEffect(() => setActiveStep('domain'), [setActiveStep]); + + let next: WizardStepType = 'sshkey'; + if (!isAutomatic) { + next = 'domaincertificates'; + } + + return ( + + } + middle={ + + } + bottom={ !validation} />} + /> + + ); +}; diff --git a/ui/frontend/src/components/DomainCertificatesDecisionPage/index.ts b/ui/frontend/src/components/DomainCertificatesDecisionPage/index.ts new file mode 100644 index 000000000..764e60228 --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesDecisionPage/index.ts @@ -0,0 +1 @@ +export * from './DomainCertificatesDecisionPage'; diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css new file mode 100644 index 000000000..7d2908fa5 --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css @@ -0,0 +1,20 @@ +.domain-certificates .pf-c-panel__main { + max-height: 16rem !important; +} +.domain-certificate__item { + width: 58rem; +} + +/* override default wizard style, we have too much content to show */ +.domain-certificate__h1 { + padding-top: 1rem !important; + align-content: center; +} + +.pf-c-form__helper-text.pf-m-error { + color: var(--pf-global--danger-color--100); +} + +.domain-certificates .pf-c-expandable-section__toggle-text { + text-align: left; +} diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx new file mode 100644 index 000000000..65198b0fc --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Stack, StackItem, Title } from '@patternfly/react-core'; +import { DomainCertificatesPanel } from './DomainCertificatesPanel'; + +import './DomainCertificates.css'; + +export const DomainCertificates: React.FC = () => { + return ( + + + + Upload your certificates + + + + Secure your SSL/TLS routes with certificates in the PEM format. + + + If a certificate is not provided, a self-signed one will be automatically generated. + + + + + + ); +}; diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPage.tsx b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPage.tsx new file mode 100644 index 000000000..732ffc4de --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPage.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { Page } from '../Page'; +import { ContentThreeRows } from '../ContentThreeRows'; +import { WizardProgress } from '../WizardProgress'; +import { useWizardProgressContext } from '../WizardProgress/WizardProgressContext'; +import { WizardFooter } from '../WizardFooter'; +import { DomainCertificates } from './DomainCertificates'; +import { useK8SStateContext } from '../K8SStateContext'; + +export const DomainCertificatesPage: React.FC = () => { + const { setActiveStep } = useWizardProgressContext(); + // No special step for that + React.useEffect(() => setActiveStep('domain'), [setActiveStep]); + const { customCertsValidation } = useK8SStateContext(); + + // Keep enabled for self-signed certs + const isNextEnabled = () => + Object.values(customCertsValidation).every( + (validation) => validation.certValidated !== 'error' && validation.keyValidated !== 'error', + ); + + return ( + + } + middle={} + bottom={ + + } + /> + + ); +}; diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx new file mode 100644 index 000000000..16b95d619 --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { + ExpandableSection, + FileUpload, + FormGroup, + Panel, + PanelMain, + PanelMainBody, + FlexItem, + Flex, + FlexProps, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + ClipboardCopy, + FormGroupProps, +} from '@patternfly/react-core'; +import { + global_success_color_100 as successColor, + global_warning_color_100 as warningColor, + global_danger_color_100 as dangerColor, +} from '@patternfly/react-tokens'; +import { + CheckCircleIcon, + ExclamationTriangleIcon, + ExclamationCircleIcon, +} from '@patternfly/react-icons'; + +import { + getApiDomain, + getConsoleDomain, + getOauthDomain, + getZtpfwDomain, + TlsCertificate, +} from '../../copy-backend-common'; +import { useK8SStateContext } from '../K8SStateContext'; +import { toBase64 } from '../utils'; + +const getTitle = ( + isExpanded: boolean, + domainCert: TlsCertificate, + name: string, + domain: string, + certValidated: FormGroupProps['validated'], + keyValidated: FormGroupProps['validated'], +): React.ReactElement | string => { + let title: React.ReactElement | string; + const forDomain = isExpanded ? undefined : <> for {domain}; + + if (!domainCert?.['tls.crt'] && !domainCert?.['tls.key']) { + title = ( + <> + {name}: generate self-signed{' '} + {forDomain} + + ); + } else if (certValidated === 'error' || keyValidated === 'error') { + title = ( + <> + {name}: incorrect {forDomain} + + ); + } else if (domainCert?.['tls.crt'] && domainCert?.['tls.key']) { + title = ( + <> + {name}: done {forDomain} + + ); + } else { + title = ( + <> + {name}: missing {forDomain} + + ); + } + + return title; +}; + +type CertificateProps = { + name: string; + domain: string; + + isSpaceItemsNone?: boolean; +}; + +const Certificate: React.FC = ({ name, domain, isSpaceItemsNone }) => { + const [isExpanded, setExpanded] = React.useState(false); + const { customCerts, setCustomCertificate, customCertsValidation } = useK8SStateContext(); + + const { + certValidated, + certLabelHelperText, + certLabelInvalid, + + keyValidated, + keyLabelInvalid, + } = customCertsValidation[domain] || {}; + + const domainCert = customCerts?.[domain] || { 'tls.crt': '', 'tls.key': '' }; + + const idCert = `file-upload-certificate-${name.replaceAll(' ', '')}`; + const idKey = `file-upload-key-${name.replaceAll(' ', '')}`; + + const onChange = async (key: 'tls.crt' | 'tls.key', file: File) => { + const newCert = { ...domainCert }; + newCert[`${key}.filename`] = file.name; + + const fr = new FileReader(); + fr.onload = () => { + newCert[key] = toBase64(fr.result as string); + setCustomCertificate(domain, newCert); + }; + fr.readAsText(file); + }; + + const onClear = (key: 'tls.crt' | 'tls.key') => { + const newCert = { ...domainCert }; + newCert[key] = ''; + newCert[`${key}.filename`] = ''; + setCustomCertificate(domain, newCert); + }; + + const spaceItems: FlexProps['spaceItems'] = isSpaceItemsNone + ? { default: 'spaceItemsXs' } + : undefined; + + return ( + setExpanded(!isExpanded)} + isExpanded={isExpanded} + displaySize="large" + > + + + Domain + + + {domain} + + + + + + + + + onChange('tls.crt', file)} + onClearClick={() => { + onClear('tls.crt'); + }} + /> + + + + + + onChange('tls.key', file)} + onClearClick={() => { + onClear('tls.key'); + }} + /> + + + + + ); +}; + +export const DomainCertificatesPanel: React.FC<{ + isScrollable?: boolean; + isSpaceItemsNone?: boolean; +}> = ({ isScrollable, isSpaceItemsNone }) => { + const { domain } = useK8SStateContext(); + + return ( + + + + + + + + + + + ); +}; diff --git a/ui/frontend/src/components/DomainCertificatesPage/index.ts b/ui/frontend/src/components/DomainCertificatesPage/index.ts new file mode 100644 index 000000000..7036a6f50 --- /dev/null +++ b/ui/frontend/src/components/DomainCertificatesPage/index.ts @@ -0,0 +1 @@ +export * from './DomainCertificatesPage'; diff --git a/ui/frontend/src/components/DomainPage/DomainPage.tsx b/ui/frontend/src/components/DomainPage/DomainPage.tsx index 0148b8a8a..be05f052a 100644 --- a/ui/frontend/src/components/DomainPage/DomainPage.tsx +++ b/ui/frontend/src/components/DomainPage/DomainPage.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Page } from '../Page'; import { ContentThreeRows } from '../ContentThreeRows'; -import { WizardProgress } from '../WizardProgress'; +import { WizardProgress, WizardStepType } from '../WizardProgress'; import { useWizardProgressContext } from '../WizardProgress/WizardProgressContext'; import { WizardFooter } from '../WizardFooter'; import { DomainSelector } from './DomainSelector'; @@ -11,14 +11,19 @@ import { useK8SStateContext } from '../K8SStateContext'; export const DomainPage: React.FC = () => { const { setActiveStep } = useWizardProgressContext(); React.useEffect(() => setActiveStep('domain'), [setActiveStep]); - const { domainValidation: validation } = useK8SStateContext(); + const { domainValidation: validation, domain, originalDomain } = useK8SStateContext(); + + let next: WizardStepType = 'sshkey'; + if (domain !== originalDomain) { + next = 'domaincertsdecision'; + } return ( } middle={} - bottom={ !validation} />} + bottom={ !validation} />} /> ); diff --git a/ui/frontend/src/components/K8SStateContext.tsx b/ui/frontend/src/components/K8SStateContext.tsx index ba4165376..7199f13e1 100644 --- a/ui/frontend/src/components/K8SStateContext.tsx +++ b/ui/frontend/src/components/K8SStateContext.tsx @@ -5,8 +5,11 @@ import { IpTripletSelectorValidationType, K8SStateContextData, K8SStateContextDataFields, + CustomCertsValidationType, } from './types'; +import { ChangeDomainInputType, TlsCertificate } from '../copy-backend-common'; import { + customCertsValidator, domainValidator, ipTripletAddressValidator, ipWithoutDots, @@ -69,71 +72,103 @@ export const K8SStateContextProvider: React.FC<{ ); const [domain, setDomain] = React.useState(''); + const [originalDomain, setOriginalDomain] = React.useState(); const [domainValidation, setDomainValidation] = React.useState(); - const handleSetDomain = React.useCallback((newDomain: string) => { - setDomainValidation(domainValidator(newDomain)); - setDomain(newDomain); - }, []); - - const fieldValues: K8SStateContextDataFields = React.useMemo( - () => ({ - username, - password, - apiaddr, - ingressIp, - domain, - }), - [username, password, apiaddr, ingressIp, domain], + const handleSetDomain = React.useCallback( + (newDomain: string) => { + setDomainValidation(domainValidator(newDomain)); + setDomain(newDomain); + if (!originalDomain) { + // Hint: This is expected to be called within initialDataLoad() only + setOriginalDomain(newDomain); + } + }, + [originalDomain], ); - const [snapshot, setSnapshot] = React.useState(); + const [customCerts, setCustomCerts] = React.useState({}); + const [customCertsValidation, setCustomCertsValidation] = + React.useState({}); + + const setCustomCertificate = React.useCallback( + (domain: string, certificate: TlsCertificate) => { + const newCustomCerts = { ...customCerts }; + newCustomCerts[domain] = certificate; + setCustomCerts(newCustomCerts); + setCustomCertsValidation(customCertsValidator(customCertsValidation, domain, certificate)); + }, + [customCertsValidation, customCerts, setCustomCerts], + ); + + const isAllValid = React.useCallback(() => { + const result = + !usernameValidation && + passwordValidation && + apiaddrValidation.valid && + ingressIpValidation.valid && + !domainValidation && + !Object.keys(customCertsValidation).find( + (d) => + customCertsValidation[d].certValidated === 'error' || + customCertsValidation[d].keyValidated === 'error', + ); + return result; + }, [ + apiaddrValidation.valid, + customCertsValidation, + domainValidation, + ingressIpValidation.valid, + passwordValidation, + usernameValidation, + ]); + + const _fv: K8SStateContextDataFields = { + username, + password, + apiaddr, + ingressIp, + domain, + originalDomain, + customCerts, + }; + + const fieldValues = React.useRef(_fv); + fieldValues.current = _fv; + + const [snapshot, setSnapshot] = React.useState(_fv); const setClean = React.useCallback(() => { - setSnapshot(fieldValues); + setSnapshot(fieldValues.current); }, [fieldValues]); - const isDirty = React.useCallback( - (): boolean => !isEqual(fieldValues, snapshot), - [fieldValues, snapshot], - ); + const isDirty = React.useCallback((): boolean => { + return !isEqual(fieldValues.current, snapshot); + }, [fieldValues, snapshot]); - const value = React.useMemo( - () => ({ - ...fieldValues, - - isDirty, - setClean, - - usernameValidation, - handleSetUsername, - - passwordValidation, - handleSetPassword, - - apiaddrValidation, - handleSetApiaddr, - - ingressIpValidation, - handleSetIngressIp, - - domainValidation, - handleSetDomain, - }), - [ - fieldValues, - isDirty, - setClean, - usernameValidation, - handleSetUsername, - passwordValidation, - handleSetPassword, - apiaddrValidation, - handleSetApiaddr, - ingressIpValidation, - handleSetIngressIp, - domainValidation, - handleSetDomain, - ], - ); + const value = { + ...fieldValues.current, + + isDirty, + setClean, + isAllValid, + + usernameValidation, + handleSetUsername, + + passwordValidation, + handleSetPassword, + + apiaddrValidation, + handleSetApiaddr, + + ingressIpValidation, + handleSetIngressIp, + + domainValidation, + handleSetDomain, + + customCertsValidation, + setCustomCertificate, + }; return {children}; }; diff --git a/ui/frontend/src/components/PersistPage/PersistPage.test.tsx b/ui/frontend/src/components/PersistPage/PersistPage.test.tsx index 24b1494ab..c52b3d305 100644 --- a/ui/frontend/src/components/PersistPage/PersistPage.test.tsx +++ b/ui/frontend/src/components/PersistPage/PersistPage.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; import { PersistPage } from './PersistPage'; import { K8SStateContextProvider } from '../K8SStateContext'; import { WizardProgressContextProvider } from '../WizardProgress'; +import { delay } from '../../test-utils'; const Component: React.FC = () => ( @@ -17,8 +19,16 @@ const Component: React.FC = () => ( ); describe('PersistPage', () => { - it('can render', () => { - const { container } = render(); + it('can render', async () => { + let container; + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + const { container: c } = render(); + container = c; + await delay(1000); + }); + expect(container).toMatchSnapshot(); // TODO: More complex scenario testing the persist() function on top of mocked data should be implemented }); diff --git a/ui/frontend/src/components/PersistPage/PersistPageBottom.tsx b/ui/frontend/src/components/PersistPage/PersistPageBottom.tsx index 8a454faaf..a568ce750 100644 --- a/ui/frontend/src/components/PersistPage/PersistPageBottom.tsx +++ b/ui/frontend/src/components/PersistPage/PersistPageBottom.tsx @@ -8,11 +8,8 @@ import { Stack, StackItem, } from '@patternfly/react-core'; -import { CheckCircleIcon, InProgressIcon } from '@patternfly/react-icons'; -import { - global_primary_color_light_100 as progressColor, - global_success_color_100 as successColor, -} from '@patternfly/react-tokens'; +import { InProgressIcon } from '@patternfly/react-icons'; +import { global_primary_color_light_100 as progressColor } from '@patternfly/react-tokens'; import { navigateToNewDomain, persist } from './persist'; import { PersistErrorType } from './types'; @@ -42,7 +39,11 @@ export const PersistPageBottom: React.FC = () => { navigateToNewDomain(state.domain, '/wizard/final'), ); } else { - console.warn('No change to persist on the PersistPage'); + console.warn( + 'No change to persist on the PersistPage. The reconcilliation might be still running if the user got here after page refresh.', + ); + // TODO: Find out what's going on and resume the progress bar for the user + navigateToNewDomain(state.domain, '/wizard/welcome'); } }, [retry, setError, progress.setProgress, state]); @@ -66,7 +67,7 @@ export const PersistPageBottom: React.FC = () => { - ) : error === undefined ? ( + ) : ( <> { /> - Saving settings for your edge cluster... - - - - - - ) : ( - <> - - + Saving settings for your edge cluster, it might take several minutes for cluster to + reconcile. - Settings succesfully saved, it might take several minutes for cluster to reconcile. + Please keep this window open until the process is finished. - + - Saving settings for your edge cluster... + Saving settings for your edge cluster, it might take several minutes for cluster to reconcile. + +
+ Please keep this window open until the process is finished.
- Saving Ingress IP + 0%
void, setProgress: UsePersistProgressType['setProgress'], state: K8SStateContextData, @@ -89,10 +89,14 @@ const waitOnreconciliation = async ( // TODO: openshift console?? } + if (!(await waitForClusterOperator(setError, 'kube-apiserver'))) { + return false; + } + // Important: keep following aligned with the last reconcile-step setProgress(PersistSteps.ReconcileAuthOperator); - console.info('waitOnreconciliation finished successfully'); + console.info('waitOnReconciliation finished successfully'); return true; }; @@ -108,19 +112,43 @@ export const persist = async ( state.username, state.password, ); + + if (persistIdpResult === PersistIdentityProviderResult.error) { + console.error('Failed to persist IDP, giving up.'); + return; + } + + if (persistIdpResult === PersistIdentityProviderResult.userCreated) { + // Let the operator reconciliation start + await delay(DELAY_BEFORE_FINAL_REDIRECT); + + if (!(await waitForClusterOperator(setError, 'authentication'))) { + return false; + } + } + + console.log('Saving of IDP is over, about to continue with Domain.'); + if (!(await persistDomain(setError, setProgress, state.domain, state.customCerts))) { + return; + } + + console.log('Domain persisted, blocking progress till reconciled.'); + // Let the operator reconciliation start + await delay(DELAY_BEFORE_FINAL_REDIRECT); + if (!(await waitForClusterOperator(setError, 'authentication'))) { + return false; + } + if ( - persistIdpResult !== PersistIdentityProviderResult.error && (await saveIngress(setError, setProgress, state.ingressIp)) && - (await saveApi(setError, setProgress, state.apiaddr)) && - (await persistDomain(setError, setProgress, state.domain)) + (await saveApi(setError, setProgress, state.apiaddr)) ) { // finished with success console.log('Data persisted, blocking progress till reconciled'); setError(null); // show the green circle of success - // TODO: show progress bar while waiting - if (!(await waitOnreconciliation(setError, setProgress, state, persistIdpResult))) { + if (!(await waitOnReconciliation(setError, setProgress, state, persistIdpResult))) { return; } @@ -135,7 +163,14 @@ export const navigateToNewDomain = async (domain: string, contextPath: string) = // We can not check livenessProbe on the new domain due to CORS // We can not use pod serving old domain either since it will be terminated and the route changed // So just wait... - const ztpfwUrl = `https://${ZTPFW_UI_ROUTE_PREFIX}.apps.${domain}${contextPath}`; + let ztpfwUrl: string; + if (!domain) { + // fallback + ztpfwUrl = `${window.location.origin}${contextPath}`; + } else { + ztpfwUrl = `https://${ZTPFW_UI_ROUTE_PREFIX}.apps.${domain}${contextPath}`; + } + console.info('Changes are persisted, about to navigate to the new domain: ', ztpfwUrl); // We should go with following: window.location.replace(ztpfwUrl); diff --git a/ui/frontend/src/components/PersistPage/persistDomain.ts b/ui/frontend/src/components/PersistPage/persistDomain.ts index f69a02cea..8192660e7 100644 --- a/ui/frontend/src/components/PersistPage/persistDomain.ts +++ b/ui/frontend/src/components/PersistPage/persistDomain.ts @@ -2,28 +2,33 @@ import { postRequest } from '../../resources'; import { PersistSteps, UsePersistProgressType } from '../PersistProgress'; import { PERSIST_DOMAIN } from './constants'; import { PersistErrorType } from './types'; +import { ChangeDomainInputType } from '../../backend-shared'; export const persistDomain = async ( setError: (error: PersistErrorType) => void, setProgress: UsePersistProgressType['setProgress'], - domain?: string, + clusterDomain?: string, + customCerts?: ChangeDomainInputType['customCerts'], ): Promise => { - if (!domain) { + if (!clusterDomain) { console.info('Domain change not requested, so skipping that step.'); setProgress(PersistSteps.PersistDomain); return true; // skip } + const input: ChangeDomainInputType = { + clusterDomain, + customCerts, + }; + try { // Due to complexity, the flow has been moved to backend to decrease risks related to network communication - await postRequest('/changeDomain', { - domain, - }).promise; + await postRequest('/changeDomain', input).promise; } catch (e) { console.error(e); setError({ title: PERSIST_DOMAIN, - message: `Failed to change the cluster domain to "${domain}".`, + message: `Failed to change the cluster domain to "${clusterDomain}".`, }); return false; } diff --git a/ui/frontend/src/components/PersistPage/types.ts b/ui/frontend/src/components/PersistPage/types.ts index 2f05434ae..674d09f10 100644 --- a/ui/frontend/src/components/PersistPage/types.ts +++ b/ui/frontend/src/components/PersistPage/types.ts @@ -2,8 +2,3 @@ export type PersistErrorType = { title: string; message: string; } | null; - -export type TlsCertificate = { - 'tls.crt': string; - 'tls.key': string; -}; diff --git a/ui/frontend/src/components/PersistPage/utils.ts b/ui/frontend/src/components/PersistPage/utils.ts index 3e53ba71a..4a88b2514 100644 --- a/ui/frontend/src/components/PersistPage/utils.ts +++ b/ui/frontend/src/components/PersistPage/utils.ts @@ -1,3 +1,4 @@ +import { ZTPFW_UI_ROUTE_PREFIX } from '../../copy-backend-common'; import { getRequest } from '../../resources'; import { getPodsOfNamespace } from '../../resources/pod'; import { delay, getZtpfwUrl } from '../utils'; @@ -5,7 +6,6 @@ import { DELAY_BEFORE_FINAL_REDIRECT, MAX_LIVENESS_CHECK_COUNT, ZTPFW_NAMESPACE, - ZTPFW_UI_ROUTE_PREFIX, } from './constants'; export const waitForLivenessProbe = async ( diff --git a/ui/frontend/src/components/PersistProgress/PersistProgress.tsx b/ui/frontend/src/components/PersistProgress/PersistProgress.tsx index c3d695190..a1eedb0eb 100644 --- a/ui/frontend/src/components/PersistProgress/PersistProgress.tsx +++ b/ui/frontend/src/components/PersistProgress/PersistProgress.tsx @@ -12,9 +12,9 @@ let persistStepsCount = 1; export const PersistSteps = { // Important: keep following steps aligned with actual order in persist() PersistIDP: persistStepsCount++, + PersistDomain: persistStepsCount++, SaveIngress: persistStepsCount++, SaveApi: persistStepsCount++, - PersistDomain: persistStepsCount++, ReconcileUIPod: persistStepsCount++, ReconcileApiOperator: persistStepsCount++, @@ -29,7 +29,7 @@ PersistStepLabels[PersistSteps.SaveApi] = 'Saving API IP'; PersistStepLabels[PersistSteps.PersistDomain] = 'Saving domain change'; PersistStepLabels[PersistSteps.ReconcileUIPod] = 'Waiting for the configuration pod'; PersistStepLabels[PersistSteps.ReconcileApiOperator] = 'Waiting for the API operator'; -PersistStepLabels[PersistSteps.ReconcileAuthOperator] = 'Waiting for the outhentication operator'; +PersistStepLabels[PersistSteps.ReconcileAuthOperator] = 'Waiting for the authentication operator'; export type UsePersistProgressType = { progress: number; diff --git a/ui/frontend/src/components/Settings/Settings.test.tsx b/ui/frontend/src/components/Settings/Settings.test.tsx index f161e3aae..63416bd7a 100644 --- a/ui/frontend/src/components/Settings/Settings.test.tsx +++ b/ui/frontend/src/components/Settings/Settings.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom'; import { SettingsContent, SettingsLoading } from './Settings'; import { K8SStateContextProvider, useK8SStateContext } from '../K8SStateContext'; import { ipWithoutDots } from '../utils'; +import { SettingsPageContextProvider } from './SettingsPageContext'; type CTX_TYPE = { ctxData?: { apiaddr: string; ingressIp: string; domain: string }; @@ -26,13 +27,15 @@ const TestedComponent: React.FC = ({ ctxData, error }) => { const Component: React.FC = (props) => ( - - - + + + + + ); -describe('Seetings', () => { +describe('Settings', () => { it('can render loading state', () => { const { container } = render(); expect(container).toMatchSnapshot(); diff --git a/ui/frontend/src/components/Settings/Settings.tsx b/ui/frontend/src/components/Settings/Settings.tsx index 11ed79110..b0ad58ddf 100644 --- a/ui/frontend/src/components/Settings/Settings.tsx +++ b/ui/frontend/src/components/Settings/Settings.tsx @@ -8,20 +8,37 @@ import { initialDataLoad } from '../WelcomePage/initialDataLoad'; import { Spinner } from './Spinner'; import { SettingsPageLeft } from './SettingsPageLeft'; import { SettingsPageRight } from './SettingsPageRight'; +import { gridSpans } from '@patternfly/react-core'; +import { SettingsPageContextProvider, useSettingsPageContext } from './SettingsPageContext'; export const SettingsContent: React.FC<{ error?: string; forceReload: () => void }> = ({ error, forceReload, -}) => ( - - } - right={ - - } - /> - -); +}) => { + const ctx = useSettingsPageContext(); + + const getSettingsSpan = (): { spanLeft: gridSpans; spanRight: gridSpans } => { + let spanLeft: gridSpans = 6; + let spanRight: gridSpans = 6; + + if (ctx.isEdit && ctx.activeTabKey === 1 /* Domain */ && !ctx.isCertificateAutomatic) { + spanLeft = 2; + spanRight = 10; + } + + return { spanLeft, spanRight }; + }; + + return ( + + } + right={} + {...getSettingsSpan()} + /> + + ); +}; export const SettingsLoading: React.FC = () => ( @@ -52,7 +69,9 @@ export const Settings: React.FC = () => { }, [handleSetApiaddr, handleSetIngressIp, handleSetDomain, isReload, setClean]); return isDataLoaded ? ( - setReload(true)} /> + + setReload(true)} /> + ) : ( ); diff --git a/ui/frontend/src/components/Settings/SettingsPageContext.tsx b/ui/frontend/src/components/Settings/SettingsPageContext.tsx new file mode 100644 index 000000000..ab5d5fc8c --- /dev/null +++ b/ui/frontend/src/components/Settings/SettingsPageContext.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +export type SettingsPageContextData = { + isEdit: boolean; + setEdit: (v: boolean) => void; + + activeTabKey: number; + setActiveTabKey: (v: number) => void; + + isCertificateAutomatic: boolean; + setCertificateAutomatic: (v: boolean) => void; +}; + +const SettingsPageContext = React.createContext(undefined); + +export const SettingsPageContextProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [isEdit, setEdit] = React.useState(false); + const [activeTabKey, setActiveTabKey] = React.useState(0); + const [isCertificateAutomatic, setCertificateAutomatic] = React.useState(true); + + const value = React.useMemo( + () => ({ + isEdit, + setEdit, + + activeTabKey, + setActiveTabKey, + + isCertificateAutomatic, + setCertificateAutomatic, + }), + [ + activeTabKey, + setActiveTabKey, + isCertificateAutomatic, + setCertificateAutomatic, + isEdit, + setEdit, + ], + ); + + return {children}; +}; + +export const useSettingsPageContext = () => { + const context = React.useContext(SettingsPageContext); + if (!context) { + throw new Error('useSettingsPageContext must be used within K8SSettingsPageContextProvider.'); + } + return context; +}; diff --git a/ui/frontend/src/components/Settings/SettingsPageDomainCertificates.tsx b/ui/frontend/src/components/Settings/SettingsPageDomainCertificates.tsx new file mode 100644 index 000000000..9556ae497 --- /dev/null +++ b/ui/frontend/src/components/Settings/SettingsPageDomainCertificates.tsx @@ -0,0 +1,41 @@ +import { FormGroup, StackItem } from '@patternfly/react-core'; +import React from 'react'; +import { AutomaticManualDecision } from '../DomainCertificatesDecisionPage/DomainCertificatesDecision'; +import { DomainCertificatesPanel } from '../DomainCertificatesPage/DomainCertificatesPanel'; +import { useSettingsPageContext } from './SettingsPageContext'; + +export const SettingsPageDomainCertificates: React.FC = () => { + const { isCertificateAutomatic, setCertificateAutomatic } = useSettingsPageContext(); + React.useEffect( + () => { + /* TODO: decide about isutomatic */ + }, + [ + /* Just once */ + ], + ); + + return ( + <> + + + + + + {!isCertificateAutomatic && ( + + + + )} + + ); +}; diff --git a/ui/frontend/src/components/Settings/SettingsPageRight.css b/ui/frontend/src/components/Settings/SettingsPageRight.css index 893437cc5..f50b0f6bb 100644 --- a/ui/frontend/src/components/Settings/SettingsPageRight.css +++ b/ui/frontend/src/components/Settings/SettingsPageRight.css @@ -1,21 +1,31 @@ -.settings-page-sumamary__form { - height: 100%; -} - .settings-page-sumamary { padding-top: var(--pf-global--spacer--2xl); margin-right: var(--pf-global--spacer--xl); margin-left: var(--pf-global--spacer--xl); } +.settings-page-sumamary__tab { + max-height: 20rem; + overflow: overlay; +} + .settings-page-sumamary__item { padding-bottom: var(--pf-global--spacer--lg); } +.summary-page-sumamary__item .pf-c-panel__main-body { + padding-left: 0; +} + +.settings-page-sumamary #automatic { + /* TODO: make it responsive*/ + padding-left: 25%; +} + .settings-page-sumamary__item__footer { margin-bottom: var(--pf-global--spacer--lg); } .settings-page-sumamary__persist-progress { - width: 28rem; + width: 26rem; } \ No newline at end of file diff --git a/ui/frontend/src/components/Settings/SettingsPageRight.tsx b/ui/frontend/src/components/Settings/SettingsPageRight.tsx index a9b5a4ac7..04944eba6 100644 --- a/ui/frontend/src/components/Settings/SettingsPageRight.tsx +++ b/ui/frontend/src/components/Settings/SettingsPageRight.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { FormEvent } from 'react'; import { Alert, AlertVariant, @@ -10,6 +10,9 @@ import { StackItem, TextInput, Title, + Tabs, + Tab, + TabTitleText, } from '@patternfly/react-core'; import { navigateToNewDomain, persist, PersistErrorType } from '../PersistPage'; @@ -17,17 +20,18 @@ import { IpTripletsSelector } from '../IpTripletsSelector'; import { useK8SStateContext } from '../K8SStateContext'; import { DeleteKubeadminButton } from './DeleteKubeadminButton'; import { PersistProgress, usePersistProgress } from '../PersistProgress'; +import { SettingsPageDomainCertificates } from './SettingsPageDomainCertificates'; +import { useSettingsPageContext } from './SettingsPageContext'; import './SettingsPageRight.css'; export const SettingsPageRight: React.FC<{ - isInitialEdit?: boolean; initialError?: string; forceReload: () => void; -}> = ({ isInitialEdit, initialError, forceReload }) => { - const [isEdit, setEdit] = React.useState(isInitialEdit); +}> = ({ initialError, forceReload }) => { const [isSaving, setIsSaving] = React.useState(false); const [_error, setError] = React.useState(); + const { activeTabKey, setActiveTabKey, isEdit, setEdit } = useSettingsPageContext(); const state = useK8SStateContext(); const progress = usePersistProgress(); @@ -41,6 +45,7 @@ export const SettingsPageRight: React.FC<{ const { isDirty, setClean, + isAllValid, apiaddr, apiaddrValidation, @@ -51,6 +56,7 @@ export const SettingsPageRight: React.FC<{ handleSetIngressIp, domain, + originalDomain, domainValidation, handleSetDomain, } = state; @@ -71,149 +77,179 @@ export const SettingsPageRight: React.FC<{ }; const isAfterRedirection = window.location.hash === '#redirected'; + const isDomainChange = originalDomain && originalDomain !== domain; + const isSaveDisabled = isSaving || !isAllValid() || !isDirty(); const onCancelEdit = () => { setEdit(false); forceReload(); }; - const isSaveDisabled = isSaving || !isDirty(); + const onFormSubmit = (e: FormEvent) => { + e.preventDefault(); + console.log('onFormSubmit() called, ignoring, click on Save instead'); + }; return ( -
- - - Settings - - - + + Settings + + + + setActiveTabKey(tabIndex as number)} + isBox={false} + aria-label="Choose tab to configure" + > + TCP/IP}> + + + + + + + + + +
+ + + +
+
+
+
+ + Domain}> +
+ + + + + + + {isEdit && isDomainChange && } + +
+
+
+
+ + {error && ( + + - -
+ {error.message} +
- - - - + )} + {isSaving && ( + + + {error === null && ( + <> + All changes have been saved, it might take several minutes for cluster to reconcile. + + )} + + )} + {!isSaving && !error && ( + + {/* Just a placeholder */} + )} + {isAfterRedirection && !isSaving && !isEdit && ( - - - + {/* TODO: Do we want to show 100% progressbar here? After redirection, it can be a fake one... */} + All changes have been saved. - {error && ( - - + {!isEdit && ( + <> + {' '} + + )} - {isAfterRedirection && !isSaving && !isEdit && ( - - {/* TODO: Do we want to shouw 100% progressbar here? After redirection, it can be a fake one... */} - All changes have been saved. - + {isEdit && ( + <> + + + )} - - {!isEdit && ( - <> - {' '} - - - )} - {isEdit && ( - <> - - - - )} - -
- + + ); }; diff --git a/ui/frontend/src/components/Settings/__snapshots__/Settings.test.tsx.snap b/ui/frontend/src/components/Settings/__snapshots__/Settings.test.tsx.snap index 9e75d4191..bff443482 100644 --- a/ui/frontend/src/components/Settings/__snapshots__/Settings.test.tsx.snap +++ b/ui/frontend/src/components/Settings/__snapshots__/Settings.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Seetings can render data 1`] = ` +exports[`Settings can render data 1`] = `
-
-
-

- Settings -

-
+ Settings + +
+
- + +
-
- - +
+ + +
+
+ + +
+
+
- - +
+
+
+ + +
+
+ + +
+
+
-
-
+
-
-
-
- +
+ + Delete kubeadmin + + +
- +
@@ -299,7 +428,7 @@ exports[`Seetings can render data 1`] = ` `; -exports[`Seetings can render data 2`] = ` +exports[`Settings can render data 2`] = `
-
-
-

- Settings -

-
+ Settings + +
+
- + +
-
- - +
+ + +
+
+ + + . + + . + + . + + +
+
+
- - - . - - . - - . - - +
+
+
+ + +
+
+ + + . + + . + + . + + +
+
+
-
-
+
-
-
-
- +
+ + Save + +
- +
@@ -668,7 +926,7 @@ exports[`Seetings can render data 2`] = ` `; -exports[`Seetings can render data 3`] = ` +exports[`Settings can render data 3`] = `
-
-
-

- Settings -

-
+ Settings + +
+
- + +
-
- - +
+ + +
+
+ + +
+
+
- - +
+
+
+ + +
+
+ + +
+
+
-
-
+
-
-
-
- +
+ + Delete kubeadmin + + +
- +
@@ -967,7 +1354,7 @@ exports[`Seetings can render data 3`] = ` `; -exports[`Seetings can render error 1`] = ` +exports[`Settings can render error 1`] = `
-
-
-

- Settings -

-
+ Settings + +
+
- + +
-
- - +
+ + +
+
+ + +
+
+
- - +
+
+
+ + +
+
+ + +
+
+
-
-
+
-
- - -
-
-
+ + + +
-
- -
-

- - Danger alert: - - Connection failed -

- + +
-
-
- + + @@ -1308,7 +1824,7 @@ exports[`Seetings can render error 1`] = ` `; -exports[`Seetings can render loading state 1`] = ` +exports[`Settings can render loading state 1`] = `
void; + setNextPage: (href: string) => void; setError: (message?: string) => void; handleSetApiaddr: K8SStateContextData['handleSetApiaddr']; handleSetIngressIp: K8SStateContextData['handleSetIngressIp']; handleSetDomain: K8SStateContextData['handleSetDomain']; setClean: K8SStateContextData['setClean']; }) => { - let ingressService, apiService, oauth, ingressConfig; + console.log('Initial data load'); + + let ingressService, apiService, oauth; + let ingressConfig: Ingress | undefined; + try { oauth = await getOAuth().promise; ingressService = await getService({ @@ -63,22 +68,18 @@ export const initialDataLoad = async ({ ), ); - let domain = ingressConfig?.spec?.domain?.trim(); - if (domain?.startsWith('apps.')) { - domain = domain.substring('apps.'.length); - } - if (domain) { - handleSetDomain(domain); + const currentHostname = getClusterDomainFromComponentRoutes(ingressConfig); + if (currentHostname) { + handleSetDomain(currentHostname); } setClean(); if (getHtpasswdIdentityProvider(oauth)) { // The Edit flow for the 2nd and later run - setNextPage && setNextPage('/settings'); - return; + setNextPage('/settings'); + } else { + // The Wizard for the very first run + setNextPage('/wizard/username'); } - - // The Wizard for the very first run - setNextPage && setNextPage('/wizard/username'); }; diff --git a/ui/frontend/src/components/Wizard/Wizard.css b/ui/frontend/src/components/Wizard/Wizard.css index 4f5677377..5ba249031 100644 --- a/ui/frontend/src/components/Wizard/Wizard.css +++ b/ui/frontend/src/components/Wizard/Wizard.css @@ -7,5 +7,12 @@ } .wizard-sublabel { + align-content: center; color: var(--pf-global--palette--black-600); + margin-left: var(--pf-global--spacer--xl); + margin-right: var(--pf-global--spacer--xl); +} + +.wizard-sublabel-dense { + margin-bottom: 0 !important; } diff --git a/ui/frontend/src/components/Wizard/Wizard.tsx b/ui/frontend/src/components/Wizard/Wizard.tsx index 89f4e7713..3698bae93 100644 --- a/ui/frontend/src/components/Wizard/Wizard.tsx +++ b/ui/frontend/src/components/Wizard/Wizard.tsx @@ -7,6 +7,8 @@ import { ApiAddressPage, IngressIpPage, DomainPage, + DomainCertificatesPage, + DomainCertificatesDecisionPage, PersistPage, FinalPage, } from '../../components'; @@ -25,6 +27,8 @@ export const Wizard: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx b/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx index 40a4d0ce1..c7933eaa8 100644 --- a/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx +++ b/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx @@ -11,7 +11,11 @@ export type WizardProgressStepType = | 'ingressip' | 'domain' | 'sshkey'; -export type WizardStepType = WizardProgressStepType | 'persist'; +export type WizardStepType = + | WizardProgressStepType + | 'persist' + | 'domaincertsdecision' + | 'domaincertificates'; export type WizardProgressSteps = { username: WizardProgressStep; diff --git a/ui/frontend/src/components/index.ts b/ui/frontend/src/components/index.ts index d9f04ce42..4ba439307 100644 --- a/ui/frontend/src/components/index.ts +++ b/ui/frontend/src/components/index.ts @@ -7,3 +7,5 @@ export * from './DomainPage'; export * from './PersistPage'; export * from './FinalPage'; export * from './DownloadSshKeyPage'; +export * from './DomainCertificatesDecisionPage'; +export * from './DomainCertificatesPage'; diff --git a/ui/frontend/src/components/types.ts b/ui/frontend/src/components/types.ts index 465be176a..bfbb95a47 100644 --- a/ui/frontend/src/components/types.ts +++ b/ui/frontend/src/components/types.ts @@ -1,4 +1,6 @@ -import { TextInputProps } from '@patternfly/react-core'; +import { FormGroupProps, TextInputProps } from '@patternfly/react-core'; +import { TlsCertificate } from '../copy-backend-common'; +import { ChangeDomainInputType } from '../backend-shared'; export type IpTripletIndex = 0 | 1 | 2 | 3; @@ -19,17 +21,31 @@ export type IpTripletSelectorValidationType = { triplets: IpTripletProps['validated'][]; }; +export type CustomCertsValidationType = { + [key: string]: { + certValidated: FormGroupProps['validated']; + certLabelHelperText?: string; + certLabelInvalid?: string; + + keyValidated: FormGroupProps['validated']; + keyLabelInvalid?: string; + }; +}; + export type K8SStateContextDataFields = { username: string; password: string; apiaddr: string; // 12 characters ingressIp: string; // 12 characters domain: string; + originalDomain?: string; + customCerts: ChangeDomainInputType['customCerts']; }; export type K8SStateContextData = K8SStateContextDataFields & { isDirty: () => boolean; setClean: () => void; + isAllValid: () => boolean; usernameValidation?: string; // just a message or empty handleSetUsername: (newVal: string) => void; @@ -45,4 +61,7 @@ export type K8SStateContextData = K8SStateContextDataFields & { handleSetDomain: (newDomain: string) => void; domainValidation?: string; + + setCustomCertificate: (domain: string, certificate: TlsCertificate) => void; + customCertsValidation: CustomCertsValidationType; }; diff --git a/ui/frontend/src/components/utils.ts b/ui/frontend/src/components/utils.ts index 887218201..a7b3291c5 100644 --- a/ui/frontend/src/components/utils.ts +++ b/ui/frontend/src/components/utils.ts @@ -1,7 +1,15 @@ +import { FormGroupProps } from '@patternfly/react-core'; +import { Buffer } from 'buffer'; + import { DNS_NAME_REGEX, USERNAME_REGEX } from '../backend-shared'; +import { TlsCertificate } from '../copy-backend-common'; import { isPasswordPolicyMet } from './PasswordPage/utils'; import { IpTripletSelectorValidationType, K8SStateContextData } from './types'; +export const toBase64 = (str: string) => Buffer.from(str).toString('base64'); +export const fromBase64ToUtf8 = (b64Str?: string): string | undefined => + b64Str === undefined ? undefined : Buffer.from(b64Str, 'base64').toString('utf8'); + export const addIpDots = (addressWithoutDots: string): string => { if (addressWithoutDots?.length === 12) { let address = addressWithoutDots.substring(0, 3).trim() + '.'; @@ -77,6 +85,68 @@ export const passwordValidator = (pwd: string): K8SStateContextData['passwordVal return isPasswordPolicyMet(pwd); }; +export const customCertsValidator = ( + oldValidation: K8SStateContextData['customCertsValidation'], + domain: string, + certificate: TlsCertificate, +): K8SStateContextData['customCertsValidation'] => { + const validation: K8SStateContextData['customCertsValidation'] = { ...oldValidation }; + + let certValidated: FormGroupProps['validated'] = 'default'; + let certLabelHelperText = ''; + let certLabelInvalid = ''; + if (!certificate?.['tls.crt'] && certificate?.['tls.key']) { + certValidated = 'error'; + certLabelInvalid = 'Both key and certificate must be provided at once.'; + } else if (!certificate?.['tls.crt']) { + certLabelHelperText = + 'When not uploaded, a self-signed certificate will be generated automatically.'; + } + + let keyValidated: FormGroupProps['validated'] = 'default'; + let keyLabelInvalid = ''; + if (certificate?.['tls.crt'] && !certificate?.['tls.key']) { + keyValidated = 'error'; + keyLabelInvalid = 'Both key and certificate must be provided at once.'; + } + + const tlsCrt = fromBase64ToUtf8(certificate['tls.crt'])?.trim().split('\n'); + const tlsKey = fromBase64ToUtf8(certificate['tls.key'])?.trim().split('\n'); + if (tlsCrt?.length && tlsKey?.length && tlsCrt.length > 2 && tlsKey.length > 2) { + // The header/footer are not required but commonly used, so let's try to check the format based on them + if ( + !tlsCrt[0].includes('--BEGIN CERTIFICATE--') || + !tlsCrt?.[tlsCrt.length - 1].includes('--END CERTIFICATE--') + ) { + certValidated = 'error'; + certLabelInvalid = 'The provided certificate does not conform PEM format.'; + } else { + certValidated = 'success'; + } + + if ( + !tlsKey[0].includes('--BEGIN PRIVATE KEY--') || + !tlsKey?.[tlsKey.length - 1].includes('--END PRIVATE KEY--') + ) { + keyValidated = 'error'; + keyLabelInvalid = 'The provided key does not conform PEM format.'; + } else { + keyValidated = 'success'; + } + } + + validation[domain] = { + certValidated, + certLabelHelperText, + certLabelInvalid, + + keyValidated, + keyLabelInvalid, + }; + + return validation; +}; + export const ipWithoutDots = (ip?: string): string => { if (ip) { const triplets = ip.split('.'); diff --git a/ui/frontend/src/index.tsx b/ui/frontend/src/index.tsx index 5604a6435..84332431c 100644 --- a/ui/frontend/src/index.tsx +++ b/ui/frontend/src/index.tsx @@ -5,6 +5,7 @@ import ReactDOM from 'react-dom'; import App from './App'; import { getBackendUrl } from './resources'; +import { GIT_BUILD_SHA } from './sha'; import './index.css'; @@ -21,5 +22,6 @@ ReactDOM.render( // reportWebVitals(); // For debugging - especially after domain change +console.info('***** The Edgecluster UI version: ', GIT_BUILD_SHA); console.log('UI Backend URL: ', getBackendUrl()); console.log('Frontend accessed at: ', window.location.href); diff --git a/ui/frontend/src/resources/frontendLogging.ts b/ui/frontend/src/resources/frontendLogging.ts new file mode 100644 index 000000000..aa45593ad --- /dev/null +++ b/ui/frontend/src/resources/frontendLogging.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +window.API_LOGGING = false; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const isFrontendLoggingEnabled = () => !!window.API_LOGGING; + +export const logFrontendRequest = (url?: string, reqInit?: RequestInit) => { + if (isFrontendLoggingEnabled()) { + console.log(`=== Request for ${url || ''}:\n`, reqInit && JSON.stringify(reqInit)); + } +}; + +export const logFrontendResponse = (url?: string, result?: any) => { + if (isFrontendLoggingEnabled()) { + console.log(`=== Response for ${url || ''}:\n`, JSON.stringify(result)); + } +}; + +if (isFrontendLoggingEnabled()) { + console.warn( + 'DEBUG BUILD ONLY. For production, turn off extensive logging in the frontendLogging.ts', + ); +} diff --git a/ui/frontend/src/resources/resource-request.ts b/ui/frontend/src/resources/resource-request.ts index 5d2ba9e6f..b121bbd01 100644 --- a/ui/frontend/src/resources/resource-request.ts +++ b/ui/frontend/src/resources/resource-request.ts @@ -8,6 +8,7 @@ import { StatusKind, } from '../backend-shared'; import { getZtpfwUrl } from '../components/utils'; +import { logFrontendRequest, logFrontendResponse } from './frontendLogging'; export interface IRequestResult { promise: Promise; @@ -349,7 +350,7 @@ export async function fetchRetry(options: { while (true) { let response: Response | undefined; try { - response = await fetch(options.url, { + const reqInit: RequestInit = { method: options.method ?? 'GET', credentials: 'include', headers, @@ -357,7 +358,9 @@ export async function fetchRetry(options: { signal: options.signal, redirect: 'manual', // mode: "cors", - }); + }; + logFrontendRequest(options.url, reqInit); + response = await fetch(options.url, reqInit); } catch (err) { if (options.signal.aborted) { throw new ResourceError(`Request aborted`, ResourceErrorCode.RequestAborted); @@ -431,11 +434,13 @@ export async function fetchRetry(options: { } if (response.status < 300) { - return { + const result = { headers: response.headers, status: response.status, data: responseData as T, }; + logFrontendResponse(options.url, result); + return result; } switch (response.status) { diff --git a/ui/frontend/src/setupTests.ts b/ui/frontend/src/setupTests.ts index c3644c4ea..171539b35 100644 --- a/ui/frontend/src/setupTests.ts +++ b/ui/frontend/src/setupTests.ts @@ -5,6 +5,7 @@ import '@testing-library/jest-dom'; import replaceAllInserter from 'string.prototype.replaceall'; import fetchMock from 'jest-fetch-mock'; +import 'jest-location-mock'; replaceAllInserter.shim(); fetchMock.enableMocks(); diff --git a/ui/frontend/src/sha.ts b/ui/frontend/src/sha.ts new file mode 100644 index 000000000..815b3df79 --- /dev/null +++ b/ui/frontend/src/sha.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line prefer-const +export let GIT_BUILD_SHA = ''; +// GIT_BUILD_SHA will be changed at build time + +/* DO NOT COMMIT CHANGES BELOW THIS LINE, THEY ARE ADDED AUTOMATICALLY */ diff --git a/ui/frontend/yarn.lock b/ui/frontend/yarn.lock index 3e91e71f6..32b4de0d1 100644 --- a/ui/frontend/yarn.lock +++ b/ui/frontend/yarn.lock @@ -1065,19 +1065,19 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4" integrity sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg== -"@eslint/eslintrc@^1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.0.5.tgz#33f1b838dbf1f923bfa517e008362b78ddbbf318" - integrity sha512-BLxsnmK3KyPunz5wmCCpqy0YelEoxxGmH73Is+Z74oOTMtExcjkr3dDR6quwrjh1YspA8DH9gnX1o069KiS9AQ== +"@eslint/eslintrc@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" + integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.2.0" - globals "^13.9.0" - ignore "^4.0.6" + espree "^9.3.2" + globals "^13.15.0" + ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.0" - minimatch "^3.0.4" + minimatch "^3.1.2" strip-json-comments "^3.1.1" "@humanwhocodes/config-array@^0.9.2": @@ -1110,6 +1110,11 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== +"@jedmao/location@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@jedmao/location/-/location-3.0.0.tgz#f2b24e937386f95252f3a1fefbf7ca2e0a4b87e9" + integrity sha512-p7mzNlgJbCioUYLUEKds3cQG4CHONVFJNYqMe6ocEtENCL/jYmMo1Q3ApwsMmU+L0ZkaDJEyv4HokaByLoPwlQ== + "@jest/console@^27.4.6": version "27.4.6" resolved "https://registry.yarnpkg.com/@jest/console/-/console-27.4.6.tgz#0742e6787f682b22bdad56f9db2a8a77f6a86107" @@ -2298,7 +2303,7 @@ acorn-import-assertions@^1.7.6: resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== -acorn-jsx@^5.3.1: +acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== @@ -2322,11 +2327,16 @@ acorn@^7.0.0, acorn@^7.1.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.2.4, acorn@^8.4.1, acorn@^8.7.0: +acorn@^8.2.4, acorn@^8.4.1: version "8.7.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== +acorn@^8.7.1: + version "8.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" + integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== + address@^1.0.1, address@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -2905,6 +2915,14 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + builtin-modules@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" @@ -3748,6 +3766,11 @@ diff-sequences@^27.4.0: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.4.0.tgz#d783920ad8d06ec718a060d00196dfef25b132a5" integrity sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww== +diff-sequences@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" + integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -4069,10 +4092,10 @@ eslint-config-prettier@^8.3.0: resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz#f7471b20b6fe8a9a9254cc684454202886a2dd7a" integrity sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew== -eslint-config-react-app@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-7.0.0.tgz#0fa96d5ec1dfb99c029b1554362ab3fa1c3757df" - integrity sha512-xyymoxtIt1EOsSaGag+/jmcywRuieQoA2JbPCjnw9HukFj9/97aGPoZVFioaotzk1K5Qt9sHO5EutZbkrAXS0g== +eslint-config-react-app@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz#73ba3929978001c5c86274c017ea57eb5fa644b4" + integrity sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA== dependencies: "@babel/core" "^7.16.0" "@babel/eslint-parser" "^7.16.3" @@ -4204,10 +4227,10 @@ eslint-scope@5.1.1, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.0.tgz#c1f6ea30ac583031f203d65c73e723b01298f153" - integrity sha512-aWwkhnS0qAXqNOgKOK0dJ2nvzEbhEvpy8OlJ9kZ0FeZnA6zpjv1/Vei+puGFFX7zkPCkHHXb7IDX3A+7yPrRWg== +eslint-scope@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" + integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" @@ -4224,11 +4247,16 @@ eslint-visitor-keys@^2.0.0, eslint-visitor-keys@^2.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.1.0, eslint-visitor-keys@^3.2.0: +eslint-visitor-keys@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.2.0.tgz#6fbb166a6798ee5991358bc2daa1ba76cc1254a1" integrity sha512-IOzT0X126zn7ALX0dwFiUQEdsfzrm4+ISsQS8nukaJXwEyYKRSnEIIDULYg1mCtGp7UUXgfGl7BIolXREQK+XQ== +eslint-visitor-keys@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" + integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== + eslint-webpack-plugin@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/eslint-webpack-plugin/-/eslint-webpack-plugin-3.1.1.tgz#83dad2395e5f572d6f4d919eedaa9cf902890fcb" @@ -4241,11 +4269,11 @@ eslint-webpack-plugin@^3.1.1: schema-utils "^3.1.1" eslint@^8.3.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.7.0.tgz#22e036842ee5b7cf87b03fe237731675b4d3633c" - integrity sha512-ifHYzkBGrzS2iDU7KjhCAVMGCvF6M3Xfs8X8b37cgrUlDt6bWRTpRh6T/gtSXv1HJ/BUGgmjvNvOEGu85Iif7w== + version "8.18.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.18.0.tgz#78d565d16c993d0b73968c523c0446b13da784fd" + integrity sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA== dependencies: - "@eslint/eslintrc" "^1.0.5" + "@eslint/eslintrc" "^1.3.0" "@humanwhocodes/config-array" "^0.9.2" ajv "^6.10.0" chalk "^4.0.0" @@ -4253,17 +4281,17 @@ eslint@^8.3.0: debug "^4.3.2" doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.1.0" + eslint-scope "^7.1.1" eslint-utils "^3.0.0" - eslint-visitor-keys "^3.2.0" - espree "^9.3.0" + eslint-visitor-keys "^3.3.0" + espree "^9.3.2" esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" - globals "^13.6.0" + globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -4272,7 +4300,7 @@ eslint@^8.3.0: json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" - minimatch "^3.0.4" + minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" regexpp "^3.2.0" @@ -4281,14 +4309,14 @@ eslint@^8.3.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^9.2.0, espree@^9.3.0: - version "9.3.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.0.tgz#c1240d79183b72aaee6ccfa5a90bc9111df085a8" - integrity sha512-d/5nCsb0JcqsSEeQzFZ8DH1RmxPcglRWh24EFTlUEmCKoehXGdpsx0RkHDubqUI8LSAIKMQp4r9SzQ3n+sm4HQ== +espree@^9.3.2: + version "9.3.2" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596" + integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA== dependencies: - acorn "^8.7.0" - acorn-jsx "^5.3.1" - eslint-visitor-keys "^3.1.0" + acorn "^8.7.1" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" @@ -4840,10 +4868,10 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.6.0, globals@^13.9.0: - version "13.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.12.0.tgz#4d733760304230a0082ed96e21e5c565f898089e" - integrity sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg== +globals@^13.15.0: + version "13.15.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac" + integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog== dependencies: type-fest "^0.20.2" @@ -5149,16 +5177,11 @@ identity-obj-proxy@^3.0.0: dependencies: harmony-reflect "^1.4.6" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== - ignore@^5.1.8, ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" @@ -5608,6 +5631,16 @@ jest-diff@^27.0.0, jest-diff@^27.4.6: jest-get-type "^27.4.0" pretty-format "^27.4.6" +jest-diff@^27.0.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" + integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== + dependencies: + chalk "^4.0.0" + diff-sequences "^27.5.1" + jest-get-type "^27.5.1" + pretty-format "^27.5.1" + jest-docblock@^27.4.0: version "27.4.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.4.0.tgz#06c78035ca93cbbb84faf8fce64deae79a59f69f" @@ -5664,6 +5697,11 @@ jest-get-type@^27.4.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.4.0.tgz#7503d2663fffa431638337b3998d39c5e928e9b5" integrity sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ== +jest-get-type@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" + integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== + jest-haste-map@^27.4.6: version "27.4.6" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-27.4.6.tgz#c60b5233a34ca0520f325b7e2cc0a0140ad0862a" @@ -5715,6 +5753,14 @@ jest-leak-detector@^27.4.6: jest-get-type "^27.4.0" pretty-format "^27.4.6" +jest-location-mock@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/jest-location-mock/-/jest-location-mock-1.0.9.tgz#f4466362423b273e12ca3716467a3d478ce78fa8" + integrity sha512-DN/v7Zsa3N4uGgWTCrMrPPxhZORr/4N5gi+u7Tk6sLdORYplrC0//wfFN5FOtx4ZdQzDVfY6rLa4d+wfTKzQHw== + dependencies: + "@jedmao/location" "^3.0.0" + jest-diff "^27.0.1" + jest-matcher-utils@^27.4.6: version "27.4.6" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.4.6.tgz#53ca7f7b58170638590e946f5363b988775509b8" @@ -6442,7 +6488,7 @@ minimatch@3.0.4: dependencies: brace-expansion "^1.1.7" -minimatch@^3.0.4: +minimatch@^3.0.4, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -7555,6 +7601,15 @@ pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.4.6: ansi-styles "^5.0.0" react-is "^17.0.1" +pretty-format@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -7689,10 +7744,10 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" whatwg-fetch "^3.6.2" -react-dev-utils@^12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.0.tgz#4eab12cdb95692a077616770b5988f0adf806526" - integrity sha512-xBQkitdxozPxt1YZ9O1097EJiVpwHr9FoAuEVURCKV0Av8NBERovJauzP7bo1ThvuhZ4shsQ1AJiu4vQpoT1AQ== +react-dev-utils@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73" + integrity sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ== dependencies: "@babel/code-frame" "^7.16.0" address "^1.1.2" @@ -7713,7 +7768,7 @@ react-dev-utils@^12.0.0: open "^8.4.0" pkg-up "^3.1.0" prompts "^2.4.2" - react-error-overlay "^6.0.10" + react-error-overlay "^6.0.11" recursive-readdir "^2.2.2" shell-quote "^1.7.3" strip-ansi "^6.0.1" @@ -7738,10 +7793,10 @@ react-dropzone@9.0.0: prop-types "^15.6.2" prop-types-extra "^1.1.0" -react-error-overlay@^6.0.10: - version "6.0.10" - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6" - integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA== +react-error-overlay@^6.0.11: + version "6.0.11" + resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" + integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== react-is@^16.13.1, react-is@^16.3.2: version "16.13.1" @@ -7773,10 +7828,10 @@ react-router@6.2.1: dependencies: history "^5.2.0" -react-scripts@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.0.tgz#6547a6d7f8b64364ef95273767466cc577cb4b60" - integrity sha512-3i0L2CyIlROz7mxETEdfif6Sfhh9Lfpzi10CtcGs1emDQStmZfWjJbAIMtRD0opVUjQuFWqHZyRZ9PPzKCFxWg== +react-scripts@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003" + integrity sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ== dependencies: "@babel/core" "^7.16.0" "@pmmmwh/react-refresh-webpack-plugin" "^0.5.3" @@ -7794,7 +7849,7 @@ react-scripts@5.0.0: dotenv "^10.0.0" dotenv-expand "^5.1.0" eslint "^8.3.0" - eslint-config-react-app "^7.0.0" + eslint-config-react-app "^7.0.1" eslint-webpack-plugin "^3.1.1" file-loader "^6.2.0" fs-extra "^10.0.0" @@ -7811,7 +7866,7 @@ react-scripts@5.0.0: postcss-preset-env "^7.0.1" prompts "^2.4.2" react-app-polyfill "^3.0.0" - react-dev-utils "^12.0.0" + react-dev-utils "^12.0.1" react-refresh "^0.11.0" resolve "^1.20.0" resolve-url-loader "^4.0.0"